about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorFrudrax Cheng <i@cynosura.one>2024-06-19 14:41:03 +0800
committerGitHub <noreply@github.com>2024-06-19 14:41:03 +0800
commitbdc1ea897fa4e1a2bb0fbd405564e98aca36f973 (patch)
treea412067a09e0c34d7d83496842ebd0e0e57f4b9d /src
parenta6d49062e6d50b7c9a6c0d50c38fcfeb8f63e46f (diff)
parent8788708bd229ee8a7049285b2e520cc657b41c00 (diff)
downloadvoidsky-bdc1ea897fa4e1a2bb0fbd405564e98aca36f973.tar.zst
Merge branch 'bluesky-social:main' into zh
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx76
-rw-r--r--src/App.web.tsx72
-rw-r--r--src/Navigation.tsx4
-rw-r--r--src/components/KnownFollowers.tsx67
-rw-r--r--src/components/NewskieDialog.tsx10
-rw-r--r--src/components/ProfileHoverCard/index.web.tsx2
-rw-r--r--src/components/moderation/PostAlerts.tsx2
-rw-r--r--src/components/moderation/PostHider.tsx12
-rw-r--r--src/lib/analytics/types.ts2
-rw-r--r--src/lib/api/index.ts17
-rw-r--r--src/lib/hooks/useTimeAgo.ts17
-rw-r--r--src/lib/statsig/events.ts2
-rw-r--r--src/lib/strings/embed-player.ts100
-rw-r--r--src/lib/strings/time.ts11
-rw-r--r--src/screens/Profile/Header/Handle.tsx6
-rw-r--r--src/screens/Profile/Header/ProfileHeaderLabeler.tsx6
-rw-r--r--src/screens/Signup/state.ts7
-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
-rw-r--r--src/view/com/modals/Threadgate.tsx11
-rw-r--r--src/view/com/notifications/FeedItem.tsx47
-rw-r--r--src/view/com/pager/TabBar.tsx4
-rw-r--r--src/view/com/post-thread/PostThread.tsx6
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx25
-rw-r--r--src/view/com/posts/FeedItem.tsx53
-rw-r--r--src/view/com/posts/FeedSlice.tsx4
-rw-r--r--src/view/com/threadgate/WhoCanReply.tsx234
-rw-r--r--src/view/com/util/List.web.tsx63
-rw-r--r--src/view/com/util/TimeElapsed.tsx6
-rw-r--r--src/view/com/util/UserAvatar.tsx35
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx45
-rw-r--r--src/view/com/util/images/ImageHorzList.tsx57
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx4
-rw-r--r--src/view/screens/Log.tsx4
47 files changed, 929 insertions, 535 deletions
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() {
                             <SelectedFeedProvider>
                               <UnreadNotifsProvider>
                                 <BackgroundNotificationPreferencesProvider>
-                                  <GestureHandlerRootView style={s.h100pct}>
-                                    <TestCtrls />
-                                    <Shell />
-                                  </GestureHandlerRootView>
+                                  <MutedThreadsProvider>
+                                    <GestureHandlerRootView style={s.h100pct}>
+                                      <TestCtrls />
+                                      <Shell />
+                                    </GestureHandlerRootView>
+                                  </MutedThreadsProvider>
                                 </BackgroundNotificationPreferencesProvider>
                               </UnreadNotifsProvider>
                             </SelectedFeedProvider>
@@ -154,21 +156,19 @@ function App() {
       <SessionProvider>
         <ShellStateProvider>
           <PrefsStateProvider>
-            <MutedThreadsProvider>
-              <InvitesStateProvider>
-                <ModalStateProvider>
-                  <DialogStateProvider>
-                    <LightboxStateProvider>
-                      <I18nProvider>
-                        <PortalProvider>
-                          <InnerApp />
-                        </PortalProvider>
-                      </I18nProvider>
-                    </LightboxStateProvider>
-                  </DialogStateProvider>
-                </ModalStateProvider>
-              </InvitesStateProvider>
-            </MutedThreadsProvider>
+            <InvitesStateProvider>
+              <ModalStateProvider>
+                <DialogStateProvider>
+                  <LightboxStateProvider>
+                    <I18nProvider>
+                      <PortalProvider>
+                        <InnerApp />
+                      </PortalProvider>
+                    </I18nProvider>
+                  </LightboxStateProvider>
+                </DialogStateProvider>
+              </ModalStateProvider>
+            </InvitesStateProvider>
           </PrefsStateProvider>
         </ShellStateProvider>
       </SessionProvider>
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() {
                           <SelectedFeedProvider>
                             <UnreadNotifsProvider>
                               <BackgroundNotificationPreferencesProvider>
-                                <SafeAreaProvider>
-                                  <Shell />
-                                </SafeAreaProvider>
+                                <MutedThreadsProvider>
+                                  <SafeAreaProvider>
+                                    <Shell />
+                                  </SafeAreaProvider>
+                                </MutedThreadsProvider>
                               </BackgroundNotificationPreferencesProvider>
                             </UnreadNotifsProvider>
                           </SelectedFeedProvider>
@@ -136,21 +138,19 @@ function App() {
     <SessionProvider>
       <ShellStateProvider>
         <PrefsStateProvider>
-          <MutedThreadsProvider>
-            <InvitesStateProvider>
-              <ModalStateProvider>
-                <DialogStateProvider>
-                  <LightboxStateProvider>
-                    <I18nProvider>
-                      <PortalProvider>
-                        <InnerApp />
-                      </PortalProvider>
-                    </I18nProvider>
-                  </LightboxStateProvider>
-                </DialogStateProvider>
-              </ModalStateProvider>
-            </InvitesStateProvider>
-          </MutedThreadsProvider>
+          <InvitesStateProvider>
+            <ModalStateProvider>
+              <DialogStateProvider>
+                <LightboxStateProvider>
+                  <I18nProvider>
+                    <PortalProvider>
+                      <InnerApp />
+                    </PortalProvider>
+                  </I18nProvider>
+                </LightboxStateProvider>
+              </DialogStateProvider>
+            </ModalStateProvider>
+          </InvitesStateProvider>
         </PrefsStateProvider>
       </ShellStateProvider>
     </SessionProvider>
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 67b89e262..5d4ba0e3f 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -54,8 +54,8 @@ import {useModalControls} from './state/modals'
 import {useUnreadNotifications} from './state/queries/notifications/unread'
 import {useSession} from './state/session'
 import {
-  setEmailConfirmationRequested,
   shouldRequestEmailConfirmation,
+  snoozeEmailConfirmationPrompt,
 } from './state/shell/reminders'
 import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
 import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
@@ -585,7 +585,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
 
     if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
       openModal({name: 'verify-email', showReminder: true})
-      setEmailConfirmationRequested()
+      snoozeEmailConfirmationPrompt()
     }
   }
 
diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx
index 63f61ce85..7b861dc66 100644
--- a/src/components/KnownFollowers.tsx
+++ b/src/components/KnownFollowers.tsx
@@ -100,7 +100,15 @@ function KnownFollowersInner({
       moderation,
     }
   })
-  const count = cachedKnownFollowers.count
+
+  // Does not have blocks applied. Always >= slices.length
+  const serverCount = cachedKnownFollowers.count
+
+  /*
+   * We check above too, but here for clarity and a reminder to _check for
+   * valid indices_
+   */
+  if (slice.length === 0) return null
 
   return (
     <Link
@@ -164,31 +172,54 @@ function KnownFollowersInner({
               },
             ]}
             numberOfLines={2}>
-            {count > 2 ? (
-              <Trans>
-                Followed by{' '}
-                <Text key={slice[0].profile.did} style={textStyle}>
-                  {slice[0].profile.displayName}
-                </Text>
-                ,{' '}
-                <Text key={slice[1].profile.did} style={textStyle}>
-                  {slice[1].profile.displayName}
-                </Text>
-                , and{' '}
-                <Plural value={count - 2} one="# other" other="# others" />
-              </Trans>
-            ) : count === 2 ? (
+            {slice.length >= 2 ? (
+              // 2-n followers, including blocks
+              serverCount > 2 ? (
+                <Trans>
+                  Followed by{' '}
+                  <Text key={slice[0].profile.did} style={textStyle}>
+                    {slice[0].profile.displayName}
+                  </Text>
+                  ,{' '}
+                  <Text key={slice[1].profile.did} style={textStyle}>
+                    {slice[1].profile.displayName}
+                  </Text>
+                  , and{' '}
+                  <Plural
+                    value={serverCount - 2}
+                    one="# other"
+                    other="# others"
+                  />
+                </Trans>
+              ) : (
+                // only 2
+                <Trans>
+                  Followed by{' '}
+                  <Text key={slice[0].profile.did} style={textStyle}>
+                    {slice[0].profile.displayName}
+                  </Text>{' '}
+                  and{' '}
+                  <Text key={slice[1].profile.did} style={textStyle}>
+                    {slice[1].profile.displayName}
+                  </Text>
+                </Trans>
+              )
+            ) : serverCount > 1 ? (
+              // 1-n followers, including blocks
               <Trans>
                 Followed by{' '}
                 <Text key={slice[0].profile.did} style={textStyle}>
                   {slice[0].profile.displayName}
                 </Text>{' '}
                 and{' '}
-                <Text key={slice[1].profile.did} style={textStyle}>
-                  {slice[1].profile.displayName}
-                </Text>
+                <Plural
+                  value={serverCount - 1}
+                  one="# other"
+                  other="# others"
+                />
               </Trans>
             ) : (
+              // only 1
               <Trans>
                 Followed by{' '}
                 <Text key={slice[0].profile.did} style={textStyle}>
diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx
index fcdae0daa..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()
@@ -30,18 +32,20 @@ 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
 
   return (
     <View style={[a.pr_2xs]}>
       <Button
+        disabled={disabled}
         label={_(
           msg`This user is new here. Press for more info about when they joined.`,
         )}
@@ -70,7 +74,7 @@ export function NewskieDialog({
             <Text style={[a.text_md]}>
               <Trans>
                 {profileName} joined Bluesky{' '}
-                {timeAgo(createdAt, {format: 'long'})} ago
+                {timeAgo(createdAt, now, {format: 'long'})} ago
               </Trans>
             </Text>
           </View>
diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx
index 4f110485e..319eccfa4 100644
--- a/src/components/ProfileHoverCard/index.web.tsx
+++ b/src/components/ProfileHoverCard/index.web.tsx
@@ -469,7 +469,7 @@ function Inner({
             )}
           </Text>
 
-          <ProfileHeaderHandle profile={profileShadow} />
+          <ProfileHeaderHandle profile={profileShadow} disableTaps />
         </View>
       </Link>
 
diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx
index 0b48b51d1..ec7529a4f 100644
--- a/src/components/moderation/PostAlerts.tsx
+++ b/src/components/moderation/PostAlerts.tsx
@@ -92,6 +92,8 @@ function PostLabel({
               <UserAvatar
                 avatar={desc.sourceAvi}
                 size={size === 'large' ? 16 : 12}
+                type="labeler"
+                shape="circle"
               />
             ) : (
               <desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
diff --git a/src/components/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx
index 8a6474297..b6fb17452 100644
--- a/src/components/moderation/PostHider.tsx
+++ b/src/components/moderation/PostHider.tsx
@@ -1,6 +1,6 @@
 import React, {ComponentProps} from 'react'
 import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {AppBskyActorDefs, ModerationUI} from '@atproto/api'
+import {AppBskyActorDefs, ModerationCause, ModerationUI} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
@@ -45,7 +45,8 @@ export function PostHider({
   const [override, setOverride] = React.useState(false)
   const control = useModerationDetailsDialogControl()
   const blur =
-    modui.blurs[0] || (interpretFilterAsBlur ? modui.filters[0] : undefined)
+    modui.blurs[0] ||
+    (interpretFilterAsBlur ? getBlurrableFilter(modui) : undefined)
   const desc = useModerationCauseDescription(blur)
 
   const onBeforePress = React.useCallback(() => {
@@ -134,6 +135,13 @@ export function PostHider({
   )
 }
 
+function getBlurrableFilter(modui: ModerationUI): ModerationCause | undefined {
+  // moderation causes get "downgraded" when they originate from embedded content
+  // a downgraded cause should *only* drive filtering in feeds, so we want to look
+  // for filters that arent downgraded
+  return modui.filters.find(filter => !filter.downgraded)
+}
+
 const styles = StyleSheet.create({
   child: {
     borderWidth: 0,
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/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<TimeAgoOptions, 'lingui'>,
     ) => {
-      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<TimeAgoOptions, 'lingui'>,
-): 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/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/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index 30ced1492..44e42fae1 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -1,7 +1,8 @@
-import {Dimensions, Platform} from 'react-native'
+import {Dimensions} from 'react-native'
 
 import {isSafari} from 'lib/browser'
 import {isWeb} from 'platform/detection'
+
 const {height: SCREEN_HEIGHT} = Dimensions.get('window')
 
 const IFRAME_HOST = isWeb
@@ -342,40 +343,17 @@ export function parseEmbedPlayerFromUrl(
     }
   }
 
-  if (urlp.hostname === 'media.tenor.com') {
-    let [_, id, filename] = urlp.pathname.split('/')
-
-    const h = urlp.searchParams.get('hh')
-    const w = urlp.searchParams.get('ww')
-    let dimensions
-    if (h && w) {
-      dimensions = {
-        height: Number(h),
-        width: Number(w),
-      }
-    }
-
-    if (id && filename && dimensions && id.includes('AAAAC')) {
-      if (Platform.OS === 'web') {
-        if (isSafari) {
-          id = id.replace('AAAAC', 'AAAP1')
-          filename = filename.replace('.gif', '.mp4')
-        } else {
-          id = id.replace('AAAAC', 'AAAP3')
-          filename = filename.replace('.gif', '.webm')
-        }
-      } else {
-        id = id.replace('AAAAC', 'AAAAM')
-      }
-
-      return {
-        type: 'tenor_gif',
-        source: 'tenor',
-        isGif: true,
-        hideDetails: true,
-        playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
-        dimensions,
-      }
+  const tenorGif = parseTenorGif(urlp)
+  if (tenorGif.success) {
+    const {playerUri, dimensions} = tenorGif
+
+    return {
+      type: 'tenor_gif',
+      source: 'tenor',
+      isGif: true,
+      hideDetails: true,
+      playerUri,
+      dimensions,
     }
   }
 
@@ -516,3 +494,55 @@ export function getGiphyMetaUri(url: URL) {
     }
   }
 }
+
+export function parseTenorGif(urlp: URL):
+  | {success: false}
+  | {
+      success: true
+      playerUri: string
+      dimensions: {height: number; width: number}
+    } {
+  if (urlp.hostname !== 'media.tenor.com') {
+    return {success: false}
+  }
+
+  let [_, id, filename] = urlp.pathname.split('/')
+
+  if (!id || !filename) {
+    return {success: false}
+  }
+
+  if (!id.includes('AAAAC')) {
+    return {success: false}
+  }
+
+  const h = urlp.searchParams.get('hh')
+  const w = urlp.searchParams.get('ww')
+
+  if (!h || !w) {
+    return {success: false}
+  }
+
+  const dimensions = {
+    height: Number(h),
+    width: Number(w),
+  }
+
+  if (isWeb) {
+    if (isSafari) {
+      id = id.replace('AAAAC', 'AAAP1')
+      filename = filename.replace('.gif', '.mp4')
+    } else {
+      id = id.replace('AAAAC', 'AAAP3')
+      filename = filename.replace('.gif', '.webm')
+    }
+  } else {
+    id = id.replace('AAAAC', 'AAAAM')
+  }
+
+  return {
+    success: true,
+    playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
+    dimensions,
+  }
+}
diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts
index 1194e0240..bfefea9bc 100644
--- a/src/lib/strings/time.ts
+++ b/src/lib/strings/time.ts
@@ -19,3 +19,14 @@ export function getAge(birthDate: Date): number {
   }
   return age
 }
+
+/**
+ * Compares two dates by year, month, and day only
+ */
+export function simpleAreDatesEqual(a: Date, b: Date): boolean {
+  return (
+    a.getFullYear() === b.getFullYear() &&
+    a.getMonth() === b.getMonth() &&
+    a.getDate() === b.getDate()
+  )
+}
diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx
index 4f438a286..268b7350f 100644
--- a/src/screens/Profile/Header/Handle.tsx
+++ b/src/screens/Profile/Header/Handle.tsx
@@ -12,8 +12,10 @@ import {Text} from '#/components/Typography'
 
 export function ProfileHeaderHandle({
   profile,
+  disableTaps,
 }: {
   profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
+  disableTaps?: boolean
 }) {
   const t = useTheme()
   const invalidHandle = isInvalidHandle(profile.handle)
@@ -21,8 +23,8 @@ export function ProfileHeaderHandle({
   return (
     <View
       style={[a.flex_row, a.gap_xs, a.align_center]}
-      pointerEvents={isAndroid ? 'box-only' : 'auto'}>
-      <NewskieDialog profile={profile} />
+      pointerEvents={disableTaps ? 'none' : isAndroid ? 'box-only' : 'auto'}>
+      <NewskieDialog profile={profile} disabled={disableTaps} />
       {profile.viewer?.followedBy && !blockHide ? (
         <View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}>
           <Text style={[t.atoms.text, a.text_sm]}>
diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
index 6588eb2e1..d266decb3 100644
--- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
@@ -82,7 +82,7 @@ let ProfileHeaderLabeler = ({
     preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
   const canSubscribe =
     isSubscribed ||
-    (preferences ? preferences?.moderationPrefs.labelers.length < 9 : false)
+    (preferences ? preferences?.moderationPrefs.labelers.length <= 20 : false)
   const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
   const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
     useUnlikeMutation()
@@ -328,8 +328,8 @@ function CantSubscribePrompt({
       <Prompt.TitleText>Unable to subscribe</Prompt.TitleText>
       <Prompt.DescriptionText>
         <Trans>
-          We're sorry! You can only subscribe to ten labelers, and you've
-          reached your limit of ten.
+          We're sorry! You can only subscribe to twenty labelers, and you've
+          reached your limit of twenty.
         </Trans>
       </Prompt.DescriptionText>
       <Prompt.Actions>
diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts
index facc680bd..87700cb88 100644
--- a/src/screens/Signup/state.ts
+++ b/src/screens/Signup/state.ts
@@ -252,7 +252,6 @@ export function useSubmitSignup({
       dispatch({type: 'setIsLoading', value: true})
 
       try {
-        onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
         await createAccount({
           service: state.serviceUrl,
           email: state.email,
@@ -262,8 +261,12 @@ export function useSubmitSignup({
           inviteCode: state.inviteCode.trim(),
           verificationCode: verificationCode,
         })
+        /*
+         * Must happen last so that if the user has multiple tabs open and
+         * createAccount fails, one tab is not stuck in onboarding — Eric
+         */
+        onboardingDispatch({type: 'start'})
       } catch (e: any) {
-        onboardingDispatch({type: 'skip'}) // undo starting the onboard
         let errMsg = e.toString()
         if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
           dispatch({
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,
   })
 }
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/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index d6c38ea61..9cd7a2917 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -8,6 +8,7 @@ import {
 } from 'react-native'
 import {
   AppBskyActorDefs,
+  AppBskyEmbedExternal,
   AppBskyEmbedImages,
   AppBskyEmbedRecordWithMedia,
   AppBskyFeedDefs,
@@ -51,6 +52,7 @@ import {TimeElapsed} from '../util/TimeElapsed'
 import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar'
 
 import hairlineWidth = StyleSheet.hairlineWidth
+import {parseTenorGif} from '#/lib/strings/embed-player'
 
 const MAX_AUTHORS = 5
 
@@ -465,17 +467,48 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
   const pal = usePalette('default')
   if (post && AppBskyFeedPost.isRecord(post?.record)) {
     const text = post.record.text
-    const images = AppBskyEmbedImages.isView(post.embed)
-      ? post.embed.images
-      : AppBskyEmbedRecordWithMedia.isView(post.embed) &&
-        AppBskyEmbedImages.isView(post.embed.media)
-      ? post.embed.media.images
-      : undefined
+    let images
+    let isGif = false
+
+    if (AppBskyEmbedImages.isView(post.embed)) {
+      images = post.embed.images
+    } else if (
+      AppBskyEmbedRecordWithMedia.isView(post.embed) &&
+      AppBskyEmbedImages.isView(post.embed.media)
+    ) {
+      images = post.embed.media.images
+    } else if (
+      AppBskyEmbedExternal.isView(post.embed) &&
+      post.embed.external.thumb
+    ) {
+      let url: URL | undefined
+      try {
+        url = new URL(post.embed.external.uri)
+      } catch {}
+      if (url) {
+        const {success} = parseTenorGif(url)
+        if (success) {
+          isGif = true
+          images = [
+            {
+              thumb: post.embed.external.thumb,
+              alt: post.embed.external.title,
+              fullsize: post.embed.external.thumb,
+            },
+          ]
+        }
+      }
+    }
+
     return (
       <>
         {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
         {images && images.length > 0 && (
-          <ImageHorzList images={images} style={styles.additionalPostImages} />
+          <ImageHorzList
+            images={images}
+            style={styles.additionalPostImages}
+            gif={isGif}
+          />
         )}
       </>
     )
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index e940e8d1a..1c83ecd6e 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -180,7 +180,7 @@ const desktopStyles = StyleSheet.create({
     position: 'absolute',
     left: 0,
     right: 0,
-    bottom: -1,
+    top: '100%',
     borderBottomWidth: 1,
   },
 })
@@ -207,7 +207,7 @@ const mobileStyles = StyleSheet.create({
     position: 'absolute',
     left: 0,
     right: 0,
-    bottom: -1,
+    top: '100%',
     borderBottomWidth: hairlineWidth,
   },
 })
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 8061eb11c..a6c1a4648 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -331,7 +331,11 @@ export function PostThread({
         <PostThreadShowHiddenReplies
           type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'}
           onPress={() =>
-            setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider)
+            setHiddenRepliesState(
+              item === SHOW_HIDDEN_REPLIES
+                ? HiddenRepliesState.Show
+                : HiddenRepliesState.ShowAndOverridePostHider,
+            )
           }
           hideTopBorder={index === 0}
         />
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 = ({
             </View>
           </View>
         </View>
-        <WhoCanReply post={post} />
+        <WhoCanReply
+          post={post}
+          isThreadAuthor={isThreadAuthor}
+          style={{borderBottomWidth: isNative ? 1 : 0}}
+        />
       </>
     )
   } 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/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 675f23a88..cc767a4a3 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -56,6 +56,7 @@ interface FeedItemProps {
   isThreadParent?: boolean
   feedContext: string | undefined
   hideTopBorder?: boolean
+  isParentBlocked?: boolean
 }
 
 export function FeedItem({
@@ -70,6 +71,7 @@ export function FeedItem({
   isThreadLastChild,
   isThreadParent,
   hideTopBorder,
+  isParentBlocked,
 }: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode {
   const postShadowed = usePostShadow(post)
   const richText = useMemo(
@@ -100,6 +102,7 @@ export function FeedItem({
         isThreadLastChild={isThreadLastChild}
         isThreadParent={isThreadParent}
         hideTopBorder={hideTopBorder}
+        isParentBlocked={isParentBlocked}
       />
     )
   }
@@ -119,6 +122,7 @@ let FeedItemInner = ({
   isThreadLastChild,
   isThreadParent,
   hideTopBorder,
+  isParentBlocked,
 }: FeedItemProps & {
   richText: RichTextAPI
   post: Shadow<AppBskyFeedDefs.PostView>
@@ -320,7 +324,7 @@ let FeedItemInner = ({
             onOpenAuthor={onOpenAuthor}
           />
           {!isThreadChild && showReplyTo && parentAuthor && (
-            <ReplyToLabel profile={parentAuthor} />
+            <ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
           )}
           <LabelsOnMyPost post={post} />
           <PostContent
@@ -409,9 +413,14 @@ let PostContent = ({
 }
 PostContent = memo(PostContent)
 
-function ReplyToLabel({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) {
+function ReplyToLabel({
+  profile,
+  blocked,
+}: {
+  profile: AppBskyActorDefs.ProfileViewBasic
+  blocked?: boolean
+}) {
   const pal = usePalette('default')
-
   return (
     <View style={[s.flexRow, s.mb2, s.alignCenter]}>
       <FontAwesomeIcon
@@ -424,23 +433,27 @@ function ReplyToLabel({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) {
         style={[pal.textLight, s.mr2]}
         lineHeight={1.2}
         numberOfLines={1}>
-        <Trans context="description">
-          Reply to{' '}
-          <ProfileHoverCard inline did={profile.did}>
-            <TextLinkOnWebOnly
-              type="md"
-              style={pal.textLight}
-              lineHeight={1.2}
-              numberOfLines={1}
-              href={makeProfileLink(profile)}
-              text={
-                profile.displayName
-                  ? sanitizeDisplayName(profile.displayName)
-                  : sanitizeHandle(profile.handle)
-              }
-            />
-          </ProfileHoverCard>
-        </Trans>
+        {blocked ? (
+          <Trans context="description">Reply to a blocked post</Trans>
+        ) : (
+          <Trans context="description">
+            Reply to{' '}
+            <ProfileHoverCard inline did={profile.did}>
+              <TextLinkOnWebOnly
+                type="md"
+                style={pal.textLight}
+                lineHeight={1.2}
+                numberOfLines={1}
+                href={makeProfileLink(profile)}
+                text={
+                  profile.displayName
+                    ? sanitizeDisplayName(profile.displayName)
+                    : sanitizeHandle(profile.handle)
+                }
+              />
+            </ProfileHoverCard>
+          </Trans>
+        )}
       </Text>
     </View>
   )
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index aeb24e8bb..3e08f253c 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -34,6 +34,7 @@ let FeedSlice = ({
           isThreadParent={isThreadParentAt(slice.items, 0)}
           isThreadChild={isThreadChildAt(slice.items, 0)}
           hideTopBorder={hideTopBorder}
+          isParentBlocked={slice.items[0].isParentBlocked}
         />
         <FeedItem
           key={slice.items[1]._reactKey}
@@ -46,6 +47,7 @@ let FeedSlice = ({
           moderation={slice.items[1].moderation}
           isThreadParent={isThreadParentAt(slice.items, 1)}
           isThreadChild={isThreadChildAt(slice.items, 1)}
+          isParentBlocked={slice.items[1].isParentBlocked}
         />
         <ViewFullThread slice={slice} />
         <FeedItem
@@ -59,6 +61,7 @@ let FeedSlice = ({
           moderation={slice.items[last].moderation}
           isThreadParent={isThreadParentAt(slice.items, last)}
           isThreadChild={isThreadChildAt(slice.items, last)}
+          isParentBlocked={slice.items[2].isParentBlocked}
           isThreadLastChild
         />
       </>
@@ -82,6 +85,7 @@ let FeedSlice = ({
           isThreadLastChild={
             isThreadChildAt(slice.items, i) && slice.items.length === i + 1
           }
+          isParentBlocked={slice.items[i].isParentBlocked}
           hideTopBorder={hideTopBorder && i === 0}
         />
       ))}
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<ViewStyle>
 }) {
+  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 (
-      <View
-        style={[
-          {
-            flexDirection: 'row',
-            alignItems: 'center',
-            gap: isMobile ? 8 : 10,
-            paddingHorizontal: isMobile ? 16 : 18,
-            paddingVertical: 12,
-            borderWidth: 1,
-            borderLeftWidth: isMobile ? 0 : 1,
-            borderRightWidth: isMobile ? 0 : 1,
-          },
-          containerStyles,
-          style,
-        ]}>
-        <View
-          style={[
-            {
-              flexDirection: 'row',
-              alignItems: 'center',
-              justifyContent: 'center',
-              width: 32,
-              height: 32,
-              borderRadius: 19,
-            },
-            iconStyles,
-          ]}>
-          <FontAwesomeIcon
-            icon={['far', 'comments']}
-            size={16}
-            color={'#fff'}
-          />
-        </View>
-        <View style={{flex: 1}}>
-          <Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
-            {!record.allow?.length ? (
-              <Trans>Replies to this thread are disabled</Trans>
-            ) : (
-              <Trans>
-                Only{' '}
-                {record.allow.map((rule, i) => (
-                  <>
-                    <Rule
-                      key={`rule-${i}`}
-                      rule={rule}
-                      post={post}
-                      lists={post.threadgate!.lists}
-                    />
-                    <Separator
-                      key={`sep-${i}`}
-                      i={i}
-                      length={record.allow!.length}
-                    />
-                  </>
-                ))}{' '}
-                can reply.
-              </Trans>
+  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 (
+    <View
+      style={[
+        {
+          flexDirection: 'row',
+          alignItems: 'center',
+          gap: 10,
+          paddingLeft: 18,
+          paddingRight: 14,
+          paddingVertical: 10,
+          borderTopWidth: 1,
+        },
+        pal.border,
+        containerStyles,
+        style,
+      ]}>
+      <View style={{flex: 1, paddingVertical: 6}}>
+        <Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
+          {!settings.length ? (
+            <Trans>Everybody can reply.</Trans>
+          ) : settings[0].type === 'nobody' ? (
+            <Trans>Replies to this thread are disabled.</Trans>
+          ) : (
+            <Trans>
+              Only{' '}
+              {settings.map((rule, i) => (
+                <>
+                  <Rule
+                    key={`rule-${i}`}
+                    rule={rule}
+                    post={post}
+                    lists={post.threadgate!.lists}
+                  />
+                  <Separator key={`sep-${i}`} i={i} length={settings.length} />
+                </>
+              ))}{' '}
+              can reply.
+            </Trans>
+          )}
+        </Text>
+      </View>
+      {isThreadAuthor && (
+        <View>
+          <Button label={_(msg`Edit`)} onPress={onPressEdit}>
+            {({hovered}) => (
+              <View
+                style={[
+                  hovered && hoverStyles,
+                  {paddingVertical: 6, paddingHorizontal: 8, borderRadius: 8},
+                ]}>
+                <Text type="sm" style={pal.link}>
+                  <Trans>Edit</Trans>
+                </Text>
+              </View>
             )}
-          </Text>
+          </Button>
         </View>
-      </View>
-    )
-  }
-  return null
+      )}
+    </View>
+  )
 }
 
 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 <Trans>mentioned users</Trans>
   }
-  if (AppBskyFeedThreadgate.isFollowingRule(rule)) {
+  if (rule.type === 'following') {
     return (
       <Trans>
         users followed by{' '}
@@ -151,7 +195,7 @@ function Rule({
       </Trans>
     )
   }
-  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)
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
index 6b0c17762..e917ab1d3 100644
--- a/src/view/com/util/List.web.tsx
+++ b/src/view/com/util/List.web.tsx
@@ -38,6 +38,7 @@ function ListImpl<ItemT>(
   {
     ListHeaderComponent,
     ListFooterComponent,
+    ListEmptyComponent,
     containWeb,
     contentContainerStyle,
     data,
@@ -72,23 +73,35 @@ function ListImpl<ItemT>(
     )
   }
 
-  let header: JSX.Element | null = null
+  const isEmpty = !data || data.length === 0
+
+  let headerComponent: JSX.Element | null = null
   if (ListHeaderComponent != null) {
     if (isValidElement(ListHeaderComponent)) {
-      header = ListHeaderComponent
+      headerComponent = ListHeaderComponent
     } else {
       // @ts-ignore Nah it's fine.
-      header = <ListHeaderComponent />
+      headerComponent = <ListHeaderComponent />
     }
   }
 
-  let footer: JSX.Element | null = null
+  let footerComponent: JSX.Element | null = null
   if (ListFooterComponent != null) {
     if (isValidElement(ListFooterComponent)) {
-      footer = ListFooterComponent
+      footerComponent = ListFooterComponent
+    } else {
+      // @ts-ignore Nah it's fine.
+      footerComponent = <ListFooterComponent />
+    }
+  }
+
+  let emptyComponent: JSX.Element | null = null
+  if (ListEmptyComponent != null) {
+    if (isValidElement(ListEmptyComponent)) {
+      emptyComponent = ListEmptyComponent
     } else {
       // @ts-ignore Nah it's fine.
-      footer = <ListFooterComponent />
+      emptyComponent = <ListEmptyComponent />
     }
   }
 
@@ -323,36 +336,38 @@ function ListImpl<ItemT>(
           onVisibleChange={handleAboveTheFoldVisibleChange}
           style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
         />
-        {onStartReached && (
+        {onStartReached && !isEmpty && (
           <Visibility
             root={containWeb ? nativeRef : null}
             onVisibleChange={onHeadVisibilityChange}
             topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
           />
         )}
-        {header}
-        {(data as Array<ItemT>).map((item, index) => {
-          const key = keyExtractor!(item, index)
-          return (
-            <Row<ItemT>
-              key={key}
-              item={item}
-              index={index}
-              renderItem={renderItem}
-              extraData={extraData}
-              onItemSeen={onItemSeen}
-              disableContentVisibility={disableContentVisibility}
-            />
-          )
-        })}
-        {onEndReached && (
+        {headerComponent}
+        {isEmpty
+          ? emptyComponent
+          : (data as Array<ItemT>)?.map((item, index) => {
+              const key = keyExtractor!(item, index)
+              return (
+                <Row<ItemT>
+                  key={key}
+                  item={item}
+                  index={index}
+                  renderItem={renderItem}
+                  extraData={extraData}
+                  onItemSeen={onItemSeen}
+                  disableContentVisibility={disableContentVisibility}
+                />
+              )
+            })}
+        {onEndReached && !isEmpty && (
           <Visibility
             root={containWeb ? nativeRef : null}
             onVisibleChange={onTailVisibilityChange}
             bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
           />
         )}
-        {footer}
+        {footerComponent}
       </View>
     </View>
   )
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/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 587b466a3..c212ea4c0 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -35,6 +35,7 @@ export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
 
 interface BaseUserAvatarProps {
   type?: UserAvatarType
+  shape?: 'circle' | 'square'
   size: number
   avatar?: string | null
 }
@@ -60,12 +61,16 @@ const BLUR_AMOUNT = isWeb ? 5 : 100
 
 let DefaultAvatar = ({
   type,
+  shape: overrideShape,
   size,
 }: {
   type: UserAvatarType
+  shape?: 'square' | 'circle'
   size: number
 }): React.ReactNode => {
+  const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
   if (type === 'algo') {
+    // TODO: shape=circle
     // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
     return (
       <Svg
@@ -84,6 +89,7 @@ let DefaultAvatar = ({
     )
   }
   if (type === 'list') {
+    // TODO: shape=circle
     // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
     return (
       <Svg
@@ -117,14 +123,18 @@ let DefaultAvatar = ({
         viewBox="0 0 32 32"
         fill="none"
         stroke="none">
-        <Rect
-          x="0"
-          y="0"
-          width="32"
-          height="32"
-          rx="3"
-          fill={tokens.color.temp_purple}
-        />
+        {finalShape === 'square' ? (
+          <Rect
+            x="0"
+            y="0"
+            width="32"
+            height="32"
+            rx="3"
+            fill={tokens.color.temp_purple}
+          />
+        ) : (
+          <Circle cx="16" cy="16" r="16" fill={tokens.color.temp_purple} />
+        )}
         <Path
           d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z"
           stroke="white"
@@ -135,6 +145,7 @@ let DefaultAvatar = ({
       </Svg>
     )
   }
+  // TODO: shape=square
   return (
     <Svg
       testID="userAvatarFallback"
@@ -159,6 +170,7 @@ export {DefaultAvatar}
 
 let UserAvatar = ({
   type = 'user',
+  shape: overrideShape,
   size,
   avatar,
   moderation,
@@ -166,9 +178,10 @@ let UserAvatar = ({
 }: UserAvatarProps): React.ReactNode => {
   const pal = usePalette('default')
   const backgroundColor = pal.colors.backgroundLight
+  const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
 
   const aviStyle = useMemo(() => {
-    if (type === 'algo' || type === 'list' || type === 'labeler') {
+    if (finalShape === 'square') {
       return {
         width: size,
         height: size,
@@ -182,7 +195,7 @@ let UserAvatar = ({
       borderRadius: Math.floor(size / 2),
       backgroundColor,
     }
-  }, [type, size, backgroundColor])
+  }, [finalShape, size, backgroundColor])
 
   const alert = useMemo(() => {
     if (!moderation?.alert) {
@@ -224,7 +237,7 @@ let UserAvatar = ({
     </View>
   ) : (
     <View style={{width: size, height: size}}>
-      <DefaultAvatar type={type} size={size} />
+      <DefaultAvatar type={type} shape={finalShape} size={size} />
       {alert}
     </View>
   )
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<AppBskyFeedDefs.PostView>
   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/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx
index 12eef14f7..bade2a444 100644
--- a/src/view/com/util/images/ImageHorzList.tsx
+++ b/src/view/com/util/images/ImageHorzList.tsx
@@ -2,39 +2,60 @@ import React from 'react'
 import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {Image} from 'expo-image'
 import {AppBskyEmbedImages} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
 
 interface Props {
   images: AppBskyEmbedImages.ViewImage[]
   style?: StyleProp<ViewStyle>
+  gif?: boolean
 }
 
-export function ImageHorzList({images, style}: Props) {
+export function ImageHorzList({images, style, gif}: Props) {
   return (
-    <View style={[styles.flexRow, style]}>
+    <View style={[a.flex_row, a.gap_xs, style]}>
       {images.map(({thumb, alt}) => (
-        <Image
+        <View
           key={thumb}
-          source={{uri: thumb}}
-          style={styles.image}
-          accessible={true}
-          accessibilityIgnoresInvertColors
-          accessibilityHint={alt}
-          accessibilityLabel=""
-        />
+          style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
+          <Image
+            key={thumb}
+            source={{uri: thumb}}
+            style={[a.flex_1, a.rounded_xs]}
+            accessible={true}
+            accessibilityIgnoresInvertColors
+            accessibilityHint={alt}
+            accessibilityLabel=""
+          />
+          {gif && (
+            <View style={styles.altContainer}>
+              <Text style={styles.alt}>
+                <Trans>GIF</Trans>
+              </Text>
+            </View>
+          )}
+        </View>
       ))}
     </View>
   )
 }
 
 const styles = StyleSheet.create({
-  flexRow: {
-    flexDirection: 'row',
-    gap: 5,
+  altContainer: {
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
+    borderRadius: 6,
+    paddingHorizontal: 6,
+    paddingVertical: 3,
+    position: 'absolute',
+    right: 5,
+    bottom: 5,
+    zIndex: 2,
   },
-  image: {
-    maxWidth: 100,
-    aspectRatio: 1,
-    flex: 1,
-    borderRadius: 4,
+  alt: {
+    color: 'white',
+    fontSize: 7,
+    fontWeight: 'bold',
   },
 })
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 = ({
       <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
         <PostDropdownBtn
           testID="postDropdownBtn"
-          postAuthor={post.author}
-          postCid={post.cid}
-          postUri={post.uri}
+          post={post}
           postFeedContext={feedContext}
           record={record}
           richText={richText}
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<string[]>([])
   const timeAgo = useGetTimeAgo()
+  const tick = useTickEveryMinute()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -72,7 +74,7 @@ export function LogScreen({}: NativeStackScreenProps<
                     />
                   ) : undefined}
                   <Text type="sm" style={[styles.ts, pal.textLight]}>
-                    {timeAgo(entry.timestamp)}
+                    {timeAgo(entry.timestamp, tick)}
                   </Text>
                 </TouchableOpacity>
                 {expanded.includes(entry.id) ? (