diff --git a/__e2e__/tests/thread-muting.test.ts b/__e2e__/tests/thread-muting.test.ts new file mode 100644 index 00000000..a5cefdb2 --- /dev/null +++ b/__e2e__/tests/thread-muting.test.ts @@ -0,0 +1,116 @@ +/* eslint-env detox/detox */ + +import {openApp, login, createServer} from '../util' + +describe('Thread muting', () => { + let service: string + beforeAll(async () => { + service = await createServer('?users&follows') + await openApp({permissions: {notifications: 'YES'}}) + }) + + it('Login, create a thread, and log out', async () => { + await login(service, 'alice', 'hunter2') + await element(by.id('homeScreenFeedTabs-Following')).tap() + await element(by.id('composeFAB')).tap() + await element(by.id('composerTextInput')).typeText('Test thread') + await element(by.id('composerPublishBtn')).tap() + await expect(element(by.id('composeFAB'))).toBeVisible() + await element(by.id('viewHeaderDrawerBtn')).tap() + await element(by.id('menuItemButton-Settings')).tap() + await element(by.id('signOutBtn')).tap() + }) + + it('Login, reply to the thread, and log out', async () => { + await login(service, 'bob', 'hunter2') + await element(by.id('homeScreenFeedTabs-Following')).tap() + const alicePosts = by.id('feedItem-by-alice.test') + await element(by.id('replyBtn').withAncestor(alicePosts)).atIndex(0).tap() + await element(by.id('composerTextInput')).typeText('Reply 1') + await element(by.id('composerPublishBtn')).tap() + await expect(element(by.id('composeFAB'))).toBeVisible() + await element(by.id('viewHeaderDrawerBtn')).tap() + await element(by.id('menuItemButton-Settings')).tap() + await element(by.id('signOutBtn')).tap() + }) + + it('Login, confirm notification exists, mute thread, and log out', async () => { + await login(service, 'alice', 'hunter2') + + await element(by.id('bottomBarNotificationsBtn')).tap() + const bobNotifs = by.id('feedItem-by-bob.test') + await expect( + element(by.id('postText').withAncestor(bobNotifs)).atIndex(0), + ).toHaveText('Reply 1') + await element(by.id('postDropdownBtn').withAncestor(bobNotifs)) + .atIndex(0) + .tap() + await element(by.id('postDropdownMuteThreadBtn')).tap() + // have to wait for the toast to clear + await waitFor(element(by.id('viewHeaderDrawerBtn'))) + .toBeVisible() + .withTimeout(5000) + + await element(by.id('viewHeaderDrawerBtn')).tap() + await element(by.id('menuItemButton-Settings')).tap() + await element(by.id('signOutBtn')).tap() + }) + + it('Login, reply to the thread twice, and log out', async () => { + await login(service, 'bob', 'hunter2') + + await element(by.id('bottomBarProfileBtn')).tap() + await element(by.id('selector-1')).tap() + const bobPosts = by.id('feedItem-by-bob.test') + await element(by.id('replyBtn').withAncestor(bobPosts)).atIndex(0).tap() + await element(by.id('composerTextInput')).typeText('Reply 2') + await element(by.id('composerPublishBtn')).tap() + await expect(element(by.id('composeFAB'))).toBeVisible() + + const alicePosts = by.id('feedItem-by-alice.test') + await element(by.id('replyBtn').withAncestor(alicePosts)).atIndex(0).tap() + await element(by.id('composerTextInput')).typeText('Reply 3') + await element(by.id('composerPublishBtn')).tap() + await expect(element(by.id('composeFAB'))).toBeVisible() + + await element(by.id('bottomBarHomeBtn')).tap() + await element(by.id('viewHeaderDrawerBtn')).tap() + await element(by.id('menuItemButton-Settings')).tap() + await element(by.id('signOutBtn')).tap() + }) + + it('Login, confirm notifications dont exist, unmute the thread, confirm notifications exist', async () => { + await login(service, 'alice', 'hunter2') + + await element(by.id('bottomBarNotificationsBtn')).tap() + const bobNotifs = by.id('feedItem-by-bob.test') + await expect( + element(by.id('postText').withAncestor(bobNotifs)).atIndex(0), + ).not.toExist() + + await element(by.id('bottomBarHomeBtn')).tap() + const alicePosts = by.id('feedItem-by-alice.test') + await element(by.id('postDropdownBtn').withAncestor(alicePosts)) + .atIndex(0) + .tap() + await element(by.id('postDropdownMuteThreadBtn')).tap() + + // TODO + // the swipe down to trigger PTR isnt working and I dont want to block on this + // -prf + // await element(by.id('bottomBarNotificationsBtn')).tap() + // await element(by.id('notifsFeed')).swipe('down', 'fast') + // await waitFor(element(by.id('postText').withAncestor(bobNotifs))) + // .toBeVisible() + // .withTimeout(5000) + // await expect( + // element(by.id('postText').withAncestor(bobNotifs)).atIndex(0), + // ).toHaveText('Reply 2') + // await expect( + // element(by.id('postText').withAncestor(bobNotifs)).atIndex(1), + // ).toHaveText('Reply 3') + // await expect( + // element(by.id('postText').withAncestor(bobNotifs)).atIndex(2), + // ).toHaveText('Reply 1') + }) +}) diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index 794beae2..acc9bffa 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -42,6 +42,17 @@ export class PostThreadItemModel { return this.postRecord?.reply?.parent.uri } + get rootUri(): string { + if (this.postRecord?.reply?.root.uri) { + return this.postRecord.reply.root.uri + } + return this.uri + } + + get isThreadMuted() { + return this.rootStore.mutedThreads.uris.has(this.rootUri) + } + constructor( public rootStore: RootStoreModel, reactKey: string, @@ -188,6 +199,14 @@ export class PostThreadItemModel { } } + async toggleThreadMute() { + if (this.isThreadMuted) { + this.rootStore.mutedThreads.uris.delete(this.rootUri) + } else { + this.rootStore.mutedThreads.uris.add(this.rootUri) + } + } + async delete() { await this.rootStore.agent.deletePost(this.post.uri) this.rootStore.emitPostDeleted(this.post.uri) @@ -230,6 +249,19 @@ export class PostThreadModel { return this.error !== '' } + get rootUri(): string { + if (this.thread) { + if (this.thread.postRecord?.reply?.root.uri) { + return this.thread.postRecord.reply.root.uri + } + } + return this.resolvedUri + } + + get isThreadMuted() { + return this.rootStore.mutedThreads.uris.has(this.rootUri) + } + // public api // = @@ -279,6 +311,14 @@ export class PostThreadModel { this.refresh() } + async toggleThreadMute() { + if (this.isThreadMuted) { + this.rootStore.mutedThreads.uris.delete(this.rootUri) + } else { + this.rootStore.mutedThreads.uris.add(this.rootUri) + } + } + // state transitions // = diff --git a/src/state/models/content/post.ts b/src/state/models/content/post.ts index b5d95bf0..7ba63336 100644 --- a/src/state/models/content/post.ts +++ b/src/state/models/content/post.ts @@ -48,6 +48,17 @@ export class PostModel implements RemoveIndex { 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 { await this._load() } + async toggleThreadMute() { + if (this.isThreadMuted) { + this.rootStore.mutedThreads.uris.delete(this.rootUri) + } else { + this.rootStore.mutedThreads.uris.add(this.rootUri) + } + } + // state transitions // = diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index ff77ab97..e2a18ea0 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -160,6 +160,13 @@ export class NotificationsFeedItemModel { return '' } + get reasonSubjectRootUri(): string | undefined { + if (this.additionalPost) { + return this.additionalPost.rootUri + } + return undefined + } + toSupportedRecord(v: unknown): SupportedRecord | undefined { for (const ns of [ AppBskyFeedPost, @@ -227,7 +234,7 @@ export class NotificationsFeedModel { // data notifications: NotificationsFeedItemModel[] = [] - queuedNotifications: undefined | ListNotifications.Notification[] = undefined + queuedNotifications: undefined | NotificationsFeedItemModel[] = undefined unreadCount = 0 // this is used to help trigger push notifications @@ -354,7 +361,13 @@ export class NotificationsFeedModel { queue.push(notif) } - this._setQueued(this._filterNotifications(queue)) + // NOTE + // because filtering depends on the added information we have to fetch + // the full models here. this is *not* ideal performance and we need + // to update the notifications route to give all the info we need + // -prf + const queueModels = await this._fetchItemModels(queue) + this._setQueued(this._filterNotifications(queueModels)) this._countUnread() } catch (e) { this.rootStore.log.error('NotificationsModel:syncQueue failed', {e}) @@ -452,7 +465,8 @@ export class NotificationsFeedModel { res.data.notifications[0], ) await notif.fetchAdditionalData() - return notif + const filtered = this._filterNotifications([notif]) + return filtered[0] } // state transitions @@ -505,23 +519,26 @@ export class NotificationsFeedModel { } _filterNotifications( - items: ListNotifications.Notification[], - ): ListNotifications.Notification[] { + items: NotificationsFeedItemModel[], + ): NotificationsFeedItemModel[] { return items.filter(item => { - return ( - this.rootStore.preferences.getLabelPreference(item.labels).pref !== + const hideByLabel = + this.rootStore.preferences.getLabelPreference(item.labels).pref === 'hide' + let mutedThread = !!( + item.reasonSubjectRootUri && + this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri) ) + return !hideByLabel && !mutedThread }) } - async _processNotifications( + async _fetchItemModels( items: ListNotifications.Notification[], ): Promise { 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 { + const itemModels = await this._fetchItemModels(groupNotifications(items)) + return this._filterNotifications(itemModels) + } + + _setQueued(queued: undefined | NotificationsFeedItemModel[]) { this.queuedNotifications = queued } diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 38faf658..58167284 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -72,6 +72,17 @@ export class PostsFeedItemModel { makeAutoObservable(this, {rootStore: false}) } + get rootUri(): string { + if (this.reply?.root.uri) { + return this.reply.root.uri + } + return this.post.uri + } + + get isThreadMuted() { + return this.rootStore.mutedThreads.uris.has(this.rootUri) + } + copy(v: FeedViewPost) { this.post = v.post this.reply = v.reply @@ -145,6 +156,14 @@ export class PostsFeedItemModel { } } + async toggleThreadMute() { + if (this.isThreadMuted) { + this.rootStore.mutedThreads.uris.delete(this.rootUri) + } else { + this.rootStore.mutedThreads.uris.add(this.rootUri) + } + } + async delete() { await this.rootStore.agent.deletePost(this.post.uri) this.rootStore.emitPostDeleted(this.post.uri) diff --git a/src/state/models/muted-threads.ts b/src/state/models/muted-threads.ts new file mode 100644 index 00000000..e6f20274 --- /dev/null +++ b/src/state/models/muted-threads.ts @@ -0,0 +1,29 @@ +/** + * This is a temporary client-side system for storing muted threads + * When the system lands on prod we should switch to that + */ + +import {makeAutoObservable} from 'mobx' +import {isObj, hasProp, isStrArray} from 'lib/type-guards' + +export class MutedThreads { + uris: Set = new Set() + + constructor() { + makeAutoObservable( + this, + {serialize: false, hydrate: false}, + {autoBind: true}, + ) + } + + serialize() { + return {uris: Array.from(this.uris)} + } + + hydrate(v: unknown) { + if (isObj(v) && hasProp(v, 'uris') && isStrArray(v.uris)) { + this.uris = new Set(v.uris) + } + } +} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 9207f27b..b3e744a4 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -20,6 +20,7 @@ import {InvitedUsers} from './invited-users' import {PreferencesModel} from './ui/preferences' import {resetToTab} from '../../Navigation' import {ImageSizesCache} from './cache/image-sizes' +import {MutedThreads} from './muted-threads' export const appInfo = z.object({ build: z.string(), @@ -41,6 +42,7 @@ export class RootStoreModel { profiles = new ProfilesCache(this) linkMetas = new LinkMetasCache(this) imageSizes = new ImageSizesCache() + mutedThreads = new MutedThreads() constructor(agent: BskyAgent) { this.agent = agent @@ -64,6 +66,7 @@ export class RootStoreModel { shell: this.shell.serialize(), preferences: this.preferences.serialize(), invitedUsers: this.invitedUsers.serialize(), + mutedThreads: this.mutedThreads.serialize(), } } @@ -90,6 +93,9 @@ export class RootStoreModel { if (hasProp(v, 'invitedUsers')) { this.invitedUsers.hydrate(v.invitedUsers) } + if (hasProp(v, 'mutedThreads')) { + this.mutedThreads.hydrate(v.mutedThreads) + } } } diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 33bde195..50bdc5dc 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -135,8 +135,9 @@ export const Feed = observer(function Feed({ /> )} - {data.length && ( + {data.length ? ( item._reactKey} @@ -155,7 +156,7 @@ export const Feed = observer(function Feed({ onScroll={onScroll} contentContainerStyle={s.contentContainer} /> - )} + ) : null} ) }) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 34df2a8e..b05111ff 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -85,7 +85,11 @@ export const FeedItem = observer(function FeedItem({ return } return ( - + { 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}> @@ -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} /> diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 60d46f5c..81f3b8c4 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -174,6 +174,21 @@ const PostLoaded = observer( ) }, [record]) + const onToggleThreadMute = React.useCallback(async () => { + try { + await item.toggleThreadMute() + if (item.isThreadMuted) { + Toast.show( + 'You will no longer received notifications for this thread', + ) + } else { + Toast.show('You will now receive notifications for this thread') + } + } catch (e) { + store.log.error('Failed to toggle thread mute', e) + } + }, [item, store]) + const onDeletePost = React.useCallback(() => { item.delete().then( () => { @@ -237,6 +252,7 @@ const PostLoaded = observer( {item.richText?.text ? ( diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index c2baa4d4..18481d4c 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -101,6 +101,20 @@ export const FeedItem = observer(function ({ ) }, [record]) + const onToggleThreadMute = React.useCallback(async () => { + track('FeedItem:ThreadMute') + try { + await item.toggleThreadMute() + if (item.isThreadMuted) { + Toast.show('You will no longer receive notifications for this thread') + } else { + Toast.show('You will now receive notifications for this thread') + } + } catch (e) { + store.log.error('Failed to toggle thread mute', e) + } + }, [track, item, store]) + const onDeletePost = React.useCallback(() => { track('FeedItem:PostDelete') item.delete().then( @@ -120,7 +134,6 @@ export const FeedItem = observer(function ({ } const isSmallTop = isThreadChild - const isNoTop = false //isChild && !item._isThreadChild const isMuted = item.post.author.viewer?.muted && ignoreMuteFor !== item.post.author.did const outerStyles = [ @@ -128,7 +141,6 @@ export const FeedItem = observer(function ({ pal.view, {borderColor: pal.colors.border}, isSmallTop ? styles.outerSmallTop : undefined, - isNoTop ? styles.outerNoTop : undefined, isThreadParent ? styles.outerNoBottom : undefined, ] @@ -146,11 +158,7 @@ export const FeedItem = observer(function ({ )} {isThreadParent && ( )} {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} /> @@ -280,10 +290,6 @@ const styles = StyleSheet.create({ paddingRight: 15, paddingBottom: 8, }, - outerNoTop: { - borderTopWidth: 0, - paddingTop: 0, - }, outerSmallTop: { borderTopWidth: 0, }, @@ -304,7 +310,6 @@ const styles = StyleSheet.create({ bottom: 0, borderLeftWidth: 2, }, - bottomReplyLineNoTop: {top: 64}, includeReason: { flexDirection: 'row', paddingLeft: 50, diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx index 6441d3c7..07a67fd8 100644 --- a/src/view/com/util/PostCtrls.tsx +++ b/src/view/com/util/PostCtrls.tsx @@ -48,11 +48,13 @@ interface PostCtrlsOpts { likeCount?: number isReposted: boolean isLiked: boolean + isThreadMuted: boolean onPressReply: () => void onPressToggleRepost: () => Promise onPressToggleLike: () => Promise 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}> { const selected = i === selectedIndex return ( - onPressItem(i)}> + onPressItem(i)}> 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(null) + const ref1 = useRef(null) + const ref2 = useRef(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} ) } return ( - + @@ -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} ) @@ -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 ( + <> + + + + + {items.map((item, index) => { + if (isBtn(item)) { + return ( + onPressItem(index)}> + {item.icon && ( + + )} + {item.label} + + ) + } else if (isSep(item)) { + return + } + return null + })} + + + ) +} + +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 ( - <> - - - - - {items.map((item, index) => ( - onPressItem(index)}> - {item.icon && ( - - )} - {item.label} - - ))} - - - ) -} diff --git a/src/view/index.ts b/src/view/index.ts index e6e34269..93c6fccc 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -8,10 +8,7 @@ import {faAngleUp} from '@fortawesome/free-solid-svg-icons/faAngleUp' import {faArrowLeft} from '@fortawesome/free-solid-svg-icons/faArrowLeft' import {faArrowRight} from '@fortawesome/free-solid-svg-icons/faArrowRight' import {faArrowUp} from '@fortawesome/free-solid-svg-icons/faArrowUp' -import { - faArrowRightFromBracket, - faQuoteLeft, -} from '@fortawesome/free-solid-svg-icons' +import {faArrowRightFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowRightFromBracket' import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket' import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare' import {faArrowRotateLeft} from '@fortawesome/free-solid-svg-icons/faArrowRotateLeft' @@ -30,6 +27,7 @@ import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser' import {faClone} from '@fortawesome/free-solid-svg-icons/faClone' import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone' import {faComment} from '@fortawesome/free-regular-svg-icons/faComment' +import {faCommentSlash} from '@fortawesome/free-solid-svg-icons/faCommentSlash' import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass' import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis' import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope' @@ -55,6 +53,7 @@ import {faPen} from '@fortawesome/free-solid-svg-icons/faPen' import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib' import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare' import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus' +import {faQuoteLeft} from '@fortawesome/free-solid-svg-icons/faQuoteLeft' import {faShare} from '@fortawesome/free-solid-svg-icons/faShare' import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare' import {faShield} from '@fortawesome/free-solid-svg-icons/faShield' @@ -104,6 +103,7 @@ export function setup() { faClone, farClone, faComment, + faCommentSlash, faCompass, faEllipsis, faEnvelope,