diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/state/models/feeds/notifications.ts | 148 | ||||
-rw-r--r-- | src/state/models/feeds/posts.ts | 47 | ||||
-rw-r--r-- | src/state/models/me.ts | 7 | ||||
-rw-r--r-- | src/view/com/notifications/Feed.tsx | 42 | ||||
-rw-r--r-- | src/view/screens/Home.tsx | 5 | ||||
-rw-r--r-- | src/view/screens/Notifications.tsx | 5 | ||||
-rw-r--r-- | src/view/shell/Drawer.tsx | 12 | ||||
-rw-r--r-- | src/view/shell/bottom-bar/BottomBar.tsx | 6 | ||||
-rw-r--r-- | src/view/shell/desktop/LeftNav.tsx | 8 |
9 files changed, 130 insertions, 150 deletions
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 12db9510d..ff77ab979 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -21,7 +21,7 @@ const MS_2DAY = MS_1HR * 48 let _idCounter = 0 -type CondFn = (notif: ListNotifications.Notification) => boolean +export const MAX_VISIBLE_NOTIFS = 30 export interface GroupedNotification extends ListNotifications.Notification { additional?: ListNotifications.Notification[] @@ -220,6 +220,7 @@ export class NotificationsFeedModel { loadMoreError = '' hasMore = true loadMoreCursor?: string + lastSync?: Date // used to linearize async modifications to state lock = new AwaitLock() @@ -259,6 +260,17 @@ export class NotificationsFeedModel { return this.queuedNotifications && this.queuedNotifications?.length > 0 } + get unreadCountLabel(): string { + const count = this.unreadCount + this.rootStore.invitedUsers.numNotifs + if (count >= MAX_VISIBLE_NOTIFS) { + return `${MAX_VISIBLE_NOTIFS}+` + } + if (count === 0) { + return '' + } + return String(count) + } + // public api // = @@ -288,10 +300,13 @@ export class NotificationsFeedModel { try { this._xLoading(isRefreshing) try { - const res = await this._fetchUntil(notif => notif.isRead, { - breakAt: 'page', + const res = await this.rootStore.agent.listNotifications({ + limit: PAGE_SIZE, }) await this._replaceAll(res) + runInAction(() => { + this.lastSync = new Date() + }) this._setQueued(undefined) this._countUnread() this._xIdle() @@ -313,55 +328,37 @@ export class NotificationsFeedModel { /** * Sync the next set of notifications to show - * returns true if the number changed */ syncQueue = bundleAsync(async () => { this.rootStore.log.debug('NotificationsModel:syncQueue') - await 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() + if (this.unreadCount >= MAX_VISIBLE_NOTIFS) { + return // no need to check } - }) - - /** - * - */ - processQueue = bundleAsync(async () => { - this.rootStore.log.debug('NotificationsModel:processQueue') - if (!this.queuedNotifications) { - return - } - this.isRefreshing = true await this.lock.acquireAsync() try { - runInAction(() => { - this.mostRecentNotificationUri = this.queuedNotifications?.[0].uri - }) - const itemModels = await this._processNotifications( - this.queuedNotifications, - ) - this._setQueued(undefined) - runInAction(() => { - this.notifications = itemModels.concat(this.notifications) + const res = await this.rootStore.agent.listNotifications({ + limit: PAGE_SIZE, }) + + const queue = [] + for (const notif of res.data.notifications) { + if (this.notifications.length) { + if (isEq(notif, this.notifications[0])) { + break + } + } else { + if (!notif.isRead) { + break + } + } + queue.push(notif) + } + + this._setQueued(this._filterNotifications(queue)) + this._countUnread() } catch (e) { - this.rootStore.log.error('NotificationsModel:processQueue failed', {e}) + this.rootStore.log.error('NotificationsModel:syncQueue failed', {e}) } finally { - runInAction(() => { - this.isRefreshing = false - }) this.lock.release() } }) @@ -423,22 +420,23 @@ export class NotificationsFeedModel { /** * Update read/unread state */ - async markAllUnqueuedRead() { + async markAllRead() { try { for (const notif of this.notifications) { notif.markGroupRead() } this._countUnread() - if (this.notifications[0]) { - await this.rootStore.agent.updateSeenNotifications( - this.notifications[0].indexedAt, - ) - } + await this.rootStore.agent.updateSeenNotifications( + this.lastSync ? this.lastSync.toISOString() : undefined, + ) } catch (e: any) { this.rootStore.log.warn('Failed to update notifications read state', e) } } + /** + * Used in background fetch to trigger notifications + */ async getNewMostRecent(): Promise<NotificationsFeedItemModel | undefined> { let old = this.mostRecentNotificationUri const res = await this.rootStore.agent.listNotifications({ @@ -486,40 +484,6 @@ export class NotificationsFeedModel { // helper functions // = - async _fetchUntil( - condFn: CondFn, - {breakAt}: {breakAt: 'page' | 'record'}, - ): Promise<ListNotifications.Response> { - 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 || res.data.notifications.length < PAGE_SIZE) { - return accRes - } - } - return accRes - } - async _replaceAll(res: ListNotifications.Response) { if (res.data.notifications[0]) { this.mostRecentNotificationUri = res.data.notifications[0].uri @@ -540,17 +504,23 @@ export class NotificationsFeedModel { }) } - async _processNotifications( + _filterNotifications( items: ListNotifications.Notification[], - ): Promise<NotificationsFeedItemModel[]> { - const promises = [] - const itemModels: NotificationsFeedItemModel[] = [] - items = items.filter(item => { + ): ListNotifications.Notification[] { + return items.filter(item => { return ( this.rootStore.preferences.getLabelPreference(item.labels).pref !== 'hide' ) }) + } + + async _processNotifications( + items: ListNotifications.Notification[], + ): Promise<NotificationsFeedItemModel[]> { + const promises = [] + const itemModels: NotificationsFeedItemModel[] = [] + items = this._filterNotifications(items) for (const item of groupNotifications(items)) { const itemModel = new NotificationsFeedItemModel( this.rootStore, @@ -581,7 +551,7 @@ export class NotificationsFeedModel { unread += notif.numUnreadInGroup } if (this.queuedNotifications) { - unread += this.queuedNotifications.length + unread += this.queuedNotifications.filter(notif => !notif.isRead).length } this.unreadCount = unread this.rootStore.emitUnreadNotifications(unread) diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index e3328c71a..38faf658a 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -237,7 +237,6 @@ export class PostsFeedModel { // data slices: PostsFeedSliceModel[] = [] - nextSlices: PostsFeedSliceModel[] = [] constructor( public rootStore: RootStoreModel, @@ -309,7 +308,6 @@ export class PostsFeedModel { this.loadMoreCursor = undefined this.pollCursor = undefined this.slices = [] - this.nextSlices = [] this.tuner.reset() } @@ -461,30 +459,27 @@ export class PostsFeedModel { } const res = await this._getFeed({limit: PAGE_SIZE}) const tuner = new FeedTuner() - const nextSlices = tuner.tune(res.data.feed, this.feedTuners) - if (nextSlices[0]?.uri !== this.slices[0]?.uri) { - const nextSlicesModels = nextSlices.map( - slice => - new PostsFeedSliceModel( - this.rootStore, - `item-${_idCounter++}`, - slice, - ), - ) - if (autoPrepend) { + const slices = tuner.tune(res.data.feed, this.feedTuners) + if (slices[0]?.uri !== this.slices[0]?.uri) { + if (!autoPrepend) { + this.setHasNewLatest(true) + } else { + this.setHasNewLatest(false) runInAction(() => { - this.slices = nextSlicesModels.concat( + const slicesModels = slices.map( + slice => + new PostsFeedSliceModel( + this.rootStore, + `item-${_idCounter++}`, + slice, + ), + ) + this.slices = slicesModels.concat( this.slices.filter(slice1 => - nextSlicesModels.find(slice2 => slice1.uri === slice2.uri), + slicesModels.find(slice2 => slice1.uri === slice2.uri), ), ) - this.setHasNewLatest(false) - }) - } else { - runInAction(() => { - this.nextSlices = nextSlicesModels }) - this.setHasNewLatest(true) } } else { this.setHasNewLatest(false) @@ -492,16 +487,6 @@ export class PostsFeedModel { } /** - * Sets the current slices to the "next slices" loaded by checkForLatest - */ - resetToLatest() { - if (this.nextSlices.length) { - this.slices = this.nextSlices - } - this.setHasNewLatest(false) - } - - /** * Removes posts from the feed upon deletion. */ onPostDeleted(uri: string) { diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 3774e1e56..e8b8e1ed0 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -7,6 +7,7 @@ import {MyFollowsCache} from './cache/my-follows' import {isObj, hasProp} from 'lib/type-guards' const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min +const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec export class MeModel { did: string = '' @@ -21,6 +22,7 @@ export class MeModel { follows: MyFollowsCache invites: ComAtprotoServerDefs.InviteCode[] = [] lastProfileStateUpdate = Date.now() + lastNotifsUpdate = Date.now() get invitesAvailable() { return this.invites.filter(isInviteAvailable).length @@ -119,7 +121,10 @@ export class MeModel { await this.fetchProfile() await this.fetchInviteCodes() } - await this.notifications.syncQueue() + if (Date.now() - this.lastNotifsUpdate > NOTIFS_UPDATE_INTERVAL) { + this.lastNotifsUpdate = Date.now() + await this.notifications.syncQueue() + } } async fetchProfile() { diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 23a3166db..33bde1955 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -14,6 +14,7 @@ import {usePalette} from 'lib/hooks/usePalette' const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} +const LOADING_SPINNER = {_reactKey: '__loading_spinner__'} export const Feed = observer(function Feed({ view, @@ -27,28 +28,42 @@ export const Feed = observer(function Feed({ onScroll?: OnScrollCb }) { const pal = usePalette('default') + const [isPTRing, setIsPTRing] = React.useState(false) const data = React.useMemo(() => { - let feedItems + let feedItems: any[] = [] + if (view.isRefreshing && !isPTRing) { + feedItems = [LOADING_SPINNER] + } if (view.hasLoaded) { if (view.isEmpty) { - feedItems = [EMPTY_FEED_ITEM] + feedItems = feedItems.concat([EMPTY_FEED_ITEM]) } else { - feedItems = view.notifications + feedItems = feedItems.concat(view.notifications) } } if (view.loadMoreError) { feedItems = (feedItems || []).concat([LOAD_MORE_ERROR_ITEM]) } return feedItems - }, [view.hasLoaded, view.isEmpty, view.notifications, view.loadMoreError]) + }, [ + view.hasLoaded, + view.isEmpty, + view.notifications, + view.loadMoreError, + view.isRefreshing, + isPTRing, + ]) const onRefresh = React.useCallback(async () => { try { + setIsPTRing(true) await view.refresh() } catch (err) { view.rootStore.log.error('Failed to refresh notifications feed', err) + } finally { + setIsPTRing(false) } - }, [view]) + }, [view, setIsPTRing]) const onEndReached = React.useCallback(async () => { try { @@ -83,6 +98,12 @@ export const Feed = observer(function Feed({ onPress={onPressRetryLoadMore} /> ) + } else if (item === LOADING_SPINNER) { + return ( + <View style={styles.loading}> + <ActivityIndicator size="small" /> + </View> + ) } return <FeedItem item={item} /> }, @@ -104,7 +125,9 @@ export const Feed = observer(function Feed({ return ( <View style={s.hContentRegion}> <CenteredView> - {view.isLoading && !data && <NotificationFeedLoadingPlaceholder />} + {view.isLoading && !data.length && ( + <NotificationFeedLoadingPlaceholder /> + )} {view.hasError && ( <ErrorMessage message={view.error} @@ -112,7 +135,7 @@ export const Feed = observer(function Feed({ /> )} </CenteredView> - {data && ( + {data.length && ( <FlatList ref={scrollElRef} data={data} @@ -121,7 +144,7 @@ export const Feed = observer(function Feed({ ListFooterComponent={FeedFooter} refreshControl={ <RefreshControl - refreshing={view.isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} @@ -138,6 +161,9 @@ export const Feed = observer(function Feed({ }) const styles = StyleSheet.create({ + loading: { + paddingVertical: 20, + }, feedFooter: {paddingTop: 20}, emptyState: {paddingVertical: 40}, }) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 1361fc3f2..ae526dea5 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -20,6 +20,7 @@ import {ComposeIcon2} from 'lib/icons' import {isDesktopWeb} from 'platform/detection' const HEADER_OFFSET = isDesktopWeb ? 50 : 40 +const POLL_FREQ = 30e3 // 30sec type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> export const HomeScreen = withAuthRequired((_opts: Props) => { @@ -150,7 +151,7 @@ const FeedPage = observer( React.useCallback(() => { const softResetSub = store.onScreenSoftReset(onSoftReset) const feedCleanup = feed.registerListeners() - const pollInterval = setInterval(doPoll, 15e3) + const pollInterval = setInterval(doPoll, POLL_FREQ) screen('Feed') store.log.debug('HomeScreen: Updating feed') @@ -176,8 +177,8 @@ const FeedPage = observer( }, [feed]) const onPressLoadLatest = React.useCallback(() => { - feed.resetToLatest() scrollToTop() + feed.refresh() }, [feed, scrollToTop]) return ( diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 76ad81611..3e34a9fab 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -38,8 +38,8 @@ export const NotificationsScreen = withAuthRequired( }, [scrollElRef]) const onPressLoadLatest = React.useCallback(() => { - store.me.notifications.processQueue() scrollToTop() + store.me.notifications.refresh() }, [store, scrollToTop]) // on-visible setup @@ -49,13 +49,12 @@ export const NotificationsScreen = withAuthRequired( store.shell.setMinimalShellMode(false) store.log.debug('NotificationsScreen: Updating feed') const softResetSub = store.onScreenSoftReset(onPressLoadLatest) - store.me.notifications.syncQueue() store.me.notifications.update() screen('Notifications') return () => { softResetSub.remove() - store.me.notifications.markAllUnqueuedRead() + store.me.notifications.markAllRead() } }, [store, screen, onPressLoadLatest]), ) diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 74e10d6a1..7128d4213 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -203,9 +203,7 @@ export const DrawerContent = observer(() => { ) } label="Notifications" - count={ - store.me.notifications.unreadCount + store.invitedUsers.numNotifs - } + count={store.me.notifications.unreadCountLabel} bold={isAtNotifications} onPress={onPressNotifications} /> @@ -291,7 +289,7 @@ function MenuItem({ }: { icon: JSX.Element label: string - count?: number + count?: string bold?: boolean onPress: () => void }) { @@ -307,14 +305,14 @@ function MenuItem({ <View style={[ styles.menuItemCount, - count > 99 + count.length > 2 ? styles.menuItemCountHundreds - : count > 9 + : count.length > 1 ? styles.menuItemCountTens : undefined, ]}> <Text style={styles.menuItemCountLabel} numberOfLines={1}> - {count > 999 ? `${Math.round(count / 1000)}k` : count} + {count} </Text> </View> ) : undefined} diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 4dcaf3eb1..a7d11d81d 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -132,9 +132,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { ) } onPress={onPressNotifications} - notificationCount={ - store.me.notifications.unreadCount + store.invitedUsers.numNotifs - } + notificationCount={store.me.notifications.unreadCountLabel} /> <Btn testID="bottomBarProfileBtn" @@ -170,7 +168,7 @@ function Btn({ }: { testID?: string icon: JSX.Element - notificationCount?: number + notificationCount?: string onPress?: (event: GestureResponderEvent) => void onLongPress?: (event: GestureResponderEvent) => void }) { diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index c5486a8fe..bcff844f1 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -70,7 +70,7 @@ function BackBtn() { } interface NavItemProps { - count?: number + count?: string href: string icon: JSX.Element iconFilled: JSX.Element @@ -95,7 +95,7 @@ const NavItem = observer( <Link href={href} style={styles.navItem}> <View style={[styles.navItemIconWrapper]}> {isCurrent ? iconFilled : icon} - {typeof count === 'number' && count > 0 && ( + {typeof count === 'string' && count && ( <Text type="button" style={styles.navItemCount}> {count} </Text> @@ -162,9 +162,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { /> <NavItem href="/notifications" - count={ - store.me.notifications.unreadCount + store.invitedUsers.numNotifs - } + count={store.me.notifications.unreadCountLabel} icon={<BellIcon strokeWidth={2} size={24} style={pal.text} />} iconFilled={ <BellIconSolid strokeWidth={1.5} size={24} style={pal.text} /> |