about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--__e2e__/tests/thread-muting.test.ts116
-rw-r--r--src/state/models/content/post-thread.ts40
-rw-r--r--src/state/models/content/post.ts19
-rw-r--r--src/state/models/feeds/notifications.ts46
-rw-r--r--src/state/models/feeds/posts.ts19
-rw-r--r--src/state/models/muted-threads.ts29
-rw-r--r--src/state/models/root-store.ts6
-rw-r--r--src/view/com/notifications/Feed.tsx5
-rw-r--r--src/view/com/notifications/FeedItem.tsx7
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx24
-rw-r--r--src/view/com/post/Post.tsx18
-rw-r--r--src/view/com/posts/FeedItem.tsx29
-rw-r--r--src/view/com/util/PostCtrls.tsx4
-rw-r--r--src/view/com/util/Selector.tsx5
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx203
-rw-r--r--src/view/index.ts8
16 files changed, 470 insertions, 108 deletions
diff --git a/__e2e__/tests/thread-muting.test.ts b/__e2e__/tests/thread-muting.test.ts
new file mode 100644
index 000000000..a5cefdb26
--- /dev/null
+++ b/__e2e__/tests/thread-muting.test.ts
@@ -0,0 +1,116 @@
+/* eslint-env detox/detox */
+
+import {openApp, login, createServer} from '../util'
+
+describe('Thread muting', () => {
+  let service: string
+  beforeAll(async () => {
+    service = await createServer('?users&follows')
+    await openApp({permissions: {notifications: 'YES'}})
+  })
+
+  it('Login, create a thread, and log out', async () => {
+    await login(service, 'alice', 'hunter2')
+    await element(by.id('homeScreenFeedTabs-Following')).tap()
+    await element(by.id('composeFAB')).tap()
+    await element(by.id('composerTextInput')).typeText('Test thread')
+    await element(by.id('composerPublishBtn')).tap()
+    await expect(element(by.id('composeFAB'))).toBeVisible()
+    await element(by.id('viewHeaderDrawerBtn')).tap()
+    await element(by.id('menuItemButton-Settings')).tap()
+    await element(by.id('signOutBtn')).tap()
+  })
+
+  it('Login, reply to the thread, and log out', async () => {
+    await login(service, 'bob', 'hunter2')
+    await element(by.id('homeScreenFeedTabs-Following')).tap()
+    const alicePosts = by.id('feedItem-by-alice.test')
+    await element(by.id('replyBtn').withAncestor(alicePosts)).atIndex(0).tap()
+    await element(by.id('composerTextInput')).typeText('Reply 1')
+    await element(by.id('composerPublishBtn')).tap()
+    await expect(element(by.id('composeFAB'))).toBeVisible()
+    await element(by.id('viewHeaderDrawerBtn')).tap()
+    await element(by.id('menuItemButton-Settings')).tap()
+    await element(by.id('signOutBtn')).tap()
+  })
+
+  it('Login, confirm notification exists, mute thread, and log out', async () => {
+    await login(service, 'alice', 'hunter2')
+
+    await element(by.id('bottomBarNotificationsBtn')).tap()
+    const bobNotifs = by.id('feedItem-by-bob.test')
+    await expect(
+      element(by.id('postText').withAncestor(bobNotifs)).atIndex(0),
+    ).toHaveText('Reply 1')
+    await element(by.id('postDropdownBtn').withAncestor(bobNotifs))
+      .atIndex(0)
+      .tap()
+    await element(by.id('postDropdownMuteThreadBtn')).tap()
+    // have to wait for the toast to clear
+    await waitFor(element(by.id('viewHeaderDrawerBtn')))
+      .toBeVisible()
+      .withTimeout(5000)
+
+    await element(by.id('viewHeaderDrawerBtn')).tap()
+    await element(by.id('menuItemButton-Settings')).tap()
+    await element(by.id('signOutBtn')).tap()
+  })
+
+  it('Login, reply to the thread twice, and log out', async () => {
+    await login(service, 'bob', 'hunter2')
+
+    await element(by.id('bottomBarProfileBtn')).tap()
+    await element(by.id('selector-1')).tap()
+    const bobPosts = by.id('feedItem-by-bob.test')
+    await element(by.id('replyBtn').withAncestor(bobPosts)).atIndex(0).tap()
+    await element(by.id('composerTextInput')).typeText('Reply 2')
+    await element(by.id('composerPublishBtn')).tap()
+    await expect(element(by.id('composeFAB'))).toBeVisible()
+
+    const alicePosts = by.id('feedItem-by-alice.test')
+    await element(by.id('replyBtn').withAncestor(alicePosts)).atIndex(0).tap()
+    await element(by.id('composerTextInput')).typeText('Reply 3')
+    await element(by.id('composerPublishBtn')).tap()
+    await expect(element(by.id('composeFAB'))).toBeVisible()
+
+    await element(by.id('bottomBarHomeBtn')).tap()
+    await element(by.id('viewHeaderDrawerBtn')).tap()
+    await element(by.id('menuItemButton-Settings')).tap()
+    await element(by.id('signOutBtn')).tap()
+  })
+
+  it('Login, confirm notifications dont exist, unmute the thread, confirm notifications exist', async () => {
+    await login(service, 'alice', 'hunter2')
+
+    await element(by.id('bottomBarNotificationsBtn')).tap()
+    const bobNotifs = by.id('feedItem-by-bob.test')
+    await expect(
+      element(by.id('postText').withAncestor(bobNotifs)).atIndex(0),
+    ).not.toExist()
+
+    await element(by.id('bottomBarHomeBtn')).tap()
+    const alicePosts = by.id('feedItem-by-alice.test')
+    await element(by.id('postDropdownBtn').withAncestor(alicePosts))
+      .atIndex(0)
+      .tap()
+    await element(by.id('postDropdownMuteThreadBtn')).tap()
+
+    // TODO
+    // the swipe down to trigger PTR isnt working and I dont want to block on this
+    // -prf
+    // await element(by.id('bottomBarNotificationsBtn')).tap()
+    // await element(by.id('notifsFeed')).swipe('down', 'fast')
+    // await waitFor(element(by.id('postText').withAncestor(bobNotifs)))
+    //   .toBeVisible()
+    //   .withTimeout(5000)
+    // await expect(
+    //   element(by.id('postText').withAncestor(bobNotifs)).atIndex(0),
+    // ).toHaveText('Reply 2')
+    // await expect(
+    //   element(by.id('postText').withAncestor(bobNotifs)).atIndex(1),
+    // ).toHaveText('Reply 3')
+    // await expect(
+    //   element(by.id('postText').withAncestor(bobNotifs)).atIndex(2),
+    // ).toHaveText('Reply 1')
+  })
+})
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts
index 794beae20..acc9bffa9 100644
--- a/src/state/models/content/post-thread.ts
+++ b/src/state/models/content/post-thread.ts
@@ -42,6 +42,17 @@ export class PostThreadItemModel {
     return this.postRecord?.reply?.parent.uri
   }
 
