From 5f5d845053e13169f89fc70a3f858b0a9e5ed4fd Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 18 Jun 2024 19:48:34 +0100 Subject: Server-side thread mutes (#4518) * update atproto/api * move thread mutes to server side * rm log * move muted threads provider to inside did key * use map instead of object --- src/App.native.tsx | 76 ++++++++++++++--------------- src/App.web.tsx | 72 +++++++++++++-------------- src/lib/statsig/events.ts | 2 + src/state/cache/thread-mutes.tsx | 44 +++++++++++++++++ src/state/muted-threads.tsx | 62 ----------------------- src/state/queries/notifications/feed.ts | 3 -- src/state/queries/notifications/unread.tsx | 5 +- src/state/queries/notifications/util.ts | 50 ------------------- src/state/queries/post.ts | 70 ++++++++++++++++++++++++++ src/view/com/util/forms/PostDropdownBtn.tsx | 45 +++++++++-------- src/view/com/util/post-ctrls/PostCtrls.tsx | 4 +- 11 files changed, 218 insertions(+), 215 deletions(-) create mode 100644 src/state/cache/thread-mutes.tsx delete mode 100644 src/state/muted-threads.tsx (limited to 'src') diff --git a/src/App.native.tsx b/src/App.native.tsx index 322e944a4..18461fdd0 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -14,40 +14,40 @@ import * as SplashScreen from 'expo-splash-screen' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useIntentHandler} from '#/lib/hooks/useIntentHandler' +import {QueryProvider} from '#/lib/react-query' import { initialize, Provider as StatsigProvider, tryFetchGates, } from '#/lib/statsig/statsig' +import {s} from '#/lib/styles' +import {ThemeProvider} from '#/lib/ThemeContext' import {logger} from '#/logger' +import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' +import {Provider as DialogStateProvider} from '#/state/dialogs' +import {Provider as InvitesStateProvider} from '#/state/invites' +import {Provider as LightboxStateProvider} from '#/state/lightbox' import {MessagesProvider} from '#/state/messages' +import {Provider as ModalStateProvider} from '#/state/modals' import {init as initPersistedState} from '#/state/persisted' +import {Provider as PrefsStateProvider} from '#/state/preferences' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' -import {readLastActiveAccount} from '#/state/session/util' -import {useIntentHandler} from 'lib/hooks/useIntentHandler' -import {QueryProvider} from 'lib/react-query' -import {s} from 'lib/styles' -import {ThemeProvider} from 'lib/ThemeContext' -import {Provider as DialogStateProvider} from 'state/dialogs' -import {Provider as InvitesStateProvider} from 'state/invites' -import {Provider as LightboxStateProvider} from 'state/lightbox' -import {Provider as ModalStateProvider} from 'state/modals' -import {Provider as MutedThreadsProvider} from 'state/muted-threads' -import {Provider as PrefsStateProvider} from 'state/preferences' -import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' +import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread' import { Provider as SessionProvider, SessionAccount, useSession, useSessionApi, -} from 'state/session' -import {Provider as ShellStateProvider} from 'state/shell' -import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' -import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' -import {TestCtrls} from 'view/com/testing/TestCtrls' -import * as Toast from 'view/com/util/Toast' -import {Shell} from 'view/shell' +} from '#/state/session' +import {readLastActiveAccount} from '#/state/session/util' +import {Provider as ShellStateProvider} from '#/state/shell' +import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' +import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' +import {TestCtrls} from '#/view/com/testing/TestCtrls' +import * as Toast from '#/view/com/util/Toast' +import {Shell} from '#/view/shell' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {Provider as PortalProvider} from '#/components/Portal' @@ -112,10 +112,12 @@ function InnerApp() { - - - - + + + + + + @@ -154,21 +156,19 @@ function App() { - - - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index 5c4dc4e63..6af3c7d6f 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -8,35 +8,35 @@ import {SafeAreaProvider} from 'react-native-safe-area-context' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useIntentHandler} from '#/lib/hooks/useIntentHandler' +import {QueryProvider} from '#/lib/react-query' import {Provider as StatsigProvider} from '#/lib/statsig/statsig' +import {ThemeProvider} from '#/lib/ThemeContext' import {logger} from '#/logger' +import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' +import {Provider as DialogStateProvider} from '#/state/dialogs' +import {Provider as InvitesStateProvider} from '#/state/invites' +import {Provider as LightboxStateProvider} from '#/state/lightbox' import {MessagesProvider} from '#/state/messages' +import {Provider as ModalStateProvider} from '#/state/modals' import {init as initPersistedState} from '#/state/persisted' +import {Provider as PrefsStateProvider} from '#/state/preferences' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' -import {readLastActiveAccount} from '#/state/session/util' -import {useIntentHandler} from 'lib/hooks/useIntentHandler' -import {QueryProvider} from 'lib/react-query' -import {ThemeProvider} from 'lib/ThemeContext' -import {Provider as DialogStateProvider} from 'state/dialogs' -import {Provider as InvitesStateProvider} from 'state/invites' -import {Provider as LightboxStateProvider} from 'state/lightbox' -import {Provider as ModalStateProvider} from 'state/modals' -import {Provider as MutedThreadsProvider} from 'state/muted-threads' -import {Provider as PrefsStateProvider} from 'state/preferences' -import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' +import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread' import { Provider as SessionProvider, SessionAccount, useSession, useSessionApi, -} from 'state/session' -import {Provider as ShellStateProvider} from 'state/shell' -import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' -import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' -import * as Toast from 'view/com/util/Toast' -import {ToastContainer} from 'view/com/util/Toast.web' -import {Shell} from 'view/shell/index' +} from '#/state/session' +import {readLastActiveAccount} from '#/state/session/util' +import {Provider as ShellStateProvider} from '#/state/shell' +import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' +import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' +import * as Toast from '#/view/com/util/Toast' +import {ToastContainer} from '#/view/com/util/Toast.web' +import {Shell} from '#/view/shell/index' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {Provider as PortalProvider} from '#/components/Portal' @@ -96,9 +96,11 @@ function InnerApp() { - - - + + + + + @@ -136,21 +138,19 @@ function App() { - - - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 9939f60c9..0d77ec8a3 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -103,6 +103,8 @@ export type LogEvents = { 'post:unrepost': { logContext: 'FeedItem' | 'PostThreadItem' | 'Post' } + 'post:mute': {} + 'post:unmute': {} 'profile:follow': { didBecomeMutual: boolean | undefined followeeClout: number | undefined diff --git a/src/state/cache/thread-mutes.tsx b/src/state/cache/thread-mutes.tsx new file mode 100644 index 000000000..b58bd430f --- /dev/null +++ b/src/state/cache/thread-mutes.tsx @@ -0,0 +1,44 @@ +import React from 'react' + +type StateContext = Map +type SetStateContext = (uri: string, value: boolean) => void + +const stateContext = React.createContext(new Map()) +const setStateContext = React.createContext( + (_: string) => false, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(() => new Map()) + + const setThreadMute = React.useCallback( + (uri: string, value: boolean) => { + setState(prev => { + const next = new Map(prev) + next.set(uri, value) + return next + }) + }, + [setState], + ) + return ( + + + {children} + + + ) +} + +export function useMutedThreads() { + return React.useContext(stateContext) +} + +export function useIsThreadMuted(uri: string, defaultValue = false) { + const state = React.useContext(stateContext) + return state.get(uri) ?? defaultValue +} + +export function useSetThreadMute() { + return React.useContext(setStateContext) +} diff --git a/src/state/muted-threads.tsx b/src/state/muted-threads.tsx deleted file mode 100644 index 84a717eb7..000000000 --- a/src/state/muted-threads.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react' -import * as persisted from '#/state/persisted' -import {track} from '#/lib/analytics/analytics' - -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 - track('Post:ThreadUnmute') - } else { - arr = arr.concat([uri]) - muted = true - track('Post:ThreadMute') - } - 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/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index d9f019af3..0607f07a1 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -26,7 +26,6 @@ import { useQueryClient, } from '@tanstack/react-query' -import {useMutedThreads} from '#/state/muted-threads' import {useAgent} from '#/state/session' import {useModerationOpts} from '../../preferences/moderation-opts' import {STALE} from '..' @@ -54,7 +53,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { const agent = useAgent() const queryClient = useQueryClient() const moderationOpts = useModerationOpts() - const threadMutes = useMutedThreads() const unreads = useUnreadNotificationsApi() const enabled = opts?.enabled !== false const lastPageCountRef = useRef(0) @@ -82,7 +80,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { cursor: pageParam, queryClient, moderationOpts, - threadMutes, fetchAdditionalData: true, }) ).page diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index ffb8d03bc..7bb325ea9 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -9,7 +9,6 @@ import EventEmitter from 'eventemitter3' import BroadcastChannel from '#/lib/broadcast' import {logger} from '#/logger' -import {useMutedThreads} from '#/state/muted-threads' import {useAgent, useSession} from '#/state/session' import {resetBadgeCount} from 'lib/notifications/notifications' import {useModerationOpts} from '../../preferences/moderation-opts' @@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const agent = useAgent() const queryClient = useQueryClient() const moderationOpts = useModerationOpts() - const threadMutes = useMutedThreads() const [numUnread, setNumUnread] = React.useState('') @@ -147,7 +145,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { limit: 40, queryClient, moderationOpts, - threadMutes, // only fetch subjects when the page is going to be used // in the notifications query, otherwise skip it @@ -192,7 +189,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } }, } - }, [setNumUnread, queryClient, moderationOpts, threadMutes, agent]) + }, [setNumUnread, queryClient, moderationOpts, agent]) checkUnreadRef.current = api.checkUnread return ( diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index 466249353..8ed1c0390 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -1,5 +1,4 @@ import { - AppBskyEmbedRecord, AppBskyFeedDefs, AppBskyFeedLike, AppBskyFeedPost, @@ -28,7 +27,6 @@ export async function fetchPage({ limit, queryClient, moderationOpts, - threadMutes, fetchAdditionalData, }: { agent: BskyAgent @@ -36,7 +34,6 @@ export async function fetchPage({ limit: number queryClient: QueryClient moderationOpts: ModerationOpts | undefined - threadMutes: string[] fetchAdditionalData: boolean }): Promise<{page: FeedPage; indexedAt: string | undefined}> { const res = await agent.listNotifications({ @@ -67,11 +64,6 @@ export async function fetchPage({ } } - // apply thread muting - notifsGrouped = notifsGrouped.filter( - notif => !isThreadMuted(notif, threadMutes), - ) - let seenAt = res.data.seenAt ? new Date(res.data.seenAt) : new Date() if (Number.isNaN(seenAt.getTime())) { seenAt = new Date() @@ -207,45 +199,3 @@ function getSubjectUri( return notif.reasonSubject } } - -export function isThreadMuted(notif: FeedNotification, threadMutes: string[]) { - // If there's a subject we want to use that. This will always work on the notifications tab - if (notif.subject) { - const record = notif.subject.record as AppBskyFeedPost.Record - // Check for a quote record - if ( - (record.reply && threadMutes.includes(record.reply.root.uri)) || - (notif.subject.uri && threadMutes.includes(notif.subject.uri)) - ) { - return true - } else if ( - AppBskyEmbedRecord.isMain(record.embed) && - threadMutes.includes(record.embed.record.uri) - ) { - return true - } - } else { - // Otherwise we just do the best that we can - const record = notif.notification.record - if (AppBskyFeedPost.isRecord(record)) { - if (record.reply && threadMutes.includes(record.reply.root.uri)) { - // We can always filter replies - return true - } else if ( - AppBskyEmbedRecord.isMain(record.embed) && - threadMutes.includes(record.embed.record.uri) - ) { - // We can also filter quotes if the quoted post is the root - return true - } - } else if ( - AppBskyFeedRepost.isRecord(record) && - threadMutes.includes(record.subject.uri) - ) { - // Finally we can filter reposts, again if the post is the root - return true - } - } - - return false -} diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index 794f48eb1..8e77bf6b9 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -8,6 +8,7 @@ import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig' import {updatePostShadow} from '#/state/cache/post-shadow' import {Shadow} from '#/state/cache/types' import {useAgent, useSession} from '#/state/session' +import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes' import {findProfileQueryData} from './profile' const RQKEY_ROOT = 'post' @@ -291,3 +292,72 @@ export function usePostDeleteMutation() { }, }) } + +export function useThreadMuteMutationQueue( + post: Shadow, + rootUri: string, +) { + const threadMuteMutation = useThreadMuteMutation() + const threadUnmuteMutation = useThreadUnmuteMutation() + const isThreadMuted = useIsThreadMuted(rootUri, post.viewer?.threadMuted) + const setThreadMute = useSetThreadMute() + + const queueToggle = useToggleMutationQueue({ + initialState: isThreadMuted, + runMutation: async (_prev, shouldLike) => { + if (shouldLike) { + await threadMuteMutation.mutateAsync({ + uri: rootUri, + }) + return true + } else { + await threadUnmuteMutation.mutateAsync({ + uri: rootUri, + }) + return false + } + }, + onSuccess(finalIsMuted) { + // finalize + setThreadMute(rootUri, finalIsMuted) + }, + }) + + const queueMuteThread = useCallback(() => { + // optimistically update + setThreadMute(rootUri, true) + return queueToggle(true) + }, [setThreadMute, rootUri, queueToggle]) + + const queueUnmuteThread = useCallback(() => { + // optimistically update + setThreadMute(rootUri, false) + return queueToggle(false) + }, [rootUri, setThreadMute, queueToggle]) + + return [isThreadMuted, queueMuteThread, queueUnmuteThread] as const +} + +function useThreadMuteMutation() { + const agent = useAgent() + return useMutation< + {}, + Error, + {uri: string} // the root post's uri + >({ + mutationFn: ({uri}) => { + logEvent('post:mute', {}) + return agent.api.app.bsky.graph.muteThread({root: uri}) + }, + }) +} + +function useThreadUnmuteMutation() { + const agent = useAgent() + return useMutation<{}, Error, {uri: string}>({ + mutationFn: ({uri}) => { + logEvent('post:unmute', {}) + return agent.api.app.bsky.graph.unmuteThread({root: uri}) + }, + }) +} diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 2486b73d5..45e00e58c 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -7,7 +7,7 @@ import { } from 'react-native' import * as Clipboard from 'expo-clipboard' import { - AppBskyActorDefs, + AppBskyFeedDefs, AppBskyFeedPost, AtUri, RichText as RichTextAPI, @@ -22,12 +22,15 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers' import {getTranslatorLink} from '#/locale/helpers' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' +import {Shadow} from '#/state/cache/post-shadow' import {useFeedFeedbackContext} from '#/state/feed-feedback' -import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' import {useLanguagePrefs} from '#/state/preferences' import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' import {useOpenLink} from '#/state/preferences/in-app-browser' -import {usePostDeleteMutation} from '#/state/queries/post' +import { + usePostDeleteMutation, + useThreadMuteMutationQueue, +} from '#/state/queries/post' import {useSession} from '#/state/session' import {getCurrentRoute} from 'lib/routes/helpers' import {shareUrl} from 'lib/sharing' @@ -62,9 +65,7 @@ import * as Toast from '../Toast' let PostDropdownBtn = ({ testID, - postAuthor, - postCid, - postUri, + post, postFeedContext, record, richText, @@ -74,9 +75,7 @@ let PostDropdownBtn = ({ timestamp, }: { testID: string - postAuthor: AppBskyActorDefs.ProfileViewBasic - postCid: string - postUri: string + post: Shadow postFeedContext: string | undefined record: AppBskyFeedPost.Record richText: RichTextAPI @@ -92,8 +91,6 @@ let PostDropdownBtn = ({ const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl const langPrefs = useLanguagePrefs() - const mutedThreads = useMutedThreads() - const toggleThreadMute = useToggleThreadMute() const postDeleteMutation = usePostDeleteMutation() const hiddenPosts = useHiddenPosts() const {hidePost} = useHiddenPostsApi() @@ -107,9 +104,15 @@ let PostDropdownBtn = ({ const loggedOutWarningPromptControl = useDialogControl() const embedPostControl = useDialogControl() const sendViaChatControl = useDialogControl() + const postUri = post.uri + const postCid = post.cid + const postAuthor = post.author const rootUri = record.reply?.root?.uri || postUri - const isThreadMuted = mutedThreads.includes(rootUri) + const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( + post, + rootUri, + ) const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) const isAuthor = postAuthor.did === currentAccount?.did @@ -162,18 +165,22 @@ let PostDropdownBtn = ({ const onToggleThreadMute = React.useCallback(() => { try { - const muted = toggleThreadMute(rootUri) - if (muted) { + if (isThreadMuted) { + unmuteThread() + Toast.show(_(msg`You will now receive notifications for this thread`)) + } else { + muteThread() Toast.show( _(msg`You will no longer receive notifications for this thread`), ) - } else { - Toast.show(_(msg`You will now receive notifications for this thread`)) } - } catch (e) { - logger.error('Failed to toggle thread mute', {message: e}) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to toggle thread mute', {message: e}) + Toast.show(_(msg`Failed to toggle thread mute, please try again`)) + } } - }, [rootUri, toggleThreadMute, _]) + }, [isThreadMuted, unmuteThread, _, muteThread]) const onCopyPostText = React.useCallback(() => { const str = richTextToString(richText, true) diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index c389855e3..c0e743db4 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -319,9 +319,7 @@ let PostCtrls = ({ Date: Tue, 18 Jun 2024 11:48:49 -0700 Subject: Fix: only apply self-thread load-more behavior on the outer edge of the reply tree (#4559) --- src/state/queries/post-thread.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index a8b1160fb..f7e5e2ecb 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -31,6 +31,7 @@ import { getEmbeddedPost, } from './util' +const REPLY_TREE_DEPTH = 10 const RQKEY_ROOT = 'post-thread' export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread'] @@ -90,7 +91,10 @@ export function usePostThreadQuery(uri: string | undefined) { gcTime: 0, queryKey: RQKEY(uri || ''), async queryFn() { - const res = await agent.getPostThread({uri: uri!, depth: 10}) + const res = await agent.getPostThread({ + uri: uri!, + depth: REPLY_TREE_DEPTH, + }) if (res.success) { const thread = responseToThreadNodes(res.data.thread) annotateSelfThread(thread) @@ -287,7 +291,12 @@ function annotateSelfThread(thread: ThreadNode) { selfThreadNode.ctx.isSelfThread = true } const last = selfThreadNodes[selfThreadNodes.length - 1] - if (last && last.post.replyCount && !last.replies?.length) { + if ( + last && + last.ctx.depth === REPLY_TREE_DEPTH && // at the edge of the tree depth + last.post.replyCount && // has replies + !last.replies?.length // replies were not hydrated + ) { last.ctx.hasMoreSelfThread = true } } -- cgit 1.4.1 From 983d85384b9e736193e6c89107df5ced447a056a Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 18 Jun 2024 13:50:07 -0500 Subject: Force callers of `getTimeAgo` to pass in the value for "now" (#4560) * Remove icky hook for now * Force callers of getTimeAgo to pass in the 'now' value * Update usage in Newskie dialog --- src/components/NewskieDialog.tsx | 7 ++++--- src/lib/hooks/useTimeAgo.ts | 17 ++++------------- src/view/com/util/TimeElapsed.tsx | 6 ++++-- src/view/screens/Log.tsx | 4 +++- 4 files changed, 15 insertions(+), 19 deletions(-) (limited to 'src') diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx index fcdae0daa..789a42d5f 100644 --- a/src/components/NewskieDialog.tsx +++ b/src/components/NewskieDialog.tsx @@ -30,12 +30,13 @@ export function NewskieDialog({ const moderation = moderateProfile(profile, moderationOpts) return sanitizeDisplayName(name, moderation.ui('displayName')) }, [moderationOpts, profile]) + const [now] = React.useState(Date.now()) const timeAgo = useGetTimeAgo() const createdAt = profile.createdAt as string | undefined const daysOld = React.useMemo(() => { if (!createdAt) return Infinity - return differenceInSeconds(new Date(), new Date(createdAt)) / 86400 - }, [createdAt]) + return differenceInSeconds(now, new Date(createdAt)) / 86400 + }, [createdAt, now]) if (!createdAt || daysOld > 7) return null @@ -70,7 +71,7 @@ export function NewskieDialog({ {profileName} joined Bluesky{' '} - {timeAgo(createdAt, {format: 'long'})} ago + {timeAgo(createdAt, now, {format: 'long'})} ago diff --git a/src/lib/hooks/useTimeAgo.ts b/src/lib/hooks/useTimeAgo.ts index 5f0782f96..efcb4754b 100644 --- a/src/lib/hooks/useTimeAgo.ts +++ b/src/lib/hooks/useTimeAgo.ts @@ -1,4 +1,4 @@ -import {useCallback, useMemo} from 'react' +import {useCallback} from 'react' import {msg, plural} from '@lingui/macro' import {I18nContext, useLingui} from '@lingui/react' import {differenceInSeconds} from 'date-fns' @@ -12,25 +12,16 @@ export function useGetTimeAgo() { const {_} = useLingui() return useCallback( ( - date: number | string | Date, + earlier: number | string | Date, + later: number | string | Date, options?: Omit, ) => { - return dateDiff(date, Date.now(), {lingui: _, format: options?.format}) + return dateDiff(earlier, later, {lingui: _, format: options?.format}) }, [_], ) } -export function useTimeAgo( - date: number | string | Date, - options?: Omit, -): string { - const timeAgo = useGetTimeAgo() - return useMemo(() => { - return timeAgo(date, {...options}) - }, [date, options, timeAgo]) -} - const NOW = 5 const MINUTE = 60 const HOUR = MINUTE * 60 diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx index d939b3163..a49585182 100644 --- a/src/view/com/util/TimeElapsed.tsx +++ b/src/view/com/util/TimeElapsed.tsx @@ -15,12 +15,14 @@ export function TimeElapsed({ const ago = useGetTimeAgo() const format = timeToString ?? ago const tick = useTickEveryMinute() - const [timeElapsed, setTimeAgo] = React.useState(() => format(timestamp)) + const [timeElapsed, setTimeAgo] = React.useState(() => + format(timestamp, tick), + ) const [prevTick, setPrevTick] = React.useState(tick) if (prevTick !== tick) { setPrevTick(tick) - setTimeAgo(format(timestamp)) + setTimeAgo(format(timestamp, tick)) } return children({timeElapsed}) diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index e10aa83ab..e6040b77e 100644 --- a/src/view/screens/Log.tsx +++ b/src/view/screens/Log.tsx @@ -7,6 +7,7 @@ import {useFocusEffect} from '@react-navigation/native' import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {getEntries} from '#/logger/logDump' +import {useTickEveryMinute} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell' import {usePalette} from 'lib/hooks/usePalette' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' @@ -24,6 +25,7 @@ export function LogScreen({}: NativeStackScreenProps< const setMinimalShellMode = useSetMinimalShellMode() const [expanded, setExpanded] = React.useState([]) const timeAgo = useGetTimeAgo() + const tick = useTickEveryMinute() useFocusEffect( React.useCallback(() => { @@ -72,7 +74,7 @@ export function LogScreen({}: NativeStackScreenProps< /> ) : undefined} - {timeAgo(entry.timestamp)} + {timeAgo(entry.timestamp, tick)} {expanded.includes(entry.id) ? ( -- cgit 1.4.1 From 4165a02b2d712ba20b9fdbf435d4cb00c03e5e52 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 18 Jun 2024 13:52:44 -0500 Subject: Prevent unecessary calls (#4561) (cherry picked from commit ecb48797675c5be24508bf47141e930c64dac14e) --- src/components/NewskieDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx index 789a42d5f..281430e31 100644 --- a/src/components/NewskieDialog.tsx +++ b/src/components/NewskieDialog.tsx @@ -30,7 +30,7 @@ export function NewskieDialog({ const moderation = moderateProfile(profile, moderationOpts) return sanitizeDisplayName(name, moderation.ui('displayName')) }, [moderationOpts, profile]) - const [now] = React.useState(Date.now()) + const [now] = React.useState(() => Date.now()) const timeAgo = useGetTimeAgo() const createdAt = profile.createdAt as string | undefined const daysOld = React.useMemo(() => { -- cgit 1.4.1 From d6ce16d15ae79c4fef943cd48dfa0cdb072e9596 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 18 Jun 2024 12:07:56 -0700 Subject: Implement thread locking (#4545) * Add the ability to edit threadgates * Fix bottom border on mobile * Refresh thread after threadgate edit --- src/lib/analytics/types.ts | 2 + src/lib/api/index.ts | 17 +- src/state/modals/index.tsx | 3 +- src/state/queries/post-thread.ts | 2 +- src/state/queries/threadgate.ts | 33 ++++ src/view/com/modals/Threadgate.tsx | 11 +- src/view/com/post-thread/PostThreadItem.tsx | 25 ++- src/view/com/threadgate/WhoCanReply.tsx | 234 +++++++++++++++++----------- 8 files changed, 219 insertions(+), 108 deletions(-) (limited to 'src') diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts index cdf535dec..720495ea1 100644 --- a/src/lib/analytics/types.ts +++ b/src/lib/analytics/types.ts @@ -32,6 +32,8 @@ export type TrackPropertiesMap = { 'Post:ThreadMute': {} // CAN BE SERVER 'Post:ThreadUnmute': {} // CAN BE SERVER 'Post:Reply': {} // CAN BE SERVER + 'Post:EditThreadgateOpened': {} + 'Post:ThreadgateEdited': {} // PROFILE events 'Profile:Follow': { username: string diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index dfaae2e01..5b1c998cb 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -270,7 +270,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) { return res } -async function createThreadgate( +export async function createThreadgate( agent: BskyAgent, postUri: string, threadgate: ThreadgateSetting[], @@ -296,10 +296,17 @@ async function createThreadgate( } const postUrip = new AtUri(postUri) - await agent.api.app.bsky.feed.threadgate.create( - {repo: agent.session!.did, rkey: postUrip.rkey}, - {post: postUri, createdAt: new Date().toISOString(), allow}, - ) + await agent.api.com.atproto.repo.putRecord({ + repo: agent.session!.did, + collection: 'app.bsky.feed.threadgate', + rkey: postUrip.rkey, + record: { + $type: 'app.bsky.feed.threadgate', + post: postUri, + allow, + createdAt: new Date().toISOString(), + }, + }) } // helpers diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index ced14335b..685b10bd8 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -70,7 +70,8 @@ export interface SelfLabelModal { export interface ThreadgateModal { name: 'threadgate' settings: ThreadgateSetting[] - onChange: (settings: ThreadgateSetting[]) => void + onChange?: (settings: ThreadgateSetting[]) => void + onConfirm?: (settings: ThreadgateSetting[]) => void } export interface ChangeHandleModal { diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index f7e5e2ecb..db85e8a17 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -32,7 +32,7 @@ import { } from './util' const REPLY_TREE_DEPTH = 10 -const RQKEY_ROOT = 'post-thread' +export const RQKEY_ROOT = 'post-thread' export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread'] diff --git a/src/state/queries/threadgate.ts b/src/state/queries/threadgate.ts index 489117582..67c6f8c08 100644 --- a/src/state/queries/threadgate.ts +++ b/src/state/queries/threadgate.ts @@ -1,5 +1,38 @@ +import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api' + export type ThreadgateSetting = | {type: 'nobody'} | {type: 'mention'} | {type: 'following'} | {type: 'list'; list: string} + +export function threadgateViewToSettings( + threadgate: AppBskyFeedDefs.ThreadgateView | undefined, +): ThreadgateSetting[] { + const record = + threadgate && + AppBskyFeedThreadgate.isRecord(threadgate.record) && + AppBskyFeedThreadgate.validateRecord(threadgate.record).success + ? threadgate.record + : null + if (!record) { + return [] + } + if (!record.allow?.length) { + return [{type: 'nobody'}] + } + return record.allow + .map(allow => { + if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') { + return {type: 'mention'} + } + if (allow.$type === 'app.bsky.feed.threadgate#followingRule') { + return {type: 'following'} + } + if (allow.$type === 'app.bsky.feed.threadgate#listRule') { + return {type: 'list', list: allow.list} + } + return undefined + }) + .filter(Boolean) as ThreadgateSetting[] +} diff --git a/src/view/com/modals/Threadgate.tsx b/src/view/com/modals/Threadgate.tsx index a2e9f391c..4a9a9e2ab 100644 --- a/src/view/com/modals/Threadgate.tsx +++ b/src/view/com/modals/Threadgate.tsx @@ -26,9 +26,11 @@ export const snapPoints = ['60%'] export function Component({ settings, onChange, + onConfirm, }: { settings: ThreadgateSetting[] - onChange: (settings: ThreadgateSetting[]) => void + onChange?: (settings: ThreadgateSetting[]) => void + onConfirm?: (settings: ThreadgateSetting[]) => void }) { const pal = usePalette('default') const {closeModal} = useModalControls() @@ -38,12 +40,12 @@ export function Component({ const onPressEverybody = () => { setSelected([]) - onChange([]) + onChange?.([]) } const onPressNobody = () => { setSelected([{type: 'nobody'}]) - onChange([{type: 'nobody'}]) + onChange?.([{type: 'nobody'}]) } const onPressAudience = (setting: ThreadgateSetting) => { @@ -57,7 +59,7 @@ export function Component({ newSelected.splice(i, 1) } setSelected(newSelected) - onChange(newSelected) + onChange?.(newSelected) } return ( @@ -124,6 +126,7 @@ export function Component({ testID="confirmBtn" onPress={() => { closeModal() + onConfirm?.(selected) }} style={styles.btn} accessibilityRole="button" diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 5ee60e4ea..6d03029d7 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -25,7 +25,7 @@ import {sanitizeHandle} from 'lib/strings/handles' import {countLines} from 'lib/strings/helpers' import {niceDate} from 'lib/strings/time' import {s} from 'lib/styles' -import {isWeb} from 'platform/detection' +import {isNative, isWeb} from 'platform/detection' import {useSession} from 'state/session' import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' import {atoms as a} from '#/alf' @@ -189,6 +189,7 @@ let PostThreadItemLoaded = ({ const itemTitle = _(msg`Post by ${post.author.handle}`) const authorHref = makeProfileLink(post.author) const authorTitle = post.author.handle + const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did const likesHref = React.useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') @@ -395,7 +396,11 @@ let PostThreadItemLoaded = ({ - + ) } else { @@ -578,7 +583,9 @@ let PostThreadItemLoaded = ({ post={post} style={{ marginTop: 4, + borderBottomWidth: 1, }} + isThreadAuthor={isThreadAuthor} /> ) @@ -681,6 +688,20 @@ function ExpandedPostDetails({ ) } +function getThreadAuthor( + post: AppBskyFeedDefs.PostView, + record: AppBskyFeedPost.Record, +): string { + if (!record.reply) { + return post.author.did + } + try { + return new AtUri(record.reply.root.uri).host + } catch { + return '' + } +} + const styles = StyleSheet.create({ outer: { borderTopWidth: hairlineWidth, diff --git a/src/view/com/threadgate/WhoCanReply.tsx b/src/view/com/threadgate/WhoCanReply.tsx index c1e36d481..3ffbaa7ae 100644 --- a/src/view/com/threadgate/WhoCanReply.tsx +++ b/src/view/com/threadgate/WhoCanReply.tsx @@ -1,128 +1,172 @@ import React from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' -import { - AppBskyFeedDefs, - AppBskyFeedThreadgate, - AppBskyGraphDefs, - AtUri, -} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {Trans} from '@lingui/macro' +import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' +import {AppBskyFeedDefs, AppBskyGraphDefs, AtUri} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' +import {useAnalytics} from '#/lib/analytics/analytics' +import {createThreadgate} from '#/lib/api' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {makeListLink, makeProfileLink} from '#/lib/routes/links' import {colors} from '#/lib/styles' +import {logger} from '#/logger' +import {isNative} from '#/platform/detection' +import {useModalControls} from '#/state/modals' +import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread' +import { + ThreadgateSetting, + threadgateViewToSettings, +} from '#/state/queries/threadgate' +import {useAgent} from '#/state/session' +import * as Toast from 'view/com/util/Toast' +import {Button} from '#/components/Button' import {TextLink} from '../util/Link' import {Text} from '../util/text/Text' export function WhoCanReply({ post, + isThreadAuthor, style, }: { post: AppBskyFeedDefs.PostView + isThreadAuthor: boolean style?: StyleProp }) { + const {track} = useAnalytics() + const {_} = useLingui() const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() + const agent = useAgent() + const queryClient = useQueryClient() + const {openModal} = useModalControls() const containerStyles = useColorSchemeStyle( { - borderColor: pal.colors.unreadNotifBorder, backgroundColor: pal.colors.unreadNotifBg, }, { - borderColor: pal.colors.unreadNotifBorder, backgroundColor: pal.colors.unreadNotifBg, }, ) - const iconStyles = useColorSchemeStyle( + const textStyles = useColorSchemeStyle( + {color: colors.blue5}, + {color: colors.blue1}, + ) + const hoverStyles = useColorSchemeStyle( { - backgroundColor: colors.blue3, + backgroundColor: colors.white, }, { - backgroundColor: colors.blue3, + backgroundColor: pal.colors.background, }, ) - const textStyles = useColorSchemeStyle( - {color: colors.gray7}, - {color: colors.blue1}, - ) - const record = React.useMemo( - () => - post.threadgate && - AppBskyFeedThreadgate.isRecord(post.threadgate.record) && - AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success - ? post.threadgate.record - : null, + const settings = React.useMemo( + () => threadgateViewToSettings(post.threadgate), [post], ) - if (record) { - return ( - - - - - - - {!record.allow?.length ? ( - Replies to this thread are disabled - ) : ( - - Only{' '} - {record.allow.map((rule, i) => ( - <> - - - - ))}{' '} - can reply. - + const isRootPost = !('reply' in post.record) + + const onPressEdit = () => { + track('Post:EditThreadgateOpened') + if (isNative && Keyboard.isVisible()) { + Keyboard.dismiss() + } + openModal({ + name: 'threadgate', + settings, + async onConfirm(newSettings: ThreadgateSetting[]) { + try { + if (newSettings.length) { + await createThreadgate(agent, post.uri, newSettings) + } else { + await agent.api.com.atproto.repo.deleteRecord({ + repo: agent.session!.did, + collection: 'app.bsky.feed.threadgate', + rkey: new AtUri(post.uri).rkey, + }) + } + Toast.show('Thread settings updated') + queryClient.invalidateQueries({ + queryKey: [POST_THREAD_RQKEY_ROOT], + }) + track('Post:ThreadgateEdited') + } catch (err) { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + logger.error('Failed to edit threadgate', {message: err}) + } + }, + }) + } + + if (!isRootPost) { + return null + } + if (!settings.length && !isThreadAuthor) { + return null + } + + return ( + + + + {!settings.length ? ( + Everybody can reply. + ) : settings[0].type === 'nobody' ? ( + Replies to this thread are disabled. + ) : ( + + Only{' '} + {settings.map((rule, i) => ( + <> + + + + ))}{' '} + can reply. + + )} + + + {isThreadAuthor && ( + + - - ) - } - return null + )} + + ) } function Rule({ @@ -130,15 +174,15 @@ function Rule({ post, lists, }: { - rule: any + rule: ThreadgateSetting post: AppBskyFeedDefs.PostView lists: AppBskyGraphDefs.ListViewBasic[] | undefined }) { const pal = usePalette('default') - if (AppBskyFeedThreadgate.isMentionRule(rule)) { + if (rule.type === 'mention') { return mentioned users } - if (AppBskyFeedThreadgate.isFollowingRule(rule)) { + if (rule.type === 'following') { return ( users followed by{' '} @@ -151,7 +195,7 @@ function Rule({ ) } - if (AppBskyFeedThreadgate.isListRule(rule)) { + if (rule.type === 'list') { const list = lists?.find(l => l.uri === rule.list) if (list) { const listUrip = new AtUri(list.uri) -- cgit 1.4.1 From 502bcad7017d72fb23c40a268b1e220f892db7da Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 18 Jun 2024 14:09:40 -0500 Subject: Disable newskie dialog tap in hover card web (#4562) --- src/components/NewskieDialog.tsx | 3 +++ src/components/ProfileHoverCard/index.web.tsx | 2 +- src/screens/Profile/Header/Handle.tsx | 6 ++++-- 3 files changed, 8 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx index 281430e31..0354bfc43 100644 --- a/src/components/NewskieDialog.tsx +++ b/src/components/NewskieDialog.tsx @@ -18,8 +18,10 @@ import {Text} from '#/components/Typography' export function NewskieDialog({ profile, + disabled, }: { profile: AppBskyActorDefs.ProfileViewDetailed + disabled?: boolean }) { const {_} = useLingui() const moderationOpts = useModerationOpts() @@ -43,6 +45,7 @@ export function NewskieDialog({ return (