Thread muting [APP-29] (#500)
* Implement thread muting * Apply filtering on background fetched notifs * Implement thread-muting testszio/stable
parent
3e78c71018
commit
22884b53ad
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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
|
||||
// =
|
||||
|
||||
|
|
|
@ -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
|
||||
// =
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue