diff options
Diffstat (limited to 'src/state')
-rw-r--r-- | src/state/cache/thread-mutes.tsx | 97 | ||||
-rw-r--r-- | src/state/modals/index.tsx | 3 | ||||
-rw-r--r-- | src/state/muted-threads.tsx | 62 | ||||
-rw-r--r-- | src/state/persisted/schema.ts | 3 | ||||
-rw-r--r-- | src/state/queries/notifications/feed.ts | 3 | ||||
-rw-r--r-- | src/state/queries/notifications/unread.tsx | 5 | ||||
-rw-r--r-- | src/state/queries/notifications/util.ts | 50 | ||||
-rw-r--r-- | src/state/queries/post-feed.ts | 6 | ||||
-rw-r--r-- | src/state/queries/post-thread.ts | 15 | ||||
-rw-r--r-- | src/state/queries/post.ts | 70 | ||||
-rw-r--r-- | src/state/queries/threadgate.ts | 33 | ||||
-rw-r--r-- | src/state/session/agent.ts | 44 | ||||
-rw-r--r-- | src/state/session/index.tsx | 5 | ||||
-rw-r--r-- | src/state/shell/reminders.e2e.ts | 4 | ||||
-rw-r--r-- | src/state/shell/reminders.ts | 53 |
15 files changed, 287 insertions, 166 deletions
diff --git a/src/state/cache/thread-mutes.tsx b/src/state/cache/thread-mutes.tsx new file mode 100644 index 000000000..dc5104c14 --- /dev/null +++ b/src/state/cache/thread-mutes.tsx @@ -0,0 +1,97 @@ +import React, {useEffect} from 'react' + +import * as persisted from '#/state/persisted' +import {useAgent, useSession} from '../session' + +type StateContext = Map<string, boolean> +type SetStateContext = (uri: string, value: boolean) => void + +const stateContext = React.createContext<StateContext>(new Map()) +const setStateContext = React.createContext<SetStateContext>( + (_: string) => false, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState<StateContext>(() => new Map()) + + const setThreadMute = React.useCallback( + (uri: string, value: boolean) => { + setState(prev => { + const next = new Map(prev) + next.set(uri, value) + return next + }) + }, + [setState], + ) + + useMigrateMutes(setThreadMute) + + return ( + <stateContext.Provider value={state}> + <setStateContext.Provider value={setThreadMute}> + {children} + </setStateContext.Provider> + </stateContext.Provider> + ) +} + +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) +} + +function useMigrateMutes(setThreadMute: SetStateContext) { + const agent = useAgent() + const {currentAccount} = useSession() + + useEffect(() => { + if (currentAccount) { + if ( + !persisted + .get('mutedThreads') + .some(uri => uri.includes(currentAccount.did)) + ) { + return + } + + let cancelled = false + + const migrate = async () => { + while (!cancelled) { + const threads = persisted.get('mutedThreads') + + const root = threads.findLast(uri => uri.includes(currentAccount.did)) + + if (!root) break + + persisted.write( + 'mutedThreads', + threads.filter(uri => uri !== root), + ) + + setThreadMute(root, true) + + await agent.api.app.bsky.graph + .muteThread({root}) + // not a big deal if this fails, since the post might have been deleted + .catch(console.error) + } + } + + migrate() + + return () => { + cancelled = true + } + } + }, [agent, currentAccount, setThreadMute]) +} 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/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<StateContext>( - persisted.defaults.mutedThreads, -) -const toggleContext = React.createContext<ToggleContext>((_: 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 ( - <stateContext.Provider value={state}> - <toggleContext.Provider value={toggleThreadMute}> - {children} - </toggleContext.Provider> - </stateContext.Provider> - ) -} - -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/persisted/schema.ts b/src/state/persisted/schema.ts index b81cf5962..9d5b17d35 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -74,7 +74,6 @@ export const schema = z.object({ flickr: z.enum(externalEmbedOptions).optional(), }) .optional(), - mutedThreads: z.array(z.string()), // should move to server invites: z.object({ copiedInvites: z.array(z.string()), }), @@ -88,6 +87,8 @@ export const schema = z.object({ disableHaptics: z.boolean().optional(), disableAutoplay: z.boolean().optional(), kawaii: z.boolean().optional(), + /** @deprecated */ + mutedThreads: z.array(z.string()), }) export type Schema = z.infer<typeof schema> 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-feed.ts b/src/state/queries/post-feed.ts index 2fb80de37..4e44c1c69 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -78,6 +78,7 @@ export interface FeedPostSliceItem { feedContext: string | undefined moderation: ModerationDecision parentAuthor?: AppBskyActorDefs.ProfileViewBasic + isParentBlocked?: boolean } export interface FeedPostSlice { @@ -311,6 +312,10 @@ export function usePostFeedQuery( const parentAuthor = item.reply?.parent?.author ?? slice.items[i + 1]?.reply?.grandparentAuthor + const replyRef = item.reply + const isParentBlocked = AppBskyFeedDefs.isBlockedPost( + replyRef?.parent, + ) return { _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`, @@ -324,6 +329,7 @@ export function usePostFeedQuery( feedContext: item.feedContext || slice.feedContext, moderation: moderations[i], parentAuthor, + isParentBlocked, } } return undefined diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index a8b1160fb..db85e8a17 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -31,7 +31,8 @@ import { getEmbeddedPost, } from './util' -const RQKEY_ROOT = 'post-thread' +const REPLY_TREE_DEPTH = 10 +export 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 } } diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index 794f48eb1..a511d6b3d 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<AppBskyFeedDefs.PostView>, + rootUri: string, +) { + const threadMuteMutation = useThreadMuteMutation() + const threadUnmuteMutation = useThreadUnmuteMutation() + const isThreadMuted = useIsThreadMuted(rootUri, post.viewer?.threadMuted) + const setThreadMute = useSetThreadMute() + + const queueToggle = useToggleMutationQueue<boolean>({ + initialState: isThreadMuted, + runMutation: async (_prev, shouldMute) => { + if (shouldMute) { + 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/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/state/session/agent.ts b/src/state/session/agent.ts index 48f5614bd..5a58937fa 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -11,6 +11,7 @@ import { import {tryFetchGates} from '#/lib/statsig/statsig' import {getAge} from '#/lib/strings/time' import {logger} from '#/logger' +import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' import { configureModerationForAccount, configureModerationForGuest, @@ -37,21 +38,7 @@ export async function createAgentAndResume( } const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency') const moderation = configureModerationForAccount(agent, storedAccount) - const prevSession: AtpSessionData = { - // Sorted in the same property order as when returned by BskyAgent (alphabetical). - accessJwt: storedAccount.accessJwt ?? '', - did: storedAccount.did, - email: storedAccount.email, - emailAuthFactor: storedAccount.emailAuthFactor, - emailConfirmed: storedAccount.emailConfirmed, - handle: storedAccount.handle, - refreshJwt: storedAccount.refreshJwt ?? '', - /** - * @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188 - */ - active: storedAccount.active ?? true, - status: storedAccount.status, - } + const prevSession: AtpSessionData = sessionAccountToSession(storedAccount) if (isSessionExpired(storedAccount)) { await networkRetry(1, () => agent.resumeSession(prevSession)) } else { @@ -191,6 +178,13 @@ export async function createAgentAndCreateAccount( agent.setPersonalDetails({birthDate: birthDate.toISOString()}) } + try { + // snooze first prompt after signup, defer to next prompt + snoozeEmailConfirmationPrompt() + } catch (e: any) { + logger.error(e, {context: `session: failed snoozeEmailConfirmationPrompt`}) + } + return prepareAgent(agent, gates, moderation, onSessionChange) } @@ -245,3 +239,23 @@ export function agentToSessionAccount( pdsUrl: agent.pdsUrl?.toString(), } } + +export function sessionAccountToSession( + account: SessionAccount, +): AtpSessionData { + return { + // Sorted in the same property order as when returned by BskyAgent (alphabetical). + accessJwt: account.accessJwt ?? '', + did: account.did, + email: account.email, + emailAuthFactor: account.emailAuthFactor, + emailConfirmed: account.emailConfirmed, + handle: account.handle, + refreshJwt: account.refreshJwt ?? '', + /** + * @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188 + */ + active: account.active ?? true, + status: account.status, + } +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 371bd459a..314945bcf 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -14,6 +14,7 @@ import { createAgentAndCreateAccount, createAgentAndLogin, createAgentAndResume, + sessionAccountToSession, } from './agent' import {getInitialState, reducer} from './reducer' @@ -175,8 +176,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { if (syncedAccount.did !== state.currentAgentState.did) { resumeSession(syncedAccount) } else { - // @ts-ignore we checked for `refreshJwt` above - state.currentAgentState.agent.session = syncedAccount + const agent = state.currentAgentState.agent as BskyAgent + agent.session = sessionAccountToSession(syncedAccount) } } }) diff --git a/src/state/shell/reminders.e2e.ts b/src/state/shell/reminders.e2e.ts index e8c12792a..94809a680 100644 --- a/src/state/shell/reminders.e2e.ts +++ b/src/state/shell/reminders.e2e.ts @@ -1,7 +1,5 @@ -export function init() {} - export function shouldRequestEmailConfirmation() { return false } -export function setEmailConfirmationRequested() {} +export function snoozeEmailConfirmationPrompt() {} diff --git a/src/state/shell/reminders.ts b/src/state/shell/reminders.ts index ee924eb00..db6ee9391 100644 --- a/src/state/shell/reminders.ts +++ b/src/state/shell/reminders.ts @@ -1,36 +1,45 @@ +import {simpleAreDatesEqual} from '#/lib/strings/time' +import {logger} from '#/logger' import * as persisted from '#/state/persisted' -import {toHashCode} from 'lib/strings/helpers' -import {isOnboardingActive} from './onboarding' import {SessionAccount} from '../session' +import {isOnboardingActive} from './onboarding' export function shouldRequestEmailConfirmation(account: SessionAccount) { - if (!account) { - return false - } - if (account.emailConfirmed) { - return false - } - if (isOnboardingActive()) { - return false - } - // only prompt once - if (persisted.get('reminders').lastEmailConfirm) { - return false - } + // ignore logged out + if (!account) return false + // ignore confirmed accounts, this is the success state of this reminder + if (account.emailConfirmed) return false + // wait for onboarding to complete + if (isOnboardingActive()) return false + + const snoozedAt = persisted.get('reminders').lastEmailConfirm const today = new Date() - // shard the users into 2 day of the week buckets - // (this is to avoid a sudden influx of email updates when - // this feature rolls out) - const code = toHashCode(account.did) % 7 - if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { + + logger.debug('Checking email confirmation reminder', { + today, + snoozedAt, + }) + + // never been snoozed, new account + if (!snoozedAt) { + return true + } + + // already snoozed today + if (simpleAreDatesEqual(new Date(Date.parse(snoozedAt)), new Date())) { return false } + return true } -export function setEmailConfirmationRequested() { +export function snoozeEmailConfirmationPrompt() { + const lastEmailConfirm = new Date().toISOString() + logger.debug('Snoozing email confirmation reminder', { + snoozedAt: lastEmailConfirm, + }) persisted.write('reminders', { ...persisted.get('reminders'), - lastEmailConfirm: new Date().toISOString(), + lastEmailConfirm, }) } |