Skip to content
Commits on Source (10)
......@@ -124,4 +124,27 @@ context('Login', () => {
cy.get('.minds-list > minds-activity:first-child m-post-menu .minds-dropdown-menu .mdl-menu__item:nth-child(4)').click();
cy.get('.minds-list > minds-activity:first-child m-post-menu m-modal-confirm .mdl-button--colored').click();
})
it('should record a view when the user scrolls and an activity is visible', async () => {
cy.server();
cy.route("POST", "**/api/v2/analytics/views/activity/*").as("view");
// create the post
cy.get('minds-newsfeed-poster textarea').type('This is a post that will record a view');
cy.get('.m-posterActionBar__PostButton').click();
cy.wait(200);
cy.scrollTo(0, '20px');
cy.wait('@view', { requestTimeout: 2000 }).then((xhr) => {
expect(xhr.status).to.equal(200);
expect(xhr.response.body).to.deep.equal({ status: 'success' });
});
// cleanup
cy.get('.minds-list > minds-activity:first-child m-post-menu .minds-more').click();
cy.get('.minds-list > minds-activity:first-child m-post-menu .minds-dropdown-menu .mdl-menu__item:nth-child(4)').click();
cy.get('.minds-list > minds-activity:first-child m-post-menu m-modal-confirm .mdl-button--colored').click();
})
})
......@@ -98,6 +98,7 @@ import { EntitiesService } from "./services/entities.service";
import { BlockListService } from "./services/block-list.service";
import { SettingsService } from "../modules/settings/settings.service";
import { ThemeService } from "./services/theme.service";
import { HorizontalInfiniteScroll } from "./components/infinite-scroll/horizontal-infinite-scroll.component";
@NgModule({
imports: [
......@@ -124,6 +125,7 @@ import { ThemeService } from "./services/theme.service";
TooltipComponent,
FooterComponent,
InfiniteScroll,
HorizontalInfiniteScroll,
CountryInputComponent,
DateInputComponent,
StateInputComponent,
......@@ -207,6 +209,7 @@ import { ThemeService } from "./services/theme.service";
TooltipComponent,
FooterComponent,
InfiniteScroll,
HorizontalInfiniteScroll,
CountryInputComponent,
DateInputComponent,
CityFinderComponent,
......
import { Component, ElementRef, EventEmitter, Input, Output } from '@angular/core';
import { GlobalScrollService, ScrollSubscription } from "../../../services/ux/global-scroll.service";
import { Subscription } from "rxjs";
@Component({
selector: 'infinite-scroll--horizontal',
template: `
<div class="mdl-spinner mdl-js-spinner is-active" [mdl] [hidden]="!inProgress"></div>
<div class="m-infinite-scroll-manual"
[class.mdl-color--blue-grey-200]="!iconOnly"
[class.mdl-color-text--blue-grey-500]="!iconOnly"
[hidden]="inProgress || !moreData"
(click)="manualLoad()"
*ngIf="!hideManual">
<ng-container i18n="@@COMMON__INFINITE_SCROLL__LOAD_MORE" *ngIf="!iconOnly">Click to load more</ng-container>
<i class="material-icons" *ngIf="iconOnly">keyboard_arrow_right</i>
</div>
<div class="m-infinite-scroll-manual"
[class.mdl-color--blue-grey-200]="!iconOnly"
[class.mdl-color-text--blue-grey-500]="!iconOnly"
[hidden]="moreData"
*ngIf="!hideManual">
<ng-container i18n="@@COMMON__INFINITE_SCROLL__NOTHING_MORE">Nothing more to load</ng-container>
</div>
`
})
export class HorizontalInfiniteScroll {
@Input() on: any;
@Input() scrollSource: any; // if not provided, it defaults to window
@Input() iconOnly: boolean = false;
@Output('load') loadHandler: EventEmitter<any> = new EventEmitter(true);
@Input() distance: any;
@Input() inProgress: boolean = false;
@Input() moreData: boolean = true;
@Input() hideManual: boolean = false;
element: any;
_content: any;
subscription: [ScrollSubscription, Subscription];
constructor(_element: ElementRef, private scroll: GlobalScrollService) {
this.element = _element.nativeElement;
}
ngOnInit() {
this.init();
}
init() {
if (!this.scrollSource) {
this.scrollSource = document;
}
this.subscription = this.scroll.listen(this.scrollSource, ((subscription, e) => {
if (this.moreData) {
let clientWidth, scrollLeft;
if (this.scrollSource === document) {
clientWidth = document.body.clientWidth;
scrollLeft = document.body.scrollLeft;
} else {
clientWidth = subscription.element.clientWidth;
scrollLeft = subscription.element.scrollLeft;
}
if (
this.element.offsetLeft
- this.element.clientWidth
- clientWidth
<= scrollLeft
) {
this.loadHandler.next(true);
}
}
}).bind(this), 100);
}
manualLoad() {
this.loadHandler.next(true);
}
ngOnDestroy() {
if (this.subscription)
this.scroll.unListen(this.subscription[0], this.subscription[1]);
}
}
import { Component, ElementRef, EventEmitter, Input, Output } from '@angular/core';
import { ScrollService } from '../../../services/ux/scroll';
export type ScrollOrientation = 'vertical' | 'horizontal';
import { GlobalScrollService, ScrollSubscription } from "../../../services/ux/global-scroll.service";
import { Subscription } from "rxjs";
@Component({
selector: 'infinite-scroll',
......@@ -13,10 +12,9 @@ export type ScrollOrientation = 'vertical' | 'horizontal';
[hidden]="inProgress || !moreData"
(click)="manualLoad()"
*ngIf="!hideManual">
<ng-container i18n="@@COMMON__INFINITE_SCROLL__LOAD_MORE" *ngIf="!iconOnly">Click to load more</ng-container>
<ng-container i18n="@@COMMON__INFINITE_SCROLL__LOAD_MORE" *ngIf="!iconOnly">Click to load more</ng-container>
<i class="material-icons" *ngIf="iconOnly && orientation == 'vertical'">keyboard_arrow_down</i>
<i class="material-icons" *ngIf="iconOnly && orientation == 'horizontal'">keyboard_arrow_right</i>
<i class="material-icons" *ngIf="iconOnly">keyboard_arrow_down</i>
</div>
<div class="m-infinite-scroll-manual"
[class.mdl-color--blue-grey-200]="!iconOnly"
......@@ -32,7 +30,6 @@ export type ScrollOrientation = 'vertical' | 'horizontal';
export class InfiniteScroll {
@Input() on: any;
@Input() scrollSource: any; // if not provided, it defaults to window
@Input() orientation: ScrollOrientation = 'vertical';
@Input() iconOnly: boolean = false;
@Output('load') loadHandler: EventEmitter<any> = new EventEmitter(true);
......@@ -44,11 +41,9 @@ export class InfiniteScroll {
element: any;
_content: any;
_listener;
private scroll: ScrollService;
subscription: [ScrollSubscription, Subscription];
constructor(_element: ElementRef) {
constructor(_element: ElementRef, private scroll: GlobalScrollService) {
this.element = _element.nativeElement;
}
......@@ -57,36 +52,30 @@ export class InfiniteScroll {
}
init() {
this.scroll = new ScrollService();
if (this.scrollSource) {
this.scroll.setScrollSource(this.scrollSource);
if (!this.scrollSource) {
this.scrollSource = document;
}
this._listener = this.scroll.listen((e) => {
this.subscription = this.scroll.listen(this.scrollSource, ((subscription, e) => {
if (this.moreData) {
switch (this.orientation) {
case 'vertical':
if (
this.element.offsetTop
- this.element.clientHeight
- this.scroll.view.clientHeight
<= this.scroll.view.scrollTop
) {
this.loadHandler.next(true);
}
break;
case 'horizontal':
if (
this.element.offsetLeft
- this.element.clientWidth
- this.scroll.view.clientWidth
<= this.scroll.view.scrollLeft
) {
this.loadHandler.next(true);
}
break;
let clientHeight, scrollTop;
if (this.scrollSource === document) {
clientHeight = document.body.clientHeight;
scrollTop = document.body.scrollTop;
} else {
clientHeight = subscription.element.clientHeight;
scrollTop = subscription.element.scrollTop;
}
if (
this.element.offsetTop
- this.element.clientHeight
- clientHeight
<= scrollTop
) {
this.loadHandler.next(true);
}
}
}, 100);
}).bind(this), 100);
}
manualLoad() {
......@@ -94,8 +83,8 @@ export class InfiniteScroll {
}
ngOnDestroy() {
if (this._listener)
this.scroll.unListen(this._listener);
if (this.subscription)
this.scroll.unListen(this.subscription[0], this.subscription[1]);
}
}
......@@ -34,8 +34,8 @@ export class BoostedContentService {
this.boostedContentSync = new BoostedContentSync(
new MindsClientHttpAdapter(this.client),
await browserStorageAdapterFactory('minds-boosted-content-190314'),
6 * 60 * 60,
5 * 60,
5 * 60 * 60, // Stale after 5 minutes
15 * 60 * 60, // Cooldown of 15 minutes
500,
);
......@@ -69,7 +69,7 @@ export class BoostedContentService {
// Garbage collection
this.boostedContentSync.gc();
setTimeout(() => this.boostedContentSync.gc(), 30 * 60 * 1000); // Every 30 minutes
setTimeout(() => this.boostedContentSync.gc(), 5 * 60 * 1000); // Every 5 minutes
// Rating changes hook
this.settingsService.ratingChanged.subscribe(rating => this.boostedContentSync.changeRating(rating));
......
......@@ -28,6 +28,8 @@ export default class BoostedContentSync {
this.synchronized = null;
this.locks = [];
this.inSync = false;
}
/**
......@@ -91,15 +93,32 @@ export default class BoostedContentSync {
await this.prune();
// Check if a sync is needed
if (!this.inSync) {
// Check if a sync is needed
if (opts.forceSync || !this.synchronized || (this.synchronized <= Date.now() - this.stale_after_ms)) {
const wasSynced = await this.sync(opts);
if (!wasSynced) {
console.info('Cannot sync, using cache');
} else {
this.synchronized = Date.now();
}
}
} else {
// Wait for sync to finish (max 100 iterations * 100ms = 10secs)
let count = 0;
while (true) {
count++;
if (opts.forceSync || !this.synchronized || (this.synchronized <= Date.now() - this.cooldown_ms)) {
const wasSynced = await this.sync(opts);
if (!this.inSync || count >= 100) {
console.info('Sync finished. Fetching.');
break;
}
if (!wasSynced) {
console.info('Cannot sync, using cache');
} else {
this.synchronized = Date.now();
await new Promise(resolve => setTimeout(resolve, 100));
}
}
......@@ -162,6 +181,10 @@ export default class BoostedContentSync {
async sync(opts) {
await this.db.ready();
// Set flag
this.inSync = true;
// Sync
try {
......@@ -198,12 +221,43 @@ export default class BoostedContentSync {
impressions: 0,
lastImpression: 0
})));
// Remove stale entries
await this.pruneStaleBoosts();
// Remove flag
this.inSync = false;
// Return
return true;
} catch (e) {
// Remove flag
this.inSync = false;
// Warn
console.warn('BoostedContentSync.sync', e);
// Return
return false;
}
}
return true;
/**
* @returns {Promise<void>}
*/
async pruneStaleBoosts() {
try {
this.db
.deleteLessThan('boosts', 'sync', Date.now() - this.stale_after_ms);
} catch (e) {
console.error('BoostedContentSync.pruneStaleBoosts', e);
throw e;
}
}
/**
......@@ -211,8 +265,7 @@ export default class BoostedContentSync {
*/
async prune() {
try {
await this.db
.deleteLessThan('boosts', 'sync', Date.now() - this.stale_after_ms);
await this.pruneStaleBoosts();
await this.db
.deleteAnyOf('boosts', 'owner_guid', (await this.resolvers.blockedUserGuids()) || []);
......
......@@ -68,7 +68,7 @@
*ngIf="(!group['is:creator'] || (session.isAdmin()) && !group['is:invited'])"
>
</minds-groups-join-button>
<button class="m-btn m-btn--slim m-btn--with-icon" style="margin-left: 8;" (click)="videochat.activate(group)" *ngIf="!group.videoChatDisabled">
<button class="m-btn m-btn--slim m-btn--with-icon" [class.m-pulsating--small]="group.hasGathering" style="margin-left: 8;" (click)="videochat.activate(group)" *ngIf="!group.videoChatDisabled">
<span class="m-gatheringIcon">Gathering</span>
<i class="material-icons">video_call</i>
</button>
......@@ -121,7 +121,7 @@
<m-hashtags-selector #hashtagsSelector
[alignLeft]="true"
[tags]="group.tags"
(tagsChange)="onTagsChange($event)"
(tagsChange)="onTagsChange($event)"p
(tagsAdded)="onTagsAdded($event)"
(tagsRemoved)="onTagsRemoved($event)"
*ngIf="editing && group['is:owner']"
......
......@@ -205,10 +205,14 @@ export class GroupsProfile {
if (this.updateMarkersSubscription)
this.updateMarkersSubscription.unsubscribe();
this.updateMarkersSubscription = this.updateMarkers.getByEntityGuid(this.guid).subscribe(marker => {
this.updateMarkersSubscription = this.updateMarkers.getByEntityGuid(this.guid).subscribe((marker => {
if (!marker)
return;
this.group.hasGathering = marker && marker.entity_guid == this.group.guid
&& marker.marker == 'gathering-heartbeat'
&& marker.updated_timestamp > (Date.now() / 1000) - 60;
let hasMarker =
(marker.read_timestamp < marker.updated_timestamp)
&& (marker.entity_guid == this.group.guid)
......@@ -216,7 +220,7 @@ export class GroupsProfile {
if (hasMarker)
this.resetMarkers();
});
}).bind(this));
// Check for comment updates
this.joinCommentsSocketRoom();
......
......@@ -15,13 +15,13 @@
</a>
</li>
<li *ngFor="let group of groups" [class.has-marker]="group.hasMarker" [class.has-gathering]="group.hasGathering$ | async">
<li *ngFor="let group of groups" [class.has-marker]="group.hasMarker">
<a [routerLink]="['/groups/profile', group.guid]">
<m-tooltip
anchor="right"
[useParentPosition]="true"
>
<img [src]="'fs/v1/avatars/' + group.guid + '/' + group.icontime" m-tooltip--anchor/>
<img [class.m-pulsating--small]="group.hasGathering$ | async" [src]="'fs/v1/avatars/' + group.guid + '/' + group.icontime" m-tooltip--anchor/>
<span>{{group.name}}</span>
<ng-container>
......
......@@ -116,37 +116,6 @@
}
}
.has-gathering::before {
position: absolute;
display: block;
//top: 0;
//left: 0;
width: 44px;
height: 44px;
box-sizing: border-box;
margin: 2px;
//margin: -15% -15%;
border-radius: 50%;
animation: pulse-ring 1.25s cubic-bezier(0.215, 0.5, 0.355, 1) infinite;
content: "";
@include m-theme(){
background-color: themed($m-blue); //two background colors?
background-color: themed($m-red);
}
}
@keyframes pulse-ring {
0% {
transform: scale(.33);
}
40% {
opacity: 0.5;
}
100% {
opacity: 0;
}
}
.has-marker::after {
border-radius: 50%;
display: block;
......
......@@ -10,7 +10,10 @@
<a [routerLink]="['/groups/profile', entity.guid]" class="m-groups--tile-block">
<div class="avatar">
<img src="{{minds.cdn_url}}fs/v1/avatars/{{entity.guid}}/large"/>
<img
[class.m-pulsating--big]="entity.hasGathering$ | async"
src="{{minds.cdn_url}}fs/v1/avatars/{{entity.guid}}/large"
/>
</div>
<div class="body">
<h3>{{entity.name}}</h3>
......
import { Component, Input } from '@angular/core';
import { Subscription } from 'rxjs';
import { interval, Subscription } from 'rxjs';
import { Session } from '../../../services/session';
import { UpdateMarkersService } from '../../../common/services/update-markers.service';
import { map, startWith, throttle } from "rxjs/operators";
@Component({
selector: 'm-groups--tile',
......@@ -25,6 +26,16 @@ export class GroupsTileComponent {
this.$updateMarker = this.updateMarkers.markers.subscribe(markers => {
if (!markers)
return;
this.entity.hasGathering$ = interval(1000).pipe(
throttle(() => interval(2000)), //only allow once per 2 seconds
startWith(0),
map(() => markers.filter(marker => marker.entity_guid == this.entity.guid
&& marker.marker == 'gathering-heartbeat'
&& marker.updated_timestamp > (Date.now() / 1000) - 60 //1 minute tolerance
).length > 0)
);
this.hasMarker = markers
.filter(marker =>
(marker.read_timestamp < marker.updated_timestamp)
......
......@@ -142,7 +142,10 @@ export class Activity {
this.editing = false;
this.activity.edited = true;
let data = Object.assign(this.activity, this.attachment.exportMeta());
let data = this.activity;
if (this.attachment.has()) {
data = Object.assign(this.activity, this.attachment.exportMeta());
}
this.client.post('api/v1/newsfeed/' + this.activity.guid, data);
}
......
......@@ -23,7 +23,7 @@
<div class="m-feeds-sorted__List" [class.m-feeds-sortedList__flex]="customType == 'channels' || customType == 'groups'">
<ng-container *ngFor="let entity of newsfeed; let i = index">
<m-featured-content
*ngIf="(i > 0 && (i % 8) === 0 && i <= 40) || i === 2"
*ngIf="shouldShowBoost(i)"
></m-featured-content>
<m-newsfeed__entity
......
......@@ -332,4 +332,12 @@ export class NewsfeedSortedComponent implements OnInit, OnDestroy {
route.push(params);
this.router.navigate(route);
}
shouldShowBoost(i: number) {
if (this.query) {
return false;
}
return (i > 0 && (i % 8) === 0 && i <= 40) || i === 2;
}
}
......@@ -5,7 +5,7 @@
<div class="minds-list">
<minds-activity *ngFor="let preActivity of prepended" [object]="preActivity" [boostToggle]="preActivity.boostToggle" (delete)="delete(preActivity)" [showRatingToggle]="true" class="mdl-card m-border item"></minds-activity>
<m-newsfeed--boost-rotator interval="4" *ngIf="showBoostRotator"></m-newsfeed--boost-rotator>
<m-newsfeed--boost-rotator interval="8" *ngIf="showBoostRotator"></m-newsfeed--boost-rotator>
<minds-activity *ngFor="let activity of newsfeed" [object]="activity" [boostToggle]="activity.boostToggle" (delete)="delete(activity)" [showRatingToggle]="true" class="mdl-card m-border item"></minds-activity>
<infinite-scroll
distance="25%"
......
......@@ -67,6 +67,7 @@
distance="25%"
(load)="load()"
[moreData]="moreData"
[scrollSource]="notificationGrid"
[inProgress]="inProgress"
*ngIf="visible"
>
......
......@@ -33,7 +33,7 @@ describe('NotificationsComponent', () => {
}),
MockComponent({
selector: 'infinite-scroll',
inputs: [ 'inProgress', 'moreData', 'inProgress' ],
inputs: [ 'inProgress', 'moreData', 'inProgress', 'scrollSource' ],
}),
MockComponent({
selector: 'm-tooltip',
......
......@@ -12,7 +12,7 @@ import { DebugElement } from '@angular/core';
import { Session } from '../../../../services/session';
import { sessionMock } from '../../../../../tests/session-mock.spec';
fdescribe('WalletBalanceTokensComponent', () => {
describe('WalletBalanceTokensComponent', () => {
let comp: WalletBalanceTokensComponent;
let fixture: ComponentFixture<WalletBalanceTokensComponent>;
......
......@@ -41,6 +41,7 @@ import { EntitiesService } from "../common/services/entities.service";
import { InMemoryStorageService } from "./in-memory-storage.service";
import { FeedsService } from "../common/services/feeds.service";
import { ThemeService } from "../common/services/theme.service";
import { GlobalScrollService } from "./ux/global-scroll.service";
export const MINDS_PROVIDERS : any[] = [
{
......@@ -48,8 +49,13 @@ export const MINDS_PROVIDERS : any[] = [
useFactory: ScrollService._,
deps: []
},
{
provide: SocketsService,
{
provide: GlobalScrollService,
useFactory: GlobalScrollService._,
deps: []
},
{
provide: SocketsService,
useFactory: SocketsService._,
deps: [ Session, NgZone ]
},
......