about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-rw-r--r--src/state/cache/thread-mutes.tsx97
-rw-r--r--src/state/modals/index.tsx3
-rw-r--r--src/state/muted-threads.tsx62
-rw-r--r--src/state/persisted/schema.ts3
-rw-r--r--src/state/queries/notifications/feed.ts3
-rw-r--r--src/state/queries/notifications/unread.tsx5
-rw-r--r--src/state/queries/notifications/util.ts50
-rw-r--r--src/state/queries/post-feed.ts6
-rw-r--r--src/state/queries/post-thread.ts15
-rw-r--r--src/state/queries/post.ts70
-rw-r--r--src/state/queries/threadgate.ts33
-rw-r--r--src/state/session/agent.ts44
-rw-r--r--src/state/session/index.tsx5
-rw-r--r--src/state/shell/reminders.e2e.ts4
-rw-r--r--src/state/shell/reminders.ts53
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,
   })
 }