From 3337c2c810268ac95957129345b580ed16bf2add Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sun, 12 Nov 2023 13:38:33 -0800 Subject: [PATCH 1/9] Move broadcast channel to lib --- src/{state/persisted => lib}/broadcast/index.ts | 5 +++++ src/{state/persisted => lib}/broadcast/index.web.ts | 0 src/state/persisted/index.ts | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) rename src/{state/persisted => lib}/broadcast/index.ts (50%) rename src/{state/persisted => lib}/broadcast/index.web.ts (100%) diff --git a/src/state/persisted/broadcast/index.ts b/src/lib/broadcast/index.ts similarity index 50% rename from src/state/persisted/broadcast/index.ts rename to src/lib/broadcast/index.ts index e0e7f724b7..aa3aef5801 100644 --- a/src/state/persisted/broadcast/index.ts +++ b/src/lib/broadcast/index.ts @@ -3,4 +3,9 @@ export default class BroadcastChannel { postMessage(_data: any) {} close() {} onmessage: (event: MessageEvent) => void = () => {} + addEventListener(_type: string, _listener: (event: MessageEvent) => void) {} + removeEventListener( + _type: string, + _listener: (event: MessageEvent) => void, + ) {} } diff --git a/src/state/persisted/broadcast/index.web.ts b/src/lib/broadcast/index.web.ts similarity index 100% rename from src/state/persisted/broadcast/index.web.ts rename to src/lib/broadcast/index.web.ts diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index a5c38513f3..f6eff42579 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -3,7 +3,7 @@ import {logger} from '#/logger' import {defaults, Schema} from '#/state/persisted/schema' import {migrate} from '#/state/persisted/legacy' import * as store from '#/state/persisted/store' -import BroadcastChannel from '#/state/persisted/broadcast' +import BroadcastChannel from '#/lib/broadcast' export type {Schema, PersistedAccount} from '#/state/persisted/schema' export {defaults} from '#/state/persisted/schema' From 3f4eeec9aeca04fe152a03e0fd435a66b1b45aec Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sun, 12 Nov 2023 13:40:00 -0800 Subject: [PATCH 2/9] Refactor view/com/post/Post and remove temporary 2 components --- src/view/com/post-thread/PostThreadItem.tsx | 4 +- src/view/com/post/Post.tsx | 272 ++++++------------- src/view/com/util/forms/PostDropdownBtn.tsx | 98 +++++-- src/view/com/util/forms/PostDropdownBtn2.tsx | 210 -------------- src/view/com/util/post-ctrls/PostCtrls.tsx | 186 +++++++------ src/view/com/util/post-ctrls/PostCtrls2.tsx | 200 -------------- 6 files changed, 252 insertions(+), 718 deletions(-) delete mode 100644 src/view/com/util/forms/PostDropdownBtn2.tsx delete mode 100644 src/view/com/util/post-ctrls/PostCtrls2.tsx diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 0535cab53c..88889fd186 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -23,8 +23,8 @@ import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' import {useStores} from 'state/index' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/post-embeds' -import {PostCtrls} from '../util/post-ctrls/PostCtrls2' -import {PostDropdownBtn} from '../util/forms/PostDropdownBtn2' +import {PostCtrls} from '../util/post-ctrls/PostCtrls' +import {PostDropdownBtn} from '../util/forms/PostDropdownBtn' import {PostHider} from '../util/moderation/PostHider' import {ContentHider} from '../util/moderation/ContentHider' import {PostAlerts} from '../util/moderation/PostAlerts' diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 667584f68e..4a5b8041eb 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -1,19 +1,14 @@ -import React, {useState} from 'react' +import React, {useState, useMemo} from 'react' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import { - ActivityIndicator, - Linking, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native' -import {AppBskyFeedPost as FeedPost} from '@atproto/api' -import {observer} from 'mobx-react-lite' -import Clipboard from '@react-native-clipboard/clipboard' -import {AtUri} from '@atproto/api' + AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + moderatePost, + PostModeration, + RichText as RichTextAPI, +} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {PostThreadModel} from 'state/models/content/post-thread' -import {PostThreadItemModel} from 'state/models/content/post-thread-item' import {Link, TextLink} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' @@ -23,174 +18,111 @@ import {ContentHider} from '../util/moderation/ContentHider' import {PostAlerts} from '../util/moderation/PostAlerts' import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' -import * as Toast from '../util/Toast' import {PreviewableUserAvatar} from '../util/UserAvatar' import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {getTranslatorLink} from '../../../locale/helpers' import {makeProfileLink} from 'lib/routes/links' import {MAX_POST_LINES} from 'lib/constants' import {countLines} from 'lib/strings/helpers' -import {logger} from '#/logger' -import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' -import {useLanguagePrefs} from '#/state/preferences' +import {useModerationOpts} from '#/state/queries/preferences' +import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' -export const Post = observer(function PostImpl({ - view, +export function Post({ + post, + dataUpdatedAt, showReplyLine, - hideError, style, }: { - view: PostThreadModel + post: AppBskyFeedDefs.PostView + dataUpdatedAt: number showReplyLine?: boolean - hideError?: boolean style?: StyleProp }) { - const pal = usePalette('default') - const [deleted, setDeleted] = useState(false) - - // deleted - // = - if (deleted) { - return - } - - // loading - // = - if (!view.hasContent && view.isLoading) { - return ( - - - - ) + const moderationOpts = useModerationOpts() + const record = useMemo( + () => + AppBskyFeedPost.isRecord(post.record) && + AppBskyFeedPost.validateRecord(post.record).success + ? post.record + : undefined, + [post], + ) + const postShadowed = usePostShadow(post, dataUpdatedAt) + const richText = useMemo( + () => + record + ? new RichTextAPI({ + text: record.text, + facets: record.facets, + }) + : undefined, + [record], + ) + const moderation = useMemo( + () => (moderationOpts ? moderatePost(post, moderationOpts) : undefined), + [moderationOpts, post], + ) + if (postShadowed === POST_TOMBSTONE) { + return null } - - // error - // = - if (view.hasError || !view.thread || !view.thread?.postRecord) { - if (hideError) { - return - } + if (record && richText && moderation) { return ( - - {view.error || 'Thread not found'} - + ) } + return null +} - // loaded - // = - - return ( - - ) -}) - -const PostLoaded = observer(function PostLoadedImpl({ - item, +function PostInner({ + post, record, - setDeleted, + richText, + moderation, showReplyLine, style, }: { - item: PostThreadItemModel - record: FeedPost.Record - setDeleted: (v: boolean) => void + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record + richText: RichTextAPI + moderation: PostModeration showReplyLine?: boolean style?: StyleProp }) { const pal = usePalette('default') const store = useStores() - const mutedThreads = useMutedThreads() - const toggleThreadMute = useToggleThreadMute() - const langPrefs = useLanguagePrefs() - const [limitLines, setLimitLines] = React.useState( - countLines(item.richText?.text) >= MAX_POST_LINES, + const [limitLines, setLimitLines] = useState( + countLines(richText?.text) >= MAX_POST_LINES, ) - const itemUri = item.post.uri - const itemCid = item.post.cid - const itemUrip = new AtUri(item.post.uri) - const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey) - const itemTitle = `Post by ${item.post.author.handle}` + const itemUrip = new AtUri(post.uri) + const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey) let replyAuthorDid = '' if (record.reply) { const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) replyAuthorDid = urip.hostname } - const translatorUrl = getTranslatorLink( - record?.text || '', - langPrefs.primaryLanguage, - ) - const onPressReply = React.useCallback(() => { store.shell.openComposer({ replyTo: { - uri: item.post.uri, - cid: item.post.cid, - text: record.text as string, + uri: post.uri, + cid: post.cid, + text: record.text, author: { - handle: item.post.author.handle, - displayName: item.post.author.displayName, - avatar: item.post.author.avatar, + handle: post.author.handle, + displayName: post.author.displayName, + avatar: post.author.avatar, }, }, }) - }, [store, item, record]) - - const onPressToggleRepost = React.useCallback(() => { - return item - .toggleRepost() - .catch(e => logger.error('Failed to toggle repost', {error: e})) - }, [item]) - - const onPressToggleLike = React.useCallback(() => { - return item - .toggleLike() - .catch(e => logger.error('Failed to toggle like', {error: e})) - }, [item]) - - const onCopyPostText = React.useCallback(() => { - Clipboard.setString(record.text) - Toast.show('Copied to clipboard') - }, [record]) - - const onOpenTranslate = React.useCallback(() => { - Linking.openURL(translatorUrl) - }, [translatorUrl]) - - const onToggleThreadMute = React.useCallback(() => { - try { - const muted = toggleThreadMute(item.data.rootUri) - if (muted) { - Toast.show('You will no longer receive notifications for this thread') - } else { - Toast.show('You will now receive notifications for this thread') - } - } catch (e) { - logger.error('Failed to toggle thread mute', {error: e}) - } - }, [item, toggleThreadMute]) - - const onDeletePost = React.useCallback(() => { - item.delete().then( - () => { - setDeleted(true) - Toast.show('Post deleted') - }, - e => { - logger.error('Failed to delete post', {error: e}) - Toast.show('Failed to delete post, please try again') - }, - ) - }, [item, setDeleted]) + }, [store, post, record]) const onPressShowMore = React.useCallback(() => { setLimitLines(false) @@ -203,17 +135,17 @@ const PostLoaded = observer(function PostLoadedImpl({ {replyAuthorDid !== '' && ( @@ -239,19 +171,16 @@ const PostLoaded = observer(function PostLoadedImpl({ )} - - {item.richText?.text ? ( + + {richText.text ? ( ) : undefined} - {item.post.embed ? ( + {post.embed ? ( - + ) : null} - + ) -}) +} const styles = StyleSheet.create({ outer: { diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index a10841450d..c457e0a461 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,6 +1,8 @@ import React from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' +import {Linking, StyleProp, View, ViewStyle} from 'react-native' +import Clipboard from '@react-native-clipboard/clipboard' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api' import {toShareUrl} from 'lib/strings/url-helpers' import {useTheme} from 'lib/ThemeContext' import {shareUrl} from 'lib/sharing' @@ -8,41 +10,83 @@ import { NativeDropdown, DropdownItem as NativeDropdownItem, } from './NativeDropdown' +import * as Toast from '../Toast' import {EventStopper} from '../EventStopper' -import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' import {useModalControls} from '#/state/modals' +import {makeProfileLink} from '#/lib/routes/links' +import {getTranslatorLink} from '#/locale/helpers' +import {useStores} from '#/state' +import {usePostDeleteMutation} from '#/state/queries/post' +import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' +import {useLanguagePrefs} from '#/state/preferences' +import {logger} from '#/logger' export function PostDropdownBtn({ testID, - itemUri, - itemCid, - itemHref, - isAuthor, - isThreadMuted, - onCopyPostText, - onOpenTranslate, - onToggleThreadMute, - onDeletePost, + post, + record, style, }: { testID: string - itemUri: string - itemCid: string - itemHref: string - itemTitle: string - isAuthor: boolean - isThreadMuted: boolean - onCopyPostText: () => void - onOpenTranslate: () => void - onToggleThreadMute: () => void - onDeletePost: () => void + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record style?: StyleProp }) { + const store = useStores() const theme = useTheme() - const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl const {openModal} = useModalControls() + const langPrefs = useLanguagePrefs() + const mutedThreads = useMutedThreads() + const toggleThreadMute = useToggleThreadMute() + const postDeleteMutation = usePostDeleteMutation() + + const rootUri = record.reply?.root?.uri || post.uri + const isThreadMuted = mutedThreads.includes(rootUri) + const isAuthor = post.author.did === store.me.did + const href = React.useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey) + }, [post.uri, post.author]) + + const translatorUrl = getTranslatorLink( + record.text, + langPrefs.primaryLanguage, + ) + + const onDeletePost = React.useCallback(() => { + postDeleteMutation.mutateAsync({uri: post.uri}).then( + () => { + Toast.show('Post deleted') + }, + e => { + logger.error('Failed to delete post', {error: e}) + Toast.show('Failed to delete post, please try again') + }, + ) + }, [post, postDeleteMutation]) + + const onToggleThreadMute = React.useCallback(() => { + try { + const muted = toggleThreadMute(rootUri) + if (muted) { + Toast.show('You will no longer receive notifications for this thread') + } else { + Toast.show('You will now receive notifications for this thread') + } + } catch (e) { + logger.error('Failed to toggle thread mute', {error: e}) + } + }, [rootUri, toggleThreadMute]) + + const onCopyPostText = React.useCallback(() => { + Clipboard.setString(record?.text || '') + Toast.show('Copied to clipboard') + }, [record]) + + const onOpenTranslate = React.useCallback(() => { + Linking.openURL(translatorUrl) + }, [translatorUrl]) const dropdownItems: NativeDropdownItem[] = [ { @@ -76,7 +120,7 @@ export function PostDropdownBtn({ { label: 'Share', onPress() { - const url = toShareUrl(itemHref) + const url = toShareUrl(href) shareUrl(url) }, testID: 'postDropdownShareBtn', @@ -113,8 +157,8 @@ export function PostDropdownBtn({ onPress() { openModal({ name: 'report', - uri: itemUri, - cid: itemCid, + uri: post.uri, + cid: post.cid, }) }, testID: 'postDropdownReportBtn', @@ -155,7 +199,7 @@ export function PostDropdownBtn({ diff --git a/src/view/com/util/forms/PostDropdownBtn2.tsx b/src/view/com/util/forms/PostDropdownBtn2.tsx deleted file mode 100644 index c457e0a461..0000000000 --- a/src/view/com/util/forms/PostDropdownBtn2.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import React from 'react' -import {Linking, StyleProp, View, ViewStyle} from 'react-native' -import Clipboard from '@react-native-clipboard/clipboard' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api' -import {toShareUrl} from 'lib/strings/url-helpers' -import {useTheme} from 'lib/ThemeContext' -import {shareUrl} from 'lib/sharing' -import { - NativeDropdown, - DropdownItem as NativeDropdownItem, -} from './NativeDropdown' -import * as Toast from '../Toast' -import {EventStopper} from '../EventStopper' -import {useModalControls} from '#/state/modals' -import {makeProfileLink} from '#/lib/routes/links' -import {getTranslatorLink} from '#/locale/helpers' -import {useStores} from '#/state' -import {usePostDeleteMutation} from '#/state/queries/post' -import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' -import {useLanguagePrefs} from '#/state/preferences' -import {logger} from '#/logger' - -export function PostDropdownBtn({ - testID, - post, - record, - style, -}: { - testID: string - post: AppBskyFeedDefs.PostView - record: AppBskyFeedPost.Record - style?: StyleProp -}) { - const store = useStores() - const theme = useTheme() - const defaultCtrlColor = theme.palette.default.postCtrl - const {openModal} = useModalControls() - const langPrefs = useLanguagePrefs() - const mutedThreads = useMutedThreads() - const toggleThreadMute = useToggleThreadMute() - const postDeleteMutation = usePostDeleteMutation() - - const rootUri = record.reply?.root?.uri || post.uri - const isThreadMuted = mutedThreads.includes(rootUri) - const isAuthor = post.author.did === store.me.did - const href = React.useMemo(() => { - const urip = new AtUri(post.uri) - return makeProfileLink(post.author, 'post', urip.rkey) - }, [post.uri, post.author]) - - const translatorUrl = getTranslatorLink( - record.text, - langPrefs.primaryLanguage, - ) - - const onDeletePost = React.useCallback(() => { - postDeleteMutation.mutateAsync({uri: post.uri}).then( - () => { - Toast.show('Post deleted') - }, - e => { - logger.error('Failed to delete post', {error: e}) - Toast.show('Failed to delete post, please try again') - }, - ) - }, [post, postDeleteMutation]) - - const onToggleThreadMute = React.useCallback(() => { - try { - const muted = toggleThreadMute(rootUri) - if (muted) { - Toast.show('You will no longer receive notifications for this thread') - } else { - Toast.show('You will now receive notifications for this thread') - } - } catch (e) { - logger.error('Failed to toggle thread mute', {error: e}) - } - }, [rootUri, toggleThreadMute]) - - const onCopyPostText = React.useCallback(() => { - Clipboard.setString(record?.text || '') - Toast.show('Copied to clipboard') - }, [record]) - - const onOpenTranslate = React.useCallback(() => { - Linking.openURL(translatorUrl) - }, [translatorUrl]) - - const dropdownItems: NativeDropdownItem[] = [ - { - label: 'Translate', - onPress() { - onOpenTranslate() - }, - testID: 'postDropdownTranslateBtn', - icon: { - ios: { - name: 'character.book.closed', - }, - android: 'ic_menu_sort_alphabetically', - web: 'language', - }, - }, - { - label: 'Copy post text', - onPress() { - onCopyPostText() - }, - testID: 'postDropdownCopyTextBtn', - icon: { - ios: { - name: 'doc.on.doc', - }, - android: 'ic_menu_edit', - web: ['far', 'paste'], - }, - }, - { - label: 'Share', - onPress() { - const url = toShareUrl(href) - shareUrl(url) - }, - testID: 'postDropdownShareBtn', - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: 'ic_menu_share', - web: 'share', - }, - }, - { - label: 'separator', - }, - { - label: isThreadMuted ? 'Unmute thread' : 'Mute thread', - onPress() { - onToggleThreadMute() - }, - testID: 'postDropdownMuteThreadBtn', - icon: { - ios: { - name: 'speaker.slash', - }, - android: 'ic_lock_silent_mode', - web: 'comment-slash', - }, - }, - { - label: 'separator', - }, - !isAuthor && { - label: 'Report post', - onPress() { - openModal({ - name: 'report', - uri: post.uri, - cid: post.cid, - }) - }, - testID: 'postDropdownReportBtn', - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', - }, - }, - isAuthor && { - label: 'separator', - }, - isAuthor && { - label: 'Delete post', - onPress() { - openModal({ - name: 'confirm', - title: 'Delete this post?', - message: 'Are you sure? This can not be undone.', - onPressConfirm: onDeletePost, - }) - }, - testID: 'postDropdownDeleteBtn', - icon: { - ios: { - name: 'trash', - }, - android: 'ic_menu_delete', - web: ['far', 'trash-can'], - }, - }, - ].filter(Boolean) as NativeDropdownItem[] - - return ( - - - - - - - - ) -} diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 7bcea0e790..a764ed525f 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -6,6 +6,7 @@ import { View, ViewStyle, } from 'react-native' +import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' import {Text} from '../text/Text' import {PostDropdownBtn} from '../forms/PostDropdownBtn' import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' @@ -17,160 +18,155 @@ import {RepostButton} from './RepostButton' import {Haptics} from 'lib/haptics' import {HITSLOP_10, HITSLOP_20} from 'lib/constants' import {useModalControls} from '#/state/modals' +import { + usePostLikeMutation, + usePostUnlikeMutation, + usePostRepostMutation, + usePostUnrepostMutation, +} from '#/state/queries/post' -interface PostCtrlsOpts { - itemUri: string - itemCid: string - itemHref: string - itemTitle: string - isAuthor: boolean - author: { - did: string - handle: string - displayName?: string | undefined - avatar?: string | undefined - } - text: string - indexedAt: string +export function PostCtrls({ + big, + post, + record, + style, + onPressReply, +}: { big?: boolean + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record style?: StyleProp - replyCount?: number - repostCount?: number - likeCount?: number - isReposted: boolean - isLiked: boolean - isThreadMuted: boolean onPressReply: () => void - onPressToggleRepost: () => Promise - onPressToggleLike: () => Promise - onCopyPostText: () => void - onOpenTranslate: () => void - onToggleThreadMute: () => void - onDeletePost: () => void -} - -export function PostCtrls(opts: PostCtrlsOpts) { +}) { const store = useStores() const theme = useTheme() const {closeModal} = useModalControls() + const postLikeMutation = usePostLikeMutation() + const postUnlikeMutation = usePostUnlikeMutation() + const postRepostMutation = usePostRepostMutation() + const postUnrepostMutation = usePostUnrepostMutation() + const defaultCtrlColor = React.useMemo( () => ({ color: theme.palette.default.postCtrl, }), [theme], ) as StyleProp + + const onPressToggleLike = React.useCallback(async () => { + if (!post.viewer?.like) { + Haptics.default() + postLikeMutation.mutate({ + uri: post.uri, + cid: post.cid, + likeCount: post.likeCount || 0, + }) + } else { + postUnlikeMutation.mutate({ + postUri: post.uri, + likeUri: post.viewer.like, + likeCount: post.likeCount || 0, + }) + } + }, [post, postLikeMutation, postUnlikeMutation]) + const onRepost = useCallback(() => { closeModal() - if (!opts.isReposted) { + if (!post.viewer?.repost) { Haptics.default() - opts.onPressToggleRepost().catch(_e => undefined) + postRepostMutation.mutate({ + uri: post.uri, + cid: post.cid, + repostCount: post.repostCount || 0, + }) } else { - opts.onPressToggleRepost().catch(_e => undefined) + postUnrepostMutation.mutate({ + postUri: post.uri, + repostUri: post.viewer.repost, + repostCount: post.repostCount || 0, + }) } - }, [opts, closeModal]) + }, [post, closeModal, postRepostMutation, postUnrepostMutation]) const onQuote = useCallback(() => { closeModal() store.shell.openComposer({ quote: { - uri: opts.itemUri, - cid: opts.itemCid, - text: opts.text, - author: opts.author, - indexedAt: opts.indexedAt, + uri: post.uri, + cid: post.cid, + text: record.text, + author: post.author, + indexedAt: post.indexedAt, }, }) Haptics.default() - }, [ - opts.author, - opts.indexedAt, - opts.itemCid, - opts.itemUri, - opts.text, - store.shell, - closeModal, - ]) - - const onPressToggleLikeWrapper = async () => { - if (!opts.isLiked) { - Haptics.default() - await opts.onPressToggleLike().catch(_e => undefined) - } else { - await opts.onPressToggleLike().catch(_e => undefined) - } - } - + }, [post, record, store.shell, closeModal]) return ( - + + hitSlop={big ? HITSLOP_20 : HITSLOP_10}> - {typeof opts.replyCount !== 'undefined' ? ( + {typeof post.replyCount !== 'undefined' ? ( - {opts.replyCount} + {post.replyCount} ) : undefined} - + - {opts.isLiked ? ( - + hitSlop={big ? HITSLOP_20 : HITSLOP_10}> + {post.viewer?.like ? ( + ) : ( )} - {typeof opts.likeCount !== 'undefined' ? ( + {typeof post.likeCount !== 'undefined' ? ( - {opts.likeCount} + {post.likeCount} ) : undefined} - {opts.big ? undefined : ( + {big ? undefined : ( )} diff --git a/src/view/com/util/post-ctrls/PostCtrls2.tsx b/src/view/com/util/post-ctrls/PostCtrls2.tsx deleted file mode 100644 index 7c8ebaee78..0000000000 --- a/src/view/com/util/post-ctrls/PostCtrls2.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import React, {useCallback} from 'react' -import { - StyleProp, - StyleSheet, - TouchableOpacity, - View, - ViewStyle, -} from 'react-native' -import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' -import {Text} from '../text/Text' -import {PostDropdownBtn} from '../forms/PostDropdownBtn2' -import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' -import {s, colors} from 'lib/styles' -import {pluralize} from 'lib/strings/helpers' -import {useTheme} from 'lib/ThemeContext' -import {useStores} from 'state/index' -import {RepostButton} from './RepostButton' -import {Haptics} from 'lib/haptics' -import {HITSLOP_10, HITSLOP_20} from 'lib/constants' -import {useModalControls} from '#/state/modals' -import { - usePostLikeMutation, - usePostUnlikeMutation, - usePostRepostMutation, - usePostUnrepostMutation, -} from '#/state/queries/post' - -export function PostCtrls({ - big, - post, - record, - style, - onPressReply, -}: { - big?: boolean - post: AppBskyFeedDefs.PostView - record: AppBskyFeedPost.Record - style?: StyleProp - onPressReply: () => void -}) { - const store = useStores() - const theme = useTheme() - const {closeModal} = useModalControls() - const postLikeMutation = usePostLikeMutation() - const postUnlikeMutation = usePostUnlikeMutation() - const postRepostMutation = usePostRepostMutation() - const postUnrepostMutation = usePostUnrepostMutation() - - const defaultCtrlColor = React.useMemo( - () => ({ - color: theme.palette.default.postCtrl, - }), - [theme], - ) as StyleProp - - const onPressToggleLike = React.useCallback(async () => { - if (!post.viewer?.like) { - Haptics.default() - postLikeMutation.mutate({ - uri: post.uri, - cid: post.cid, - likeCount: post.likeCount || 0, - }) - } else { - postUnlikeMutation.mutate({ - postUri: post.uri, - likeUri: post.viewer.like, - likeCount: post.likeCount || 0, - }) - } - }, [post, postLikeMutation, postUnlikeMutation]) - - const onRepost = useCallback(() => { - closeModal() - if (!post.viewer?.repost) { - Haptics.default() - postRepostMutation.mutate({ - uri: post.uri, - cid: post.cid, - repostCount: post.repostCount || 0, - }) - } else { - postUnrepostMutation.mutate({ - postUri: post.uri, - repostUri: post.viewer.repost, - repostCount: post.repostCount || 0, - }) - } - }, [post, closeModal, postRepostMutation, postUnrepostMutation]) - - const onQuote = useCallback(() => { - closeModal() - store.shell.openComposer({ - quote: { - uri: post.uri, - cid: post.cid, - text: record.text, - author: post.author, - indexedAt: post.indexedAt, - }, - }) - Haptics.default() - }, [post, record, store.shell, closeModal]) - return ( - - - - {typeof post.replyCount !== 'undefined' ? ( - - {post.replyCount} - - ) : undefined} - - - - {post.viewer?.like ? ( - - ) : ( - - )} - {typeof post.likeCount !== 'undefined' ? ( - - {post.likeCount} - - ) : undefined} - - {big ? undefined : ( - - )} - {/* used for adding pad to the right side */} - - - ) -} - -const styles = StyleSheet.create({ - ctrls: { - flexDirection: 'row', - justifyContent: 'space-between', - }, - ctrl: { - flexDirection: 'row', - alignItems: 'center', - }, - ctrlPad: { - paddingTop: 5, - paddingBottom: 5, - paddingLeft: 5, - paddingRight: 5, - }, - ctrlIconLiked: { - color: colors.like, - }, - mt1: { - marginTop: 1, - }, -}) From 31f5b4dcb7c336a8e48ee118a5c159c91081a5dd Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sun, 12 Nov 2023 13:40:16 -0800 Subject: [PATCH 3/9] Add useModerationOpts hook --- src/state/queries/preferences/index.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index d64bbd9542..da995a17e5 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -1,3 +1,4 @@ +import {useMemo} from 'react' import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' import {LabelPreference, BskyFeedViewPreference} from '@atproto/api' @@ -15,6 +16,7 @@ import { DEFAULT_HOME_FEED_PREFS, DEFAULT_THREAD_VIEW_PREFS, } from '#/state/queries/preferences/const' +import {getModerationOpts} from '#/state/queries/preferences/moderation' export * from '#/state/queries/preferences/types' export * from '#/state/queries/preferences/moderation' @@ -79,6 +81,19 @@ export function usePreferencesQuery() { }) } +export function useModerationOpts() { + const {currentAccount} = useSession() + const prefs = usePreferencesQuery() + return useMemo(() => { + return prefs?.data + ? getModerationOpts({ + userDid: currentAccount?.did || '', + preferences: prefs.data, + }) + : undefined + }, [currentAccount, prefs.data]) +} + export function useClearPreferencesMutation() { const {agent} = useSession() const queryClient = useQueryClient() From 3c603b7ac8ff8145af0663bec0803d956a8bfb74 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sun, 12 Nov 2023 13:41:10 -0800 Subject: [PATCH 4/9] Refactor notifications to use react-query --- src/state/queries/notifications/feed.ts | 190 +++++++++++++++++++++ src/state/queries/notifications/unread.tsx | 50 ++++++ src/view/com/notifications/Feed.tsx | 173 ++++++++++--------- src/view/com/notifications/FeedItem.tsx | 169 +++++++++--------- src/view/com/posts/Feed.tsx | 10 +- src/view/com/posts/FeedItem.tsx | 2 +- src/view/screens/Notifications.tsx | 27 +-- 7 files changed, 423 insertions(+), 198 deletions(-) create mode 100644 src/state/queries/notifications/feed.ts create mode 100644 src/state/queries/notifications/unread.tsx diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts new file mode 100644 index 0000000000..75a68454b6 --- /dev/null +++ b/src/state/queries/notifications/feed.ts @@ -0,0 +1,190 @@ +import { + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedRepost, + AppBskyFeedLike, + AppBskyNotificationListNotifications, + BskyAgent, +} from '@atproto/api' +import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' +import {useSession} from '../../session' +import chunk from 'lodash.chunk' + +const GROUPABLE_REASONS = ['like', 'repost', 'follow'] +const PAGE_SIZE = 30 +const MS_1HR = 1e3 * 60 * 60 +const MS_2DAY = MS_1HR * 48 + +type RQPageParam = string | undefined +type NotificationType = + | 'post-like' + | 'feedgen-like' + | 'repost' + | 'mention' + | 'reply' + | 'quote' + | 'follow' + | 'unknown' + +export function RQKEY() { + return ['notification-feed'] +} + +export interface FeedNotification { + _reactKey: string + type: NotificationType + notification: AppBskyNotificationListNotifications.Notification + additional?: AppBskyNotificationListNotifications.Notification[] + subjectUri?: string + subject?: AppBskyFeedDefs.PostView +} + +export interface FeedPage { + cursor: string | undefined + items: FeedNotification[] +} + +export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { + const {agent} = useSession() + const enabled = opts?.enabled !== false + + return useInfiniteQuery< + FeedPage, + Error, + InfiniteData, + QueryKey, + RQPageParam + >({ + queryKey: RQKEY(), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + console.log('fetch notifs', pageParam) + const res = await agent.listNotifications({ + limit: PAGE_SIZE, + cursor: pageParam, + }) + + // group notifications which are essentially similar (follows, likes on a post) + const notifsGrouped = groupNotifications(res.data.notifications) + + // we fetch subjects of notifications (usually posts) now instead of lazily + // in the UI to avoid relayouts + const subjects = await fetchSubjects(agent, notifsGrouped) + for (const notif of notifsGrouped) { + if (notif.subjectUri) { + notif.subject = subjects.get(notif.subjectUri) + } + } + + return { + cursor: res.data.cursor, + items: notifsGrouped, + } + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + enabled, + }) +} + +function groupNotifications( + notifs: AppBskyNotificationListNotifications.Notification[], +): FeedNotification[] { + const groupedNotifs: FeedNotification[] = [] + for (const notif of notifs) { + const ts = +new Date(notif.indexedAt) + let grouped = false + if (GROUPABLE_REASONS.includes(notif.reason)) { + for (const groupedNotif of groupedNotifs) { + const ts2 = +new Date(groupedNotif.notification.indexedAt) + if ( + Math.abs(ts2 - ts) < MS_2DAY && + notif.reason === groupedNotif.notification.reason && + notif.reasonSubject === groupedNotif.notification.reasonSubject && + notif.author.did !== groupedNotif.notification.author.did + ) { + groupedNotif.additional = groupedNotif.additional || [] + groupedNotif.additional.push(notif) + grouped = true + break + } + } + } + if (!grouped) { + const type = toKnownType(notif) + groupedNotifs.push({ + _reactKey: `notif-${notif.uri}`, + type, + notification: notif, + subjectUri: getSubjectUri(type, notif), + }) + } + } + return groupedNotifs +} + +async function fetchSubjects( + agent: BskyAgent, + groupedNotifs: FeedNotification[], +): Promise> { + const uris = new Set() + for (const notif of groupedNotifs) { + if (notif.subjectUri) { + uris.add(notif.subjectUri) + } + } + const uriChunks = chunk(Array.from(uris), 25) + const postsChunks = await Promise.all( + uriChunks.map(uris => + agent.app.bsky.feed.getPosts({uris}).then(res => res.data.posts), + ), + ) + const map = new Map() + for (const post of postsChunks.flat()) { + if ( + AppBskyFeedPost.isRecord(post.record) && + AppBskyFeedPost.validateRecord(post.record).success + ) { + map.set(post.uri, post) + } + } + return map +} + +function toKnownType( + notif: AppBskyNotificationListNotifications.Notification, +): NotificationType { + if (notif.reason === 'like') { + if (notif.reasonSubject?.includes('feed.generator')) { + return 'feedgen-like' + } + return 'post-like' + } + if ( + notif.reason === 'repost' || + notif.reason === 'mention' || + notif.reason === 'reply' || + notif.reason === 'quote' || + notif.reason === 'follow' + ) { + return notif.reason as NotificationType + } + return 'unknown' +} + +function getSubjectUri( + type: NotificationType, + notif: AppBskyNotificationListNotifications.Notification, +): string | undefined { + if (type === 'reply' || type === 'quote' || type === 'mention') { + return notif.uri + } else if (type === 'post-like' || type === 'repost') { + if ( + AppBskyFeedRepost.isRecord(notif.record) || + AppBskyFeedLike.isRecord(notif.record) + ) { + return typeof notif.record.subject?.uri === 'string' + ? notif.record.subject?.uri + : undefined + } + } +} diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx new file mode 100644 index 0000000000..a7cc283b19 --- /dev/null +++ b/src/state/queries/notifications/unread.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import BroadcastChannel from '#/lib/broadcast' + +const broadcast = new BroadcastChannel('NOTIFS_BROADCAST_CHANNEL') + +interface Context { + numUnread: number + setNumUnread: (num: number) => void +} + +const context = React.createContext({ + numUnread: 0, + setNumUnread() {}, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [numUnread, setNumUnread] = React.useState(0) + const state = React.useMemo( + () => ({ + numUnread, + setNumUnread(num: number) { + setNumUnread(num) + console.log('notifs broadcasting', num) + broadcast.postMessage({event: String(num)}) + }, + }), + [numUnread, setNumUnread], + ) + + // listen for broadcasts + React.useEffect(() => { + const listener = ({data}: MessageEvent) => { + const count = parseInt(data.event, 10) + console.log('notifs broadcast got', count, data.event) + if (typeof count === 'number') { + setNumUnread(count) + } + } + broadcast.addEventListener('message', listener) + return () => { + broadcast.removeEventListener('message', listener) + } + }, [setNumUnread]) + + return {children} +} + +export function useUnreadNotifications() { + return React.useContext(context) +} diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 4794a9867c..2366737a80 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -1,8 +1,6 @@ import React, {MutableRefObject} from 'react' -import {observer} from 'mobx-react-lite' import {CenteredView, FlatList} from '../util/Views' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' -import {NotificationsFeedModel} from 'state/models/feeds/notifications' import {FeedItem} from './FeedItem' import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' @@ -12,20 +10,21 @@ import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' +import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' import {logger} from '#/logger' +import {cleanError} from '#/lib/strings/errors' +import {useModerationOpts} from '#/state/queries/preferences' const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -const LOADING_SPINNER = {_reactKey: '__loading_spinner__'} +const LOADING_ITEM = {_reactKey: '__loading__'} -export const Feed = observer(function Feed({ - view, +export function Feed({ scrollElRef, onPressTryAgain, onScroll, ListHeaderComponent, }: { - view: NotificationsFeedModel scrollElRef?: MutableRefObject | null> onPressTryAgain?: () => void onScroll?: OnScrollHandler @@ -33,35 +32,45 @@ export const Feed = observer(function Feed({ }) { const pal = usePalette('default') const [isPTRing, setIsPTRing] = React.useState(false) - const data = React.useMemo(() => { - let feedItems: any[] = [] - if (view.isRefreshing && !isPTRing) { - feedItems = [LOADING_SPINNER] - } - if (view.hasLoaded) { - if (view.isEmpty) { - feedItems = feedItems.concat([EMPTY_FEED_ITEM]) - } else { - feedItems = feedItems.concat(view.notifications) + + const moderationOpts = useModerationOpts() + const { + data, + dataUpdatedAt, + isFetching, + isFetched, + isError, + error, + refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useNotificationFeedQuery({enabled: !!moderationOpts}) + const isEmpty = !isFetching && !data?.pages[0]?.items.length + + const items = React.useMemo(() => { + let arr: any[] = [] + if (isFetched) { + if (isEmpty) { + arr = arr.concat([EMPTY_FEED_ITEM]) + } else if (data) { + for (const page of data?.pages) { + arr = arr.concat(page.items) + } } + if (isError && !isEmpty) { + arr = arr.concat([LOAD_MORE_ERROR_ITEM]) + } + } else { + arr.push(LOADING_ITEM) } - if (view.loadMoreError) { - feedItems = (feedItems || []).concat([LOAD_MORE_ERROR_ITEM]) - } - return feedItems - }, [ - view.hasLoaded, - view.isEmpty, - view.notifications, - view.loadMoreError, - view.isRefreshing, - isPTRing, - ]) + return arr + }, [isFetched, isError, isEmpty, data]) const onRefresh = React.useCallback(async () => { try { setIsPTRing(true) - await view.refresh() + await refetch() } catch (err) { logger.error('Failed to refresh notifications feed', { error: err, @@ -69,21 +78,21 @@ export const Feed = observer(function Feed({ } finally { setIsPTRing(false) } - }, [view, setIsPTRing]) + }, [refetch, setIsPTRing]) const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + try { - await view.loadMore() + await fetchNextPage() } catch (err) { - logger.error('Failed to load more notifications', { - error: err, - }) + logger.error('Failed to load more notifications', {error: err}) } - }, [view]) + }, [isFetching, hasNextPage, isError, fetchNextPage]) const onPressRetryLoadMore = React.useCallback(() => { - view.retryLoadMore() - }, [view]) + fetchNextPage() + }, [fetchNextPage]) // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // VirtualizedList: You have a large list that is slow to update - make sure your @@ -106,78 +115,72 @@ export const Feed = observer(function Feed({ onPress={onPressRetryLoadMore} /> ) - } else if (item === LOADING_SPINNER) { - return ( - - - - ) + } else if (item === LOADING_ITEM) { + return } - return + return ( + + ) }, - [onPressRetryLoadMore], + [onPressRetryLoadMore, dataUpdatedAt, moderationOpts], ) const FeedFooter = React.useCallback( () => - view.isLoading ? ( + isFetchingNextPage ? ( ) : ( ), - [view], + [isFetchingNextPage], ) const scrollHandler = useAnimatedScrollHandler(onScroll || {}) return ( - - {view.isLoading && !data.length && ( - - )} - {view.hasError && ( + {error && ( + - )} - - {data.length ? ( - item._reactKey} - renderItem={renderItem} - ListHeaderComponent={ListHeaderComponent} - ListFooterComponent={FeedFooter} - refreshControl={ - - } - onEndReached={onEndReached} - onEndReachedThreshold={0.6} - onScroll={scrollHandler} - scrollEventThrottle={1} - contentContainerStyle={s.contentContainer} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> - ) : null} + + )} + item._reactKey} + renderItem={renderItem} + ListHeaderComponent={ListHeaderComponent} + ListFooterComponent={FeedFooter} + refreshControl={ + + } + onEndReached={onEndReached} + onEndReachedThreshold={0.6} + onScroll={scrollHandler} + scrollEventThrottle={1} + contentContainerStyle={s.contentContainer} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> ) -}) +} const styles = StyleSheet.create({ - loading: { - paddingVertical: 20, - }, feedFooter: {paddingTop: 20}, emptyState: {paddingVertical: 40}, }) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 0387ed38dd..16806972ce 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -1,5 +1,4 @@ import React, {useMemo, useState, useEffect} from 'react' -import {observer} from 'mobx-react-lite' import { Animated, TouchableOpacity, @@ -9,6 +8,9 @@ import { } from 'react-native' import { AppBskyEmbedImages, + AppBskyFeedDefs, + AppBskyFeedPost, + ModerationOpts, ProfileModeration, moderateProfile, AppBskyEmbedRecordWithMedia, @@ -19,8 +21,7 @@ import { FontAwesomeIconStyle, Props, } from '@fortawesome/react-native-fontawesome' -import {NotificationsFeedItemModel} from 'state/models/feeds/notifications' -import {PostThreadModel} from 'state/models/content/post-thread' +import {FeedNotification} from '#/state/queries/notifications/feed' import {s, colors} from 'lib/styles' import {niceDate} from 'lib/strings/time' import {sanitizeDisplayName} from 'lib/strings/display-names' @@ -33,7 +34,6 @@ import {UserPreviewLink} from '../util/UserPreviewLink' import {ImageHorzList} from '../util/images/ImageHorzList' import {Post} from '../post/Post' import {Link, TextLink} from '../util/Link' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {formatCount} from '../util/numeric/format' @@ -56,40 +56,36 @@ interface Author { moderation: ProfileModeration } -export const FeedItem = observer(function FeedItemImpl({ +export function FeedItem({ item, + dataUpdatedAt, + moderationOpts, }: { - item: NotificationsFeedItemModel + item: FeedNotification + dataUpdatedAt: number + moderationOpts: ModerationOpts }) { - const store = useStores() const pal = usePalette('default') const [isAuthorsExpanded, setAuthorsExpanded] = useState(false) const itemHref = useMemo(() => { - if (item.isLike || item.isRepost) { - const urip = new AtUri(item.subjectUri) - return `/profile/${urip.host}/post/${urip.rkey}` - } else if (item.isFollow) { - return makeProfileLink(item.author) - } else if (item.isReply) { - const urip = new AtUri(item.uri) + if (item.type === 'post-like' || item.type === 'repost') { + if (item.subjectUri) { + const urip = new AtUri(item.subjectUri) + return `/profile/${urip.host}/post/${urip.rkey}` + } + } else if (item.type === 'follow') { + return makeProfileLink(item.notification.author) + } else if (item.type === 'reply') { + const urip = new AtUri(item.notification.uri) return `/profile/${urip.host}/post/${urip.rkey}` - } else if (item.isCustomFeedLike) { - const urip = new AtUri(item.subjectUri) - return `/profile/${urip.host}/feed/${urip.rkey}` + } else if (item.type === 'feedgen-like') { + if (item.subjectUri) { + const urip = new AtUri(item.subjectUri) + return `/profile/${urip.host}/feed/${urip.rkey}` + } } return '' }, [item]) - const itemTitle = useMemo(() => { - if (item.isLike || item.isRepost) { - return 'Post' - } else if (item.isFollow) { - return item.author.handle - } else if (item.isReply) { - return 'Post' - } else if (item.isCustomFeedLike) { - return 'Custom Feed' - } - }, [item]) const onToggleAuthorsExpanded = () => { setAuthorsExpanded(currentlyExpanded => !currentlyExpanded) @@ -98,15 +94,12 @@ export const FeedItem = observer(function FeedItemImpl({ const authors: Author[] = useMemo(() => { return [ { - href: makeProfileLink(item.author), - did: item.author.did, - handle: item.author.handle, - displayName: item.author.displayName, - avatar: item.author.avatar, - moderation: moderateProfile( - item.author, - store.preferences.moderationOpts, - ), + href: makeProfileLink(item.notification.author), + did: item.notification.author.did, + handle: item.notification.author.handle, + displayName: item.notification.author.displayName, + avatar: item.notification.author.avatar, + moderation: moderateProfile(item.notification.author, moderationOpts), }, ...(item.additional?.map(({author}) => { return { @@ -115,33 +108,38 @@ export const FeedItem = observer(function FeedItemImpl({ handle: author.handle, displayName: author.displayName, avatar: author.avatar, - moderation: moderateProfile(author, store.preferences.moderationOpts), + moderation: moderateProfile(author, moderationOpts), } }) || []), ] - }, [store, item.additional, item.author]) + }, [item, moderationOpts]) - if (item.additionalPost?.notFound) { + // TODO: apply moderation filter + + if (item.subjectUri && !item.subject) { // don't render anything if the target post was deleted or unfindable return } - if (item.isReply || item.isMention || item.isQuote) { - if (!item.additionalPost || item.additionalPost?.error) { - // hide errors - it doesnt help the user to show them - return + if ( + item.type === 'reply' || + item.type === 'mention' || + item.type === 'quote' + ) { + if (!item.subject) { + return null } return ( + accessible={ + (item.type === 'post-like' && authors.length === 1) || + item.type === 'repost' + }> {/* TODO: Prevent conditional rendering and move toward composable notifications for clearer accessibility labeling */} @@ -244,24 +246,24 @@ export const FeedItem = observer(function FeedItemImpl({ ) : undefined} {action} - + {({timeElapsed}) => ( + title={niceDate(item.notification.indexedAt)}> {' ' + timeElapsed} )} - {item.isLike || item.isRepost || item.isQuote ? ( - + {item.type === 'post-like' || item.type === 'repost' ? ( + ) : null} ) -}) +} function ExpandListPressable({ hasMultipleAuthors, @@ -423,34 +425,25 @@ function ExpandedAuthorsList({ ) } -function AdditionalPostText({ - additionalPost, -}: { - additionalPost?: PostThreadModel -}) { +function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { const pal = usePalette('default') - if ( - !additionalPost || - !additionalPost.thread?.postRecord || - additionalPost.error - ) { - return + if (post && AppBskyFeedPost.isRecord(post?.record)) { + const text = post.record.text + const images = AppBskyEmbedImages.isView(post.embed) + ? post.embed.images + : AppBskyEmbedRecordWithMedia.isView(post.embed) && + AppBskyEmbedImages.isView(post.embed.media) + ? post.embed.media.images + : undefined + return ( + <> + {text?.length > 0 && {text}} + {images && images?.length > 0 && ( + + )} + + ) } - const text = additionalPost.thread?.postRecord.text - const images = AppBskyEmbedImages.isView(additionalPost.thread.post.embed) - ? additionalPost.thread.post.embed.images - : AppBskyEmbedRecordWithMedia.isView(additionalPost.thread.post.embed) && - AppBskyEmbedImages.isView(additionalPost.thread.post.embed.media) - ? additionalPost.thread.post.embed.media.images - : undefined - return ( - <> - {text?.length > 0 && {text}} - {images && images?.length > 0 && ( - - )} - - ) } const styles = StyleSheet.create({ diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 50afc19508..6cbad7f716 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -68,7 +68,7 @@ export function Feed({ const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() - const [isRefreshing, setIsRefreshing] = React.useState(false) + const [isPTRing, setIsPTRing] = React.useState(false) const checkForNewRef = React.useRef<(() => void) | null>(null) const opts = React.useMemo(() => ({enabled}), [enabled]) @@ -137,15 +137,15 @@ export function Feed({ const onRefresh = React.useCallback(async () => { track('Feed:onRefresh') - setIsRefreshing(true) + setIsPTRing(true) try { await refetch() onHasNew?.(false) } catch (err) { logger.error('Failed to refresh posts feed', {error: err}) } - setIsRefreshing(false) - }, [refetch, track, setIsRefreshing, onHasNew]) + setIsPTRing(false) + }, [refetch, track, setIsPTRing, onHasNew]) const onEndReached = React.useCallback(async () => { if (isFetching || !hasNextPage || isError) return @@ -233,7 +233,7 @@ export function Feed({ ListHeaderComponent={ListHeaderComponent} refreshControl={ export const NotificationsScreen = withAuthRequired( - observer(function NotificationsScreenImpl({}: Props) { + function NotificationsScreenImpl({}: Props) { const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() @@ -36,16 +33,10 @@ export const NotificationsScreen = withAuthRequired( const pal = usePalette('default') const {isDesktop} = useWebMediaQueries() - const hasNew = - store.me.notifications.hasNewLatest && - !store.me.notifications.isRefreshing + const hasNew = false // TODO // event handlers // = - const onPressTryAgain = React.useCallback(() => { - store.me.notifications.refresh() - }, [store]) - const scrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: 0}) resetMainScroll() @@ -53,8 +44,8 @@ export const NotificationsScreen = withAuthRequired( const onPressLoadLatest = React.useCallback(() => { scrollToTop() - store.me.notifications.refresh() - }, [store, scrollToTop]) + // store.me.notifications.refresh() TODO + }, [scrollToTop]) // on-visible setup // = @@ -63,16 +54,16 @@ export const NotificationsScreen = withAuthRequired( setMinimalShellMode(false) logger.debug('NotificationsScreen: Updating feed') const softResetSub = store.onScreenSoftReset(onPressLoadLatest) - store.me.notifications.update() screen('Notifications') return () => { softResetSub.remove() - store.me.notifications.markAllRead() + // store.me.notifications.markAllRead() TODO } }, [store, screen, onPressLoadLatest, setMinimalShellMode]), ) + /* TODO useTabFocusEffect( 'Notifications', React.useCallback( @@ -97,7 +88,7 @@ export const NotificationsScreen = withAuthRequired( }, [store], ), - ) + )*/ const ListHeaderComponent = React.useCallback(() => { if (isDesktop) { @@ -145,8 +136,6 @@ export const NotificationsScreen = withAuthRequired( ) - }), + }, ) From f8c2f42bbb9c0f606a123423fb6fb6e4dca048fe Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sun, 12 Nov 2023 15:47:02 -0800 Subject: [PATCH 5/9] Fix: only trigger updates in useModerationOpts when the values have changed --- src/state/queries/preferences/index.ts | 35 ++++++++++++++++++-------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index da995a17e5..4f10b01a6f 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -1,6 +1,11 @@ -import {useMemo} from 'react' +import {useEffect, useState} from 'react' import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' -import {LabelPreference, BskyFeedViewPreference} from '@atproto/api' +import { + LabelPreference, + BskyFeedViewPreference, + ModerationOpts, +} from '@atproto/api' +import isEqual from 'lodash.isequal' import {track} from '#/lib/analytics/analytics' import {getAge} from '#/lib/strings/time' @@ -25,7 +30,7 @@ export * from '#/state/queries/preferences/const' export const usePreferencesQueryKey = ['getPreferences'] export function usePreferencesQuery() { - const {agent} = useSession() + const {agent, hasSession} = useSession() return useQuery({ queryKey: usePreferencesQueryKey, queryFn: async () => { @@ -78,20 +83,28 @@ export function usePreferencesQuery() { } return preferences }, + enabled: hasSession, }) } export function useModerationOpts() { const {currentAccount} = useSession() + const [opts, setOpts] = useState() const prefs = usePreferencesQuery() - return useMemo(() => { - return prefs?.data - ? getModerationOpts({ - userDid: currentAccount?.did || '', - preferences: prefs.data, - }) - : undefined - }, [currentAccount, prefs.data]) + useEffect(() => { + if (!prefs.data) { + return + } + // only update this hook when the moderation options change + const newOpts = getModerationOpts({ + userDid: currentAccount?.did || '', + preferences: prefs.data, + }) + if (!isEqual(opts, newOpts)) { + setOpts(newOpts) + } + }, [prefs.data, currentAccount, opts, setOpts]) + return opts } export function useClearPreferencesMutation() { From 9d31f55bf21ba0de9b870305600cbfad398727ea Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sun, 12 Nov 2023 15:48:40 -0800 Subject: [PATCH 6/9] Implement unread notification tracking --- src/App.native.tsx | 61 ++++----- src/App.web.tsx | 61 ++++----- src/Navigation.tsx | 18 ++- src/lib/hooks/useSetTitle.ts | 12 +- src/lib/notifications/notifications.ts | 4 +- src/state/models/me.ts | 20 --- src/state/queries/notifications/feed.ts | 1 - src/state/queries/notifications/unread.tsx | 145 +++++++++++++++++---- src/view/com/notifications/Feed.tsx | 10 ++ src/view/screens/Notifications.tsx | 40 ++---- src/view/shell/Drawer.tsx | 10 +- src/view/shell/bottom-bar/BottomBar.tsx | 10 +- src/view/shell/desktop/LeftNav.tsx | 4 +- 13 files changed, 229 insertions(+), 167 deletions(-) diff --git a/src/App.native.tsx b/src/App.native.tsx index 8479465fd2..d97d872ca8 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -31,6 +31,7 @@ import { useSession, useSessionApi, } from 'state/session' +import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import * as persisted from '#/state/persisted' import {i18n} from '@lingui/core' import {I18nProvider} from '@lingui/react' @@ -72,22 +73,20 @@ const InnerApp = observer(function AppImpl() { } return ( - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + ) }) @@ -103,19 +102,23 @@ function App() { } return ( - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + ) } diff --git a/src/App.web.tsx b/src/App.web.tsx index 81e03d0798..5967fb7518 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -29,6 +29,7 @@ import { useSession, useSessionApi, } from 'state/session' +import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import * as persisted from '#/state/persisted' const InnerApp = observer(function AppImpl() { @@ -60,22 +61,20 @@ const InnerApp = observer(function AppImpl() { } return ( - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + ) }) @@ -91,19 +90,23 @@ function App() { } return ( - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + ) } diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 381f33cf94..fb88dc84f8 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import {StyleSheet} from 'react-native' import * as SplashScreen from 'expo-splash-screen' -import {observer} from 'mobx-react-lite' import { NavigationContainer, createNavigationContainerRef, @@ -33,10 +32,10 @@ import {isNative} from 'platform/detection' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {router} from './routes' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from './state' import {bskyTitle} from 'lib/strings/headings' import {JSX} from 'react/jsx-runtime' import {timeout} from 'lib/async/timeout' +import {useUnreadNotifications} from './state/queries/notifications/unread' import {HomeScreen} from './view/screens/Home' import {SearchScreen} from './view/screens/Search' @@ -346,7 +345,7 @@ function NotificationsTabNavigator() { ) } -const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() { +function MyProfileTabNavigator() { const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) return ( ) -}) +} /** * The FlatNavigator is used by Web to represent the routes * in a single ("flat") stack. */ -const FlatNavigator = observer(function FlatNavigatorImpl() { +const FlatNavigator = () => { const pal = usePalette('default') - const store = useStores() - const unreadCountLabel = store.me.notifications.unreadCountLabel + const numUnread = useUnreadNotifications() - const title = (page: string) => bskyTitle(page, unreadCountLabel) + const title = (page: string) => bskyTitle(page, numUnread) return ( NotificationsScreen} options={{title: title('Notifications')}} /> - {commonScreens(Flat as typeof HomeTab, unreadCountLabel)} + {commonScreens(Flat as typeof HomeTab, numUnread)} ) -}) +} /** * The RoutesContainer should wrap all components which need access diff --git a/src/lib/hooks/useSetTitle.ts b/src/lib/hooks/useSetTitle.ts index c5c7a5ca16..129023f717 100644 --- a/src/lib/hooks/useSetTitle.ts +++ b/src/lib/hooks/useSetTitle.ts @@ -3,18 +3,14 @@ import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {bskyTitle} from 'lib/strings/headings' -import {useStores} from 'state/index' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' -/** - * Requires consuming component to be wrapped in `observer`: - * https://stackoverflow.com/a/71488009 - */ export function useSetTitle(title?: string) { const navigation = useNavigation() - const {unreadCountLabel} = useStores().me.notifications + const numUnread = useUnreadNotifications() useEffect(() => { if (title) { - navigation.setOptions({title: bskyTitle(title, unreadCountLabel)}) + navigation.setOptions({title: bskyTitle(title, numUnread)}) } - }, [title, navigation, unreadCountLabel]) + }, [title, navigation, numUnread]) } diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index 73f9c56f60..985fd096b5 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -83,7 +83,7 @@ export function init(store: RootStoreModel) { ) if (event.request.trigger.type === 'push') { // refresh notifications in the background - store.me.notifications.syncQueue() + // store.me.notifications.syncQueue() TODO // handle payload-based deeplinks let payload if (isIOS) { @@ -121,7 +121,7 @@ export function init(store: RootStoreModel) { logger.DebugContext.notifications, ) track('Notificatons:OpenApp') - store.me.notifications.refresh() // refresh notifications + // store.me.notifications.refresh() // refresh notifications TODO resetToTab('NotificationsTab') // open notifications tab } }, diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 4bbb5a04b9..edf1715df8 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -4,14 +4,12 @@ import { ComAtprotoServerListAppPasswords, } from '@atproto/api' import {RootStoreModel} from './root-store' -import {NotificationsFeedModel} from './feeds/notifications' import {MyFeedsUIModel} from './ui/my-feeds' import {MyFollowsCache} from './cache/my-follows' import {isObj, hasProp} from 'lib/type-guards' import {logger} from '#/logger' const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min -const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec export class MeModel { did: string = '' @@ -21,13 +19,11 @@ export class MeModel { avatar: string = '' followsCount: number | undefined followersCount: number | undefined - notifications: NotificationsFeedModel myFeeds: MyFeedsUIModel follows: MyFollowsCache invites: ComAtprotoServerDefs.InviteCode[] = [] appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = [] lastProfileStateUpdate = Date.now() - lastNotifsUpdate = Date.now() get invitesAvailable() { return this.invites.filter(isInviteAvailable).length @@ -39,13 +35,11 @@ export class MeModel { {rootStore: false, serialize: false, hydrate: false}, {autoBind: true}, ) - this.notifications = new NotificationsFeedModel(this.rootStore) this.myFeeds = new MyFeedsUIModel(this.rootStore) this.follows = new MyFollowsCache(this.rootStore) } clear() { - this.notifications.clear() this.myFeeds.clear() this.follows.clear() this.rootStore.profiles.cache.clear() @@ -103,16 +97,6 @@ export class MeModel { if (sess.hasSession) { this.did = sess.currentSession?.did || '' await this.fetchProfile() - /* dont await */ this.notifications.setup().catch(e => { - logger.error('Failed to setup notifications model', { - error: e, - }) - }) - /* dont await */ this.notifications.setup().catch(e => { - logger.error('Failed to setup notifications model', { - error: e, - }) - }) this.myFeeds.clear() /* dont await */ this.myFeeds.saved.refresh() this.rootStore.emitSessionLoaded() @@ -131,10 +115,6 @@ export class MeModel { await this.fetchInviteCodes() await this.fetchAppPasswords() } - if (Date.now() - this.lastNotifsUpdate > NOTIFS_UPDATE_INTERVAL) { - this.lastNotifsUpdate = Date.now() - await this.notifications.syncQueue() - } } async fetchProfile() { diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 75a68454b6..be20100b34 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -57,7 +57,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { >({ queryKey: RQKEY(), async queryFn({pageParam}: {pageParam: RQPageParam}) { - console.log('fetch notifs', pageParam) const res = await agent.listNotifications({ limit: PAGE_SIZE, cursor: pageParam, diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index a7cc283b19..df57c40cfe 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -1,40 +1,56 @@ import React from 'react' +import { + AppBskyNotificationListNotifications, + ModerationOpts, + moderateProfile, + moderatePost, +} from '@atproto/api' import BroadcastChannel from '#/lib/broadcast' +import {useSession} from '#/state/session' +import {useModerationOpts} from '../preferences' + +const UPDATE_INTERVAL = 30 * 1e3 // 30sec const broadcast = new BroadcastChannel('NOTIFS_BROADCAST_CHANNEL') -interface Context { - numUnread: number - setNumUnread: (num: number) => void +type StateContext = string + +interface ApiContext { + markAllRead: () => Promise + checkUnread: () => Promise } -const context = React.createContext({ - numUnread: 0, - setNumUnread() {}, +const stateContext = React.createContext('') + +const apiContext = React.createContext({ + async markAllRead() {}, + async checkUnread() {}, }) export function Provider({children}: React.PropsWithChildren<{}>) { - const [numUnread, setNumUnread] = React.useState(0) - const state = React.useMemo( - () => ({ - numUnread, - setNumUnread(num: number) { - setNumUnread(num) - console.log('notifs broadcasting', num) - broadcast.postMessage({event: String(num)}) - }, - }), - [numUnread, setNumUnread], - ) + const {hasSession, agent} = useSession() + const moderationOpts = useModerationOpts() + + const [numUnread, setNumUnread] = React.useState('') + + const checkUnreadRef = React.useRef<(() => Promise) | null>(null) + const lastSyncRef = React.useRef(new Date()) + + // periodic sync + React.useEffect(() => { + if (!hasSession || !checkUnreadRef.current) { + return + } + checkUnreadRef.current() // fire on init + const interval = setInterval(checkUnreadRef.current, UPDATE_INTERVAL) + return () => clearInterval(interval) + }, [hasSession]) // listen for broadcasts React.useEffect(() => { const listener = ({data}: MessageEvent) => { - const count = parseInt(data.event, 10) - console.log('notifs broadcast got', count, data.event) - if (typeof count === 'number') { - setNumUnread(count) - } + lastSyncRef.current = new Date() + setNumUnread(data.event) } broadcast.addEventListener('message', listener) return () => { @@ -42,9 +58,88 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } }, [setNumUnread]) - return {children} + // create API + const api = React.useMemo(() => { + return { + async markAllRead() { + // update server + await agent.updateSeenNotifications(lastSyncRef.current.toISOString()) + + // update & broadcast + setNumUnread('') + broadcast.postMessage({event: ''}) + }, + + async checkUnread() { + // count + const res = await agent.listNotifications({limit: 40}) + const filtered = res.data.notifications.filter( + notif => !notif.isRead && !shouldFilter(notif, moderationOpts), + ) + const num = + filtered.length >= 30 + ? '30+' + : filtered.length === 0 + ? '' + : String(filtered.length) + + // track last sync + const now = new Date() + const lastIndexed = filtered[0] && new Date(filtered[0].indexedAt) + lastSyncRef.current = + !lastIndexed || now > lastIndexed ? now : lastIndexed + + // update & broadcast + setNumUnread(num) + broadcast.postMessage({event: num}) + }, + } + }, [setNumUnread, agent, moderationOpts]) + checkUnreadRef.current = api.checkUnread + + return ( + + {children} + + ) } export function useUnreadNotifications() { - return React.useContext(context) + return React.useContext(stateContext) +} + +export function useUnreadNotificationsApi() { + return React.useContext(apiContext) +} + +// TODO this should be in the sdk as moderateNotification -prf +function shouldFilter( + notif: AppBskyNotificationListNotifications.Notification, + moderationOpts: ModerationOpts | undefined, +): boolean { + if (!moderationOpts) { + return false + } + const profile = moderateProfile(notif.author, moderationOpts) + if ( + profile.account.filter || + profile.profile.filter || + notif.author.viewer?.muted + ) { + return true + } + if ( + notif.type === 'reply' || + notif.type === 'quote' || + notif.type === 'mention' + ) { + // NOTE: the notification overlaps the post enough for this to work + const post = moderatePost(notif, moderationOpts) + if (post.content.filter) { + return true + } + } + // TODO: thread muting is not being applied + // (this requires fetching the post) + return false } diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 2366737a80..e82c654be4 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -11,6 +11,7 @@ import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIX import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' +import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread' import {logger} from '#/logger' import {cleanError} from '#/lib/strings/errors' import {useModerationOpts} from '#/state/queries/preferences' @@ -34,6 +35,7 @@ export function Feed({ const [isPTRing, setIsPTRing] = React.useState(false) const moderationOpts = useModerationOpts() + const {markAllRead} = useUnreadNotificationsApi() const { data, dataUpdatedAt, @@ -47,6 +49,14 @@ export function Feed({ fetchNextPage, } = useNotificationFeedQuery({enabled: !!moderationOpts}) const isEmpty = !isFetching && !data?.pages[0]?.items.length + const firstItem = data?.pages[0]?.items[0] + + // mark all read on fresh data + React.useEffect(() => { + if (firstItem) { + markAllRead() + } + }, [firstItem, markAllRead]) const items = React.useMemo(() => { let arr: any[] = [] diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 2bf92c808e..e0f2340732 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -1,6 +1,7 @@ import React from 'react' import {FlatList, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' import { NativeStackScreenProps, NotificationsTabNavigatorParams, @@ -18,6 +19,8 @@ import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' import {logger} from '#/logger' import {useSetMinimalShellMode} from '#/state/shell' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' +import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' type Props = NativeStackScreenProps< NotificationsTabNavigatorParams, @@ -32,8 +35,9 @@ export const NotificationsScreen = withAuthRequired( const {screen} = useAnalytics() const pal = usePalette('default') const {isDesktop} = useWebMediaQueries() - - const hasNew = false // TODO + const unreadNotifs = useUnreadNotifications() + const queryClient = useQueryClient() + const hasNew = !!unreadNotifs // event handlers // = @@ -44,8 +48,8 @@ export const NotificationsScreen = withAuthRequired( const onPressLoadLatest = React.useCallback(() => { scrollToTop() - // store.me.notifications.refresh() TODO - }, [scrollToTop]) + queryClient.invalidateQueries({queryKey: NOTIFS_RQKEY()}) + }, [scrollToTop, queryClient]) // on-visible setup // = @@ -58,38 +62,10 @@ export const NotificationsScreen = withAuthRequired( return () => { softResetSub.remove() - // store.me.notifications.markAllRead() TODO } }, [store, screen, onPressLoadLatest, setMinimalShellMode]), ) - /* TODO - useTabFocusEffect( - 'Notifications', - React.useCallback( - isInside => { - // on mobile: - // fires with `isInside=true` when the user navigates to the root tab - // but not when the user goes back to the screen by pressing back - // on web: - // essentially equivalent to useFocusEffect because we dont used tabbed - // navigation - if (isInside) { - if (isWeb) { - store.me.notifications.syncQueue() - } else { - if (store.me.notifications.unreadCount > 0) { - store.me.notifications.refresh() - } else { - store.me.notifications.syncQueue() - } - } - } - }, - [store], - ), - )*/ - const ListHeaderComponent = React.useCallback(() => { if (isDesktop) { return ( diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 609348e4dd..8a84a07c66 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -49,6 +49,7 @@ import {useSetDrawerOpen} from '#/state/shell' import {useModalControls} from '#/state/modals' import {useSession, SessionAccount} from '#/state/session' import {useProfileQuery} from '#/state/queries/profile' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' export function DrawerProfileCard({ account, @@ -110,8 +111,7 @@ export const DrawerContent = observer(function DrawerContentImpl() { const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = useNavigationTabState() const {currentAccount} = useSession() - - const {notifications} = store.me + const numUnreadNotifications = useUnreadNotifications() // events // = @@ -286,11 +286,11 @@ export const DrawerContent = observer(function DrawerContentImpl() { label="Notifications" accessibilityLabel={_(msg`Notifications`)} accessibilityHint={ - notifications.unreadCountLabel === '' + numUnreadNotifications === '' ? '' - : `${notifications.unreadCountLabel} unread` + : `${numUnreadNotifications} unread` } - count={notifications.unreadCountLabel} + count={numUnreadNotifications} bold={isAtNotifications} onPress={onPressNotifications} /> diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 3dd7f57c55..81552635f3 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -28,6 +28,7 @@ import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' import {useModalControls} from '#/state/modals' import {useShellLayout} from '#/state/shell/shell-layout' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds' @@ -43,9 +44,8 @@ export const BottomBar = observer(function BottomBarImpl({ const {footerHeight} = useShellLayout() const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = useNavigationTabState() - + const numUnreadNotifications = useUnreadNotifications() const {footerMinimalShellTransform} = useMinimalShellMode() - const {notifications} = store.me const onPressTab = React.useCallback( (tab: TabOptions) => { @@ -178,14 +178,14 @@ export const BottomBar = observer(function BottomBarImpl({ ) } onPress={onPressNotifications} - notificationCount={notifications.unreadCountLabel} + notificationCount={numUnreadNotifications} accessible={true} accessibilityRole="tab" accessibilityLabel={_(msg`Notifications`)} accessibilityHint={ - notifications.unreadCountLabel === '' + numUnreadNotifications === '' ? '' - : `${notifications.unreadCountLabel} unread` + : `${numUnreadNotifications} unread` } /> Date: Sun, 12 Nov 2023 15:58:46 -0800 Subject: [PATCH 7/9] Add moderation filtering to notifications --- src/state/queries/notifications/feed.ts | 27 ++++++++++++-- src/state/queries/notifications/unread.tsx | 41 ++-------------------- src/state/queries/notifications/util.ts | 38 ++++++++++++++++++++ src/view/com/notifications/FeedItem.tsx | 2 -- 4 files changed, 65 insertions(+), 43 deletions(-) create mode 100644 src/state/queries/notifications/util.ts diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index be20100b34..9d491c3a49 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -6,9 +6,12 @@ import { AppBskyNotificationListNotifications, BskyAgent, } from '@atproto/api' +import chunk from 'lodash.chunk' import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' import {useSession} from '../../session' -import chunk from 'lodash.chunk' +import {useModerationOpts} from '../preferences' +import {shouldFilterNotif} from './util' +import {useMutedThreads} from '#/state/muted-threads' const GROUPABLE_REASONS = ['like', 'repost', 'follow'] const PAGE_SIZE = 30 @@ -46,6 +49,8 @@ export interface FeedPage { export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { const {agent} = useSession() + const moderationOpts = useModerationOpts() + const threadMutes = useMutedThreads() const enabled = opts?.enabled !== false return useInfiniteQuery< @@ -62,8 +67,13 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { cursor: pageParam, }) + // filter out notifs by mod rules + const notifs = res.data.notifications.filter( + notif => !shouldFilterNotif(notif, moderationOpts), + ) + // group notifications which are essentially similar (follows, likes on a post) - const notifsGrouped = groupNotifications(res.data.notifications) + let notifsGrouped = groupNotifications(notifs) // we fetch subjects of notifications (usually posts) now instead of lazily // in the UI to avoid relayouts @@ -74,6 +84,11 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { } } + // apply thread muting + notifsGrouped = notifsGrouped.filter( + notif => !isThreadMuted(notif, threadMutes), + ) + return { cursor: res.data.cursor, items: notifsGrouped, @@ -187,3 +202,11 @@ function getSubjectUri( } } } + +function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean { + if (!notif.subject) { + return false + } + const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects() + return mutes.includes(record.reply?.root.uri || notif.subject.uri) +} diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index df57c40cfe..c6917f59aa 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -1,13 +1,8 @@ import React from 'react' -import { - AppBskyNotificationListNotifications, - ModerationOpts, - moderateProfile, - moderatePost, -} from '@atproto/api' import BroadcastChannel from '#/lib/broadcast' import {useSession} from '#/state/session' import {useModerationOpts} from '../preferences' +import {shouldFilterNotif} from './util' const UPDATE_INTERVAL = 30 * 1e3 // 30sec @@ -74,7 +69,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // count const res = await agent.listNotifications({limit: 40}) const filtered = res.data.notifications.filter( - notif => !notif.isRead && !shouldFilter(notif, moderationOpts), + notif => !notif.isRead && !shouldFilterNotif(notif, moderationOpts), ) const num = filtered.length >= 30 @@ -111,35 +106,3 @@ export function useUnreadNotifications() { export function useUnreadNotificationsApi() { return React.useContext(apiContext) } - -// TODO this should be in the sdk as moderateNotification -prf -function shouldFilter( - notif: AppBskyNotificationListNotifications.Notification, - moderationOpts: ModerationOpts | undefined, -): boolean { - if (!moderationOpts) { - return false - } - const profile = moderateProfile(notif.author, moderationOpts) - if ( - profile.account.filter || - profile.profile.filter || - notif.author.viewer?.muted - ) { - return true - } - if ( - notif.type === 'reply' || - notif.type === 'quote' || - notif.type === 'mention' - ) { - // NOTE: the notification overlaps the post enough for this to work - const post = moderatePost(notif, moderationOpts) - if (post.content.filter) { - return true - } - } - // TODO: thread muting is not being applied - // (this requires fetching the post) - return false -} diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts new file mode 100644 index 0000000000..c49d1851a1 --- /dev/null +++ b/src/state/queries/notifications/util.ts @@ -0,0 +1,38 @@ +import { + AppBskyNotificationListNotifications, + ModerationOpts, + moderateProfile, + moderatePost, +} from '@atproto/api' + +// TODO this should be in the sdk as moderateNotification -prf +export function shouldFilterNotif( + notif: AppBskyNotificationListNotifications.Notification, + moderationOpts: ModerationOpts | undefined, +): boolean { + if (!moderationOpts) { + return false + } + const profile = moderateProfile(notif.author, moderationOpts) + if ( + profile.account.filter || + profile.profile.filter || + notif.author.viewer?.muted + ) { + return true + } + if ( + notif.type === 'reply' || + notif.type === 'quote' || + notif.type === 'mention' + ) { + // NOTE: the notification overlaps the post enough for this to work + const post = moderatePost(notif, moderationOpts) + if (post.content.filter) { + return true + } + } + // TODO: thread muting is not being applied + // (this requires fetching the post) + return false +} diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 16806972ce..dd785a6821 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -114,8 +114,6 @@ export function FeedItem({ ] }, [item, moderationOpts]) - // TODO: apply moderation filter - if (item.subjectUri && !item.subject) { // don't render anything if the target post was deleted or unfindable return From e7be406ae87d7af5129511e9ec41d873b2d67baf Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sun, 12 Nov 2023 16:03:54 -0800 Subject: [PATCH 8/9] Handle native/push notifications --- src/App.native.tsx | 2 +- src/lib/notifications/notifications.ts | 10 +++++----- src/state/models/root-store.ts | 8 -------- src/state/queries/notifications/unread.tsx | 5 +++++ 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/App.native.tsx b/src/App.native.tsx index d97d872ca8..e976fce4fa 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -54,7 +54,7 @@ const InnerApp = observer(function AppImpl() { setupState().then(store => { setRootStore(store) analytics.init(store) - notifications.init(store) + notifications.init(store, queryClient) store.onSessionDropped(() => { Toast.show('Sorry! Your session expired. Please log in again.') }) diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index 985fd096b5..d46479a05b 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -1,18 +1,18 @@ import * as Notifications from 'expo-notifications' +import {QueryClient} from '@tanstack/react-query' import {RootStoreModel} from '../../state' import {resetToTab} from '../../Navigation' import {devicePlatform, isIOS} from 'platform/detection' import {track} from 'lib/analytics/analytics' import {logger} from '#/logger' +import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed' const SERVICE_DID = (serviceUrl?: string) => serviceUrl?.includes('staging') ? 'did:web:api.staging.bsky.dev' : 'did:web:api.bsky.app' -export function init(store: RootStoreModel) { - store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count)) - +export function init(store: RootStoreModel, queryClient: QueryClient) { store.onSessionLoaded(async () => { // request notifications permission once the user has logged in const perms = await Notifications.getPermissionsAsync() @@ -83,7 +83,7 @@ export function init(store: RootStoreModel) { ) if (event.request.trigger.type === 'push') { // refresh notifications in the background - // store.me.notifications.syncQueue() TODO + queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()}) // handle payload-based deeplinks let payload if (isIOS) { @@ -121,7 +121,7 @@ export function init(store: RootStoreModel) { logger.DebugContext.notifications, ) track('Notificatons:OpenApp') - // store.me.notifications.refresh() // refresh notifications TODO + queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()}) resetToTab('NotificationsTab') // open notifications tab } }, diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index c07cf30781..288e8b8e11 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -203,14 +203,6 @@ export class RootStoreModel { emitScreenSoftReset() { DeviceEventEmitter.emit('screen-soft-reset') } - - // the unread notifications count has changed - onUnreadNotifications(handler: (count: number) => void): EmitterSubscription { - return DeviceEventEmitter.addListener('unread-notifications', handler) - } - emitUnreadNotifications(count: number) { - DeviceEventEmitter.emit('unread-notifications', count) - } } const throwawayInst = new RootStoreModel( diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index c6917f59aa..91aa6f3c2e 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -1,8 +1,10 @@ import React from 'react' +import * as Notifications from 'expo-notifications' import BroadcastChannel from '#/lib/broadcast' import {useSession} from '#/state/session' import {useModerationOpts} from '../preferences' import {shouldFilterNotif} from './util' +import {isNative} from '#/platform/detection' const UPDATE_INTERVAL = 30 * 1e3 // 30sec @@ -77,6 +79,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { : filtered.length === 0 ? '' : String(filtered.length) + if (isNative) { + Notifications.setBadgeCountAsync(Math.min(filtered.length, 30)) + } // track last sync const now = new Date() From f594604df95224274f532e73d9b839416fb04831 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sun, 12 Nov 2023 16:04:45 -0800 Subject: [PATCH 9/9] Remove dead code --- src/state/models/feeds/notifications.ts | 671 ------------------------ 1 file changed, 671 deletions(-) delete mode 100644 src/state/models/feeds/notifications.ts diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts deleted file mode 100644 index 5f34feb662..0000000000 --- a/src/state/models/feeds/notifications.ts +++ /dev/null @@ -1,671 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - AppBskyNotificationListNotifications as ListNotifications, - AppBskyActorDefs, - AppBskyFeedDefs, - AppBskyFeedPost, - AppBskyFeedRepost, - AppBskyFeedLike, - AppBskyGraphFollow, - ComAtprotoLabelDefs, - moderatePost, - moderateProfile, -} from '@atproto/api' -import AwaitLock from 'await-lock' -import chunk from 'lodash.chunk' -import {bundleAsync} from 'lib/async/bundle' -import {RootStoreModel} from '../root-store' -import {PostThreadModel} from '../content/post-thread' -import {cleanError} from 'lib/strings/errors' -import {logger} from '#/logger' -import {isThreadMuted} from '#/state/muted-threads' - -const GROUPABLE_REASONS = ['like', 'repost', 'follow'] -const PAGE_SIZE = 30 -const MS_1HR = 1e3 * 60 * 60 -const MS_2DAY = MS_1HR * 48 - -export const MAX_VISIBLE_NOTIFS = 30 - -export interface GroupedNotification extends ListNotifications.Notification { - additional?: ListNotifications.Notification[] -} - -type SupportedRecord = - | AppBskyFeedPost.Record - | AppBskyFeedRepost.Record - | AppBskyFeedLike.Record - | AppBskyGraphFollow.Record - -export class NotificationsFeedItemModel { - // ui state - _reactKey: string = '' - - // data - uri: string = '' - cid: string = '' - author: AppBskyActorDefs.ProfileViewBasic = { - did: '', - handle: '', - avatar: '', - } - reason: string = '' - reasonSubject?: string - record?: SupportedRecord - isRead: boolean = false - indexedAt: string = '' - labels?: ComAtprotoLabelDefs.Label[] - additional?: NotificationsFeedItemModel[] - - // additional data - additionalPost?: PostThreadModel - - constructor( - public rootStore: RootStoreModel, - reactKey: string, - v: GroupedNotification, - ) { - makeAutoObservable(this, {rootStore: false}) - this._reactKey = reactKey - this.copy(v) - } - - copy(v: GroupedNotification, preserve = false) { - this.uri = v.uri - this.cid = v.cid - this.author = v.author - this.reason = v.reason - this.reasonSubject = v.reasonSubject - this.record = this.toSupportedRecord(v.record) - this.isRead = v.isRead - this.indexedAt = v.indexedAt - this.labels = v.labels - if (v.additional?.length) { - this.additional = [] - for (const add of v.additional) { - this.additional.push( - new NotificationsFeedItemModel(this.rootStore, '', add), - ) - } - } else if (!preserve) { - this.additional = undefined - } - } - - get shouldFilter(): boolean { - if (this.additionalPost?.thread) { - const postMod = moderatePost( - this.additionalPost.thread.data.post, - this.rootStore.preferences.moderationOpts, - ) - return postMod.content.filter || false - } - const profileMod = moderateProfile( - this.author, - this.rootStore.preferences.moderationOpts, - ) - return profileMod.account.filter || false - } - - 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' && !this.isCustomFeedLike // the reason property for custom feed likes is also 'like' - } - - get isRepost() { - return this.reason === 'repost' - } - - get isMention() { - return this.reason === 'mention' - } - - get isReply() { - return this.reason === 'reply' - } - - get isQuote() { - return this.reason === 'quote' - } - - get isFollow() { - return this.reason === 'follow' - } - - get isCustomFeedLike() { - return ( - this.reason === 'like' && this.reasonSubject?.includes('feed.generator') - ) - } - - get needsAdditionalData() { - if ( - this.isLike || - this.isRepost || - this.isReply || - this.isQuote || - this.isMention - ) { - return !this.additionalPost - } - return false - } - - get additionalDataUri(): string | undefined { - if (this.isReply || this.isQuote || this.isMention) { - return this.uri - } else if (this.isLike || this.isRepost) { - return this.subjectUri - } - } - - get subjectUri(): string { - if (this.reasonSubject) { - return this.reasonSubject - } - const record = this.record - if ( - AppBskyFeedRepost.isRecord(record) || - AppBskyFeedLike.isRecord(record) - ) { - return record.subject.uri - } - return '' - } - - get reasonSubjectRootUri(): string | undefined { - if (this.additionalPost) { - return this.additionalPost.rootUri - } - return undefined - } - - toSupportedRecord(v: unknown): SupportedRecord | undefined { - for (const ns of [ - AppBskyFeedPost, - AppBskyFeedRepost, - AppBskyFeedLike, - AppBskyGraphFollow, - ]) { - if (ns.isRecord(v)) { - const valid = ns.validateRecord(v) - if (valid.success) { - return v - } else { - logger.warn('Received an invalid record', { - record: v, - error: valid.error, - }) - return - } - } - } - logger.warn( - 'app.bsky.notifications.list served an unsupported record type', - {record: v}, - ) - } - - setAdditionalData(additionalPost: AppBskyFeedDefs.PostView) { - if (this.additionalPost) { - this.additionalPost._replaceAll({ - success: true, - headers: {}, - data: { - thread: { - post: additionalPost, - }, - }, - }) - } else { - this.additionalPost = PostThreadModel.fromPostView( - this.rootStore, - additionalPost, - ) - } - } -} - -export class NotificationsFeedModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - loadMoreError = '' - hasMore = true - loadMoreCursor?: string - - /** - * The last time notifications were seen. Refers to either the - * user's machine clock or the value of the `indexedAt` property on their - * latest notification, whichever was greater at the time of viewing. - */ - lastSync?: Date - - // used to linearize async modifications to state - lock = new AwaitLock() - - // data - notifications: NotificationsFeedItemModel[] = [] - queuedNotifications: undefined | NotificationsFeedItemModel[] = undefined - unreadCount = 0 - - // this is used to help trigger push notifications - mostRecentNotificationUri: string | undefined - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - mostRecentNotificationUri: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.notifications.length !== 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get hasNewLatest() { - return Boolean( - this.queuedNotifications && this.queuedNotifications?.length > 0, - ) - } - - get unreadCountLabel(): string { - const count = this.unreadCount - if (count >= MAX_VISIBLE_NOTIFS) { - return `${MAX_VISIBLE_NOTIFS}+` - } - if (count === 0) { - return '' - } - return String(count) - } - - // public api - // = - - /** - * Nuke all data - */ - clear() { - logger.debug('NotificationsModel:clear') - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = false - this.error = '' - this.hasMore = true - this.loadMoreCursor = undefined - this.notifications = [] - this.unreadCount = 0 - this.rootStore.emitUnreadNotifications(0) - this.mostRecentNotificationUri = undefined - } - - /** - * Load for first render - */ - setup = bundleAsync(async (isRefreshing: boolean = false) => { - logger.debug('NotificationsModel:refresh', {isRefreshing}) - await this.lock.acquireAsync() - try { - this._xLoading(isRefreshing) - try { - const res = await this.rootStore.agent.listNotifications({ - limit: PAGE_SIZE, - }) - await this._replaceAll(res) - this._setQueued(undefined) - this._countUnread() - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } finally { - this.lock.release() - } - }) - - /** - * Reset and load - */ - async refresh() { - this.isRefreshing = true // set optimistically for UI - return this.setup(true) - } - - /** - * Sync the next set of notifications to show - */ - syncQueue = bundleAsync(async () => { - logger.debug('NotificationsModel:syncQueue') - if (this.unreadCount >= MAX_VISIBLE_NOTIFS) { - return // no need to check - } - await this.lock.acquireAsync() - try { - 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) - } - - // NOTE - // because filtering depends on the added information we have to fetch - // the full models here. this is *not* ideal performance and we need - // to update the notifications route to give all the info we need - // -prf - const queueModels = await this._fetchItemModels(queue) - this._setQueued(this._filterNotifications(queueModels)) - this._countUnread() - } catch (e) { - logger.error('NotificationsModel:syncQueue failed', { - error: e, - }) - } finally { - this.lock.release() - } - - // if there are no notifications, we should refresh the list - // this will only run for new users who have no notifications - // NOTE: needs to be after the lock is released - if (this.isEmpty) { - this.refresh() - } - }) - - /** - * Load more posts to the end of the notifications - */ - loadMore = bundleAsync(async () => { - if (!this.hasMore) { - return - } - await this.lock.acquireAsync() - try { - this._xLoading() - try { - const res = await this.rootStore.agent.listNotifications({ - limit: PAGE_SIZE, - cursor: this.loadMoreCursor, - }) - await this._appendAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(undefined, e) - runInAction(() => { - this.hasMore = false - }) - } - } finally { - this.lock.release() - } - }) - - /** - * Attempt to load more again after a failure - */ - async retryLoadMore() { - this.loadMoreError = '' - this.hasMore = true - return this.loadMore() - } - - // unread notification in-place - // = - async update() { - const promises = [] - for (const item of this.notifications) { - if (item.additionalPost) { - promises.push(item.additionalPost.update()) - } - } - await Promise.all(promises).catch(e => { - logger.error('Uncaught failure during notifications update()', e) - }) - } - - /** - * Update read/unread state - */ - async markAllRead() { - try { - for (const notif of this.notifications) { - notif.markGroupRead() - } - this._countUnread() - await this.rootStore.agent.updateSeenNotifications( - this.lastSync ? this.lastSync.toISOString() : undefined, - ) - } catch (e: any) { - logger.warn('Failed to update notifications read state', { - error: e, - }) - } - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(error?: any, loadMoreError?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(error) - this.loadMoreError = cleanError(loadMoreError) - if (error) { - logger.error('Failed to fetch notifications', {error}) - } - if (loadMoreError) { - logger.error('Failed to load more notifications', { - error: loadMoreError, - }) - } - } - - // helper functions - // = - - async _replaceAll(res: ListNotifications.Response) { - const latest = res.data.notifications[0] - - if (latest) { - const now = new Date() - const lastIndexed = new Date(latest.indexedAt) - const nowOrLastIndexed = now > lastIndexed ? now : lastIndexed - - this.mostRecentNotificationUri = latest.uri - this.lastSync = nowOrLastIndexed - } - - return this._appendAll(res, true) - } - - async _appendAll(res: ListNotifications.Response, replace = false) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - const itemModels = await this._processNotifications(res.data.notifications) - runInAction(() => { - if (replace) { - this.notifications = itemModels - } else { - this.notifications = this.notifications.concat(itemModels) - } - }) - } - - _filterNotifications( - items: NotificationsFeedItemModel[], - ): NotificationsFeedItemModel[] { - return items - .filter(item => { - const hideByLabel = item.shouldFilter - let mutedThread = !!( - item.reasonSubjectRootUri && isThreadMuted(item.reasonSubjectRootUri) - ) - return !hideByLabel && !mutedThread - }) - .map(item => { - if (item.additional?.length) { - item.additional = this._filterNotifications(item.additional) - } - return item - }) - } - - async _fetchItemModels( - items: ListNotifications.Notification[], - ): Promise { - // construct item models and track who needs more data - const itemModels: NotificationsFeedItemModel[] = [] - const addedPostMap = new Map() - for (const item of items) { - const itemModel = new NotificationsFeedItemModel( - this.rootStore, - `notification-${item.uri}`, - item, - ) - const uri = itemModel.additionalDataUri - if (uri) { - const models = addedPostMap.get(uri) || [] - models.push(itemModel) - addedPostMap.set(uri, models) - } - itemModels.push(itemModel) - } - - // fetch additional data - if (addedPostMap.size > 0) { - const uriChunks = chunk(Array.from(addedPostMap.keys()), 25) - const postsChunks = await Promise.all( - uriChunks.map(uris => - this.rootStore.agent.app.bsky.feed - .getPosts({uris}) - .then(res => res.data.posts), - ), - ) - for (const post of postsChunks.flat()) { - this.rootStore.posts.set(post.uri, post) - const models = addedPostMap.get(post.uri) - if (models?.length) { - for (const model of models) { - model.setAdditionalData(post) - } - } - } - } - - return itemModels - } - - async _processNotifications( - items: ListNotifications.Notification[], - ): Promise { - const itemModels = await this._fetchItemModels(groupNotifications(items)) - return this._filterNotifications(itemModels) - } - - _setQueued(queued: undefined | NotificationsFeedItemModel[]) { - this.queuedNotifications = queued - } - - _countUnread() { - let unread = 0 - for (const notif of this.notifications) { - unread += notif.numUnreadInGroup - } - if (this.queuedNotifications) { - unread += this.queuedNotifications.filter(notif => !notif.isRead).length - } - this.unreadCount = unread - this.rootStore.emitUnreadNotifications(unread) - } -} - -function groupNotifications( - items: ListNotifications.Notification[], -): GroupedNotification[] { - const items2: GroupedNotification[] = [] - for (const item of items) { - const ts = +new Date(item.indexedAt) - let grouped = false - if (GROUPABLE_REASONS.includes(item.reason)) { - for (const item2 of items2) { - const ts2 = +new Date(item2.indexedAt) - if ( - Math.abs(ts2 - ts) < MS_2DAY && - item.reason === item2.reason && - item.reasonSubject === item2.reasonSubject && - item.author.did !== item2.author.did - ) { - item2.additional = item2.additional || [] - item2.additional.push(item) - grouped = true - break - } - } - } - if (!grouped) { - items2.push(item) - } - } - return items2 -} - -type N = ListNotifications.Notification | NotificationsFeedItemModel -function isEq(a: N, b: N) { - // this function has a key subtlety- the indexedAt comparison - // the reason for this is reposts: they set the URI of the original post, not of the repost record - // the indexedAt time will be for the repost however, so we use that to help us - return a.uri === b.uri && a.indexedAt === b.indexedAt -}