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
}
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
// =

View File

@ -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
// =

View File

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

View File

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

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

@ -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,

View File

@ -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"

View File

@ -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={

View File

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

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 {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,