diff options
Diffstat (limited to 'src/view/com')
-rw-r--r-- | src/view/com/modals/Threadgate.tsx | 11 | ||||
-rw-r--r-- | src/view/com/notifications/FeedItem.tsx | 47 | ||||
-rw-r--r-- | src/view/com/pager/TabBar.tsx | 4 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThread.tsx | 6 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 25 | ||||
-rw-r--r-- | src/view/com/posts/FeedItem.tsx | 53 | ||||
-rw-r--r-- | src/view/com/posts/FeedSlice.tsx | 4 | ||||
-rw-r--r-- | src/view/com/threadgate/WhoCanReply.tsx | 234 | ||||
-rw-r--r-- | src/view/com/util/List.web.tsx | 63 | ||||
-rw-r--r-- | src/view/com/util/TimeElapsed.tsx | 6 | ||||
-rw-r--r-- | src/view/com/util/UserAvatar.tsx | 35 | ||||
-rw-r--r-- | src/view/com/util/forms/PostDropdownBtn.tsx | 45 | ||||
-rw-r--r-- | src/view/com/util/images/ImageHorzList.tsx | 57 | ||||
-rw-r--r-- | src/view/com/util/post-ctrls/PostCtrls.tsx | 4 |
14 files changed, 386 insertions, 208 deletions
diff --git a/src/view/com/modals/Threadgate.tsx b/src/view/com/modals/Threadgate.tsx index a2e9f391c..4a9a9e2ab 100644 --- a/src/view/com/modals/Threadgate.tsx +++ b/src/view/com/modals/Threadgate.tsx @@ -26,9 +26,11 @@ export const snapPoints = ['60%'] export function Component({ settings, onChange, + onConfirm, }: { settings: ThreadgateSetting[] - onChange: (settings: ThreadgateSetting[]) => void + onChange?: (settings: ThreadgateSetting[]) => void + onConfirm?: (settings: ThreadgateSetting[]) => void }) { const pal = usePalette('default') const {closeModal} = useModalControls() @@ -38,12 +40,12 @@ export function Component({ const onPressEverybody = () => { setSelected([]) - onChange([]) + onChange?.([]) } const onPressNobody = () => { setSelected([{type: 'nobody'}]) - onChange([{type: 'nobody'}]) + onChange?.([{type: 'nobody'}]) } const onPressAudience = (setting: ThreadgateSetting) => { @@ -57,7 +59,7 @@ export function Component({ newSelected.splice(i, 1) } setSelected(newSelected) - onChange(newSelected) + onChange?.(newSelected) } return ( @@ -124,6 +126,7 @@ export function Component({ testID="confirmBtn" onPress={() => { closeModal() + onConfirm?.(selected) }} style={styles.btn} accessibilityRole="button" diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index d6c38ea61..9cd7a2917 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -8,6 +8,7 @@ import { } from 'react-native' import { AppBskyActorDefs, + AppBskyEmbedExternal, AppBskyEmbedImages, AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, @@ -51,6 +52,7 @@ import {TimeElapsed} from '../util/TimeElapsed' import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar' import hairlineWidth = StyleSheet.hairlineWidth +import {parseTenorGif} from '#/lib/strings/embed-player' const MAX_AUTHORS = 5 @@ -465,17 +467,48 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { const pal = usePalette('default') if (post && AppBskyFeedPost.isRecord(post?.record)) { const text = post.record.text - const images = AppBskyEmbedImages.isView(post.embed) - ? post.embed.images - : AppBskyEmbedRecordWithMedia.isView(post.embed) && - AppBskyEmbedImages.isView(post.embed.media) - ? post.embed.media.images - : undefined + let images + let isGif = false + + if (AppBskyEmbedImages.isView(post.embed)) { + images = post.embed.images + } else if ( + AppBskyEmbedRecordWithMedia.isView(post.embed) && + AppBskyEmbedImages.isView(post.embed.media) + ) { + images = post.embed.media.images + } else if ( + AppBskyEmbedExternal.isView(post.embed) && + post.embed.external.thumb + ) { + let url: URL | undefined + try { + url = new URL(post.embed.external.uri) + } catch {} + if (url) { + const {success} = parseTenorGif(url) + if (success) { + isGif = true + images = [ + { + thumb: post.embed.external.thumb, + alt: post.embed.external.title, + fullsize: post.embed.external.thumb, + }, + ] + } + } + } + return ( <> {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} {images && images.length > 0 && ( - <ImageHorzList images={images} style={styles.additionalPostImages} /> + <ImageHorzList + images={images} + style={styles.additionalPostImages} + gif={isGif} + /> )} </> ) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index e940e8d1a..1c83ecd6e 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -180,7 +180,7 @@ const desktopStyles = StyleSheet.create({ position: 'absolute', left: 0, right: 0, - bottom: -1, + top: '100%', borderBottomWidth: 1, }, }) @@ -207,7 +207,7 @@ const mobileStyles = StyleSheet.create({ position: 'absolute', left: 0, right: 0, - bottom: -1, + top: '100%', borderBottomWidth: hairlineWidth, }, }) diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 8061eb11c..a6c1a4648 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -331,7 +331,11 @@ export function PostThread({ <PostThreadShowHiddenReplies type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'} onPress={() => - setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider) + setHiddenRepliesState( + item === SHOW_HIDDEN_REPLIES + ? HiddenRepliesState.Show + : HiddenRepliesState.ShowAndOverridePostHider, + ) } hideTopBorder={index === 0} /> diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 5ee60e4ea..6d03029d7 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -25,7 +25,7 @@ import {sanitizeHandle} from 'lib/strings/handles' import {countLines} from 'lib/strings/helpers' import {niceDate} from 'lib/strings/time' import {s} from 'lib/styles' -import {isWeb} from 'platform/detection' +import {isNative, isWeb} from 'platform/detection' import {useSession} from 'state/session' import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' import {atoms as a} from '#/alf' @@ -189,6 +189,7 @@ let PostThreadItemLoaded = ({ const itemTitle = _(msg`Post by ${post.author.handle}`) const authorHref = makeProfileLink(post.author) const authorTitle = post.author.handle + const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did const likesHref = React.useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') @@ -395,7 +396,11 @@ let PostThreadItemLoaded = ({ </View> </View> </View> - <WhoCanReply post={post} /> + <WhoCanReply + post={post} + isThreadAuthor={isThreadAuthor} + style={{borderBottomWidth: isNative ? 1 : 0}} + /> </> ) } else { @@ -578,7 +583,9 @@ let PostThreadItemLoaded = ({ post={post} style={{ marginTop: 4, + borderBottomWidth: 1, }} + isThreadAuthor={isThreadAuthor} /> </> ) @@ -681,6 +688,20 @@ function ExpandedPostDetails({ ) } +function getThreadAuthor( + post: AppBskyFeedDefs.PostView, + record: AppBskyFeedPost.Record, +): string { + if (!record.reply) { + return post.author.did + } + try { + return new AtUri(record.reply.root.uri).host + } catch { + return '' + } +} + const styles = StyleSheet.create({ outer: { borderTopWidth: hairlineWidth, diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 675f23a88..cc767a4a3 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -56,6 +56,7 @@ interface FeedItemProps { isThreadParent?: boolean feedContext: string | undefined hideTopBorder?: boolean + isParentBlocked?: boolean } export function FeedItem({ @@ -70,6 +71,7 @@ export function FeedItem({ isThreadLastChild, isThreadParent, hideTopBorder, + isParentBlocked, }: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode { const postShadowed = usePostShadow(post) const richText = useMemo( @@ -100,6 +102,7 @@ export function FeedItem({ isThreadLastChild={isThreadLastChild} isThreadParent={isThreadParent} hideTopBorder={hideTopBorder} + isParentBlocked={isParentBlocked} /> ) } @@ -119,6 +122,7 @@ let FeedItemInner = ({ isThreadLastChild, isThreadParent, hideTopBorder, + isParentBlocked, }: FeedItemProps & { richText: RichTextAPI post: Shadow<AppBskyFeedDefs.PostView> @@ -320,7 +324,7 @@ let FeedItemInner = ({ onOpenAuthor={onOpenAuthor} /> {!isThreadChild && showReplyTo && parentAuthor && ( - <ReplyToLabel profile={parentAuthor} /> + <ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} /> )} <LabelsOnMyPost post={post} /> <PostContent @@ -409,9 +413,14 @@ let PostContent = ({ } PostContent = memo(PostContent) -function ReplyToLabel({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) { +function ReplyToLabel({ + profile, + blocked, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + blocked?: boolean +}) { const pal = usePalette('default') - return ( <View style={[s.flexRow, s.mb2, s.alignCenter]}> <FontAwesomeIcon @@ -424,23 +433,27 @@ function ReplyToLabel({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) { style={[pal.textLight, s.mr2]} lineHeight={1.2} numberOfLines={1}> - <Trans context="description"> - Reply to{' '} - <ProfileHoverCard inline did={profile.did}> - <TextLinkOnWebOnly - type="md" - style={pal.textLight} - lineHeight={1.2} - numberOfLines={1} - href={makeProfileLink(profile)} - text={ - profile.displayName - ? sanitizeDisplayName(profile.displayName) - : sanitizeHandle(profile.handle) - } - /> - </ProfileHoverCard> - </Trans> + {blocked ? ( + <Trans context="description">Reply to a blocked post</Trans> + ) : ( + <Trans context="description"> + Reply to{' '} + <ProfileHoverCard inline did={profile.did}> + <TextLinkOnWebOnly + type="md" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1} + href={makeProfileLink(profile)} + text={ + profile.displayName + ? sanitizeDisplayName(profile.displayName) + : sanitizeHandle(profile.handle) + } + /> + </ProfileHoverCard> + </Trans> + )} </Text> </View> ) diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index aeb24e8bb..3e08f253c 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -34,6 +34,7 @@ let FeedSlice = ({ isThreadParent={isThreadParentAt(slice.items, 0)} isThreadChild={isThreadChildAt(slice.items, 0)} hideTopBorder={hideTopBorder} + isParentBlocked={slice.items[0].isParentBlocked} /> <FeedItem key={slice.items[1]._reactKey} @@ -46,6 +47,7 @@ let FeedSlice = ({ moderation={slice.items[1].moderation} isThreadParent={isThreadParentAt(slice.items, 1)} isThreadChild={isThreadChildAt(slice.items, 1)} + isParentBlocked={slice.items[1].isParentBlocked} /> <ViewFullThread slice={slice} /> <FeedItem @@ -59,6 +61,7 @@ let FeedSlice = ({ moderation={slice.items[last].moderation} isThreadParent={isThreadParentAt(slice.items, last)} isThreadChild={isThreadChildAt(slice.items, last)} + isParentBlocked={slice.items[2].isParentBlocked} isThreadLastChild /> </> @@ -82,6 +85,7 @@ let FeedSlice = ({ isThreadLastChild={ isThreadChildAt(slice.items, i) && slice.items.length === i + 1 } + isParentBlocked={slice.items[i].isParentBlocked} hideTopBorder={hideTopBorder && i === 0} /> ))} diff --git a/src/view/com/threadgate/WhoCanReply.tsx b/src/view/com/threadgate/WhoCanReply.tsx index c1e36d481..3ffbaa7ae 100644 --- a/src/view/com/threadgate/WhoCanReply.tsx +++ b/src/view/com/threadgate/WhoCanReply.tsx @@ -1,128 +1,172 @@ import React from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' -import { - AppBskyFeedDefs, - AppBskyFeedThreadgate, - AppBskyGraphDefs, - AtUri, -} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {Trans} from '@lingui/macro' +import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' +import {AppBskyFeedDefs, AppBskyGraphDefs, AtUri} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' +import {useAnalytics} from '#/lib/analytics/analytics' +import {createThreadgate} from '#/lib/api' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {makeListLink, makeProfileLink} from '#/lib/routes/links' import {colors} from '#/lib/styles' +import {logger} from '#/logger' +import {isNative} from '#/platform/detection' +import {useModalControls} from '#/state/modals' +import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread' +import { + ThreadgateSetting, + threadgateViewToSettings, +} from '#/state/queries/threadgate' +import {useAgent} from '#/state/session' +import * as Toast from 'view/com/util/Toast' +import {Button} from '#/components/Button' import {TextLink} from '../util/Link' import {Text} from '../util/text/Text' export function WhoCanReply({ post, + isThreadAuthor, style, }: { post: AppBskyFeedDefs.PostView + isThreadAuthor: boolean style?: StyleProp<ViewStyle> }) { + const {track} = useAnalytics() + const {_} = useLingui() const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() + const agent = useAgent() + const queryClient = useQueryClient() + const {openModal} = useModalControls() const containerStyles = useColorSchemeStyle( { - borderColor: pal.colors.unreadNotifBorder, backgroundColor: pal.colors.unreadNotifBg, }, { - borderColor: pal.colors.unreadNotifBorder, backgroundColor: pal.colors.unreadNotifBg, }, ) - const iconStyles = useColorSchemeStyle( + const textStyles = useColorSchemeStyle( + {color: colors.blue5}, + {color: colors.blue1}, + ) + const hoverStyles = useColorSchemeStyle( { - backgroundColor: colors.blue3, + backgroundColor: colors.white, }, { - backgroundColor: colors.blue3, + backgroundColor: pal.colors.background, }, ) - const textStyles = useColorSchemeStyle( - {color: colors.gray7}, - {color: colors.blue1}, - ) - const record = React.useMemo( - () => - post.threadgate && - AppBskyFeedThreadgate.isRecord(post.threadgate.record) && - AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success - ? post.threadgate.record - : null, + const settings = React.useMemo( + () => threadgateViewToSettings(post.threadgate), [post], ) - if (record) { - return ( - <View - style={[ - { - flexDirection: 'row', - alignItems: 'center', - gap: isMobile ? 8 : 10, - paddingHorizontal: isMobile ? 16 : 18, - paddingVertical: 12, - borderWidth: 1, - borderLeftWidth: isMobile ? 0 : 1, - borderRightWidth: isMobile ? 0 : 1, - }, - containerStyles, - style, - ]}> - <View - style={[ - { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - width: 32, - height: 32, - borderRadius: 19, - }, - iconStyles, - ]}> - <FontAwesomeIcon - icon={['far', 'comments']} - size={16} - color={'#fff'} - /> - </View> - <View style={{flex: 1}}> - <Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}> - {!record.allow?.length ? ( - <Trans>Replies to this thread are disabled</Trans> - ) : ( - <Trans> - Only{' '} - {record.allow.map((rule, i) => ( - <> - <Rule - key={`rule-${i}`} - rule={rule} - post={post} - lists={post.threadgate!.lists} - /> - <Separator - key={`sep-${i}`} - i={i} - length={record.allow!.length} - /> - </> - ))}{' '} - can reply. - </Trans> + const isRootPost = !('reply' in post.record) + + const onPressEdit = () => { + track('Post:EditThreadgateOpened') + if (isNative && Keyboard.isVisible()) { + Keyboard.dismiss() + } + openModal({ + name: 'threadgate', + settings, + async onConfirm(newSettings: ThreadgateSetting[]) { + try { + if (newSettings.length) { + await createThreadgate(agent, post.uri, newSettings) + } else { + await agent.api.com.atproto.repo.deleteRecord({ + repo: agent.session!.did, + collection: 'app.bsky.feed.threadgate', + rkey: new AtUri(post.uri).rkey, + }) + } + Toast.show('Thread settings updated') + queryClient.invalidateQueries({ + queryKey: [POST_THREAD_RQKEY_ROOT], + }) + track('Post:ThreadgateEdited') + } catch (err) { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + logger.error('Failed to edit threadgate', {message: err}) + } + }, + }) + } + + if (!isRootPost) { + return null + } + if (!settings.length && !isThreadAuthor) { + return null + } + + return ( + <View + style={[ + { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingLeft: 18, + paddingRight: 14, + paddingVertical: 10, + borderTopWidth: 1, + }, + pal.border, + containerStyles, + style, + ]}> + <View style={{flex: 1, paddingVertical: 6}}> + <Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}> + {!settings.length ? ( + <Trans>Everybody can reply.</Trans> + ) : settings[0].type === 'nobody' ? ( + <Trans>Replies to this thread are disabled.</Trans> + ) : ( + <Trans> + Only{' '} + {settings.map((rule, i) => ( + <> + <Rule + key={`rule-${i}`} + rule={rule} + post={post} + lists={post.threadgate!.lists} + /> + <Separator key={`sep-${i}`} i={i} length={settings.length} /> + </> + ))}{' '} + can reply. + </Trans> + )} + </Text> + </View> + {isThreadAuthor && ( + <View> + <Button label={_(msg`Edit`)} onPress={onPressEdit}> + {({hovered}) => ( + <View + style={[ + hovered && hoverStyles, + {paddingVertical: 6, paddingHorizontal: 8, borderRadius: 8}, + ]}> + <Text type="sm" style={pal.link}> + <Trans>Edit</Trans> + </Text> + </View> )} - </Text> + </Button> </View> - </View> - ) - } - return null + )} + </View> + ) } function Rule({ @@ -130,15 +174,15 @@ function Rule({ post, lists, }: { - rule: any + rule: ThreadgateSetting post: AppBskyFeedDefs.PostView lists: AppBskyGraphDefs.ListViewBasic[] | undefined }) { const pal = usePalette('default') - if (AppBskyFeedThreadgate.isMentionRule(rule)) { + if (rule.type === 'mention') { return <Trans>mentioned users</Trans> } - if (AppBskyFeedThreadgate.isFollowingRule(rule)) { + if (rule.type === 'following') { return ( <Trans> users followed by{' '} @@ -151,7 +195,7 @@ function Rule({ </Trans> ) } - if (AppBskyFeedThreadgate.isListRule(rule)) { + if (rule.type === 'list') { const list = lists?.find(l => l.uri === rule.list) if (list) { const listUrip = new AtUri(list.uri) diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 6b0c17762..e917ab1d3 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -38,6 +38,7 @@ function ListImpl<ItemT>( { ListHeaderComponent, ListFooterComponent, + ListEmptyComponent, containWeb, contentContainerStyle, data, @@ -72,23 +73,35 @@ function ListImpl<ItemT>( ) } - let header: JSX.Element | null = null + const isEmpty = !data || data.length === 0 + + let headerComponent: JSX.Element | null = null if (ListHeaderComponent != null) { if (isValidElement(ListHeaderComponent)) { - header = ListHeaderComponent + headerComponent = ListHeaderComponent } else { // @ts-ignore Nah it's fine. - header = <ListHeaderComponent /> + headerComponent = <ListHeaderComponent /> } } - let footer: JSX.Element | null = null + let footerComponent: JSX.Element | null = null if (ListFooterComponent != null) { if (isValidElement(ListFooterComponent)) { - footer = ListFooterComponent + footerComponent = ListFooterComponent + } else { + // @ts-ignore Nah it's fine. + footerComponent = <ListFooterComponent /> + } + } + + let emptyComponent: JSX.Element | null = null + if (ListEmptyComponent != null) { + if (isValidElement(ListEmptyComponent)) { + emptyComponent = ListEmptyComponent } else { // @ts-ignore Nah it's fine. - footer = <ListFooterComponent /> + emptyComponent = <ListEmptyComponent /> } } @@ -323,36 +336,38 @@ function ListImpl<ItemT>( onVisibleChange={handleAboveTheFoldVisibleChange} style={[styles.aboveTheFoldDetector, {height: headerOffset}]} /> - {onStartReached && ( + {onStartReached && !isEmpty && ( <Visibility root={containWeb ? nativeRef : null} onVisibleChange={onHeadVisibilityChange} topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'} /> )} - {header} - {(data as Array<ItemT>).map((item, index) => { - const key = keyExtractor!(item, index) - return ( - <Row<ItemT> - key={key} - item={item} - index={index} - renderItem={renderItem} - extraData={extraData} - onItemSeen={onItemSeen} - disableContentVisibility={disableContentVisibility} - /> - ) - })} - {onEndReached && ( + {headerComponent} + {isEmpty + ? emptyComponent + : (data as Array<ItemT>)?.map((item, index) => { + const key = keyExtractor!(item, index) + return ( + <Row<ItemT> + key={key} + item={item} + index={index} + renderItem={renderItem} + extraData={extraData} + onItemSeen={onItemSeen} + disableContentVisibility={disableContentVisibility} + /> + ) + })} + {onEndReached && !isEmpty && ( <Visibility root={containWeb ? nativeRef : null} onVisibleChange={onTailVisibilityChange} bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'} /> )} - {footer} + {footerComponent} </View> </View> ) diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx index d939b3163..a49585182 100644 --- a/src/view/com/util/TimeElapsed.tsx +++ b/src/view/com/util/TimeElapsed.tsx @@ -15,12 +15,14 @@ export function TimeElapsed({ const ago = useGetTimeAgo() const format = timeToString ?? ago const tick = useTickEveryMinute() - const [timeElapsed, setTimeAgo] = React.useState(() => format(timestamp)) + const [timeElapsed, setTimeAgo] = React.useState(() => + format(timestamp, tick), + ) const [prevTick, setPrevTick] = React.useState(tick) if (prevTick !== tick) { setPrevTick(tick) - setTimeAgo(format(timestamp)) + setTimeAgo(format(timestamp, tick)) } return children({timeElapsed}) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 587b466a3..c212ea4c0 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -35,6 +35,7 @@ export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' interface BaseUserAvatarProps { type?: UserAvatarType + shape?: 'circle' | 'square' size: number avatar?: string | null } @@ -60,12 +61,16 @@ const BLUR_AMOUNT = isWeb ? 5 : 100 let DefaultAvatar = ({ type, + shape: overrideShape, size, }: { type: UserAvatarType + shape?: 'square' | 'circle' size: number }): React.ReactNode => { + const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') if (type === 'algo') { + // TODO: shape=circle // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. return ( <Svg @@ -84,6 +89,7 @@ let DefaultAvatar = ({ ) } if (type === 'list') { + // TODO: shape=circle // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. return ( <Svg @@ -117,14 +123,18 @@ let DefaultAvatar = ({ viewBox="0 0 32 32" fill="none" stroke="none"> - <Rect - x="0" - y="0" - width="32" - height="32" - rx="3" - fill={tokens.color.temp_purple} - /> + {finalShape === 'square' ? ( + <Rect + x="0" + y="0" + width="32" + height="32" + rx="3" + fill={tokens.color.temp_purple} + /> + ) : ( + <Circle cx="16" cy="16" r="16" fill={tokens.color.temp_purple} /> + )} <Path d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z" stroke="white" @@ -135,6 +145,7 @@ let DefaultAvatar = ({ </Svg> ) } + // TODO: shape=square return ( <Svg testID="userAvatarFallback" @@ -159,6 +170,7 @@ export {DefaultAvatar} let UserAvatar = ({ type = 'user', + shape: overrideShape, size, avatar, moderation, @@ -166,9 +178,10 @@ let UserAvatar = ({ }: UserAvatarProps): React.ReactNode => { const pal = usePalette('default') const backgroundColor = pal.colors.backgroundLight + const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') const aviStyle = useMemo(() => { - if (type === 'algo' || type === 'list' || type === 'labeler') { + if (finalShape === 'square') { return { width: size, height: size, @@ -182,7 +195,7 @@ let UserAvatar = ({ borderRadius: Math.floor(size / 2), backgroundColor, } - }, [type, size, backgroundColor]) + }, [finalShape, size, backgroundColor]) const alert = useMemo(() => { if (!moderation?.alert) { @@ -224,7 +237,7 @@ let UserAvatar = ({ </View> ) : ( <View style={{width: size, height: size}}> - <DefaultAvatar type={type} size={size} /> + <DefaultAvatar type={type} shape={finalShape} size={size} /> {alert} </View> ) diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 2486b73d5..45e00e58c 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -7,7 +7,7 @@ import { } from 'react-native' import * as Clipboard from 'expo-clipboard' import { - AppBskyActorDefs, + AppBskyFeedDefs, AppBskyFeedPost, AtUri, RichText as RichTextAPI, @@ -22,12 +22,15 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers' import {getTranslatorLink} from '#/locale/helpers' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' +import {Shadow} from '#/state/cache/post-shadow' import {useFeedFeedbackContext} from '#/state/feed-feedback' -import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' import {useLanguagePrefs} from '#/state/preferences' import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' import {useOpenLink} from '#/state/preferences/in-app-browser' -import {usePostDeleteMutation} from '#/state/queries/post' +import { + usePostDeleteMutation, + useThreadMuteMutationQueue, +} from '#/state/queries/post' import {useSession} from '#/state/session' import {getCurrentRoute} from 'lib/routes/helpers' import {shareUrl} from 'lib/sharing' @@ -62,9 +65,7 @@ import * as Toast from '../Toast' let PostDropdownBtn = ({ testID, - postAuthor, - postCid, - postUri, + post, postFeedContext, record, richText, @@ -74,9 +75,7 @@ let PostDropdownBtn = ({ timestamp, }: { testID: string - postAuthor: AppBskyActorDefs.ProfileViewBasic - postCid: string - postUri: string + post: Shadow<AppBskyFeedDefs.PostView> postFeedContext: string | undefined record: AppBskyFeedPost.Record richText: RichTextAPI @@ -92,8 +91,6 @@ let PostDropdownBtn = ({ const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl const langPrefs = useLanguagePrefs() - const mutedThreads = useMutedThreads() - const toggleThreadMute = useToggleThreadMute() const postDeleteMutation = usePostDeleteMutation() const hiddenPosts = useHiddenPosts() const {hidePost} = useHiddenPostsApi() @@ -107,9 +104,15 @@ let PostDropdownBtn = ({ const loggedOutWarningPromptControl = useDialogControl() const embedPostControl = useDialogControl() const sendViaChatControl = useDialogControl() + const postUri = post.uri + const postCid = post.cid + const postAuthor = post.author const rootUri = record.reply?.root?.uri || postUri - const isThreadMuted = mutedThreads.includes(rootUri) + const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( + post, + rootUri, + ) const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) const isAuthor = postAuthor.did === currentAccount?.did @@ -162,18 +165,22 @@ let PostDropdownBtn = ({ const onToggleThreadMute = React.useCallback(() => { try { - const muted = toggleThreadMute(rootUri) - if (muted) { + if (isThreadMuted) { + unmuteThread() + Toast.show(_(msg`You will now receive notifications for this thread`)) + } else { + muteThread() Toast.show( _(msg`You will no longer receive notifications for this thread`), ) - } else { - Toast.show(_(msg`You will now receive notifications for this thread`)) } - } catch (e) { - logger.error('Failed to toggle thread mute', {message: e}) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to toggle thread mute', {message: e}) + Toast.show(_(msg`Failed to toggle thread mute, please try again`)) + } } - }, [rootUri, toggleThreadMute, _]) + }, [isThreadMuted, unmuteThread, _, muteThread]) const onCopyPostText = React.useCallback(() => { const str = richTextToString(richText, true) diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx index 12eef14f7..bade2a444 100644 --- a/src/view/com/util/images/ImageHorzList.tsx +++ b/src/view/com/util/images/ImageHorzList.tsx @@ -2,39 +2,60 @@ import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {Image} from 'expo-image' import {AppBskyEmbedImages} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {atoms as a} from '#/alf' +import {Text} from '#/components/Typography' interface Props { images: AppBskyEmbedImages.ViewImage[] style?: StyleProp<ViewStyle> + gif?: boolean } -export function ImageHorzList({images, style}: Props) { +export function ImageHorzList({images, style, gif}: Props) { return ( - <View style={[styles.flexRow, style]}> + <View style={[a.flex_row, a.gap_xs, style]}> {images.map(({thumb, alt}) => ( - <Image + <View key={thumb} - source={{uri: thumb}} - style={styles.image} - accessible={true} - accessibilityIgnoresInvertColors - accessibilityHint={alt} - accessibilityLabel="" - /> + style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}> + <Image + key={thumb} + source={{uri: thumb}} + style={[a.flex_1, a.rounded_xs]} + accessible={true} + accessibilityIgnoresInvertColors + accessibilityHint={alt} + accessibilityLabel="" + /> + {gif && ( + <View style={styles.altContainer}> + <Text style={styles.alt}> + <Trans>GIF</Trans> + </Text> + </View> + )} + </View> ))} </View> ) } const styles = StyleSheet.create({ - flexRow: { - flexDirection: 'row', - gap: 5, + altContainer: { + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 3, + position: 'absolute', + right: 5, + bottom: 5, + zIndex: 2, }, - image: { - maxWidth: 100, - aspectRatio: 1, - flex: 1, - borderRadius: 4, + alt: { + color: 'white', + fontSize: 7, + fontWeight: 'bold', }, }) diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index c389855e3..c0e743db4 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -319,9 +319,7 @@ let PostCtrls = ({ <View style={big ? a.align_center : [a.flex_1, a.align_start]}> <PostDropdownBtn testID="postDropdownBtn" - postAuthor={post.author} - postCid={post.cid} - postUri={post.uri} + post={post} postFeedContext={feedContext} record={record} richText={richText} |