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
|
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
|
||||||
// =
|
// =
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
// =
|
// =
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 {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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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={
|
||||||
|
|
|
@ -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,21 +215,19 @@ export function PostDropdownBtn({
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
isAuthor
|
isAuthor && {
|
||||||
? {
|
testID: 'postDropdownDeleteBtn',
|
||||||
testID: 'postDropdownDeleteBtn',
|
icon: ['far', 'trash-can'],
|
||||||
icon: ['far', 'trash-can'],
|
label: 'Delete post',
|
||||||
label: 'Delete post',
|
onPress() {
|
||||||
onPress() {
|
store.shell.openModal({
|
||||||
store.shell.openModal({
|
name: 'confirm',
|
||||||
name: 'confirm',
|
title: 'Delete this post?',
|
||||||
title: 'Delete this post?',
|
message: 'Are you sure? This can not be undone.',
|
||||||
message: 'Are you sure? This can not be undone.',
|
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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue