Thread muting [APP-29] (#500)

* Implement thread muting

* Apply filtering on background fetched notifs

* Implement thread-muting tests
zio/stable
Paul Frazee 2023-04-20 17:16:56 -05:00 committed by GitHub
parent 3e78c71018
commit 22884b53ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 470 additions and 108 deletions

View File

@ -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')
})
})

View File

@ -42,6 +42,17 @@ export class PostThreadItemModel {
return this.postRecord?.reply?.parent.uri 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( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
reactKey: string, 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() { async delete() {
await this.rootStore.agent.deletePost(this.post.uri) await this.rootStore.agent.deletePost(this.post.uri)
this.rootStore.emitPostDeleted(this.post.uri) this.rootStore.emitPostDeleted(this.post.uri)
@ -230,6 +249,19 @@ export class PostThreadModel {
return this.error !== '' 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 // public api
// = // =
@ -279,6 +311,14 @@ export class PostThreadModel {
this.refresh() this.refresh()
} }
async toggleThreadMute() {
if (this.isThreadMuted) {
this.rootStore.mutedThreads.uris.delete(this.rootUri)
} else {
this.rootStore.mutedThreads.uris.add(this.rootUri)
}
}
// state transitions // state transitions
// = // =

View File

@ -48,6 +48,17 @@ export class PostModel implements RemoveIndex<Post.Record> {
return this.hasLoaded && !this.hasContent 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 // public api
// = // =
@ -55,6 +66,14 @@ export class PostModel implements RemoveIndex<Post.Record> {
await this._load() 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 // state transitions
// = // =

View File

@ -160,6 +160,13 @@ export class NotificationsFeedItemModel {
return '' return ''
} }
get reasonSubjectRootUri(): string | undefined {
if (this.additionalPost) {
return this.additionalPost.rootUri
}
return undefined
}
toSupportedRecord(v: unknown): SupportedRecord | undefined { toSupportedRecord(v: unknown): SupportedRecord | undefined {
for (const ns of [ for (const ns of [
AppBskyFeedPost, AppBskyFeedPost,
@ -227,7 +234,7 @@ export class NotificationsFeedModel {
// data // data
notifications: NotificationsFeedItemModel[] = [] notifications: NotificationsFeedItemModel[] = []
queuedNotifications: undefined | ListNotifications.Notification[] = undefined queuedNotifications: undefined | NotificationsFeedItemModel[] = undefined
unreadCount = 0 unreadCount = 0
// this is used to help trigger push notifications // this is used to help trigger push notifications
@ -354,7 +361,13 @@ export class NotificationsFeedModel {
queue.push(notif) 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() this._countUnread()
} catch (e) { } catch (e) {
this.rootStore.log.error('NotificationsModel:syncQueue failed', {e}) this.rootStore.log.error('NotificationsModel:syncQueue failed', {e})
@ -452,7 +465,8 @@ export class NotificationsFeedModel {
res.data.notifications[0], res.data.notifications[0],
) )
await notif.fetchAdditionalData() await notif.fetchAdditionalData()
return notif const filtered = this._filterNotifications([notif])
return filtered[0]
} }
// state transitions // state transitions
@ -505,23 +519,26 @@ export class NotificationsFeedModel {
} }
_filterNotifications( _filterNotifications(
items: ListNotifications.Notification[], items: NotificationsFeedItemModel[],
): ListNotifications.Notification[] { ): NotificationsFeedItemModel[] {
return items.filter(item => { return items.filter(item => {
return ( const hideByLabel =
this.rootStore.preferences.getLabelPreference(item.labels).pref !== this.rootStore.preferences.getLabelPreference(item.labels).pref ===
'hide' 'hide'
let mutedThread = !!(
item.reasonSubjectRootUri &&
this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri)
) )
return !hideByLabel && !mutedThread
}) })
} }
async _processNotifications( async _fetchItemModels(
items: ListNotifications.Notification[], items: ListNotifications.Notification[],
): Promise<NotificationsFeedItemModel[]> { ): Promise<NotificationsFeedItemModel[]> {
const promises = [] const promises = []
const itemModels: NotificationsFeedItemModel[] = [] const itemModels: NotificationsFeedItemModel[] = []
items = this._filterNotifications(items) for (const item of items) {
for (const item of groupNotifications(items)) {
const itemModel = new NotificationsFeedItemModel( const itemModel = new NotificationsFeedItemModel(
this.rootStore, this.rootStore,
`item-${_idCounter++}`, `item-${_idCounter++}`,
@ -541,7 +558,14 @@ export class NotificationsFeedModel {
return itemModels 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 this.queuedNotifications = queued
} }

View File

@ -72,6 +72,17 @@ export class PostsFeedItemModel {
makeAutoObservable(this, {rootStore: false}) 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) { copy(v: FeedViewPost) {
this.post = v.post this.post = v.post
this.reply = v.reply 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() { async delete() {
await this.rootStore.agent.deletePost(this.post.uri) await this.rootStore.agent.deletePost(this.post.uri)
this.rootStore.emitPostDeleted(this.post.uri) this.rootStore.emitPostDeleted(this.post.uri)

View File

@ -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)
}
}
}

View File

@ -20,6 +20,7 @@ import {InvitedUsers} from './invited-users'
import {PreferencesModel} from './ui/preferences' import {PreferencesModel} from './ui/preferences'
import {resetToTab} from '../../Navigation' import {resetToTab} from '../../Navigation'
import {ImageSizesCache} from './cache/image-sizes' import {ImageSizesCache} from './cache/image-sizes'
import {MutedThreads} from './muted-threads'
export const appInfo = z.object({ export const appInfo = z.object({
build: z.string(), build: z.string(),
@ -41,6 +42,7 @@ export class RootStoreModel {
profiles = new ProfilesCache(this) profiles = new ProfilesCache(this)
linkMetas = new LinkMetasCache(this) linkMetas = new LinkMetasCache(this)
imageSizes = new ImageSizesCache() imageSizes = new ImageSizesCache()
mutedThreads = new MutedThreads()
constructor(agent: BskyAgent) { constructor(agent: BskyAgent) {
this.agent = agent this.agent = agent
@ -64,6 +66,7 @@ export class RootStoreModel {
shell: this.shell.serialize(), shell: this.shell.serialize(),
preferences: this.preferences.serialize(), preferences: this.preferences.serialize(),
invitedUsers: this.invitedUsers.serialize(), invitedUsers: this.invitedUsers.serialize(),
mutedThreads: this.mutedThreads.serialize(),
} }
} }
@ -90,6 +93,9 @@ export class RootStoreModel {
if (hasProp(v, 'invitedUsers')) { if (hasProp(v, 'invitedUsers')) {
this.invitedUsers.hydrate(v.invitedUsers) this.invitedUsers.hydrate(v.invitedUsers)
} }
if (hasProp(v, 'mutedThreads')) {
this.mutedThreads.hydrate(v.mutedThreads)
}
} }
} }

View File

@ -135,8 +135,9 @@ export const Feed = observer(function Feed({
/> />
)} )}
</CenteredView> </CenteredView>
{data.length && ( {data.length ? (
<FlatList <FlatList
testID="notifsFeed"
ref={scrollElRef} ref={scrollElRef}
data={data} data={data}
keyExtractor={item => item._reactKey} keyExtractor={item => item._reactKey}
@ -155,7 +156,7 @@ export const Feed = observer(function Feed({
onScroll={onScroll} onScroll={onScroll}
contentContainerStyle={s.contentContainer} contentContainerStyle={s.contentContainer}
/> />
)} ) : null}
</View> </View>
) )
}) })

View File

@ -85,7 +85,11 @@ export const FeedItem = observer(function FeedItem({
return <View /> return <View />
} }
return ( return (
<Link href={itemHref} title={itemTitle} noFeedback> <Link
testID={`feedItem-by-${item.author.handle}`}
href={itemHref}
title={itemTitle}
noFeedback>
<Post <Post
uri={item.uri} uri={item.uri}
initView={item.additionalPost} initView={item.additionalPost}
@ -147,6 +151,7 @@ export const FeedItem = observer(function FeedItem({
return ( return (
<Link <Link
testID={`feedItem-by-${item.author.handle}`}
style={[ style={[
styles.outer, styles.outer,
pal.view, pal.view,

View File

@ -77,25 +77,43 @@ export const PostThreadItem = observer(function PostThreadItem({
onPost: onPostReply, onPost: onPostReply,
}) })
}, [store, item, record, onPostReply]) }, [store, item, record, onPostReply])
const onPressToggleRepost = React.useCallback(() => { const onPressToggleRepost = React.useCallback(() => {
return item return item
.toggleRepost() .toggleRepost()
.catch(e => store.log.error('Failed to toggle repost', e)) .catch(e => store.log.error('Failed to toggle repost', e))
}, [item, store]) }, [item, store])
const onPressToggleLike = React.useCallback(() => { const onPressToggleLike = React.useCallback(() => {
return item return item
.toggleLike() .toggleLike()
.catch(e => store.log.error('Failed to toggle like', e)) .catch(e => store.log.error('Failed to toggle like', e))
}, [item, store]) }, [item, store])
const onCopyPostText = React.useCallback(() => { const onCopyPostText = React.useCallback(() => {
Clipboard.setString(record?.text || '') Clipboard.setString(record?.text || '')
Toast.show('Copied to clipboard') Toast.show('Copied to clipboard')
}, [record]) }, [record])
const onOpenTranslate = React.useCallback(() => { const onOpenTranslate = React.useCallback(() => {
Linking.openURL( Linking.openURL(
encodeURI(`https://translate.google.com/#auto|en|${record?.text || ''}`), encodeURI(`https://translate.google.com/#auto|en|${record?.text || ''}`),
) )
}, [record]) }, [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(() => { const onDeletePost = React.useCallback(() => {
item.delete().then( item.delete().then(
() => { () => {
@ -175,8 +193,10 @@ export const PostThreadItem = observer(function PostThreadItem({
itemHref={itemHref} itemHref={itemHref}
itemTitle={itemTitle} itemTitle={itemTitle}
isAuthor={item.post.author.did === store.me.did} isAuthor={item.post.author.did === store.me.did}
isThreadMuted={item.isThreadMuted}
onCopyPostText={onCopyPostText} onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate} onOpenTranslate={onOpenTranslate}
onToggleThreadMute={onToggleThreadMute}
onDeletePost={onDeletePost}> onDeletePost={onDeletePost}>
<FontAwesomeIcon <FontAwesomeIcon
icon="ellipsis-h" icon="ellipsis-h"
@ -269,11 +289,13 @@ export const PostThreadItem = observer(function PostThreadItem({
isAuthor={item.post.author.did === store.me.did} isAuthor={item.post.author.did === store.me.did}
isReposted={!!item.post.viewer?.repost} isReposted={!!item.post.viewer?.repost}
isLiked={!!item.post.viewer?.like} isLiked={!!item.post.viewer?.like}
isThreadMuted={item.isThreadMuted}
onPressReply={onPressReply} onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost} onPressToggleRepost={onPressToggleRepost}
onPressToggleLike={onPressToggleLike} onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText} onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate} onOpenTranslate={onOpenTranslate}
onToggleThreadMute={onToggleThreadMute}
onDeletePost={onDeletePost} onDeletePost={onDeletePost}
/> />
</View> </View>
@ -357,11 +379,13 @@ export const PostThreadItem = observer(function PostThreadItem({
likeCount={item.post.likeCount} likeCount={item.post.likeCount}
isReposted={!!item.post.viewer?.repost} isReposted={!!item.post.viewer?.repost}
isLiked={!!item.post.viewer?.like} isLiked={!!item.post.viewer?.like}
isThreadMuted={item.isThreadMuted}
onPressReply={onPressReply} onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost} onPressToggleRepost={onPressToggleRepost}
onPressToggleLike={onPressToggleLike} onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText} onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate} onOpenTranslate={onOpenTranslate}
onToggleThreadMute={onToggleThreadMute}
onDeletePost={onDeletePost} onDeletePost={onDeletePost}
/> />
</View> </View>

View File

@ -174,6 +174,21 @@ const PostLoaded = observer(
) )
}, [record]) }, [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(() => { const onDeletePost = React.useCallback(() => {
item.delete().then( item.delete().then(
() => { () => {
@ -237,6 +252,7 @@ const PostLoaded = observer(
{item.richText?.text ? ( {item.richText?.text ? (
<View style={styles.postTextContainer}> <View style={styles.postTextContainer}>
<RichText <RichText
testID="postText"
type="post-text" type="post-text"
richText={item.richText} richText={item.richText}
lineHeight={1.3} lineHeight={1.3}
@ -263,11 +279,13 @@ const PostLoaded = observer(
likeCount={item.post.likeCount} likeCount={item.post.likeCount}
isReposted={!!item.post.viewer?.repost} isReposted={!!item.post.viewer?.repost}
isLiked={!!item.post.viewer?.like} isLiked={!!item.post.viewer?.like}
isThreadMuted={item.isThreadMuted}
onPressReply={onPressReply} onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost} onPressToggleRepost={onPressToggleRepost}
onPressToggleLike={onPressToggleLike} onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText} onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate} onOpenTranslate={onOpenTranslate}
onToggleThreadMute={onToggleThreadMute}
onDeletePost={onDeletePost} onDeletePost={onDeletePost}
/> />
</View> </View>

View File

@ -101,6 +101,20 @@ export const FeedItem = observer(function ({
) )
}, [record]) }, [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(() => { const onDeletePost = React.useCallback(() => {
track('FeedItem:PostDelete') track('FeedItem:PostDelete')
item.delete().then( item.delete().then(
@ -120,7 +134,6 @@ export const FeedItem = observer(function ({
} }
const isSmallTop = isThreadChild const isSmallTop = isThreadChild
const isNoTop = false //isChild && !item._isThreadChild
const isMuted = const isMuted =
item.post.author.viewer?.muted && ignoreMuteFor !== item.post.author.did item.post.author.viewer?.muted && ignoreMuteFor !== item.post.author.did
const outerStyles = [ const outerStyles = [
@ -128,7 +141,6 @@ export const FeedItem = observer(function ({
pal.view, pal.view,
{borderColor: pal.colors.border}, {borderColor: pal.colors.border},
isSmallTop ? styles.outerSmallTop : undefined, isSmallTop ? styles.outerSmallTop : undefined,
isNoTop ? styles.outerNoTop : undefined,
isThreadParent ? styles.outerNoBottom : undefined, isThreadParent ? styles.outerNoBottom : undefined,
] ]
@ -146,11 +158,7 @@ export const FeedItem = observer(function ({
)} )}
{isThreadParent && ( {isThreadParent && (
<View <View
style={[ style={[styles.bottomReplyLine, {borderColor: pal.colors.replyLine}]}
styles.bottomReplyLine,
{borderColor: pal.colors.replyLine},
isNoTop ? styles.bottomReplyLineNoTop : undefined,
]}
/> />
)} )}
{item.reasonRepost && ( {item.reasonRepost && (
@ -260,11 +268,13 @@ export const FeedItem = observer(function ({
likeCount={item.post.likeCount} likeCount={item.post.likeCount}
isReposted={!!item.post.viewer?.repost} isReposted={!!item.post.viewer?.repost}
isLiked={!!item.post.viewer?.like} isLiked={!!item.post.viewer?.like}
isThreadMuted={item.isThreadMuted}
onPressReply={onPressReply} onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost} onPressToggleRepost={onPressToggleRepost}
onPressToggleLike={onPressToggleLike} onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText} onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate} onOpenTranslate={onOpenTranslate}
onToggleThreadMute={onToggleThreadMute}
onDeletePost={onDeletePost} onDeletePost={onDeletePost}
/> />
</View> </View>
@ -280,10 +290,6 @@ const styles = StyleSheet.create({
paddingRight: 15, paddingRight: 15,
paddingBottom: 8, paddingBottom: 8,
}, },
outerNoTop: {
borderTopWidth: 0,
paddingTop: 0,
},
outerSmallTop: { outerSmallTop: {
borderTopWidth: 0, borderTopWidth: 0,
}, },
@ -304,7 +310,6 @@ const styles = StyleSheet.create({
bottom: 0, bottom: 0,
borderLeftWidth: 2, borderLeftWidth: 2,
}, },
bottomReplyLineNoTop: {top: 64},
includeReason: { includeReason: {
flexDirection: 'row', flexDirection: 'row',
paddingLeft: 50, paddingLeft: 50,

View File

@ -48,11 +48,13 @@ interface PostCtrlsOpts {
likeCount?: number likeCount?: number
isReposted: boolean isReposted: boolean
isLiked: boolean isLiked: boolean
isThreadMuted: boolean
onPressReply: () => void onPressReply: () => void
onPressToggleRepost: () => Promise<void> onPressToggleRepost: () => Promise<void>
onPressToggleLike: () => Promise<void> onPressToggleLike: () => Promise<void>
onCopyPostText: () => void onCopyPostText: () => void
onOpenTranslate: () => void onOpenTranslate: () => void
onToggleThreadMute: () => void
onDeletePost: () => void onDeletePost: () => void
} }
@ -255,8 +257,10 @@ export function PostCtrls(opts: PostCtrlsOpts) {
itemHref={opts.itemHref} itemHref={opts.itemHref}
itemTitle={opts.itemTitle} itemTitle={opts.itemTitle}
isAuthor={opts.isAuthor} isAuthor={opts.isAuthor}
isThreadMuted={opts.isThreadMuted}
onCopyPostText={opts.onCopyPostText} onCopyPostText={opts.onCopyPostText}
onOpenTranslate={opts.onOpenTranslate} onOpenTranslate={opts.onOpenTranslate}
onToggleThreadMute={opts.onToggleThreadMute}
onDeletePost={opts.onDeletePost}> onDeletePost={opts.onDeletePost}>
<FontAwesomeIcon <FontAwesomeIcon
icon="ellipsis-h" icon="ellipsis-h"

View File

@ -94,7 +94,10 @@ export function Selector({
{items.map((item, i) => { {items.map((item, i) => {
const selected = i === selectedIndex const selected = i === selectedIndex
return ( return (
<Pressable key={item} onPress={() => onPressItem(i)}> <Pressable
testID={`selector-${i}`}
key={item}
onPress={() => onPressItem(i)}>
<View style={styles.item} ref={itemRefs[i]}> <View style={styles.item} ref={itemRefs[i]}>
<Text <Text
style={ style={

View File

@ -22,16 +22,22 @@ import {useTheme} from 'lib/ThemeContext'
import {isAndroid, isIOS} from 'platform/detection' import {isAndroid, isIOS} from 'platform/detection'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import * as Toast from '../../util/Toast' import * as Toast from '../../util/Toast'
import {isWeb} from 'platform/detection'
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} 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 testID?: string
icon?: IconProp icon?: IconProp
label: string label: string
onPress: () => void onPress: () => void
} }
export interface DropdownItemSeparator {
sep: true
}
export type DropdownItem = DropdownItemButton | DropdownItemSeparator
type MaybeDropdownItem = DropdownItem | false | undefined type MaybeDropdownItem = DropdownItem | false | undefined
export type DropdownButtonType = ButtonType | 'bare' export type DropdownButtonType = ButtonType | 'bare'
@ -59,10 +65,12 @@ export function DropdownButton({
rightOffset?: number rightOffset?: number
bottomOffset?: number bottomOffset?: number
}) { }) {
const ref = useRef<TouchableOpacity>(null) const ref1 = useRef<TouchableOpacity>(null)
const ref2 = useRef<View>(null)
const onPress = () => { const onPress = () => {
ref.current?.measure( const ref = ref1.current || ref2.current
ref?.measure(
( (
_x: number, _x: number,
_y: number, _y: number,
@ -75,7 +83,14 @@ export function DropdownButton({
menuWidth = 200 menuWidth = 200
} }
const winHeight = Dimensions.get('window').height 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 const newX = openToRight
? pageX + width + rightOffset ? pageX + width + rightOffset
: pageX + width - menuWidth : pageX + width - menuWidth
@ -100,13 +115,13 @@ export function DropdownButton({
style={style} style={style}
onPress={onPress} onPress={onPress}
hitSlop={HITSLOP} hitSlop={HITSLOP}
ref={ref}> ref={ref1}>
{children} {children}
</TouchableOpacity> </TouchableOpacity>
) )
} }
return ( return (
<View ref={ref}> <View ref={ref2}>
<Button testID={testID} onPress={onPress} style={style} label={label}> <Button testID={testID} onPress={onPress} style={style} label={label}>
{children} {children}
</Button> </Button>
@ -122,8 +137,10 @@ export function PostDropdownBtn({
itemCid, itemCid,
itemHref, itemHref,
isAuthor, isAuthor,
isThreadMuted,
onCopyPostText, onCopyPostText,
onOpenTranslate, onOpenTranslate,
onToggleThreadMute,
onDeletePost, onDeletePost,
}: { }: {
testID?: string testID?: string
@ -134,8 +151,10 @@ export function PostDropdownBtn({
itemHref: string itemHref: string
itemTitle: string itemTitle: string
isAuthor: boolean isAuthor: boolean
isThreadMuted: boolean
onCopyPostText: () => void onCopyPostText: () => void
onOpenTranslate: () => void onOpenTranslate: () => void
onToggleThreadMute: () => void
onDeletePost: () => void onDeletePost: () => void
}) { }) {
const store = useStores() 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', testID: 'postDropdownReportBtn',
icon: 'circle-exclamation', icon: 'circle-exclamation',
@ -186,8 +215,7 @@ export function PostDropdownBtn({
}) })
}, },
}, },
isAuthor isAuthor && {
? {
testID: 'postDropdownDeleteBtn', testID: 'postDropdownDeleteBtn',
icon: ['far', 'trash-can'], icon: ['far', 'trash-can'],
label: 'Delete post', label: 'Delete post',
@ -199,8 +227,7 @@ export function PostDropdownBtn({
onPressConfirm: onDeletePost, onPressConfirm: onDeletePost,
}) })
}, },
} },
: undefined,
].filter(Boolean) as DropdownItem[] ].filter(Boolean) as DropdownItem[]
return ( return (
@ -208,7 +235,7 @@ export function PostDropdownBtn({
testID={testID} testID={testID}
style={style} style={style}
items={dropdownItems} items={dropdownItems}
menuWidth={200}> menuWidth={isWeb ? 220 : 200}>
{children} {children}
</DropdownButton> </DropdownButton>
) )
@ -222,7 +249,10 @@ function createDropdownMenu(
): RootSiblings { ): RootSiblings {
const onPressItem = (index: number) => { const onPressItem = (index: number) => {
sibling.destroy() sibling.destroy()
items[index].onPress() const item = items[index]
if (isBtn(item)) {
item.onPress()
}
} }
const onOuterPress = () => sibling.destroy() const onOuterPress = () => sibling.destroy()
const sibling = new RootSiblings( const sibling = new RootSiblings(
@ -240,6 +270,74 @@ function createDropdownMenu(
return sibling 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({ const styles = StyleSheet.create({
bg: { bg: {
position: 'absolute', position: 'absolute',
@ -277,57 +375,8 @@ const styles = StyleSheet.create({
label: { label: {
fontSize: 18, 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>
</>
)
}

View File

@ -8,10 +8,7 @@ import {faAngleUp} from '@fortawesome/free-solid-svg-icons/faAngleUp'
import {faArrowLeft} from '@fortawesome/free-solid-svg-icons/faArrowLeft' import {faArrowLeft} from '@fortawesome/free-solid-svg-icons/faArrowLeft'
import {faArrowRight} from '@fortawesome/free-solid-svg-icons/faArrowRight' import {faArrowRight} from '@fortawesome/free-solid-svg-icons/faArrowRight'
import {faArrowUp} from '@fortawesome/free-solid-svg-icons/faArrowUp' import {faArrowUp} from '@fortawesome/free-solid-svg-icons/faArrowUp'
import { import {faArrowRightFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowRightFromBracket'
faArrowRightFromBracket,
faQuoteLeft,
} from '@fortawesome/free-solid-svg-icons'
import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket' import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare' import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare'
import {faArrowRotateLeft} from '@fortawesome/free-solid-svg-icons/faArrowRotateLeft' 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} from '@fortawesome/free-solid-svg-icons/faClone'
import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone' import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
import {faComment} from '@fortawesome/free-regular-svg-icons/faComment' 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 {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass'
import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis' import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis'
import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope' 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 {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare' import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare'
import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus' 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 {faShare} from '@fortawesome/free-solid-svg-icons/faShare'
import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare' import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare'
import {faShield} from '@fortawesome/free-solid-svg-icons/faShield' import {faShield} from '@fortawesome/free-solid-svg-icons/faShield'
@ -104,6 +103,7 @@ export function setup() {
faClone, faClone,
farClone, farClone,
faComment, faComment,
faCommentSlash,
faCompass, faCompass,
faEllipsis, faEllipsis,
faEnvelope, faEnvelope,