diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 4daa3ca8de..455beaab22 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -20,6 +20,8 @@ const MS_2DAY = MS_1HR * 48 let _idCounter = 0 +type CondFn = (notif: ListNotifications.Notification) => boolean + export interface GroupedNotification extends ListNotifications.Notification { additional?: ListNotifications.Notification[] } @@ -83,6 +85,27 @@ export class NotificationsFeedItemModel { } } + get numUnreadInGroup(): number { + if (this.additional?.length) { + return ( + this.additional.reduce( + (acc, notif) => acc + notif.numUnreadInGroup, + 0, + ) + (this.isRead ? 0 : 1) + ) + } + return this.isRead ? 0 : 1 + } + + markGroupRead() { + if (this.additional?.length) { + for (const notif of this.additional) { + notif.markGroupRead() + } + } + this.isRead = true + } + get isLike() { return this.reason === 'like' } @@ -192,7 +215,6 @@ export class NotificationsFeedModel { hasLoaded = false error = '' loadMoreError = '' - params: ListNotifications.QueryParams hasMore = true loadMoreCursor?: string @@ -201,25 +223,21 @@ export class NotificationsFeedModel { // data notifications: NotificationsFeedItemModel[] = [] + queuedNotifications: undefined | ListNotifications.Notification[] = undefined unreadCount = 0 // this is used to help trigger push notifications mostRecentNotificationUri: string | undefined - constructor( - public rootStore: RootStoreModel, - params: ListNotifications.QueryParams, - ) { + constructor(public rootStore: RootStoreModel) { makeAutoObservable( this, { rootStore: false, - params: false, mostRecentNotificationUri: false, }, {autoBind: true}, ) - this.params = params } get hasContent() { @@ -234,6 +252,10 @@ export class NotificationsFeedModel { return this.hasLoaded && !this.hasContent } + get hasNewLatest() { + return this.queuedNotifications && this.queuedNotifications?.length > 0 + } + // public api // = @@ -258,19 +280,17 @@ export class NotificationsFeedModel { * Load for first render */ setup = bundleAsync(async (isRefreshing: boolean = false) => { - this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing}) - if (isRefreshing) { - this.isRefreshing = true // set optimistically for UI - } + this.rootStore.log.debug('NotificationsModel:refresh', {isRefreshing}) await this.lock.acquireAsync() try { this._xLoading(isRefreshing) try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, + const res = await this._fetchUntil(notif => notif.isRead, { + breakAt: 'page', }) - const res = await this.rootStore.agent.listNotifications(params) await this._replaceAll(res) + this._setQueued(undefined) + this._countUnread() this._xIdle() } catch (e: any) { this._xIdle(e) @@ -284,9 +304,59 @@ export class NotificationsFeedModel { * Reset and load */ async refresh() { + this.isRefreshing = true // set optimistically for UI return this.setup(true) } + /** + * Sync the next set of notifications to show + * returns true if the number changed + */ + syncQueue = bundleAsync(async () => { + this.rootStore.log.debug('NotificationsModel:syncQueue') + this.lock.acquireAsync() + try { + const res = await this._fetchUntil( + notif => + this.notifications.length + ? isEq(notif, this.notifications[0]) + : notif.isRead, + {breakAt: 'record'}, + ) + this._setQueued(res.data.notifications) + this._countUnread() + } catch (e) { + this.rootStore.log.error('NotificationsModel:syncQueue failed', {e}) + } finally { + this.lock.release() + } + }) + + /** + * + */ + processQueue = bundleAsync(async () => { + this.rootStore.log.debug('NotificationsModel:processQueue') + if (!this.queuedNotifications) { + return + } + this.lock.acquireAsync() + try { + this.mostRecentNotificationUri = this.queuedNotifications[0].uri + const itemModels = await this._processNotifications( + this.queuedNotifications, + ) + this._setQueued(undefined) + runInAction(() => { + this.notifications = itemModels.concat(this.notifications) + }) + } catch (e) { + this.rootStore.log.error('NotificationsModel:processQueue failed', {e}) + } finally { + this.lock.release() + } + }) + /** * Load more posts to the end of the notifications */ @@ -298,11 +368,10 @@ export class NotificationsFeedModel { try { this._xLoading() try { - const params = Object.assign({}, this.params, { + const res = await this.rootStore.agent.listNotifications({ limit: PAGE_SIZE, cursor: this.loadMoreCursor, }) - const res = await this.rootStore.agent.listNotifications(params) await this._appendAll(res) this._xIdle() } catch (e: any) { @@ -325,101 +394,37 @@ export class NotificationsFeedModel { return this.loadMore() } - /** - * Load more posts at the start of the notifications - */ - loadLatest = bundleAsync(async () => { - if (this.notifications.length === 0 || this.unreadCount > PAGE_SIZE) { - return this.refresh() - } - this.lock.acquireAsync() - try { - this._xLoading() - try { - const res = await this.rootStore.agent.listNotifications({ - limit: PAGE_SIZE, - }) - await this._prependAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('NotificationsView: Failed to load latest', { - params: this.params, - e, - }) - } - } finally { - this.lock.release() - } - }) - - /** - * Update content in-place - */ - update = bundleAsync(async () => { - await this.lock.acquireAsync() - try { - if (!this.notifications.length) { - return - } - this._xLoading() - let numToFetch = this.notifications.length - let cursor - try { - do { - const res: ListNotifications.Response = - await this.rootStore.agent.listNotifications({ - cursor, - limit: Math.min(numToFetch, 100), - }) - if (res.data.notifications.length === 0) { - break // sanity check - } - this._updateAll(res) - numToFetch -= res.data.notifications.length - cursor = res.data.cursor - } while (cursor && numToFetch > 0) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('NotificationsView: Failed to update', { - params: this.params, - e, - }) - } - } finally { - this.lock.release() - } - }) - - // unread notification apis + // unread notification in-place // = - - /** - * Get the current number of unread notifications - * returns true if the number changed - */ - loadUnreadCount = bundleAsync(async () => { - const old = this.unreadCount - const res = await this.rootStore.agent.countUnreadNotifications() - runInAction(() => { - this.unreadCount = res.data.count + async update() { + const promises = [] + for (const item of this.notifications) { + if (item.additionalPost) { + promises.push(item.additionalPost.update()) + } + } + await Promise.all(promises).catch(e => { + this.rootStore.log.error( + 'Uncaught failure during notifications update()', + e, + ) }) - this.rootStore.emitUnreadNotifications(this.unreadCount) - return this.unreadCount !== old - }) + } /** * Update read/unread state */ - async markAllRead() { + async markAllUnqueuedRead() { try { - this.unreadCount = 0 - this.rootStore.emitUnreadNotifications(0) for (const notif of this.notifications) { - notif.isRead = true + notif.markGroupRead() + } + this._countUnread() + if (this.notifications[0]) { + await this.rootStore.agent.updateSeenNotifications( + this.notifications[0].indexedAt, + ) } - await this.rootStore.agent.updateSeenNotifications() } catch (e: any) { this.rootStore.log.warn('Failed to update notifications read state', e) } @@ -472,6 +477,40 @@ export class NotificationsFeedModel { // helper functions // = + async _fetchUntil( + condFn: CondFn, + {breakAt}: {breakAt: 'page' | 'record'}, + ): Promise { + const accRes: ListNotifications.Response = { + success: true, + headers: {}, + data: {cursor: undefined, notifications: []}, + } + for (let i = 0; i <= 10; i++) { + const res = await this.rootStore.agent.listNotifications({ + limit: PAGE_SIZE, + cursor: accRes.data.cursor, + }) + accRes.data.cursor = res.data.cursor + + let pageIsDone = false + for (const notif of res.data.notifications) { + if (condFn(notif)) { + if (breakAt === 'record') { + return accRes + } else { + pageIsDone = true + } + } + accRes.data.notifications.push(notif) + } + if (pageIsDone) { + return accRes + } + } + return accRes + } + async _replaceAll(res: ListNotifications.Response) { if (res.data.notifications[0]) { this.mostRecentNotificationUri = res.data.notifications[0].uri @@ -482,25 +521,7 @@ export class NotificationsFeedModel { async _appendAll(res: ListNotifications.Response, replace = false) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor - const promises = [] - const itemModels: NotificationsFeedItemModel[] = [] - for (const item of groupNotifications(res.data.notifications)) { - const itemModel = new NotificationsFeedItemModel( - this.rootStore, - `item-${_idCounter++}`, - item, - ) - if (itemModel.needsAdditionalData) { - promises.push(itemModel.fetchAdditionalData()) - } - itemModels.push(itemModel) - } - await Promise.all(promises).catch(e => { - this.rootStore.log.error( - 'Uncaught failure during notifications-view _appendAll()', - e, - ) - }) + const itemModels = await this._processNotifications(res.data.notifications) runInAction(() => { if (replace) { this.notifications = itemModels @@ -510,16 +531,12 @@ export class NotificationsFeedModel { }) } - async _prependAll(res: ListNotifications.Response) { + async _processNotifications( + items: ListNotifications.Notification[], + ): Promise { const promises = [] const itemModels: NotificationsFeedItemModel[] = [] - const dedupedNotifs = res.data.notifications.filter( - n1 => - !this.notifications.find( - n2 => isEq(n1, n2) || n2.additional?.find(n3 => isEq(n1, n3)), - ), - ) - for (const item of groupNotifications(dedupedNotifs)) { + for (const item of groupNotifications(items)) { const itemModel = new NotificationsFeedItemModel( this.rootStore, `item-${_idCounter++}`, @@ -532,22 +549,27 @@ export class NotificationsFeedModel { } await Promise.all(promises).catch(e => { this.rootStore.log.error( - 'Uncaught failure during notifications-view _prependAll()', + 'Uncaught failure during notifications _processNotifications()', e, ) }) - runInAction(() => { - this.notifications = itemModels.concat(this.notifications) - }) + return itemModels } - _updateAll(res: ListNotifications.Response) { - for (const item of res.data.notifications) { - const existingItem = this.notifications.find(item2 => isEq(item, item2)) - if (existingItem) { - existingItem.copy(item, true) - } + _setQueued(queued: undefined | ListNotifications.Notification[]) { + this.queuedNotifications = queued + } + + _countUnread() { + let unread = 0 + for (const notif of this.notifications) { + unread += notif.numUnreadInGroup } + if (this.queuedNotifications) { + unread += this.queuedNotifications.length + } + this.unreadCount = unread + this.rootStore.emitUnreadNotifications(unread) } } diff --git a/src/state/models/me.ts b/src/state/models/me.ts index a0591aeca3..3774e1e567 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -119,7 +119,7 @@ export class MeModel { await this.fetchProfile() await this.fetchInviteCodes() } - await this.notifications.loadUnreadCount() + await this.notifications.syncQueue() } async fetchProfile() { diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 2196b34691..23a3166db3 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -45,7 +45,6 @@ export const Feed = observer(function Feed({ const onRefresh = React.useCallback(async () => { try { await view.refresh() - await view.markAllRead() } catch (err) { view.rootStore.log.error('Failed to refresh notifications feed', err) } diff --git a/src/view/com/util/LoadLatestBtn.tsx b/src/view/com/util/LoadLatestBtn.tsx index fd05ecc9cc..88b6dffd9c 100644 --- a/src/view/com/util/LoadLatestBtn.tsx +++ b/src/view/com/util/LoadLatestBtn.tsx @@ -10,31 +10,33 @@ import {useStores} from 'state/index' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} -export const LoadLatestBtn = observer(({onPress}: {onPress: () => void}) => { - const store = useStores() - const safeAreaInsets = useSafeAreaInsets() - return ( - - - - Load new posts - - - - ) -}) +export const LoadLatestBtn = observer( + ({onPress, label}: {onPress: () => void; label: string}) => { + const store = useStores() + const safeAreaInsets = useSafeAreaInsets() + return ( + + + + Load new {label} + + + + ) + }, +) const styles = StyleSheet.create({ loadLatest: { diff --git a/src/view/com/util/LoadLatestBtn.web.tsx b/src/view/com/util/LoadLatestBtn.web.tsx index ba33f92a78..f552dfeb77 100644 --- a/src/view/com/util/LoadLatestBtn.web.tsx +++ b/src/view/com/util/LoadLatestBtn.web.tsx @@ -6,7 +6,13 @@ import {UpIcon} from 'lib/icons' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} -export const LoadLatestBtn = ({onPress}: {onPress: () => void}) => { +export const LoadLatestBtn = ({ + onPress, + label, +}: { + onPress: () => void + label: string +}) => { const pal = usePalette('default') return ( void}) => { hitSlop={HITSLOP}> - Load new posts + Load new {label} ) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 260df04016..fac522c68a 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -194,7 +194,7 @@ const FeedPage = observer( headerOffset={HEADER_OFFSET} /> {feed.hasNewLatest && !feed.isRefreshing && ( - + )} (null) const {screen} = useAnalytics() - const {appState} = useAppState({ - onForeground: () => doPoll(true), - }) // event handlers // = - const onPressTryAgain = () => { + const onPressTryAgain = React.useCallback(() => { store.me.notifications.refresh() - } + }, [store]) + const scrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: 0}) }, [scrollElRef]) - // periodic polling - // = - const doPoll = React.useCallback( - async (isForegrounding = false) => { - if (isForegrounding) { - // app is foregrounding, refresh optimistically - store.log.debug('NotificationsScreen: Refreshing on app foreground') - await Promise.all([ - store.me.notifications.loadUnreadCount(), - store.me.notifications.refresh(), - ]) - } else if (appState === 'active') { - // periodic poll, refresh if there are new notifs - store.log.debug('NotificationsScreen: Polling for new notifications') - const didChange = await store.me.notifications.loadUnreadCount() - if (didChange) { - store.log.debug('NotificationsScreen: Loading new notifications') - await store.me.notifications.loadLatest() - } - } - }, - [appState, store], - ) - useEffect(() => { - const pollInterval = setInterval(doPoll, NOTIFICATIONS_POLL_INTERVAL) - return () => clearInterval(pollInterval) - }, [doPoll]) + const onPressLoadLatest = React.useCallback(() => { + store.me.notifications.processQueue() + scrollToTop() + }, [store, scrollToTop]) // on-visible setup // = @@ -75,16 +48,16 @@ export const NotificationsScreen = withAuthRequired( React.useCallback(() => { store.shell.setMinimalShellMode(false) store.log.debug('NotificationsScreen: Updating feed') - const softResetSub = store.onScreenSoftReset(scrollToTop) - store.me.notifications.loadUnreadCount() - store.me.notifications.loadLatest() + const softResetSub = store.onScreenSoftReset(onPressLoadLatest) + store.me.notifications.syncQueue() + store.me.notifications.update() screen('Notifications') return () => { softResetSub.remove() - store.me.notifications.markAllRead() + store.me.notifications.markAllUnqueuedRead() } - }, [store, screen, scrollToTop]), + }, [store, screen, onPressLoadLatest]), ) return ( @@ -97,6 +70,11 @@ export const NotificationsScreen = withAuthRequired( onScroll={onMainScroll} scrollElRef={scrollElRef} /> + + {store.me.notifications.hasNewLatest && + !store.me.notifications.isRefreshing && ( + + )} ) }),