diff --git a/src/app/community/community.component.ts b/src/app/community/community.component.ts index b18b14112b..fddb6148e0 100644 --- a/src/app/community/community.component.ts +++ b/src/app/community/community.component.ts @@ -3,7 +3,7 @@ import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; import { UntypedFormBuilder } from '@angular/forms'; import { Subject, forkJoin, iif, of, throwError } from 'rxjs'; -import { takeUntil, finalize, switchMap, map, catchError, tap, debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { takeUntil, finalize, switchMap, map, catchError, tap, debounceTime, distinctUntilChanged, take } from 'rxjs/operators'; import { StateService } from '../shared/state.service'; import { NewsService } from '../news/news.service'; import { DialogsFormService } from '../shared/dialogs/dialogs-form.service'; @@ -69,6 +69,7 @@ export class CommunityComponent implements OnInit, OnDestroy { availableLabels: string[] = []; selectedLabel = ''; pinned = false; + attachmentMap: Record = {}; get leadersTabLabel(): string { return this.configuration.planetType === 'nation' ? $localize`Nation Leaders` : $localize`Community Leaders`; @@ -108,7 +109,6 @@ export class CommunityComponent implements OnInit, OnDestroy { const newsSortValue = (item: any) => item.sharedDate || item.doc.time; this.newsService.newsUpdated$.pipe(takeUntil(this.onDestroy$)).subscribe(news => { this.news = news.sort((a, b) => newsSortValue(b) - newsSortValue(a)); - this.news.forEach(item => item.doc.messageLower = item.doc.message?.toLowerCase() || ''); this.filteredNews = this.news; this.availableLabels = this.getAvailableLabels(this.news); this.isLoading = false; @@ -291,9 +291,13 @@ export class CommunityComponent implements OnInit, OnDestroy { getLinks(planetCode?) { return this.teamsService.getTeamMembers(this.team || this.teamObject(planetCode), true).pipe(map((docs) => { - const { link: links, transaction: finances, report: reports } = docs.reduce((docObject, doc) => ({ - ...docObject, [doc.docType]: [ ...(docObject[doc.docType] || []), doc ] - }), { link: [], transaction: [] }); + const { link: links, transaction: finances, report: reports } = docs.reduce((docObject, doc) => { + if (!docObject[doc.docType]) { + docObject[doc.docType] = []; + } + docObject[doc.docType].push(doc); + return docObject; + }, { link: [], transaction: [], report: [] }); return { links, finances, reports }; })); } @@ -312,21 +316,40 @@ export class CommunityComponent implements OnInit, OnDestroy { setCouncillors(users) { const planetCode = this.planetCode ? this.planetCode : this.stateService.configuration.code; - this.couchService.findAll('attachments').subscribe((attachments: any[]) => { - this.councillors = users - .filter(user => planetCode === user.doc.planetCode && (user.doc.isUserAdmin || user.doc.roles.indexOf('leader')) !== -1) - .map(user => { - const { _id: userId, planetCode: userPlanetCode, name } = user.doc; - const attachmentId = `org.couchdb.user:${name}@${userPlanetCode}`; - const attachmentDoc: any = attachments.find(attachment => attachment._id === attachmentId); - const avatar = attachmentDoc ? - `${environment.couchAddress}/attachments/${attachmentId}/${Object.keys(attachmentDoc._attachments)[0]}` : - (user.imageSrc || 'assets/image.png'); - return { avatar, userDoc: user, userId, name: user.doc.name, userPlanetCode: user.doc.planetCode, ...user }; - }); + const councillorUsers = users + .filter(user => planetCode === user.doc.planetCode && (user.doc.isUserAdmin || user.doc.roles.indexOf('leader')) !== -1); + const attachmentIds = councillorUsers + .map(user => `org.couchdb.user:${user.doc.name}@${user.doc.planetCode}`) + .filter(id => !!id); + + this.fetchMissingAttachments(attachmentIds).pipe(take(1)).subscribe(() => { + this.councillors = councillorUsers.map(user => { + const { _id: userId, planetCode: userPlanetCode, name } = user.doc; + const attachmentId = `org.couchdb.user:${name}@${userPlanetCode}`; + const attachmentDoc: any = this.attachmentMap[attachmentId]; + const avatar = attachmentDoc ? + `${environment.couchAddress}/attachments/${attachmentId}/${Object.keys(attachmentDoc._attachments)[0]}` : + (user.imageSrc || 'assets/image.png'); + return { avatar, userDoc: user, userId, name: user.doc.name, userPlanetCode: user.doc.planetCode, ...user }; + }); }); } + private fetchMissingAttachments(ids: string[]) { + const missing = ids.filter(id => !this.attachmentMap[id]); + if (missing.length === 0) { + return of(undefined); + } + return this.couchService.findAttachmentsByIds(missing).pipe( + tap((attachments: any[]) => { + attachments.forEach(attachment => { + this.attachmentMap[attachment._id] = attachment; + }); + }), + map(() => undefined) + ); + } + openAddLinkDialog() { this.dialog.open(CommunityLinkDialogComponent, { width: '50vw', @@ -498,7 +521,12 @@ export class CommunityComponent implements OnInit, OnDestroy { } if (this.voiceSearch) { const lower = this.voiceSearch.toLowerCase(); - filtered = filtered.filter(item => item.doc.messageLower.includes(lower)); + filtered = filtered.filter(item => { + if (typeof item.doc.messageLower !== 'string') { + item.doc.messageLower = (item.doc.message || '').toLowerCase(); + } + return item.doc.messageLower.includes(lower); + }); } this.filteredNews = filtered; } diff --git a/src/app/news/news-list.component.ts b/src/app/news/news-list.component.ts index 396a1683da..bf41587188 100644 --- a/src/app/news/news-list.component.ts +++ b/src/app/news/news-list.component.ts @@ -59,7 +59,6 @@ export class NewsListComponent implements OnInit, OnChanges, AfterViewInit, OnDe ) {} ngOnInit() { - this.router.events.subscribe(() => { this.initNews(); }); @@ -68,17 +67,22 @@ export class NewsListComponent implements OnInit, OnChanges, AfterViewInit, OnDe } ngOnChanges() { + this.isLoadingMore = false; let isLatest = true; this.replyObject = {}; this.items.forEach(item => { - this.replyObject[item.doc.replyTo || 'root'] = [ ...(this.replyObject[item.doc.replyTo || 'root'] || []), item ]; + const key = item.doc.replyTo || 'root'; + if (!this.replyObject[key]) { + this.replyObject[key] = []; + } + this.replyObject[key].push(item); if (!item.doc.replyTo && isLatest) { item.latestMessage = true; isLatest = false; } }); this.displayedItems = this.replyObject[this.replyViewing._id]; - this.loadPagedItems(true); + this.updateDisplayedItems(true); if (this.replyViewing._id !== 'root') { this.replyViewing = this.items.find(item => item._id === this.replyViewing._id); } @@ -146,7 +150,7 @@ export class NewsListComponent implements OnInit, OnChanges, AfterViewInit, OnDe const news = this.items.find(item => item._id === newsId) || { _id: 'root' }; this.replyViewing = news; this.displayedItems = this.replyObject[news._id]; - this.loadPagedItems(true); + this.updateDisplayedItems(true); this.isMainPostShared = this.replyViewing._id === 'root' || this.newsService.postSharedWithCommunity(this.replyViewing); this.showMainPostShare = !this.replyViewing.doc || !this.replyViewing.doc.replyTo || ( @@ -282,27 +286,38 @@ export class NewsListComponent implements OnInit, OnChanges, AfterViewInit, OnDe }; } - loadPagedItems(initial = true) { - let pageSize = this.pageSize; - if (initial) { + loadMoreItems() { + if (this.isLoadingMore) { + return; + } + this.updateDisplayedItems(); + } + + private updateDisplayedItems(reset = false) { + const news = this.getCurrentItems(); + if (reset) { this.displayedItems = []; this.nextStartIndex = 0; - // Take maximum so if fewer posts than page size adding a post doesn't add a "Load More" button - pageSize = Math.max(this.pageEnd[this.replyViewing._id] || this.pageSize, this.pageSize); } - const news = this.getCurrentItems(); + + const pageSize = reset ? Math.max(this.pageEnd[this.replyViewing._id] || this.pageSize, this.pageSize) : this.pageSize; const { items, endIndex, hasMore } = this.paginateItems(news, this.nextStartIndex, pageSize); - this.displayedItems = [ ...this.displayedItems, ...items ]; + this.displayedItems = reset ? items : [ ...this.displayedItems, ...items ]; this.pageEnd[this.replyViewing._id] = this.displayedItems.length; this.nextStartIndex = endIndex; - this.hasMoreNews = hasMore; - this.isLoadingMore = false; this.totalReplies = news.length; - } - loadMoreItems() { - this.isLoadingMore = true; - this.loadPagedItems(false); + const reachedLocalEnd = endIndex >= news.length; + const shouldFetchRemote = reachedLocalEnd && !hasMore && this.newsService.canLoadMore(); + + if (shouldFetchRemote) { + this.isLoadingMore = true; + this.newsService.loadMoreNews(); + } else { + this.isLoadingMore = false; + } + + this.hasMoreNews = hasMore || shouldFetchRemote || this.newsService.canLoadMore(); } } diff --git a/src/app/news/news.service.ts b/src/app/news/news.service.ts index 3c006d9c37..73c90cc2e3 100644 --- a/src/app/news/news.service.ts +++ b/src/app/news/news.service.ts @@ -1,6 +1,5 @@ import { Injectable } from '@angular/core'; -import { Subject, forkJoin, of } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { of, Subject } from 'rxjs'; import { CouchService } from '../shared/couchdb.service'; import { StateService } from '../shared/state.service'; import { UserService } from '../shared/user.service'; @@ -9,6 +8,7 @@ import { findDocuments } from '../shared/mangoQueries'; import { environment } from '../../environments/environment'; import { dedupeObjectArray } from '../shared/utils'; import { planetAndParentId } from '../manager-dashboard/reports/reports.utils'; +import { map } from 'rxjs/operators'; @Injectable({ providedIn: 'root' @@ -19,6 +19,12 @@ export class NewsService { imgUrlPrefix = environment.couchAddress; newsUpdated$ = new Subject(); currentOptions: { selectors: any, viewId: string } = { selectors: {}, viewId: '' }; + private currentBookmark: string | undefined; + private hasMoreResults = true; + private isLoading = false; + private pageSize = 50; + private accumulatedNews: any[] = []; + private avatarCache: Record = {}; constructor( private couchService: CouchService, @@ -29,19 +35,22 @@ export class NewsService { requestNews({ selectors, viewId } = this.currentOptions) { this.currentOptions = { selectors, viewId }; - forkJoin([ - this.couchService.findAll(this.dbName, findDocuments(selectors, 0, [ { 'time': 'desc' } ])), - this.couchService.findAll('attachments') - ]).subscribe(([ newsItems, avatars ]) => { - this.newsUpdated$.next(newsItems.map((item: any) => ( - { doc: item, sharedDate: this.findShareDate(item, viewId), avatar: this.findAvatar(item.user, avatars), _id: item._id } - ))); - }); + this.resetPagination(); + this.fetchNewsPage(true); + } + + loadMoreNews() { + if (!this.canLoadMore()) { return; } + this.fetchNewsPage(false); + } + + canLoadMore(): boolean { + return this.hasMoreResults && !this.isLoading; } - findAvatar(user: any, attachments: any[]) { + findAvatar(user: any, attachments: Record) { const attachmentId = `${user._id}@${user.planetCode}`; - const attachment = attachments.find(avatar => avatar._id === attachmentId); + const attachment = attachments ? attachments[attachmentId] : undefined; const extractFilename = (object) => Object.keys(object._attachments)[0]; return attachment ? `${this.imgUrlPrefix}/attachments/${attachmentId}/${extractFilename(attachment)}` : @@ -54,6 +63,86 @@ export class NewsService { return ((item.viewIn || []).find(view => view._id === viewId) || {}).sharedDate; } + private collectAttachmentIds(newsItems: any[]): string[] { + const ids = new Set(); + newsItems.forEach((item: any) => { + const user = item?.user; + if (user && user._id && user.planetCode) { + ids.add(`${user._id}@${user.planetCode}`); + } + }); + return Array.from(ids); + } + + private resetPagination() { + this.currentBookmark = undefined; + this.hasMoreResults = true; + this.isLoading = false; + this.accumulatedNews = []; + this.avatarCache = {}; + } + + private fetchNewsPage(reset: boolean) { + const { selectors } = this.currentOptions; + const baseQuery = findDocuments(selectors, 0, [ { 'time': 'desc' } ], this.pageSize); + const query: any = { ...baseQuery }; + if (!reset && this.currentBookmark) { + query.bookmark = this.currentBookmark; + } + this.isLoading = true; + this.couchService.post(`${this.dbName}/_find`, query).subscribe({ + next: (response: any) => { + const docs = response?.docs ?? []; + const bookmark = response?.bookmark; + this.isLoading = false; + this.currentBookmark = bookmark; + if (reset) { + this.accumulatedNews = []; + } + if (docs.length === 0) { + this.hasMoreResults = false; + this.emitNews(); + return; + } + this.hasMoreResults = docs.length === this.pageSize && !!bookmark; + this.accumulatedNews = [ ...this.accumulatedNews, ...docs ]; + this.emitNews(); + this.loadMissingAttachments(docs); + }, + error: () => { + this.isLoading = false; + this.hasMoreResults = false; + } + }); + } + + private loadMissingAttachments(newsItems: any[]) { + const missingIds = this.collectAttachmentIds(newsItems).filter(id => !(id in this.avatarCache)); + if (missingIds.length === 0) { + return; + } + this.couchService.findAttachmentsByIds(missingIds).subscribe((attachments: any[]) => { + if (!attachments || attachments.length === 0) { return; } + attachments.forEach((attachment: any) => { + if (attachment && attachment._id) { + this.avatarCache[attachment._id] = attachment; + } + }); + this.emitNews(); + }); + } + + private emitNews() { + const viewId = this.currentOptions.viewId; + const formatted = this.accumulatedNews.map((item: any) => ({ + doc: item, + sharedDate: this.findShareDate(item, viewId), + avatar: this.findAvatar(item.user, this.avatarCache), + _id: item._id + })); + this.newsUpdated$.next([ ...formatted ]); + } + postNews(post, successMessage = $localize`Thank you for submitting your message`, isMessageEdit = true) { const { configuration } = this.stateService; const message = typeof post.message === 'string' ? post.message : post.message.text; diff --git a/src/app/shared/couchdb.service.ts b/src/app/shared/couchdb.service.ts index 9d53140a3c..7a41bd9bfa 100644 --- a/src/app/shared/couchdb.service.ts +++ b/src/app/shared/couchdb.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@angular/core'; import { HttpHeaders, HttpClient, HttpRequest } from '@angular/common/http'; import { environment } from '../../environments/environment'; -import { Observable, of, empty, throwError } from 'rxjs'; -import { catchError, map, expand, toArray, flatMap, switchMap } from 'rxjs/operators'; +import { Observable, of, empty, throwError, forkJoin } from 'rxjs'; +import { catchError, map, expand, toArray, flatMap, switchMap } from 'rxjs/operators'; import { debug } from '../debug-operator'; import { PlanetMessageService } from './planet-message.service'; import { findDocuments } from './mangoQueries'; @@ -97,15 +97,33 @@ export class CouchService { })); } - findAll(db: string, query: any = { 'selector': { '_id': { '$gt': null } }, 'limit': 1000 }, opts?: any) { - return this.findAllRequest(db, query, opts).pipe(flatMap(({ docs }) => docs), toArray()); - } - - findAllStream(db: string, query: any = { 'selector': { '_id': { '$gt': null } }, 'limit': 1000 }, opts?: any) { - return this.findAllRequest(db, query, opts).pipe(map(({ docs }) => docs)); - } - - private findAllRequest(db: string, query: any, opts: any) { + findAll(db: string, query: any = { 'selector': { '_id': { '$gt': null } }, 'limit': 1000 }, opts?: any) { + return this.findAllRequest(db, query, opts).pipe(flatMap(({ docs }) => docs), toArray()); + } + + findAllStream(db: string, query: any = { 'selector': { '_id': { '$gt': null } }, 'limit': 1000 }, opts?: any) { + return this.findAllRequest(db, query, opts).pipe(map(({ docs }) => docs)); + } + + findAttachmentsByIds(ids: string[], opts?: any) { + const uniqueIds = Array.from(new Set((ids || []).filter(id => !!id))); + if (uniqueIds.length === 0) { + return of([]); + } + const chunkSize = 50; + const queries = []; + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + queries.push( + this.findAll('attachments', findDocuments({ '_id': { '$in': chunk } }, 0, 0, chunk.length), opts) + ); + } + return forkJoin(queries).pipe( + map((results: any[]) => results.reduce((acc: any[], docs: any[]) => acc.concat(docs), [])) + ); + } + + private findAllRequest(db: string, query: any, opts: any) { return this.post(db + '/_find', query, opts).pipe( catchError(() => { return of({ docs: [], rows: [] });