+  get rootUri(): string {
+    if (this.postRecord?.reply?.root.uri) {
+      return this.postRecord.reply.root.uri
+    }
+    return this.uri
+  }
+
+  get isThreadMuted() {
+    return this.rootStore.mutedThreads.uris.has(this.rootUri)
+  }
+
   constructor(
     public rootStore: RootStoreModel,
     reactKey: string,
@@ -188,6 +199,14 @@ export class PostThreadItemModel {
     }
   }
 
+  async toggleThreadMute() {
+    if (this.isThreadMuted) {
+      this.rootStore.mutedThreads.uris.delete(this.rootUri)
+    } else {
+      this.rootStore.mutedThreads.uris.add(this.rootUri)
+    }
+  }
+
   async delete() {
     await this.rootStore.agent.deletePost(this.post.uri)
     this.rootStore.emitPostDeleted(this.post.uri)
@@ -230,6 +249,19 @@ export class PostThreadModel {
     return this.error !== ''
   }
 
+  get rootUri(): string {
+    if (this.thread) {
+      if (this.thread.postRecord?.reply?.root.uri) {
+        return this.thread.postRecord.reply.root.uri
+      }
+    }
+    return this.resolvedUri
+  }
+
+  get isThreadMuted() {
+    return this.rootStore.mutedThreads.uris.has(this.rootUri)
+  }
+
   // public api
   // =
 
@@ -279,6 +311,14 @@ export class PostThreadModel {
     this.refresh()
   }
 
+  async toggleThreadMute() {
+    if (this.isThreadMuted) {
+      this.rootStore.mutedThreads.uris.delete(this.rootUri)
+    } else {
+      this.rootStore.mutedThreads.uris.add(this.rootUri)
+    }
+  }
+
   // state transitions
   // =
 
diff --git a/src/state/models/content/post.ts b/src/state/models/content/post.ts
index b5d95bf01..7ba633366 100644
--- a/src/state/models/content/post.ts
+++ b/src/state/models/content/post.ts
@@ -48,6 +48,17 @@ export class PostModel implements RemoveIndex<Post.Record> {
     return this.hasLoaded && !this.hasContent
   }
 
+  get rootUri(): string {
+    if (this.reply?.root.uri) {
+      return this.reply.root.uri
+    }
+    return this.uri
+  }
+
+  get isThreadMuted() {
+    return this.rootStore.mutedThreads.uris.has(this.rootUri)
+  }
+
   // public api
   // =
 
@@ -55,6 +66,14 @@ export class PostModel implements RemoveIndex<Post.Record> {
     await this._load()
   }
 
+  async toggleThreadMute() {
+    if (this.isThreadMuted) {
+      this.rootStore.mutedThreads.uris.delete(this.rootUri)
+    } else {
+      this.rootStore.mutedThreads.uris.add(this.rootUri)
+    }
+  }
+
   // state transitions
   // =
 
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts
index ff77ab979..e2a18ea04 100644
--- a/src/state/models/feeds/notifications.ts
+++ b/src/state/models/feeds/notifications.ts
@@ -160,6 +160,13 @@ export class NotificationsFeedItemModel {
     return ''
   }
 
+  get reasonSubjectRootUri(): string | undefined {
+    if (this.additionalPost) {
+      return this.additionalPost.rootUri
+    }
+    return undefined
+  }
+
   toSupportedRecord(v: unknown): SupportedRecord | undefined {
     for (const ns of [
       AppBskyFeedPost,
@@ -227,7 +234,7 @@ export class NotificationsFeedModel {
 
   // data
   notifications: NotificationsFeedItemModel[] = []
-  queuedNotifications: undefined | ListNotifications.Notification[] = undefined
+  queuedNotifications: undefined | NotificationsFeedItemModel[] = undefined
   unreadCount = 0
 
   // this is used to help trigger push notifications
@@ -354,7 +361,13 @@ export class NotificationsFeedModel {
         queue.push(notif)
       }
 
-      this._setQueued(this._filterNotifications(queue))
+      // NOTE
+      // because filtering depends on the added information we have to fetch
+      // the full models here. this is *not* ideal performance and we need
+      // to update the notifications route to give all the info we need
+      // -prf
+      const queueModels = await this._fetchItemModels(queue)
+      this._setQueued(this._filterNotifications(queueModels))
       this._countUnread()
     } catch (e) {
       this.rootStore.log.error('NotificationsModel:syncQueue failed', {e})
@@ -452,7 +465,8 @@ export class NotificationsFeedModel {
       res.data.notifications[0],
     )
     await notif.fetchAdditionalData()
-    return notif
+    const filtered = this._filterNotifications([notif])
+    return filtered[0]
   }
 
   // state transitions
@@ -505,23 +519,26 @@ export class NotificationsFeedModel {
   }
 
   _filterNotifications(
-    items: ListNotifications.Notification[],
-  ): ListNotifications.Notification[] {
+    items: NotificationsFeedItemModel[],
+  ): NotificationsFeedItemModel[] {
     return items.filter(item => {
-      return (
-        this.rootStore.preferences.getLabelPreference(item.labels).pref !==
+      const hideByLabel =
+        this.rootStore.preferences.getLabelPreference(item.labels).pref ===
         'hide'
+      let mutedThread = !!(
+        item.reasonSubjectRootUri &&
+        this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri)
       )
+      return !hideByLabel && !mutedThread
     })
   }
 
-  async _processNotifications(
+  async _fetchItemModels(
     items: ListNotifications.Notification[],
   ): Promise<NotificationsFeedItemModel[]> {
     const promises = []
     const itemModels: NotificationsFeedItemModel[] = []
-    items = this._filterNotifications(items)
-    for (const item of groupNotifications(items)) {
+    for (const item of items) {
       const itemModel = new NotificationsFeedItemModel(
         this.rootStore,
         `item-${_idCounter++}`,
@@ -541,7 +558,14 @@ export class NotificationsFeedModel {
     return itemModels
   }
 
-  _setQueued(queued: undefined | ListNotifications.Notification[]) {
+  async _processNotifications(
+    items: ListNotifications.Notification[],
+  ): Promise<NotificationsFeedItemModel[]> {
+    const itemModels = await this._fetchItemModels(groupNotifications(items))
+    return this._filterNotifications(itemModels)
+  }
+
+  _setQueued(queued: undefined | NotificationsFeedItemModel[]) {
     this.queuedNotifications = queued
   }
 
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index 38faf658a..58167284d 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -72,6 +72,17 @@ export class PostsFeedItemModel {
     makeAutoObservable(this, {rootStore: false})
   }
 
+  get rootUri(): string {
+    if (this.reply?.root.uri) {
+      return this.reply.root.uri
+    }
+    return this.post.uri
+  }
+
+  get isThreadMuted() {
+    return this.rootStore.mutedThreads.uris.has(this.rootUri)
+  }
+
   copy(v: FeedViewPost) {
     this.post = v.post
     this.reply = v.reply
@@ -145,6 +156,14 @@ export class PostsFeedItemModel {
     }
   }
 
+  async toggleThreadMute() {
+    if (this.isThreadMuted) {
+      this.rootStore.mutedThreads.uris.delete(this.rootUri)
+    } else {
+      this.rootStore.mutedThreads.uris.add(this.rootUri)
+    }
+  }
+
   async delete() {
     await this.rootStore.agent.deletePost(this.post.uri)
     this.rootStore.emitPostDeleted(this.post.uri)
diff --git a/src/state/models/muted-threads.ts b/src/state/models/muted-threads.ts
new file mode 100644
index 000000000..e6f202745
--- /dev/null
+++ b/src/state/models/muted-threads.ts
@@ -0,0 +1,29 @@
+/**
+ * This is a temporary client-side system for storing muted threads
+ * When the system lands on prod we should switch to that
+ */
+
+import {makeAutoObservable} from 'mobx'
+import {isObj, hasProp, isStrArray} from 'lib/type-guards'
+
+export class MutedThreads {
+  uris: Set<string> = new Set()
+
+  constructor() {
+    makeAutoObservable(
+      this,
+      {serialize: false, hydrate: false},
+      {autoBind: true},
+    )
+  }
+
+  serialize() {
+    return {uris: Array.from(this.uris)}
+  }
+
+  hydrate(v: unknown) {
+    if (isObj(v) && hasProp(v, 'uris') && isStrArray(v.uris)) {
+      this.uris = new Set(v.uris)
+    }
+  }
+}
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 9207f27ba..b3e744a40 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -20,6 +20,7 @@ import {InvitedUsers} from './invited-users'
 import {PreferencesModel} from './ui/preferences'
 import {resetToTab} from '../../Navigation'
 import {ImageSizesCache} from './cache/image-sizes'
+import {MutedThreads} from './muted-threads'
 
 export const appInfo = z.object({
   build: z.string(),
@@ -41,6 +42,7 @@ export class RootStoreModel {
   profiles = new ProfilesCache(this)
   linkMetas = new LinkMetasCache(this)
   imageSizes = new ImageSizesCache()
+  mutedThreads = new MutedThreads()
 
   constructor(agent: BskyAgent) {
     this.agent = agent
@@ -64,6 +66,7 @@ export class RootStoreModel {
       shell: this.shell.serialize(),
       preferences: this.preferences.serialize(),
       invitedUsers: this.invitedUsers.serialize(),
+      mutedThreads: this.mutedThreads.serialize(),
     }
   }
 
@@ -90,6 +93,9 @@ export class RootStoreModel {
       if (hasProp(v, 'invitedUsers')) {
         this.invitedUsers.hydrate(v.invitedUsers)
       }
+      if (hasProp(v, 'mutedThreads')) {
+        this.mutedThreads.hydrate(v.mutedThreads)
+      }
     }
   }
 
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index 33bde1955..50bdc5dc9 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -135,8 +135,9 @@ export const Feed = observer(function Feed({
           />
         )}
       </CenteredView>
-      {data.length && (
+      {data.length ? (
         <FlatList
+          testID="notifsFeed"
           ref={scrollElRef}
           data={data}
           keyExtractor={item => item._reactKey}
@@ -155,7 +156,7 @@ export const Feed = observer(function Feed({
           onScroll={onScroll}
           contentContainerStyle={s.contentContainer}
         />
-      )}
+      ) : null}
     </View>
   )
 })
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index 34df2a8ed..b05111ffc 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -85,7 +85,11 @@ export const FeedItem = observer(function FeedItem({
       return <View />
     }
     return (
-      <Link href={itemHref} title={itemTitle} noFeedback>
+      <Link
+        testID={`feedItem-by-${item.author.handle}`}
+        href={itemHref}
+        title={itemTitle}
+        noFeedback>
         <Post
           uri={item.uri}
           initView={item.additionalPost}
@@ -147,6 +151,7 @@ export const FeedItem = observer(function FeedItem({
 
   return (
     <Link
+      testID={`feedItem-by-${item.author.handle}`}
       style={[
         styles.outer,
         pal.view,
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 6e8758f7e..e779f018e 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -77,25 +77,43 @@ export const PostThreadItem = observer(function PostThreadItem({
       onPost: onPostReply,
     })
   }, [store, item, record, onPostReply])
+
   const onPressToggleRepost = React.useCallback(() => {
     return item
       .toggleRepost()
       .catch(e => store.log.error('Failed to toggle repost', e))
   }, [item, store])
+
   const onPressToggleLike = React.useCallback(() => {
     return item
       .toggleLike()
       .catch(e => store.log.error('Failed to toggle like', e))
   }, [item, store])
+
   const onCopyPostText = React.useCallback(() => {
     Clipboard.setString(record?.text || '')
     Toast.show('Copied to clipboard')
   }, [record])
+
   const onOpenTranslate = React.useCallback(() => {
     Linking.openURL(
       encodeURI(`https://translate.google.com/#auto|en|${record?.text || ''}`),
     )
   }, [record])
+
+  const onToggleThreadMute = React.useCallback(async () => {
+    try {
+      await item.toggleThreadMute()
+      if (item.isThreadMuted) {
+        Toast.show('You will no longer received notifications for this thread')
+      } else {
+        Toast.show('You will now receive notifications for this thread')
+      }
+    } catch (e) {
+      store.log.error('Failed to toggle thread mute', e)
+    }
+  }, [item, store])
+
   const onDeletePost = React.useCallback(() => {
     item.delete().then(
       () => {
@@ -175,8 +193,10 @@ export const PostThreadItem = observer(function PostThreadItem({
                 itemHref={itemHref}
                 itemTitle={itemTitle}
                 isAuthor={item.post.author.did === store.me.did}
+                isThreadMuted={item.isThreadMuted}
                 onCopyPostText={onCopyPostText}
                 onOpenTranslate={onOpenTranslate}
+                onToggleThreadMute={onToggleThreadMute}
                 onDeletePost={onDeletePost}>
                 <FontAwesomeIcon
                   icon="ellipsis-h"
@@ -269,11 +289,13 @@ export const PostThreadItem = observer(function PostThreadItem({
               isAuthor={item.post.author.did === store.me.did}
               isReposted={!!item.post.viewer?.repost}
               isLiked={!!item.post.viewer?.like}
+              isThreadMuted={item.isThreadMuted}
               onPressReply={onPressReply}
               onPressToggleRepost={onPressToggleRepost}
               onPressToggleLike={onPressToggleLike}
               onCopyPostText={onCopyPostText}
               onOpenTranslate={onOpenTranslate}
+              onToggleThreadMute={onToggleThreadMute}
               onDeletePost={onDeletePost}
             />
           </View>
@@ -357,11 +379,13 @@ export const PostThreadItem = observer(function PostThreadItem({
                 likeCount={item.post.likeCount}
                 isReposted={!!item.post.viewer?.repost}
                 isLiked={!!item.post.viewer?.like}
+                isThreadMuted={item.isThreadMuted}
                 onPressReply={onPressReply}
                 onPressToggleRepost={onPressToggleRepost}
                 onPressToggleLike={onPressToggleLike}
                 onCopyPostText={onCopyPostText}
                 onOpenTranslate={onOpenTranslate}
+                onToggleThreadMute={onToggleThreadMute}
                 onDeletePost={onDeletePost}
               />
             </View>
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 60d46f5cc..81f3b8c45 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -174,6 +174,21 @@ const PostLoaded = observer(
       )
     }, [record])
 
+    const onToggleThreadMute = React.useCallback(async () => {
+      try {
+        await item.toggleThreadMute()
+        if (item.isThreadMuted) {
+          Toast.show(
+            'You will no longer received notifications for this thread',
+          )
+        } else {
+          Toast.show('You will now receive notifications for this thread')
+        }
+      } catch (e) {
+        store.log.error('Failed to toggle thread mute', e)
+      }
+    }, [item, store])
+
     const onDeletePost = React.useCallback(() => {
       item.delete().then(
         () => {
@@ -237,6 +252,7 @@ const PostLoaded = observer(
               {item.richText?.text ? (
                 <View style={styles.postTextContainer}>
                   <RichText
+                    testID="postText"
                     type="post-text"
                     richText={item.richText}
                     lineHeight={1.3}
@@ -263,11 +279,13 @@ const PostLoaded = observer(
               likeCount={item.post.likeCount}
               isReposted={!!item.post.viewer?.repost}
               isLiked={!!item.post.viewer?.like}
+              isThreadMuted={item.isThreadMuted}
               onPressReply={onPressReply}
               onPressToggleRepost={onPressToggleRepost}
               onPressToggleLike={onPressToggleLike}
               onCopyPostText={onCopyPostText}
               onOpenTranslate={onOpenTranslate}
+              onToggleThreadMute={onToggleThreadMute}
               onDeletePost={onDeletePost}
             />
           </View>
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index c2baa4d4d..18481d4cb 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -101,6 +101,20 @@ export const FeedItem = observer(function ({
     )
   }, [record])
 
+  const onToggleThreadMute = React.useCallback(async () => {
+    track('FeedItem:ThreadMute')
+    try {
+      await item.toggleThreadMute()
+      if (item.isThreadMuted) {
+        Toast.show('You will no longer receive notifications for this thread')
+      } else {
+        Toast.show('You will now receive notifications for this thread')
+      }
+    } catch (e) {
+      store.log.error('Failed to toggle thread mute', e)
+    }
+  }, [track, item, store])
+
   const onDeletePost = React.useCallback(() => {
     track('FeedItem:PostDelete')
     item.delete().then(
@@ -120,7 +134,6 @@ export const FeedItem = observer(function ({
   }
 
   const isSmallTop = isThreadChild
-  const isNoTop = false //isChild && !item._isThreadChild
   const isMuted =
     item.post.author.viewer?.muted && ignoreMuteFor !== item.post.author.did
   const outerStyles = [
@@ -128,7 +141,6 @@ export const FeedItem = observer(function ({
     pal.view,
     {borderColor: pal.colors.border},
     isSmallTop ? styles.outerSmallTop : undefined,
-    isNoTop ? styles.outerNoTop : undefined,
     isThreadParent ? styles.outerNoBottom : undefined,
   ]
 
@@ -146,11 +158,7 @@ export const FeedItem = observer(function ({
       )}
       {isThreadParent && (
         <View
-          style={[
-            styles.bottomReplyLine,
-            {borderColor: pal.colors.replyLine},
-            isNoTop ? styles.bottomReplyLineNoTop : undefined,
-          ]}
+          style={[styles.bottomReplyLine, {borderColor: pal.colors.replyLine}]}
         />
       )}
       {item.reasonRepost && (
@@ -260,11 +268,13 @@ export const FeedItem = observer(function ({
             likeCount={item.post.likeCount}
             isReposted={!!item.post.viewer?.repost}
             isLiked={!!item.post.viewer?.like}
+            isThreadMuted={item.isThreadMuted}
             onPressReply={onPressReply}
             onPressToggleRepost={onPressToggleRepost}
             onPressToggleLike={onPressToggleLike}
             onCopyPostText={onCopyPostText}
             onOpenTranslate={onOpenTranslate}
+            onToggleThreadMute={onToggleThreadMute}
             onDeletePost={onDeletePost}
           />
         </View>
@@ -280,10 +290,6 @@ const styles = StyleSheet.create({
     paddingRight: 15,
     paddingBottom: 8,
   },
-  outerNoTop: {
-    borderTopWidth: 0,
-    paddingTop: 0,
-  },
   outerSmallTop: {
     borderTopWidth: 0,
   },
@@ -304,7 +310,6 @@ const styles = StyleSheet.create({
     bottom: 0,
     borderLeftWidth: 2,
   },
-  bottomReplyLineNoTop: {top: 64},
   includeReason: {
     flexDirection: 'row',
     paddingLeft: 50,
diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx
index 6441d3c77..07a67fd8a 100644
--- a/src/view/com/util/PostCtrls.tsx
+++ b/src/view/com/util/PostCtrls.tsx
@@ -48,11 +48,13 @@ interface PostCtrlsOpts {
   likeCount?: number
   isReposted: boolean
   isLiked: boolean
+  isThreadMuted: boolean
   onPressReply: () => void
   onPressToggleRepost: () => Promise<void>
   onPressToggleLike: () => Promise<void>
   onCopyPostText: () => void
   onOpenTranslate: () => void
+  onToggleThreadMute: () => void
   onDeletePost: () => void
 }
 
@@ -255,8 +257,10 @@ export function PostCtrls(opts: PostCtrlsOpts) {
             itemHref={opts.itemHref}
             itemTitle={opts.itemTitle}
             isAuthor={opts.isAuthor}
+            isThreadMuted={opts.isThreadMuted}
             onCopyPostText={opts.onCopyPostText}
             onOpenTranslate={opts.onOpenTranslate}
+            onToggleThreadMute={opts.onToggleThreadMute}
             onDeletePost={opts.onDeletePost}>
             <FontAwesomeIcon
               icon="ellipsis-h"
diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx
index 872b78184..016ea77b8 100644
--- a/src/view/com/util/Selector.tsx
+++ b/src/view/com/util/Selector.tsx
@@ -94,7 +94,10 @@ export function Selector({
       {items.map((item, i) => {
         const selected = i === selectedIndex
         return (
-          <Pressable key={item} onPress={() => onPressItem(i)}>
+          <Pressable
+            testID={`selector-${i}`}
+            key={item}
+            onPress={() => onPressItem(i)}>
             <View style={styles.item} ref={itemRefs[i]}>
               <Text
                 style={
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
index f21323efb..2aeae9bae 100644
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ b/src/view/com/util/forms/DropdownButton.tsx
@@ -22,16 +22,22 @@ import {useTheme} from 'lib/ThemeContext'
 import {isAndroid, isIOS} from 'platform/detection'
 import Clipboard from '@react-native-clipboard/clipboard'
 import * as Toast from '../../util/Toast'
+import {isWeb} from 'platform/detection'
 
 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
-const ESTIMATED_MENU_ITEM_HEIGHT = 52
+const ESTIMATED_BTN_HEIGHT = 50
+const ESTIMATED_SEP_HEIGHT = 16
 
-export interface DropdownItem {
+export interface DropdownItemButton {
   testID?: string
   icon?: IconProp
   label: string
   onPress: () => void
 }
+export interface DropdownItemSeparator {
+  sep: true
+}
+export type DropdownItem = DropdownItemButton | DropdownItemSeparator
 type MaybeDropdownItem = DropdownItem | false | undefined
 
 export type DropdownButtonType = ButtonType | 'bare'
@@ -59,10 +65,12 @@ export function DropdownButton({
   rightOffset?: number
   bottomOffset?: number
 }) {
-  const ref = useRef<TouchableOpacity>(null)
+  const ref1 = useRef<TouchableOpacity>(null)
+  const ref2 = useRef<View>(null)
 
   const onPress = () => {
-    ref.current?.measure(
+    const ref = ref1.current || ref2.current
+    ref?.measure(
       (
         _x: number,
         _y: number,
@@ -75,7 +83,14 @@ export function DropdownButton({
           menuWidth = 200
         }
         const winHeight = Dimensions.get('window').height
-        const estimatedMenuHeight = items.length * ESTIMATED_MENU_ITEM_HEIGHT
+        let estimatedMenuHeight = 0
+        for (const item of items) {
+          if (item && isSep(item)) {
+            estimatedMenuHeight += ESTIMATED_SEP_HEIGHT
+          } else if (item && isBtn(item)) {
+            estimatedMenuHeight += ESTIMATED_BTN_HEIGHT
+          }
+        }
         const newX = openToRight
           ? pageX + width + rightOffset
           : pageX + width - menuWidth
@@ -100,13 +115,13 @@ export function DropdownButton({
         style={style}
         onPress={onPress}
         hitSlop={HITSLOP}
-        ref={ref}>
+        ref={ref1}>
         {children}
       </TouchableOpacity>
     )
   }
   return (
-    <View ref={ref}>
+    <View ref={ref2}>
       <Button testID={testID} onPress={onPress} style={style} label={label}>
         {children}
       </Button>
@@ -122,8 +137,10 @@ export function PostDropdownBtn({
   itemCid,
   itemHref,
   isAuthor,
+  isThreadMuted,
   onCopyPostText,
   onOpenTranslate,
+  onToggleThreadMute,
   onDeletePost,
 }: {
   testID?: string
@@ -134,8 +151,10 @@ export function PostDropdownBtn({
   itemHref: string
   itemTitle: string
   isAuthor: boolean
+  isThreadMuted: boolean
   onCopyPostText: () => void
   onOpenTranslate: () => void
+  onToggleThreadMute: () => void
   onDeletePost: () => void
 }) {
   const store = useStores()
@@ -174,6 +193,16 @@ export function PostDropdownBtn({
         }
       },
     },
+    {sep: true},
+    {
+      testID: 'postDropdownMuteThreadBtn',
+      icon: 'comment-slash',
+      label: isThreadMuted ? 'Unmute thread' : 'Mute thread',
+      onPress() {
+        onToggleThreadMute()
+      },
+    },
+    {sep: true},
     {
       testID: 'postDropdownReportBtn',
       icon: 'circle-exclamation',
@@ -186,21 +215,19 @@ export function PostDropdownBtn({
         })
       },
     },
-    isAuthor
-      ? {
-          testID: 'postDropdownDeleteBtn',
-          icon: ['far', 'trash-can'],
-          label: 'Delete post',
-          onPress() {
-            store.shell.openModal({
-              name: 'confirm',
-              title: 'Delete this post?',
-              message: 'Are you sure? This can not be undone.',
-              onPressConfirm: onDeletePost,
-            })
-          },
-        }
-      : undefined,
+    isAuthor && {
+      testID: 'postDropdownDeleteBtn',
+      icon: ['far', 'trash-can'],
+      label: 'Delete post',
+      onPress() {
+        store.shell.openModal({
+          name: 'confirm',
+          title: 'Delete this post?',
+          message: 'Are you sure? This can not be undone.',
+          onPressConfirm: onDeletePost,
+        })
+      },
+    },
   ].filter(Boolean) as DropdownItem[]
 
   return (
@@ -208,7 +235,7 @@ export function PostDropdownBtn({
       testID={testID}
       style={style}
       items={dropdownItems}
-      menuWidth={200}>
+      menuWidth={isWeb ? 220 : 200}>
       {children}
     </DropdownButton>
   )
@@ -222,7 +249,10 @@ function createDropdownMenu(
 ): RootSiblings {
   const onPressItem = (index: number) => {
     sibling.destroy()
-    items[index].onPress()
+    const item = items[index]
+    if (isBtn(item)) {
+      item.onPress()
+    }
   }
   const onOuterPress = () => sibling.destroy()
   const sibling = new RootSiblings(
@@ -240,6 +270,74 @@ function createDropdownMenu(
   return sibling
 }
 
+type DropDownItemProps = {
+  onOuterPress: () => void
+  x: number
+  y: number
+  width: number
+  items: DropdownItem[]
+  onPressItem: (index: number) => void
+}
+
+const DropdownItems = ({
+  onOuterPress,
+  x,
+  y,
+  width,
+  items,
+  onPressItem,
+}: DropDownItemProps) => {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const dropDownBackgroundColor =
+    theme.colorScheme === 'dark' ? pal.btn : pal.view
+
+  return (
+    <>
+      <TouchableWithoutFeedback onPress={onOuterPress}>
+        <View style={[styles.bg]} />
+      </TouchableWithoutFeedback>
+      <View
+        style={[
+          styles.menu,
+          {left: x, top: y, width},
+          dropDownBackgroundColor,
+        ]}>
+        {items.map((item, index) => {
+          if (isBtn(item)) {
+            return (
+              <TouchableOpacity
+                testID={item.testID}
+                key={index}
+                style={[styles.menuItem]}
+                onPress={() => onPressItem(index)}>
+                {item.icon && (
+                  <FontAwesomeIcon
+                    style={styles.icon}
+                    icon={item.icon}
+                    color={pal.text.color as string}
+                  />
+                )}
+                <Text style={[styles.label, pal.text]}>{item.label}</Text>
+              </TouchableOpacity>
+            )
+          } else if (isSep(item)) {
+            return <View key={index} style={[styles.separator, pal.border]} />
+          }
+          return null
+        })}
+      </View>
+    </>
+  )
+}
+
+function isSep(item: DropdownItem): item is DropdownItemSeparator {
+  return 'sep' in item && item.sep
+}
+function isBtn(item: DropdownItem): item is DropdownItemButton {
+  return !isSep(item)
+}
+
 const styles = StyleSheet.create({
   bg: {
     position: 'absolute',
@@ -277,57 +375,8 @@ const styles = StyleSheet.create({
   label: {
     fontSize: 18,
   },
+  separator: {
+    borderTopWidth: 1,
+    marginVertical: 8,
+  },
 })
-type DropDownItemProps = {
-  onOuterPress: () => void
-  x: number
-  y: number
-  width: number
-  items: DropdownItem[]
-  onPressItem: (index: number) => void
-}
-
-const DropdownItems = ({
-  onOuterPress,
-  x,
-  y,
-  width,
-  items,
-  onPressItem,
-}: DropDownItemProps) => {
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const dropDownBackgroundColor =
-    theme.colorScheme === 'dark' ? pal.btn : pal.view
-
-  return (
-    <>
-      <TouchableWithoutFeedback onPress={onOuterPress}>
-        <View style={[styles.bg]} />
-      </TouchableWithoutFeedback>
-      <View
-        style={[
-          styles.menu,
-          {left: x, top: y, width},
-          dropDownBackgroundColor,
-        ]}>
-        {items.map((item, index) => (
-          <TouchableOpacity
-            testID={item.testID}
-            key={index}
-            style={[styles.menuItem]}
-            onPress={() => onPressItem(index)}>
-            {item.icon && (
-              <FontAwesomeIcon
-                style={styles.icon}
-                icon={item.icon}
-                color={pal.text.color as string}
-              />
-            )}
-            <Text style={[styles.label, pal.text]}>{item.label}</Text>
-          </TouchableOpacity>
-        ))}
-      </View>
-    </>
-  )
-}
diff --git a/src/view/index.ts b/src/view/index.ts
index e6e342697..93c6fccc5 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -8,10 +8,7 @@ import {faAngleUp} from '@fortawesome/free-solid-svg-icons/faAngleUp'
 import {faArrowLeft} from '@fortawesome/free-solid-svg-icons/faArrowLeft'
 import {faArrowRight} from '@fortawesome/free-solid-svg-icons/faArrowRight'
 import {faArrowUp} from '@fortawesome/free-solid-svg-icons/faArrowUp'
-import {
-  faArrowRightFromBracket,
-  faQuoteLeft,
-} from '@fortawesome/free-solid-svg-icons'
+import {faArrowRightFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowRightFromBracket'
 import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
 import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare'
 import {faArrowRotateLeft} from '@fortawesome/free-solid-svg-icons/faArrowRotateLeft'
@@ -30,6 +27,7 @@ import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser'
 import {faClone} from '@fortawesome/free-solid-svg-icons/faClone'
 import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
 import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
+import {faCommentSlash} from '@fortawesome/free-solid-svg-icons/faCommentSlash'
 import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass'
 import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis'
 import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope'
@@ -55,6 +53,7 @@ import {faPen} from '@fortawesome/free-solid-svg-icons/faPen'
 import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
 import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare'
 import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus'
+import {faQuoteLeft} from '@fortawesome/free-solid-svg-icons/faQuoteLeft'
 import {faShare} from '@fortawesome/free-solid-svg-icons/faShare'
 import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare'
 import {faShield} from '@fortawesome/free-solid-svg-icons/faShield'
@@ -104,6 +103,7 @@ export function setup() {
     faClone,
     farClone,
     faComment,
+    faCommentSlash,
     faCompass,
     faEllipsis,
     faEnvelope,