From 74f8390f1d879350ebb6516fade2b1d83d1601e7 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 8 Nov 2023 09:08:42 -0800 Subject: [PATCH] Move muted threads to new persistence + context (#1838) --- src/App.native.tsx | 5 +- src/App.web.tsx | 5 +- src/state/models/content/post-thread-item.ts | 8 --- src/state/models/content/post-thread.ts | 12 ---- src/state/models/feeds/notifications.ts | 4 +- src/state/models/feeds/post.ts | 18 ------ src/state/models/muted-threads.ts | 29 ---------- src/state/models/root-store.ts | 6 -- src/state/muted-threads.tsx | 59 ++++++++++++++++++++ src/view/com/post-thread/PostThreadItem.tsx | 17 +++--- src/view/com/post/Post.tsx | 13 +++-- src/view/com/posts/FeedItem.tsx | 13 +++-- 12 files changed, 95 insertions(+), 94 deletions(-) delete mode 100644 src/state/models/muted-threads.ts create mode 100644 src/state/muted-threads.tsx diff --git a/src/App.native.tsx b/src/App.native.tsx index f5d35cf7..4500b5d0 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -22,6 +22,7 @@ import * as Toast from 'view/com/util/Toast' import {queryClient} from 'lib/react-query' import {TestCtrls} from 'view/com/testing/TestCtrls' import {Provider as ShellStateProvider} from 'state/shell' +import {Provider as MutedThreadsProvider} from 'state/muted-threads' SplashScreen.preventAutoHideAsync() @@ -78,7 +79,9 @@ function App() { return ( - + + + ) } diff --git a/src/App.web.tsx b/src/App.web.tsx index adad9ddb..9792274b 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -17,6 +17,7 @@ import {ToastContainer} from 'view/com/util/Toast.web' import {ThemeProvider} from 'lib/ThemeContext' import {queryClient} from 'lib/react-query' import {Provider as ShellStateProvider} from 'state/shell' +import {Provider as MutedThreadsProvider} from 'state/muted-threads' const InnerApp = observer(function AppImpl() { const colorMode = useColorMode() @@ -68,7 +69,9 @@ function App() { return ( - + + + ) } diff --git a/src/state/models/content/post-thread-item.ts b/src/state/models/content/post-thread-item.ts index 942f3acc..855b038c 100644 --- a/src/state/models/content/post-thread-item.ts +++ b/src/state/models/content/post-thread-item.ts @@ -63,10 +63,6 @@ export class PostThreadItemModel { return this.post.uri } - get isThreadMuted() { - return this.data.isThreadMuted - } - get moderation(): PostModeration { return this.data.moderation } @@ -129,10 +125,6 @@ export class PostThreadItemModel { this.data.toggleRepost() } - async toggleThreadMute() { - this.data.toggleThreadMute() - } - async delete() { this.data.delete() } diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index fd194056..65e74f7c 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -74,10 +74,6 @@ export class PostThreadModel { return this.resolvedUri } - get isThreadMuted() { - return this.rootStore.mutedThreads.uris.has(this.rootUri) - } - get isCachedPostAReply() { if (AppBskyFeedPost.isRecord(this.thread?.post.record)) { return !!this.thread?.post.record.reply @@ -140,14 +136,6 @@ export class PostThreadModel { this.refresh() } - async toggleThreadMute() { - if (this.isThreadMuted) { - this.rootStore.mutedThreads.uris.delete(this.rootUri) - } else { - this.rootStore.mutedThreads.uris.add(this.rootUri) - } - } - // state transitions // = diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 607e3038..272d5288 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -18,6 +18,7 @@ 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 @@ -550,8 +551,7 @@ export class NotificationsFeedModel { .filter(item => { const hideByLabel = item.shouldFilter let mutedThread = !!( - item.reasonSubjectRootUri && - this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri) + item.reasonSubjectRootUri && isThreadMuted(item.reasonSubjectRootUri) ) return !hideByLabel && !mutedThread }) diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts index d064edc2..4fa1213b 100644 --- a/src/state/models/feeds/post.ts +++ b/src/state/models/feeds/post.ts @@ -75,10 +75,6 @@ export class PostsFeedItemModel { return this.post.uri } - get isThreadMuted() { - return this.rootStore.mutedThreads.uris.has(this.rootUri) - } - get moderation(): PostModeration { return moderatePost(this.post, this.rootStore.preferences.moderationOpts) } @@ -172,20 +168,6 @@ export class PostsFeedItemModel { } } - async toggleThreadMute() { - try { - if (this.isThreadMuted) { - this.rootStore.mutedThreads.uris.delete(this.rootUri) - track('Post:ThreadUnmute') - } else { - this.rootStore.mutedThreads.uris.add(this.rootUri) - track('Post:ThreadMute') - } - } catch (error) { - logger.error('Failed to toggle thread mute', {error}) - } - } - async delete() { try { await this.rootStore.agent.deletePost(this.post.uri) diff --git a/src/state/models/muted-threads.ts b/src/state/models/muted-threads.ts deleted file mode 100644 index e6f20274..00000000 --- a/src/state/models/muted-threads.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * This is a temporary client-side system for storing muted threads - * When the system lands on prod we should switch to that - */ - -import {makeAutoObservable} from 'mobx' -import {isObj, hasProp, isStrArray} from 'lib/type-guards' - -export class MutedThreads { - uris: Set = new Set() - - constructor() { - makeAutoObservable( - this, - {serialize: false, hydrate: false}, - {autoBind: true}, - ) - } - - serialize() { - return {uris: Array.from(this.uris)} - } - - hydrate(v: unknown) { - if (isObj(v) && hasProp(v, 'uris') && isStrArray(v.uris)) { - this.uris = new Set(v.uris) - } - } -} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index f04a9922..fadd279f 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -19,7 +19,6 @@ import {InvitedUsers} from './invited-users' import {PreferencesModel} from './ui/preferences' import {resetToTab} from '../../Navigation' import {ImageSizesCache} from './cache/image-sizes' -import {MutedThreads} from './muted-threads' import {reset as resetNavigation} from '../../Navigation' import {logger} from '#/logger' @@ -49,7 +48,6 @@ export class RootStoreModel { posts = new PostsCache(this) linkMetas = new LinkMetasCache(this) imageSizes = new ImageSizesCache() - mutedThreads = new MutedThreads() constructor(agent: BskyAgent) { this.agent = agent @@ -71,7 +69,6 @@ export class RootStoreModel { me: this.me.serialize(), preferences: this.preferences.serialize(), invitedUsers: this.invitedUsers.serialize(), - mutedThreads: this.mutedThreads.serialize(), } } @@ -95,9 +92,6 @@ export class RootStoreModel { if (hasProp(v, 'invitedUsers')) { this.invitedUsers.hydrate(v.invitedUsers) } - if (hasProp(v, 'mutedThreads')) { - this.mutedThreads.hydrate(v.mutedThreads) - } } } diff --git a/src/state/muted-threads.tsx b/src/state/muted-threads.tsx new file mode 100644 index 00000000..2b3a7de6 --- /dev/null +++ b/src/state/muted-threads.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['mutedThreads'] +type ToggleContext = (uri: string) => boolean + +const stateContext = React.createContext( + persisted.defaults.mutedThreads, +) +const toggleContext = React.createContext((_: string) => false) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('mutedThreads')) + + const toggleThreadMute = React.useCallback( + (uri: string) => { + let muted = false + setState((arr: string[]) => { + if (arr.includes(uri)) { + arr = arr.filter(v => v !== uri) + muted = false + } else { + arr = arr.concat([uri]) + muted = true + } + persisted.write('mutedThreads', arr) + return arr + }) + return muted + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('mutedThreads')) + }) + }, [setState]) + + return ( + + + {children} + + + ) +} + +export function useMutedThreads() { + return React.useContext(stateContext) +} + +export function useToggleThreadMute() { + return React.useContext(toggleContext) +} + +export function isThreadMuted(uri: string) { + return persisted.get('mutedThreads').includes(uri) +} diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 351a4670..9aec638e 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -37,6 +37,7 @@ import {makeProfileLink} from 'lib/routes/links' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {MAX_POST_LINES} from 'lib/constants' import {logger} from '#/logger' +import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' export const PostThreadItem = observer(function PostThreadItem({ item, @@ -51,6 +52,8 @@ export const PostThreadItem = observer(function PostThreadItem({ }) { const pal = usePalette('default') const store = useStores() + const mutedThreads = useMutedThreads() + const toggleThreadMute = useToggleThreadMute() const [deleted, setDeleted] = React.useState(false) const [limitLines, setLimitLines] = React.useState( countLines(item.richText?.text) >= MAX_POST_LINES, @@ -130,10 +133,10 @@ export const PostThreadItem = observer(function PostThreadItem({ Linking.openURL(translatorUrl) }, [translatorUrl]) - const onToggleThreadMute = React.useCallback(async () => { + const onToggleThreadMute = React.useCallback(() => { try { - await item.toggleThreadMute() - if (item.isThreadMuted) { + 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') @@ -141,7 +144,7 @@ export const PostThreadItem = observer(function PostThreadItem({ } catch (e) { logger.error('Failed to toggle thread mute', {error: e}) } - }, [item]) + }, [item, toggleThreadMute]) const onDeletePost = React.useCallback(() => { item.delete().then( @@ -284,7 +287,7 @@ export const PostThreadItem = observer(function PostThreadItem({ itemHref={itemHref} itemTitle={itemTitle} isAuthor={item.post.author.did === store.me.did} - isThreadMuted={item.isThreadMuted} + isThreadMuted={mutedThreads.includes(item.data.rootUri)} onCopyPostText={onCopyPostText} onOpenTranslate={onOpenTranslate} onToggleThreadMute={onToggleThreadMute} @@ -391,7 +394,7 @@ export const PostThreadItem = observer(function PostThreadItem({ isAuthor={item.post.author.did === store.me.did} isReposted={!!item.post.viewer?.repost} isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} + isThreadMuted={mutedThreads.includes(item.data.rootUri)} onPressReply={onPressReply} onPressToggleRepost={onPressToggleRepost} onPressToggleLike={onPressToggleLike} @@ -534,7 +537,7 @@ export const PostThreadItem = observer(function PostThreadItem({ likeCount={item.post.likeCount} isReposted={!!item.post.viewer?.repost} isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} + isThreadMuted={mutedThreads.includes(item.data.rootUri)} onPressReply={onPressReply} onPressToggleRepost={onPressToggleRepost} onPressToggleLike={onPressToggleLike} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 4ec9db77..db490333 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -33,6 +33,7 @@ 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' export const Post = observer(function PostImpl({ view, @@ -106,6 +107,8 @@ const PostLoaded = observer(function PostLoadedImpl({ }) { const pal = usePalette('default') const store = useStores() + const mutedThreads = useMutedThreads() + const toggleThreadMute = useToggleThreadMute() const [limitLines, setLimitLines] = React.useState( countLines(item.richText?.text) >= MAX_POST_LINES, ) @@ -161,10 +164,10 @@ const PostLoaded = observer(function PostLoadedImpl({ Linking.openURL(translatorUrl) }, [translatorUrl]) - const onToggleThreadMute = React.useCallback(async () => { + const onToggleThreadMute = React.useCallback(() => { try { - await item.toggleThreadMute() - if (item.isThreadMuted) { + 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') @@ -172,7 +175,7 @@ const PostLoaded = observer(function PostLoadedImpl({ } catch (e) { logger.error('Failed to toggle thread mute', {error: e}) } - }, [item]) + }, [item, toggleThreadMute]) const onDeletePost = React.useCallback(() => { item.delete().then( @@ -286,7 +289,7 @@ const PostLoaded = observer(function PostLoadedImpl({ likeCount={item.post.likeCount} isReposted={!!item.post.viewer?.repost} isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} + isThreadMuted={mutedThreads.includes(item.data.rootUri)} onPressReply={onPressReply} onPressToggleRepost={onPressToggleRepost} onPressToggleLike={onPressToggleLike} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index aeee3e20..772bb256 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -33,6 +33,7 @@ import {isEmbedByEmbedder} from 'lib/embeds' import {MAX_POST_LINES} from 'lib/constants' import {countLines} from 'lib/strings/helpers' import {logger} from '#/logger' +import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' export const FeedItem = observer(function FeedItemImpl({ item, @@ -50,6 +51,8 @@ export const FeedItem = observer(function FeedItemImpl({ }) { const store = useStores() const pal = usePalette('default') + const mutedThreads = useMutedThreads() + const toggleThreadMute = useToggleThreadMute() const {track} = useAnalytics() const [deleted, setDeleted] = useState(false) const [limitLines, setLimitLines] = useState( @@ -114,11 +117,11 @@ export const FeedItem = observer(function FeedItemImpl({ Linking.openURL(translatorUrl) }, [translatorUrl]) - const onToggleThreadMute = React.useCallback(async () => { + const onToggleThreadMute = React.useCallback(() => { track('FeedItem:ThreadMute') try { - await item.toggleThreadMute() - if (item.isThreadMuted) { + const muted = toggleThreadMute(item.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') @@ -126,7 +129,7 @@ export const FeedItem = observer(function FeedItemImpl({ } catch (e) { logger.error('Failed to toggle thread mute', {error: e}) } - }, [track, item]) + }, [track, toggleThreadMute, item]) const onDeletePost = React.useCallback(() => { track('FeedItem:PostDelete') @@ -360,7 +363,7 @@ export const FeedItem = observer(function FeedItemImpl({ likeCount={item.post.likeCount} isReposted={!!item.post.viewer?.repost} isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} + isThreadMuted={mutedThreads.includes(item.rootUri)} onPressReply={onPressReply} onPressToggleRepost={onPressToggleRepost} onPressToggleLike={onPressToggleLike}