diff --git a/assets/icons/messagePlus_stroke2_corner0_rounded.svg b/assets/icons/messagePlus_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..bf9e277fb8 --- /dev/null +++ b/assets/icons/messagePlus_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyembed/src/color-mode.ts b/bskyembed/src/color-mode.ts index b34624e312..3b9219cb3c 100644 --- a/bskyembed/src/color-mode.ts +++ b/bskyembed/src/color-mode.ts @@ -9,7 +9,11 @@ export function applyTheme(theme: 'light' | 'dark') { document.documentElement.classList.add(theme) } -export function initSystemColorMode() { +export function initSystemColorMode({additionalBodyClasses = ''} = {}) { + if (additionalBodyClasses) { + document.body.classList.add(additionalBodyClasses) + } + applyTheme( window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' diff --git a/bskyembed/src/screens/landing.tsx b/bskyembed/src/screens/landing.tsx index b4bb0f7e91..9c7cd68a40 100644 --- a/bskyembed/src/screens/landing.tsx +++ b/bskyembed/src/screens/landing.tsx @@ -28,7 +28,7 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js` const root = document.getElementById('app') if (!root) throw new Error('No root element') -initSystemColorMode() +initSystemColorMode({additionalBodyClasses: 'dark:bg-dimmedBgDarken'}) const agent = new AtpAgent({ service: 'https://public.api.bsky.app', @@ -119,7 +119,7 @@ function LandingPage() { }, [uri]) return ( -
+
diff --git a/eslint.config.mjs b/eslint.config.mjs index a0f0db9140..8e7bb941a8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -250,6 +250,14 @@ export default defineConfig( '@typescript-eslint/prefer-promise-reject-errors': 'warn', '@typescript-eslint/await-thenable': 'warn', + "no-restricted-imports": ["error", { + "paths": [{ + "name": "react", + "importNames": ["React", "default"], + "message": "React is already in the global type namespace. Use named imports for runtime modules." + }] + }], + /** * Turn off rules that we haven't enforced thus far */ diff --git a/package.json b/package.json index 66f6ddd0a6..1623c5c139 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.19.8", + "@atproto/api": "^0.19.9", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.7", @@ -275,7 +275,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-native": "^5.0.0", "eslint-plugin-react-native-a11y": "^3.5.1", - "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-simple-import-sort": "^13.0.0", "file-loader": "6.2.0", "globals": "^17.0.0", "husky": "^8.0.3", diff --git a/patches/react-native-keyboard-controller+1.21.5.patch b/patches/react-native-keyboard-controller+1.21.5.patch new file mode 100644 index 0000000000..d721bbe494 --- /dev/null +++ b/patches/react-native-keyboard-controller+1.21.5.patch @@ -0,0 +1,48 @@ +diff --git a/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +index 24a25ae..2c5ff6d 100644 +--- a/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts ++++ b/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +@@ -1,8 +1,6 @@ + import { useCallback } from "react"; +-import { Platform } from "react-native"; + import { scrollTo, useAnimatedReaction } from "react-native-reanimated"; + +-import { IS_FABRIC } from "../../../architecture"; + import { isScrollAtEnd, shouldShiftContent } from "../useChatKeyboard/helpers"; + + import type { KeyboardLiftBehavior } from "../useChatKeyboard/types"; +@@ -52,7 +50,6 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { + scroll, + layout, + size, +- contentOffsetY, + inverted, + keyboardLiftBehavior, + freeze, +@@ -62,20 +59,14 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { + (target: number) => { + "worklet"; + +- if (contentOffsetY && IS_FABRIC) { +- // eslint-disable-next-line react-compiler/react-compiler +- contentOffsetY.value = target; +- } else if (Platform.OS === "android") { +- // Defer scrollTo so the animatedProps inset commit lands first; +- // otherwise the native ScrollView clamps to the old range. +- requestAnimationFrame(() => { +- scrollTo(scrollViewRef, 0, target, false); +- }); +- } else { ++ // Always defer scrollTo so the animatedProps inset commit lands first; ++ // otherwise the native ScrollView clamps contentOffset to the old ++ // contentInset range (iOS Fabric) or the old contentInsetBottom (Android). ++ requestAnimationFrame(() => { + scrollTo(scrollViewRef, 0, target, false); +- } ++ }); + }, +- [scrollViewRef, contentOffsetY], ++ [scrollViewRef], + ); + + useAnimatedReaction( diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 7ef150e9e5..6f661e03e9 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -78,6 +78,7 @@ import HashtagScreen from '#/screens/Hashtag' import {LogScreen} from '#/screens/Log' import {MessagesScreen} from '#/screens/Messages/ChatList' import {MessagesConversationScreen} from '#/screens/Messages/Conversation' +import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings' import {MessagesInboxScreen} from '#/screens/Messages/Inbox' import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {ModerationScreen} from '#/screens/Moderation' @@ -568,6 +569,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { getComponent={() => MessagesConversationScreen} options={{title: title(msg`Chat`), requireAuth: true}} /> + MessagesConversationSettingsScreen} + options={{title: title(msg`Group chat settings`), requireAuth: true}} + /> MessagesSettingsScreen} diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx index 600970459a..83b7f474d1 100644 --- a/src/ageAssurance/components/NoAccessScreen.tsx +++ b/src/ageAssurance/components/NoAccessScreen.tsx @@ -20,8 +20,8 @@ import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAp import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' import {AgeAssuranceInitDialog} from '#/components/ageAssurance/AgeAssuranceInitDialog' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {useDialogControl} from '#/components/Dialog' import * as Dialog from '#/components/Dialog' +import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog' import {Full as Logo} from '#/components/icons/Logo' diff --git a/src/analytics/PassiveAnalytics.tsx b/src/analytics/PassiveAnalytics.tsx index 25dfea929a..34b5f2d275 100644 --- a/src/analytics/PassiveAnalytics.tsx +++ b/src/analytics/PassiveAnalytics.tsx @@ -2,6 +2,8 @@ import {useEffect, useRef} from 'react' import {getCurrentState, onAppStateChange} from '#/lib/appState' import {useAnalytics} from '#/analytics' +import {Features, features} from '#/analytics/features' +import {IS_DEV, IS_TESTFLIGHT} from '#/env' /** * Tracks passive analytics like app foreground/background time. @@ -24,6 +26,20 @@ export function PassiveAnalytics() { ), }) } + + if (IS_DEV || IS_TESTFLIGHT) { + const feats = Object.values(Features).reduce( + (acc, feat) => { + acc[feat] = features.evalFeature(feat) + return acc + }, + {} as Record, + ) + ax.logger.info('FEATURES', { + features: feats, + definitions: features.getFeatures(), + }) + } }) return () => sub.remove() }, [ax]) diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 7c87c75917..36a354bb49 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -14,6 +14,6 @@ export enum Features { GroupChatsEnable = 'group_chats:enable', DmsNewMessageComposerEnable = 'dms:new_message_composer:enable', KlipyGifProviderEnable = 'klipy_gif_provider:enable', - + PostGalleryEmbedEnable = 'post_gallery_embed:enable', AATest = 'aa-test', } diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 1f6b25278b..1d74e1c5b8 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -563,6 +563,9 @@ export type Events = { | 'ChatsList' | 'SendViaChatDialog' } + 'groupchat:create': { + logContext: 'NewChatDialog' + } 'starterPack:addUser': { starterPack?: string } @@ -1043,4 +1046,19 @@ export type Events = { 'profile:associated:germ:click-self-info': {} 'profile:associated:germ:self-disconnect': {} 'profile:associated:germ:self-reconnect': {} + + // Gallery carousel events + 'post:gallery:swipe': { + fromImage: number + toImage: number + totalImages: number + } + 'post:gallery:openLightbox': { + fromImage: number + totalImages: number + } + 'post:gallery:impression': { + totalImages: number + postUri: string + } } diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx new file mode 100644 index 0000000000..2dd2f3b203 --- /dev/null +++ b/src/components/AvatarBubbles.tsx @@ -0,0 +1,260 @@ +import {useCallback, useEffect} from 'react' +import {type StyleProp, View, type ViewStyle} from 'react-native' +import Animated, { + Easing, + interpolate, + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, +} from 'react-native-reanimated' + +import {useSession} from '#/state/session' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme} from '#/alf' +import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' +import type * as bsky from '#/types/bsky' + +type Props = { + animate?: boolean + profiles: bsky.profile.AnyProfileView[] + size?: 'small' | 'medium' | 'large' +} + +export function AvatarBubbles({ + animate = false, + profiles: allProfiles, + size = 'large', +}: Props) { + const {currentAccount} = useSession() + const profiles = allProfiles.filter(p => p.did !== currentAccount?.did) + const containerSize = size === 'small' ? 40 : size === 'medium' ? 56 : 120 + const scale = size === 'small' ? 40 / 120 : size === 'medium' ? 56 / 120 : 1 + const marginOffset = size === 'small' || size === 'medium' ? -2 : 0 + + const initialValue = animate ? 0 : 1 + const p0 = useSharedValue(initialValue) + const p1 = useSharedValue(initialValue) + const p2 = useSharedValue(initialValue) + const p3 = useSharedValue(initialValue) + + const animateScale = (p: Animated.SharedValue, index: number) => { + p.set(0) + p.set(() => + withDelay( + 500 + index * 100, + withTiming(1, { + duration: 250, + easing: Easing.out(Easing.back(1.75)), + }), + ), + ) + } + + const playScaleAnimation = useCallback(() => { + animateScale(p0, 0) + animateScale(p1, 1) + animateScale(p2, 2) + animateScale(p3, 3) + }, [p0, p1, p2, p3]) + + useEffect(() => { + if (!animate) return + playScaleAnimation() + }, [animate, playScaleAnimation]) + + let avatars = ( + <> + + + + ) + + if (profiles.length === 3) { + avatars = ( + <> + + + + + ) + } + + if (profiles.length >= 4) { + avatars = ( + <> + + + + + + ) + } + + return ( + + + {avatars} + + + ) +} + +function AvatarBubble({ + profile, + scale, + size, + style, + x, + y, + includeProfileBorder, +}: { + profile?: bsky.profile.AnyProfileView + scale: Animated.SharedValue + size: number + style?: StyleProp + x: number + y: number + includeProfileBorder?: boolean +}) { + const t = useTheme() + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + {translateX: x}, + {translateY: y}, + {scale: interpolate(scale.get(), [0, 1], [0, 1])}, + ], + })) + + return ( + + {profile ? ( + + ) : ( + + )} + + ) +} + +function Avatar({ + profile, + size = 76, +}: { + profile: bsky.profile.AnyProfileView + size?: number +}) { + return ( + + ) +} + +function AvatarPlaceholder({size = 76}: {size?: number}) { + const t = useTheme() + + return ( + + + + ) +} diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index e94eaf7795..cce4332dc4 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -482,7 +482,11 @@ function TriggerClone({ ) } -export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) { +export function AuxiliaryView({ + children, + align = 'left', + style, +}: AuxiliaryViewProps) { const context = useContextMenuContext() const {width: screenWidth} = useWindowDimensions() const {top: topInset} = useSafeAreaInsets() @@ -556,6 +560,7 @@ export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) { : {right: screenWidth - measurement.x - measurement.width}, animatedStyle, a.z_20, + style, ]}> {children} diff --git a/src/components/ContextMenu/types.ts b/src/components/ContextMenu/types.ts index 7d4f3019a8..260d95e85c 100644 --- a/src/components/ContextMenu/types.ts +++ b/src/components/ContextMenu/types.ts @@ -21,6 +21,7 @@ export type { export type AuxiliaryViewProps = { children?: React.ReactNode align?: 'left' | 'right' + style?: StyleProp } export type ItemProps = Omit & { diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index a3f0d46377..fba3256d56 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -12,8 +12,10 @@ import {useLightboxControls} from '#/state/lightbox' import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' import {atoms as a} from '#/alf' import {AutoSizedImage} from '#/components/images/AutoSizedImage' +import {Gallery} from '#/components/images/Gallery' import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid' import {PostEmbedViewContext} from '#/components/Post/Embed/types' +import {useAnalytics} from '#/analytics' import {type EmbedType} from '#/types/bsky/post' import {type CommonProps} from './types' @@ -23,8 +25,10 @@ export function ImageEmbed({ }: CommonProps & { embed: EmbedType<'images'> }) { + const ax = useAnalytics() const {openLightbox} = useLightboxControls() const {images} = embed.view + const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable) if (images.length > 0) { const items = images.map(img => ({ @@ -95,6 +99,19 @@ export function ImageEmbed({ ) } + if (galleryEnabled) { + return ( + + + + ) + } + return ( - - {({active}) => ( - <> - {!active && !linkDisabled && ( - - )} - {linkDisabled ? ( - - {contents} - - ) : ( - - {contents} - - )} - - )} - - + + + + {({active}) => ( + <> + {!active && !linkDisabled && ( + + )} + {linkDisabled ? ( + + {contents} + + ) : ( + + {contents} + + )} + + )} + + + ) } diff --git a/src/components/PostControls/PostMenu/index.tsx b/src/components/PostControls/PostMenu/index.tsx index 7ed620bd64..a744efbaa8 100644 --- a/src/components/PostControls/PostMenu/index.tsx +++ b/src/components/PostControls/PostMenu/index.tsx @@ -11,8 +11,8 @@ import {useLingui} from '@lingui/react/macro' import {type Shadow} from '#/state/cache/post-shadow' import {EventStopper} from '#/view/com/util/EventStopper' import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' -import {useMenuControl} from '#/components/Menu' import * as Menu from '#/components/Menu' +import {useMenuControl} from '#/components/Menu' import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' import {PostMenuItems} from './PostMenuItems' diff --git a/src/components/PostControls/ShareMenu/index.tsx b/src/components/PostControls/ShareMenu/index.tsx index 755b60e41f..efac6a3f01 100644 --- a/src/components/PostControls/ShareMenu/index.tsx +++ b/src/components/PostControls/ShareMenu/index.tsx @@ -18,8 +18,8 @@ import {useFeedFeedbackContext} from '#/state/feed-feedback' import {EventStopper} from '#/view/com/util/EventStopper' import {native} from '#/alf' import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' -import {useMenuControl} from '#/components/Menu' import * as Menu from '#/components/Menu' +import {useMenuControl} from '#/components/Menu' import {useAnalytics} from '#/analytics' import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' import {ShareMenuItems} from './ShareMenuItems' diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx index 775eedd769..5c81153a10 100644 --- a/src/components/ProgressGuide/FollowDialog.tsx +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -109,21 +109,32 @@ export function FollowDialogWithoutGuide({ let lastSelectedInterest = '' let lastSearchText = '' +const FOR_YOU_TAB = 'all' + function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { const {t: l} = useLingui() const ax = useAnalytics() - const interestsDisplayNames = useInterestsDisplayNames() + const rawInterestsDisplayNames = useInterestsDisplayNames() const {data: preferences} = usePreferencesQuery() const personalizedInterests = preferences?.interests?.tags - const interests = Object.keys(interestsDisplayNames) - .sort(boostInterests(popularInterests)) - .sort(boostInterests(personalizedInterests)) + const interests = useMemo( + () => [ + FOR_YOU_TAB, + ...Object.keys(rawInterestsDisplayNames) + .sort(boostInterests(popularInterests)) + .sort(boostInterests(personalizedInterests)), + ], + [rawInterestsDisplayNames, personalizedInterests], + ) + const interestsDisplayNames = useMemo( + () => ({ + [FOR_YOU_TAB]: l`For You`, + ...rawInterestsDisplayNames, + }), + [l, rawInterestsDisplayNames], + ) const [selectedInterest, setSelectedInterest] = useState( - () => - lastSelectedInterest || - (personalizedInterests && interests.includes(personalizedInterests[0]) - ? personalizedInterests[0] - : interests[0]), + () => lastSelectedInterest || FOR_YOU_TAB, ) const [searchText, setSearchText] = useState(lastSearchText) const moderationOpts = useModerationOpts() @@ -137,14 +148,15 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { lastSelectedInterest = selectedInterest }, [searchText, selectedInterest]) - const { - data: suggestions, - isFetching: isFetchingSuggestions, - error: suggestionsError, - } = useGetSuggestedUsersForSeeMoreQuery({ - category: selectedInterest, + const isForYou = selectedInterest === FOR_YOU_TAB + + const seeMoreQuery = useGetSuggestedUsersForSeeMoreQuery({ + category: isForYou ? undefined : selectedInterest, limit: 50, }) + const suggestions = seeMoreQuery.data + const isFetchingSuggestions = seeMoreQuery.isFetching + const suggestionsError = seeMoreQuery.error const { data: searchResults, isFetching: isFetchingSearchResults, @@ -277,7 +289,10 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { recId: recIdForLogging, position: position !== -1 ? position : 0, suggestedDid: item.profile.did, - category: selectedInterestRef.current, + category: + selectedInterestRef.current === FOR_YOU_TAB + ? null + : selectedInterestRef.current, }) } } diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx index 766fdbe9ac..0dd640d3c0 100644 --- a/src/components/StarterPack/ShareDialog.tsx +++ b/src/components/StarterPack/ShareDialog.tsx @@ -10,8 +10,8 @@ import {shareUrl} from '#/lib/sharing' import {getStarterPackOgCard} from '#/lib/strings/starter-pack' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {type DialogControlProps} from '#/components/Dialog' import * as Dialog from '#/components/Dialog' +import {type DialogControlProps} from '#/components/Dialog' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download' import {QrCode_Stroke2_Corner0_Rounded as QrCodeIcon} from '#/components/icons/QrCode' diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index c1f54e2394..3ed704f99d 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -1,7 +1,6 @@ import {View} from 'react-native' import {type ChatBskyConvoDefs} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {atoms as a} from '#/alf' import {MessageContextMenu} from '#/components/dms/MessageContextMenu' @@ -15,7 +14,7 @@ export function ActionsWrapper({ isFromSelf: boolean children: React.ReactNode }) { - const {_} = useLingui() + const {t: l} = useLingui() return ( @@ -32,7 +31,7 @@ export function ActionsWrapper({ ]} accessible={true} accessibilityActions={[ - {name: 'activate', label: _(msg`Open message options`)}, + {name: 'activate', label: l`Open message options`}, ]} onAccessibilityAction={() => trigger.control.open('full')}> {children} diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index ae9a4a3c4a..587a25e95b 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -25,9 +25,9 @@ import {AfterReportDialog} from '#/components/dms/AfterReportDialog' import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt' -import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' -import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' -import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' +import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' +import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' import { @@ -95,7 +95,7 @@ let ConvoMenu = ({ shape="round" variant="ghost" style={[a.bg_transparent]}> - + )} @@ -220,9 +220,9 @@ function MenuContent({ } if (userBlock) { - queueUnblock() + void queueUnblock() } else { - queueBlock() + void queueBlock() } }, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock]) @@ -233,7 +233,7 @@ function MenuContent({ Leave conversation - + ) : ( <> @@ -245,7 +245,7 @@ function MenuContent({ Mark as read - + )} Leave conversation - + diff --git a/src/components/dms/DateDivider.tsx b/src/components/dms/DateDivider.tsx index dfc2d53da5..0a54de39fc 100644 --- a/src/components/dms/DateDivider.tsx +++ b/src/components/dms/DateDivider.tsx @@ -1,8 +1,6 @@ import {memo} from 'react' import {View} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {subDays} from 'date-fns' import {atoms as a, useTheme} from '#/alf' @@ -29,7 +27,7 @@ const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, { }) let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { - const {_} = useLingui() + const {t: l} = useLingui() const t = useTheme() let date: string @@ -42,9 +40,9 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { const oneWeekAgo = subDays(today, 7) if (localDateString(today) === localDateString(timestamp)) { - date = _(msg`Today`) + date = l`Today` } else if (localDateString(yesterday) === localDateString(timestamp)) { - date = _(msg`Yesterday`) + date = l`Yesterday` } else { if (timestamp < oneWeekAgo) { if (timestamp.getFullYear() === today.getFullYear()) { @@ -58,7 +56,7 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { } return ( - + { a.px_md, ]}> - - {date} - {' '} - at {time} + {date} at {time} diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index dda99c77e2..2460aa585d 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -2,8 +2,7 @@ import {memo, useCallback} from 'react' import {LayoutAnimation, Platform} from 'react-native' import * as Clipboard from 'expo-clipboard' import {type ChatBskyConvoDefs, RichText} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' @@ -12,13 +11,14 @@ import {useConvoActive} from '#/state/messages/convo' import {useLanguagePrefs} from '#/state/preferences' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {useSession} from '#/state/session' +import {atoms as a} from '#/alf' import * as ContextMenu from '#/components/ContextMenu' import {type TriggerProps} from '#/components/ContextMenu/types' import {AfterReportDialog} from '#/components/dms/AfterReportDialog' -import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' +import {BubbleQuestion_Stroke2_Corner0_Rounded as TranslateIcon} from '#/components/icons/Bubble' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' -import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import {ReportDialog} from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' import {usePromptControl} from '#/components/Prompt' @@ -35,7 +35,7 @@ export let MessageContextMenu = ({ message: ChatBskyConvoDefs.MessageView children: TriggerProps['children'] }): React.ReactNode => { - const {_} = useLingui() + const {t: l} = useLingui() const ax = useAnalytics() const {currentAccount} = useSession() const queryClient = useQueryClient() @@ -47,6 +47,7 @@ export let MessageContextMenu = ({ const translate = useGoogleTranslate() const isFromSelf = message.sender?.did === currentAccount?.did + const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable) const onCopyMessage = useCallback(() => { const str = richTextToString( @@ -58,10 +59,10 @@ export let MessageContextMenu = ({ ) void Clipboard.setStringAsync(str) - Toast.show(_(msg`Copied to clipboard`), { + Toast.show(l`Copied to clipboard`, { type: 'success', }) - }, [_, message.text, message.facets]) + }, [l, message.text, message.facets]) const onPressTranslateMessage = useCallback(() => { void translate(message.text, langPrefs.primaryLanguage) @@ -79,11 +80,9 @@ export let MessageContextMenu = ({ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) convo .deleteMessage(message.id) - .then(() => - Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))), - ) - .catch(() => Toast.show(_(msg`Failed to delete message`))) - }, [_, convo, message.id]) + .then(() => Toast.show(l({message: 'Message deleted', context: 'toast'}))) + .catch(() => Toast.show(l`Failed to delete message`)) + }, [l, convo, message.id]) const onEmojiSelect = useCallback( (emoji: string) => { @@ -96,17 +95,17 @@ export let MessageContextMenu = ({ ) { convo .removeReaction(message.id, emoji) - .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`))) + .catch(() => Toast.show(l`Failed to remove emoji reaction`)) } else { if (hasReachedReactionLimit(message, currentAccount?.did)) return convo.addReaction(message.id, emoji).catch(() => - Toast.show(_(msg`Failed to add emoji reaction`), { + Toast.show(l`Failed to add emoji reaction`, { type: 'error', }), ) } }, - [_, convo, message, currentAccount?.did], + [l, convo, message, currentAccount?.did], ) const sender = convo.convo.members.find( @@ -117,7 +116,9 @@ export let MessageContextMenu = ({ <> {IS_NATIVE && ( - + + label={l`Message options`} + contentLabel={l`Message from @${ + sender?.handle ?? 'unknown' // should always be defined + }: ${message.text}`}> {children} - + {message.text.length > 0 && ( <> - {_(msg`Translate`)} - + {l`Translate`} + - {_(msg`Copy message text`)} + {l`Copy message text`} @@ -159,23 +160,22 @@ export let MessageContextMenu = ({ )} deleteControl.open()}> - {_(msg`Delete for me`)} - + {l`Delete for me`} + {!isFromSelf && ( reportControl.open()}> - {_(msg`Report`)} - + {l`Report`} + )} - - diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 386b85d7f9..adfc3e67b1 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -1,13 +1,17 @@ -import {memo, useCallback, useMemo} from 'react' +import {memo, useCallback, useMemo, useState} from 'react' import { type GestureResponderEvent, + Pressable, type StyleProp, type TextStyle, View, } from 'react-native' import Animated, { + FadeIn, + FadeOut, LayoutAnimationConfig, LinearTransition, + useSharedValue, ZoomIn, ZoomOut, } from 'react-native-reanimated' @@ -16,217 +20,420 @@ import { ChatBskyConvoDefs, RichText as RichTextAPI, } from '@atproto/api' -import {type I18n} from '@lingui/core' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {plural} from '@lingui/core/macro' +import {Trans, useLingui} from '@lingui/react/macro' +import {HITSLOP_10} from '#/lib/constants' import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import {useConvoActive} from '#/state/messages/convo' import {type ConvoItem} from '#/state/messages/convo/types' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useSession} from '#/state/session' -import {TimeElapsed} from '#/view/com/util/TimeElapsed' -import {atoms as a, native, useTheme} from '#/alf' +import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, native, useTheme, web} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' +import * as Dialog from '#/components/Dialog' +import {useDialogControl} from '#/components/Dialog' import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {InlineLinkText} from '#/components/Link' +import * as ProfileCard from '#/components/ProfileCard' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' -import {IS_NATIVE} from '#/env' +import type * as bsky from '#/types/bsky' import {DateDivider} from './DateDivider' import {MessageItemEmbed} from './MessageItemEmbed' -import {localDateString} from './util' + +const AVATAR_SIZE = 28 +const CLUSTERED_MESSAGE_GAP = 2 +const BORDER_RADIUS = 18 +const SQUARED_BORDER_RADIUS = 4 +const DISPLAY_NAME_INSET = 22 + +// 42px avatar + 2 * 8px my_sm margins +const ROW_HEIGHT = 58 + +const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000 +const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 + +type Reaction = { + key: string + value: string + senders: ChatBskyConvoDefs.ReactionViewSender[] + count: number +} + +function isWithinCluster({ + isPending, + adjacentMessage, + isFromSameSender, + currentSentAt, + direction, +}: { + isPending: boolean + adjacentMessage: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null + isFromSameSender: boolean + currentSentAt: string + direction: 'prev' | 'next' +}): boolean { + if (!isFromSameSender) return true + if (isPending && adjacentMessage) return false + if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) { + const thisDate = new Date(currentSentAt) + const adjDate = new Date(adjacentMessage.sentAt) + const diff = + direction === 'next' + ? adjDate.getTime() - thisDate.getTime() + : thisDate.getTime() - adjDate.getTime() + return diff > CLUSTERED_MESSAGE_THRESHOLD_MS + } + return true +} let MessageItem = ({ item, + isGroupChat = false, + profile, }: { item: ConvoItem & {type: 'message' | 'pending-message'} + isGroupChat?: boolean + profile?: bsky.profile.AnyProfileView }): React.ReactNode => { const t = useTheme() const {currentAccount} = useSession() - const {_} = useLingui() + const {t: l} = useLingui() const {convo} = useConvoActive() + const moderationOpts = useModerationOpts() + + const reactionsControl = useDialogControl() const {message, nextMessage, prevMessage} = item const isPending = item.type === 'pending-message' + const displayName = sanitizeDisplayName( + profile?.displayName || sanitizeHandle(profile?.handle ?? ''), + ) + const isFromSelf = message.sender?.did === currentAccount?.did + const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage) const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage) - const isNextFromSelf = - nextIsMessage && nextMessage.sender?.did === currentAccount?.did + const isPrevFromSameSender = + prevIsMessage && prevMessage.sender?.did === message.sender?.did + const isNextFromSameSender = + nextIsMessage && nextMessage.sender?.did === message.sender?.did - const isNextFromSameSender = isNextFromSelf === isFromSelf + const isFirstInCluster = useMemo( + () => + isWithinCluster({ + isPending, + adjacentMessage: prevMessage, + isFromSameSender: isPrevFromSameSender, + currentSentAt: message.sentAt, + direction: 'prev', + }), + [isPending, prevMessage, isPrevFromSameSender, message.sentAt], + ) - const isNewDay = useMemo(() => { - if (!prevMessage) return true + const isLastInCluster = useMemo( + () => + isWithinCluster({ + isPending, + adjacentMessage: nextMessage, + isFromSameSender: isNextFromSameSender, + currentSentAt: message.sentAt, + direction: 'next', + }), + [isPending, nextMessage, isNextFromSameSender, message.sentAt], + ) - const thisDate = new Date(message.sentAt) - const prevDate = new Date(prevMessage.sentAt) + const hasLargeGapFromPrev = + !ChatBskyConvoDefs.isMessageView(prevMessage) || + new Date(message.sentAt).getTime() - + new Date(prevMessage.sentAt).getTime() > + MESSAGE_GAP_THRESHOLD_MS - return localDateString(thisDate) !== localDateString(prevDate) - }, [message, prevMessage]) + const showDateDivider = hasLargeGapFromPrev - const isLastMessageOfDay = useMemo(() => { - if (!nextMessage || !nextIsMessage) return true + const isInCluster = !(isFirstInCluster && isLastInCluster) + const isInMiddleOfCluster = + isInCluster && !isFirstInCluster && !isLastInCluster - const thisDate = new Date(message.sentAt) - const prevDate = new Date(nextMessage.sentAt) + const hasReactions = message.reactions && message.reactions.length > 0 + const squaredBottomCorner = + !hasReactions && isInCluster && (isInMiddleOfCluster || isFirstInCluster) + const squaredTopCorner = + isInCluster && (isInMiddleOfCluster || isLastInCluster) - return localDateString(thisDate) !== localDateString(prevDate) - }, [message.sentAt, nextIsMessage, nextMessage]) - - const needsTail = isLastMessageOfDay || !isNextFromSameSender - - const isLastInGroup = useMemo(() => { - // if this message is pending, it means the next message is pending too - if (isPending && nextMessage) { - return false - } - - // or, if there's a 5 minute gap between this message and the next - if (ChatBskyConvoDefs.isMessageView(nextMessage)) { - const thisDate = new Date(message.sentAt) - const nextDate = new Date(nextMessage.sentAt) - - const diff = nextDate.getTime() - thisDate.getTime() - - // 5 minutes - return diff > 5 * 60 * 1000 - } - - return true - }, [message, nextMessage, isPending]) - - const pendingColor = t.palette.primary_200 + const pendingColor = t.palette.primary_300 const rt = useMemo(() => { return new RichTextAPI({text: message.text, facets: message.facets}) }, [message.text, message.facets]) + const hasEmbedAndText = + AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 + + const avatar = profile ? ( + + ) : ( + + ) + + const groupedReactions = useMemo(() => { + const reactions = message.reactions ?? [] + const grouped = new Map< + string, + { + key: string + value: string + senders: ChatBskyConvoDefs.ReactionViewSender[] + count: number + } + >() + for (const reaction of reactions) { + if (!reaction) continue + const existing = grouped.get(reaction.value) + if (existing) { + existing.senders.push(reaction.sender) + existing.count++ + } else { + grouped.set(reaction.value, { + key: reaction.value, + value: reaction.value, + senders: [reaction.sender], + count: 1, + }) + } + } + return Array.from(grouped.values()) + }, [message.reactions]) + + const reactions = useMemo(() => message.reactions ?? [], [message.reactions]) + + const reactionsLabel = useMemo(() => { + if (reactions.length === 0) return '' + if (reactions.length === 1) { + const reaction = reactions[0] + const sender = reaction.sender + if (sender.did === currentAccount?.did) { + return l`You reacted ${reaction.value}` + } else { + const senderDid = reaction.sender.did + const sender = convo.members.find(member => member.did === senderDid) + if (sender) { + return l`${sanitizeDisplayName( + sender.displayName || sender.handle, + )} reacted ${reaction.value}` + } + return l`Someone reacted ${reaction.value}` + } + } + return l`${plural(reactions.length, { + one: '# person', + other: '# people', + })} reacted – ${groupedReactions.map(g => g.value).join(' ')}` + }, [reactions, groupedReactions, currentAccount?.did, convo.members, l]) + const appliedReactions = ( - {message.reactions && message.reactions.length > 0 && ( - + {hasReactions ? ( + <> - {message.reactions.map((reaction, _i, reactions) => { - let label - if (reaction.sender.did === currentAccount?.did) { - label = _(msg`You reacted ${reaction.value}`) - } else { - const senderDid = reaction.sender.did - const sender = convo.members.find( - member => member.did === senderDid, - ) - if (sender) { - label = _( - msg`${sanitizeDisplayName( - sender.displayName || sender.handle, - )} reacted ${reaction.value}`, - ) - } else { - label = _(msg`Someone reacted ${reaction.value}`) - } + + isGroupChat ? reactionsControl.open() : undefined + }> + {groupedReactions.map(group => ( 1 && native(ZoomOut.delay(200))} + exiting={ + groupedReactions.length > 1 && native(ZoomOut.delay(200)) + } layout={native(LinearTransition.delay(300))} - key={reaction.sender.did + reaction.value} - style={[a.p_2xs]} - accessible={true} - accessibilityLabel={label} - accessibilityHint={_( - msg`Double tap or long press the message to add a reaction`, - )}> + key={group.value} + style={[a.p_2xs]}> - {reaction.value} + {group.value} - ) - })} + ))} + {groupedReactions.length !== reactions.length && + reactions.length > 1 ? ( + + + {reactions.length} + + + ) : null} + - - )} + + + ) : null} ) return ( <> - {isNewDay && } + {showDateDivider && ( + + + + )} - - {AppBskyEmbedRecord.isView(message.embed) && ( - - )} - {rt.text.length > 0 && ( - - + + {isGroupChat && !isFromSelf && isLastInCluster ? ( + + {avatar} - )} - - {IS_NATIVE && appliedReactions} - - - {!IS_NATIVE && appliedReactions} - - {isLastInGroup && ( + ) : null} + + {isGroupChat && + !isFromSelf && + isFirstInCluster && + !isOnlyEmoji(message.text) ? ( + + {displayName} + + ) : null} + + {rt.text.length > 0 && ( + + + + )} + {AppBskyEmbedRecord.isView(message.embed) && ( + + )} + {appliedReactions} + + + + {isLastInCluster && ( )} @@ -244,8 +451,7 @@ let MessageItemMetadata = ({ style: StyleProp }): React.ReactNode => { const t = useTheme() - const {_} = useLingui() - const {message} = item + const {t: l} = useLingui() const handleRetry = useCallback( (e: GestureResponderEvent) => { @@ -258,75 +464,251 @@ let MessageItemMetadata = ({ [item], ) - const relativeTimestamp = useCallback( - (i18n: I18n, timestamp: string) => { - const date = new Date(timestamp) - const now = new Date() + const errorColor = t.palette.negative_400 - const time = i18n.date(date, { - hour: 'numeric', - minute: 'numeric', - }) - - const diff = now.getTime() - date.getTime() - - // if under 30 seconds - if (diff < 1000 * 30) { - return _(msg`Now`) - } - - return time - }, - [_], - ) - - return ( - - - {({timeElapsed}) => ( - - {timeElapsed} - - )} - - - {item.type === 'pending-message' && item.failed && ( - <> - {' '} - ·{' '} - - {_(msg`Failed to send`)} + switch (item.type) { + case 'pending-message': + return item.failed ? ( + + + Message failed to send. {item.retry && ( <> {' '} - ·{' '} - {_(msg`Retry`)} + style={[a.text_xs, {color: errorColor}]}> + Tap to retry + . )} - - )} - - ) + + ) : null + default: + return null + } } MessageItemMetadata = memo(MessageItemMetadata) export {MessageItemMetadata} + +function ReactionsDialog({ + control, + members, + reactions, + groupedReactions, +}: { + control: Dialog.DialogControlProps + members: bsky.profile.AnyProfileView[] + reactions?: ChatBskyConvoDefs.ReactionView[] + groupedReactions?: Reaction[] +}) { + const t = useTheme() + const {t: l} = useLingui() + + const [selected, setSelected] = useState('all') + + const handleFilter = (value: string) => { + setSelected(value) + } + + const filteredMembers = + selected === 'all' + ? members + : members.filter(m => + reactions?.some(r => r.sender.did === m.did && r.value === selected), + ) + + const minHeight = members.length * ROW_HEIGHT + + return ( + setSelected('all')} + nativeOptions={{preventExpansion: true, minHeight}}> + + + + Reactions + + + + + {filteredMembers.map(profile => { + const displayName = sanitizeDisplayName( + profile?.displayName || sanitizeHandle(profile?.handle ?? ''), + ) + const handle = sanitizeHandle(profile?.handle ?? '', '@') + const reaction = reactions?.find( + ({sender}) => sender.did === profile.did, + ) + const rt = reaction + ? new RichTextAPI({text: reaction.value}) + : undefined + + return rt ? ( + + + + + + {displayName} + + + {handle} + + + + + + + + ) : null + })} + + + ) +} + +function ReactionTabs({ + groupedReactions, + selected, + totalReactions, + onFilter, +}: { + groupedReactions?: Reaction[] + selected: string + totalReactions: number + onFilter: (value: string) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + const contentSize = useSharedValue(0) + const scrollX = useSharedValue(0) + + const handlePress = (value: string) => { + onFilter(value) + } + + const tabs = [ + { + key: 'all', + value: l`All`, + senders: [], + count: totalReactions, + } as Reaction, + ...(groupedReactions ?? []), + ] + + return ( + + { + scrollX.set(Math.round(e.nativeEvent.contentOffset.x)) + }}> + { + contentSize.set(e.nativeEvent.layout.width) + }}> + {tabs?.map((reaction, index) => ( + + ))} + + + + ) +} + +function ReactionTab({ + index, + reaction, + selected, + total, + onPress, +}: { + index: number + reaction: Reaction + selected: string + total: number + onPress: (value: string) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + return ( + onPress(reaction.key)}> + + {l`${reaction.value} ${reaction.count}`} + + + ) +} diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index 67f07dd4fc..ba48b6e123 100644 --- a/src/components/dms/MessageItemEmbed.tsx +++ b/src/components/dms/MessageItemEmbed.tsx @@ -2,14 +2,24 @@ import {memo} from 'react' import {useWindowDimensions, View} from 'react-native' import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api' -import {atoms as a, native, tokens, useTheme, web} from '#/alf' +import {atoms as a, native, useTheme, web} from '#/alf' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {MessageContextProvider} from './MessageContext' +const CLUSTERED_MESSAGE_GAP = 2 +const BORDER_RADIUS = 20 +const SQUARED_BORDER_RADIUS = 4 + let MessageItemEmbed = ({ embed, + isFromSelf, + squaredTopCorner, + squaredBottomCorner, }: { embed: $Typed + isFromSelf: boolean + squaredTopCorner: boolean + squaredBottomCorner: boolean }): React.ReactNode => { const t = useTheme() const screen = useWindowDimensions() @@ -18,7 +28,7 @@ let MessageItemEmbed = ({ - + diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index 3f7694a342..4d7c2d7e33 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -5,24 +5,29 @@ import { type ModerationCause, type ModerationDecision, } from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' +import {useNavigation} from '@react-navigation/native' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {makeProfileLink} from '#/lib/routes/links' -import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {type NavigationProp} from '#/lib/routes/types' +import {logger} from '#/logger' import {type Shadow} from '#/state/cache/profile-shadow' import {isConvoActive, useConvo} from '#/state/messages/convo' import {type ConvoItem} from '#/state/messages/convo/types' +import {useSession} from '#/state/session' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, useTheme, web} from '#/alf' +import {atoms as a, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' +import {Button, ButtonIcon} from '#/components/Button' import {ConvoMenu} from '#/components/dms/ConvoMenu' -import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' +import {Bell2Off_Filled_Corner0_Rounded as BellOffIcon} from '#/components/icons/Bell2' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' -import {PostAlerts} from '#/components/moderation/PostAlerts' import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' -import {IS_WEB} from '#/env' +import {IS_LIQUID_GLASS, IS_WEB} from '#/env' const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE @@ -48,7 +53,7 @@ export function MessagesListHeader({ }, [moderation]) return ( - + @@ -72,19 +77,12 @@ export function MessagesListHeader({ - @@ -108,22 +106,27 @@ function HeaderReady({ userBlock?: ModerationCause } }) { - const {_} = useLingui() + const {t: l} = useLingui() const t = useTheme() const convoState = useConvo() + const {currentAccount} = useSession() + + const navigation = useNavigation() + + const groupInfo = convoState.getGroupInfo?.() + const isGroupChat = groupInfo != null const isDeletedAccount = profile?.handle === 'missing.invalid' - const displayName = isDeletedAccount - ? _(msg`Deleted Account`) - : sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) + const displayName = isGroupChat + ? (groupInfo.name ?? l`${profile.handle}'s group chat`) + : isDeletedAccount + ? l`Deleted Account` + : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) - // @ts-ignore findLast is polyfilled - esb const latestMessageFromOther = convoState.items.findLast( (item: ConvoItem) => - item.type === 'message' && item.message.sender.did === profile.did, + item.type === 'message' && + item.message.sender.did !== currentAccount?.did, ) const latestReportableMessage = @@ -131,85 +134,95 @@ function HeaderReady({ ? latestMessageFromOther.message : undefined + const handleNavigateToSettings = () => { + const convoId = convoState.convo?.id + if (convoId) { + navigation.navigate('MessagesConversationSettings', { + conversation: convoId, + }) + } else { + logger.error(`handleNavigateToSettings: missing convo ID`) + } + } + return ( - - - - - - {displayName} - - - - {!isDeletedAccount && ( - - @{profile.handle} + {isGroupChat ? ( + + + + {displayName} + + + ) : ( + + + + + + {displayName} + + {convoState.convo?.muted && ( <> - {' '} - ·{' '} - + {' '} + ·{' '} + + )} - - )} - - + + + + )} - {isConvoActive(convoState) && ( - - )} + {isConvoActive(convoState) ? ( + isGroupChat ? ( + + ) : ( + + ) + ) : null} - - - - ) } diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index 72b417665c..f0861baf45 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -3,13 +3,14 @@ import {Trans, useLingui} from '@lingui/react/macro' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {logger} from '#/logger' +import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import {FAB} from '#/view/com/util/fab/FAB' import {useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow' -import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {MessagePlus_Stroke2_Corner0_Rounded as NewChatIcon} from '#/components/icons/Message' import * as Toast from '#/components/Toast' import {useAnalytics} from '#/analytics' @@ -38,12 +39,28 @@ export function NewChat({ }, onError: error => { logger.error('Failed to create chat', {safeMessage: error}) - Toast.show(l`An issue occurred starting the chat`, { + Toast.show(l`An issue occurred starting the chat, please try again`, { type: 'error', }) }, }) + const {mutate: createGroupChat} = useCreateGroupChat({ + onSuccess: data => { + onNewChat(data.convo.id) + ax.metric('groupchat:create', {logContext: 'NewChatDialog'}) + }, + onError: error => { + logger.error('Failed to create groupchat', {safeMessage: error}) + Toast.show( + l`An issue occurred creating the group chat, please try again`, + { + type: 'error', + }, + ) + }, + }) + const onCreateChat = useCallback( (did: string) => { control.close(() => createChat([did])) @@ -52,10 +69,12 @@ export function NewChat({ ) const onCreateGroupChat = useCallback( - (_dids: string[], _groupName: string) => { - control.close() + (members: string[], name: string) => { + control.close(() => { + createGroupChat({members, name}) + }) }, - [control], + [control, createGroupChat], ) const onPress = useCallback(() => { @@ -74,7 +93,7 @@ export function NewChat({ } + icon={} accessibilityRole="button" accessibilityLabel={l`New chat`} accessibilityHint="" diff --git a/src/components/icons/Message.tsx b/src/components/icons/Message.tsx index e3ca70f01b..35d6deb222 100644 --- a/src/components/icons/Message.tsx +++ b/src/components/icons/Message.tsx @@ -15,3 +15,7 @@ export const Message_Stroke2_Corner0_Rounded_Filled = createSinglePathSVG({ export const Message_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M4 12a8 8 0 1 1 4.445 7.169 1 1 0 0 0-.629-.088l-3.537.662.7-3.415a1 1 0 0 0-.09-.66A7.961 7.961 0 0 1 4 12Zm8-10C6.477 2 2 6.477 2 12c0 1.523.341 2.968.951 4.262l-.93 4.537a1 1 0 0 0 1.163 1.184l4.68-.876A9.968 9.968 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2ZM7.5 13.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z', }) + +export const MessagePlus_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.136-.893l-4.68.876A1 1 0 0 1 2.02 20.8l.93-4.537A10 10 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 2a8 8 0 0 0-7.111 11.668 1 1 0 0 1 .09.66l-.7 3.415 3.537-.662c.214-.04.435-.009.63.088A8 8 0 1 0 12 4Zm0 4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1Z', +}) diff --git a/src/components/images/Gallery/const.ts b/src/components/images/Gallery/const.ts new file mode 100644 index 0000000000..443b65ecb8 --- /dev/null +++ b/src/components/images/Gallery/const.ts @@ -0,0 +1,3 @@ +export const ITEM_GAP = 8 // tokens.space.sm +export const MIN_ASPECT_RATIO = 2 / 3 // portrait limit +export const MAX_ASPECT_RATIO = 3 / 2 // landscape limit diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx new file mode 100644 index 0000000000..7c3a84e9d0 --- /dev/null +++ b/src/components/images/Gallery/index.tsx @@ -0,0 +1,531 @@ +import { + cloneElement, + createContext, + isValidElement, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import {FlatList, Pressable, useWindowDimensions, View} from 'react-native' +import Animated, { + type AnimatedRef, + useAnimatedRef, +} from 'react-native-reanimated' +import {Image} from 'expo-image' +import {type AppBskyEmbedImages} from '@atproto/api' +import {utils} from '@bsky.app/alf' +import {Trans, useLingui} from '@lingui/react/macro' +import debounce from 'lodash.debounce' + +import {type Dimensions} from '#/lib/media/types' +import {mergeRefs} from '#/lib/merge-refs' +import {useA11y} from '#/state/a11y' +import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' +import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' +import {AutoSizedImage} from '#/components/images/AutoSizedImage' +import { + ITEM_GAP, + MAX_ASPECT_RATIO, + MIN_ASPECT_RATIO, +} from '#/components/images/Gallery/const' +import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers' +import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers' +import {getAspectRatio} from '#/components/images/Gallery/utils' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {PostEmbedViewContext} from '#/components/Post/Embed/types' +import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' +import {IS_WEB} from '#/env' + +export * from './const' +export * from './maybeApplyGalleryOffsetStyles' + +interface GalleryProps { + images: AppBskyEmbedImages.ViewImage[] + onPress?: ( + index: number, + containerRefs: AnimatedRef[], + fetchedDims: (Dimensions | null)[], + ) => void + onPressIn?: (index: number) => void + viewContext?: PostEmbedViewContext +} + +const Context = createContext<{ + bleedRef: React.RefObject + bleedWidth: number +}>({ + bleedRef: {current: null}, + bleedWidth: 0, +}) + +export function GalleryBleed({children}: {children: React.ReactNode}) { + const ref = useRef(null) + const [bleedWidth, setBleedWidth] = useState(0) + + if (!isValidElement(children)) { + throw new Error('GalleryBleed children must be a single React element') + } + + const node = children as React.ReactElement + + return ( + + {cloneElement(node, { + ref: mergeRefs([ref, node?.props?.ref]), + onLayout: (e: {nativeEvent: {layout: {width: number}}}) => { + setBleedWidth(e.nativeEvent.layout.width) + node.props.onLayout?.(e) + }, + style: [node.props.style, a.overflow_hidden], + })} + + ) +} + +export function useGalleryBleed() { + return useContext(Context) +} + +export function Gallery({ + images, + onPress, + onPressIn, + viewContext, +}: GalleryProps) { + const {t: l} = useLingui() + const ax = useAnalytics() + const {screenReaderEnabled} = useA11y() + const largeAltBadge = useLargeAltBadgeEnabled() + const bps = useBreakpoints() + const window = useWindowDimensions() + const contentHeight = useMemo(() => { + if (bps.gtMobile) { + return 300 + } else if (bps.gtPhone) { + return 260 + } else { + return 200 + } + }, [bps]) + const isWithinQuote = + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + const hideBadges = isWithinQuote + + /* + * Container overflow styles + * + * Uses measureLayout to get the Gallery's offset relative to the GalleryBleed + * ancestor. This is a layout-relative measurement that doesn't depend on + * scroll position, so it works correctly for off-screen FlatList items. + */ + const {bleedRef, bleedWidth} = useGalleryBleed() + const contentRef = useRef(null) + const [contentDims, setContentDims] = useState<{x: number; width: number}>() + const measure = () => { + if (contentRef.current && bleedRef.current) { + contentRef.current.measureLayout( + bleedRef.current, + (x, _y, w) => { + setContentDims({x, width: w}) + }, + () => {}, + ) + } + } + const width = bleedWidth || Math.min(600, window.width) + const insetLeft = contentDims?.x ?? 0 + const insetRight = + bleedWidth > 0 + ? bleedWidth - (contentDims?.x ?? 0) - (contentDims?.width ?? 0) + : 0 + /* End container overflow styles */ + + const flatListRef = useRef(null) + const itemWidthsRef = useRef>(new Map()) + const itemRefsRef = useRef>(new Map()) + const containerRefsRef = useRef>>(new Map()) + const thumbDimsRef = useRef>(new Map()) + const currentIndexRef = useRef(0) + + const emitSwipeMetric = useMemo( + () => + debounce((fromIndex: number, toIndex: number) => { + ax.metric('post:gallery:swipe', { + fromImage: fromIndex + 1, // convert to 1-based index for easier analysis + toImage: toIndex + 1, // convert to 1-based index for easier analysis + totalImages: images.length, + }) + }, 200), + [ax, images.length], + ) + + const setCurrentIndex = (index: number) => { + const prev = currentIndexRef.current + if (prev !== index) { + currentIndexRef.current = index + emitSwipeMetric(prev, index) + } + } + + const scrollTo = (offset: number) => { + flatListRef.current?.scrollToOffset({offset, animated: false}) + } + + const onSettle = (index: number) => { + setCurrentIndex(index) + if (!IS_WEB) return + // Update tabIndex: only the active image is tab-focusable + itemRefsRef.current.forEach((node, i) => { + const el = node as unknown as HTMLElement + el.tabIndex = i === index ? 0 : -1 + }) + const el = itemRefsRef.current.get(index) as unknown as HTMLElement | null + el?.focus({preventScroll: true}) + } + + useKeyboardHandlers({ + flatListRef, + itemWidthsRef, + currentIndexRef, + scrollTo, + onSettle, + imageCount: images.length, + }) + + usePointerHandlers({ + flatListRef, + itemWidthsRef, + currentIndexRef, + scrollTo, + onSettle, + imageCount: images.length, + }) + + if (screenReaderEnabled) { + return ( + + {images.map((image, index) => ( + + onPress?.(index, [containerRef], [dims]) + } + onPressIn={() => onPressIn?.(index)} + hideBadge={ + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + } + /> + ))} + + ) + } + + return ( + + + item.thumb + index} + renderItem={({item, index}) => { + return ( + { + itemWidthsRef.current.set(i, w) + }} + itemRef={node => { + if (node) { + itemRefsRef.current.set(index, node) + } else { + itemRefsRef.current.delete(index) + } + }} + onContainerRef={(i, ref) => { + containerRefsRef.current.set(i, ref) + }} + onThumbDims={(i, dims) => { + thumbDimsRef.current.set(i, dims) + }} + onPress={ + onPress + ? () => { + ax.metric('post:gallery:openLightbox', { + fromImage: index + 1, // convert to 1-based index for easier analysis + totalImages: images.length, + }) + const refs: AnimatedRef[] = [] + const dims: (Dimensions | null)[] = [] + for (let i = 0; i < images.length; i++) { + refs.push(containerRefsRef.current.get(i)!) + dims.push(thumbDimsRef.current.get(i) ?? null) + } + onPress(index, refs, dims) + } + : undefined + } + onPressIn={onPressIn ? () => onPressIn(index) : undefined} + /> + ) + }} + onScroll={e => { + // web handles via onSettle in the web hooks + if (IS_WEB) return + const offsetX = e.nativeEvent.contentOffset.x + let accumulated = 0 + for (let i = 0; i < images.length; i++) { + const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP + if (offsetX < accumulated + w / 2) { + setCurrentIndex(i) + break + } + accumulated += w + if (i === images.length - 1) { + setCurrentIndex(i) + } + } + }} + style={[ + { + height: contentHeight, + marginLeft: -insetLeft, + width, + }, + ]} + contentContainerStyle={{ + gap: ITEM_GAP, + paddingLeft: insetLeft, + paddingRight: insetRight, + }} + /> + + + ) +} + +function computeDims({ + height, + aspectRatio, +}: { + height: number + aspectRatio?: number +}) { + /* + * Old images, or images from other clients can sometimes not have + * aspectRatio populated. In these cases, default to square and we'll + * resize once the image loads. + * + * Clamp between MIN_ASPECT_RATIO (portrait) and MAX_ASPECT_RATIO + * (landscape) so items stay a reasonable size in the carousel. + */ + const raw = aspectRatio ?? 1 + const clamped = Math.max(MIN_ASPECT_RATIO, Math.min(raw, MAX_ASPECT_RATIO)) + const width = Math.floor(height * clamped) + return {width, height, aspectRatio: clamped, isCropped: raw !== clamped} +} + +function GalleryImage({ + contentHeight: height, + image, + index, + imageCount, + onWidthChange, + itemRef, + hideBadges, + largeAltBadge, + onContainerRef, + onThumbDims, + onPress, + onPressIn, +}: { + contentHeight: number + image: AppBskyEmbedImages.ViewImage + index: number + imageCount: number + onWidthChange: (index: number, width: number) => void + itemRef: (node: View | null) => void + hideBadges?: boolean + largeAltBadge?: boolean + onContainerRef: (index: number, ref: AnimatedRef) => void + onThumbDims: (index: number, dims: Dimensions) => void + onPress?: () => void + onPressIn?: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const [focused, setFocused] = useState(false) + const containerRef = useAnimatedRef() + const [aspectRatio, setAspectRatio] = useState(() => + getAspectRatio(image.aspectRatio), + ) + const {isCropped, ...dims} = computeDims({height, aspectRatio}) + const hasAlt = !!image.alt + + useEffect(() => { + onWidthChange(index, dims.width) + }, [index, dims.width, onWidthChange]) + + useEffect(() => { + onContainerRef(index, containerRef) + }, [index, containerRef, onContainerRef]) + + return ( + + setFocused(true)} + onBlur={() => setFocused(false)} + accessibilityRole="button" + accessibilityLabel={image.alt || l`Image ${index + 1}`} + accessibilityHint={l`Opens full image`} + android_ripple={{ + color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), + foreground: true, + }} + style={[ + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + web({ + cursor: 'inherit', + outline: 0, + border: 0, + }), + ]}> + { + const ar = getAspectRatio(e.source) + if (ar && ar !== aspectRatio) { + setAspectRatio(ar) + } + onThumbDims(index, { + width: e.source.width, + height: e.source.height, + }) + }} + /> + + {(hasAlt || isCropped) && !hideBadges ? ( + + {isCropped && ( + + + + )} + {hasAlt && ( + + + ALT + + + )} + + ) : null} + + + + + ) +} diff --git a/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts b/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts new file mode 100644 index 0000000000..5081b5a2d5 --- /dev/null +++ b/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts @@ -0,0 +1,102 @@ +import { + AppBskyEmbedImages, + AppBskyEmbedRecordWithMedia, + type AppBskyFeedDefs, + AppBskyFeedPost, + type ModerationCause, + type ModerationUI, +} from '@atproto/api' + +import {unique} from '#/lib/moderation' +import {type AppModerationCause} from '#/components/Pills' +import {Features, features} from '#/analytics/features' +import * as bsky from '#/types/bsky' + +export const POST_META_NO_CONTENT_OFFSET = {paddingTop: 10} +export const POST_EMBED_NO_CONTENT_OFFSET = {paddingTop: 6} + +export function maybeApplyGalleryOffsetStyles( + placement: 'meta' | 'embed', + { + post, + modui, + additionalCauses, + }: { + post: AppBskyFeedDefs.PostView + modui: ModerationUI + additionalCauses?: ModerationCause[] | AppModerationCause[] + }, +) { + // don't ever check gates like this, except this one time + if (!features.isOn(Features.PostGalleryEmbedEnable)) return + + if ( + !bsky.dangerousIsType( + post.record, + AppBskyFeedPost.isRecord, + ) + ) { + return + } + + /* + * First check if we even have images + */ + const embed = post.record.embed + const isImageEmbed = + embed && + bsky.dangerousIsType( + embed, + AppBskyEmbedImages.isMain, + ) + const isRecordWithMedia = + embed && + bsky.dangerousIsType( + embed, + AppBskyEmbedRecordWithMedia.isMain, + ) + let hasImages = false + if (isImageEmbed) { + // one image, not a gallery + if (embed.images.length === 1) return + hasImages = true + } + if (isRecordWithMedia) { + if ( + bsky.dangerousIsType( + embed.media, + AppBskyEmbedImages.isMain, + ) + ) { + // one image, not a gallery + if (embed.media.images.length === 1) return + } + hasImages = true + } + if (!hasImages) return + + /* + * Then check if we have any text + */ + let hasLabels = false + if (modui.alert) { + hasLabels = modui.alerts.filter(unique).length > 0 + } + if (modui.inform) { + hasLabels = hasLabels || modui.informs.filter(unique).length > 0 + } + if (additionalCauses?.length) { + hasLabels = true + } + + /* + * If no text or labels, then we need a lil bump + */ + const shouldApplyOffset = !post.record.text && !hasLabels + + return shouldApplyOffset + ? placement === 'meta' + ? POST_META_NO_CONTENT_OFFSET + : POST_EMBED_NO_CONTENT_OFFSET + : {} +} diff --git a/src/components/images/Gallery/tween.ts b/src/components/images/Gallery/tween.ts new file mode 100644 index 0000000000..4d0042e475 --- /dev/null +++ b/src/components/images/Gallery/tween.ts @@ -0,0 +1,40 @@ +function ease(t: number, b: number, c: number, d: number) { + return t === d ? b + c : c * (-Math.pow(2, (-10 * t) / d) + 1) + b +} + +/** + * Tween from `start` to `end` over `duration` ms using an exponential ease-out. + * Returns a function that starts the tween. That function returns a stop handle. + * + * Adapted from tinkerbell. + */ +export function tween(start: number, end: number, duration: number) { + return function run(cb: (v: number) => void, done?: () => void) { + let ts: number | undefined + let frame: number + + frame = (function tick(last: number) { + return requestAnimationFrame(t => { + if (!ts) ts = t + const te = t - ts + const next = Math.round(ease(te, start, end - start, duration)) + if ( + (end > start + ? next < end && last <= end + : next > end && last >= end) && + te <= duration + ) { + frame = tick(next) + cb(next) + } else { + cb(end) + done?.() + } + }) + })(start) + + return function stop() { + cancelAnimationFrame(frame) + } + } +} diff --git a/src/components/images/Gallery/useKeyboardHandlers.ts b/src/components/images/Gallery/useKeyboardHandlers.ts new file mode 100644 index 0000000000..324ea28d2f --- /dev/null +++ b/src/components/images/Gallery/useKeyboardHandlers.ts @@ -0,0 +1,8 @@ +export function useKeyboardHandlers(_args: { + flatListRef: any + itemWidthsRef: any + currentIndexRef: any + scrollTo: any + onSettle: any + imageCount: any +}) {} diff --git a/src/components/images/Gallery/useKeyboardHandlers.web.ts b/src/components/images/Gallery/useKeyboardHandlers.web.ts new file mode 100644 index 0000000000..62cf79c287 --- /dev/null +++ b/src/components/images/Gallery/useKeyboardHandlers.web.ts @@ -0,0 +1,91 @@ +import {useEffect} from 'react' +import {type FlatList} from 'react-native' + +import {tween} from '#/components/images/Gallery/tween' +import {getOffsetForIndex} from '#/components/images/Gallery/utils' + +const SETTLE_DURATION = 700 + +export function useKeyboardHandlers({ + flatListRef, + itemWidthsRef, + currentIndexRef, + scrollTo, + onSettle, + imageCount, +}: { + flatListRef: React.RefObject + itemWidthsRef: React.RefObject> + currentIndexRef: React.RefObject + scrollTo: (offset: number) => void + onSettle: (index: number) => void + imageCount: number +}) { + useEffect(() => { + if (imageCount <= 1) return + + let stopTween: (() => void) | null = null + let pendingIndex: number | null = null + + const onKeyDown = (e: KeyboardEvent) => { + const el = + flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null + if (!el || !el.contains(document.activeElement)) return + + const current = pendingIndex ?? currentIndexRef.current + let targetIndex: number | undefined + + if (e.key === 'ArrowRight') { + if (current < imageCount - 1) { + targetIndex = current + 1 + } + } else if (e.key === 'ArrowLeft') { + if (current > 0) { + targetIndex = current - 1 + } + } + + if (targetIndex != null) { + e.preventDefault() + + if (stopTween) { + stopTween() + stopTween = null + } + + pendingIndex = targetIndex + const from = el.scrollLeft + const to = getOffsetForIndex(itemWidthsRef.current, targetIndex) + const idx = targetIndex + + stopTween = tween( + from, + to, + SETTLE_DURATION, + )( + v => { + scrollTo(v) + }, + () => { + stopTween = null + pendingIndex = null + onSettle(idx) + }, + ) + } + } + + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + if (stopTween) stopTween() + } + }, [ + flatListRef, + itemWidthsRef, + currentIndexRef, + scrollTo, + onSettle, + imageCount, + ]) +} diff --git a/src/components/images/Gallery/usePointerHandlers.ts b/src/components/images/Gallery/usePointerHandlers.ts new file mode 100644 index 0000000000..661c20b511 --- /dev/null +++ b/src/components/images/Gallery/usePointerHandlers.ts @@ -0,0 +1,8 @@ +export function usePointerHandlers(_args: { + flatListRef: any + itemWidthsRef: any + currentIndexRef: any + scrollTo: any + onSettle: any + imageCount: any +}) {} diff --git a/src/components/images/Gallery/usePointerHandlers.web.ts b/src/components/images/Gallery/usePointerHandlers.web.ts new file mode 100644 index 0000000000..25bebfccbd --- /dev/null +++ b/src/components/images/Gallery/usePointerHandlers.web.ts @@ -0,0 +1,270 @@ +import {useEffect} from 'react' +import {type FlatList} from 'react-native' + +import {ITEM_GAP} from '#/components/images/Gallery/const' +import {tween} from '#/components/images/Gallery/tween' +import {getOffsetForIndex} from '#/components/images/Gallery/utils' + +const DRAG_THRESHOLD = 3 +const FLICK_DECAY = 0.85 +const FLICK_MIN_VELOCITY = 0.1 +const ADVANCE_THRESHOLD = 0.15 +const FRAME_MS = 1000 / 60 +const SETTLE_DURATION = 700 +const OVERSCROLL_RESISTANCE = 0.4 +const BOUNCE_DURATION = 700 + +function whichByDistance( + itemWidths: Map, + currentIndex: number, + distance: number, + direction: -1 | 1, + imageCount: number, +): number { + let remaining = distance + let i = currentIndex + + while (remaining > 0 && i >= 0 && i < imageCount) { + const w = (itemWidths.get(i) ?? 0) + ITEM_GAP + if (remaining > w) { + remaining -= w + i -= direction + } else if (remaining > w * ADVANCE_THRESHOLD) { + i -= direction + break + } else { + break + } + } + + return Math.max(0, Math.min(i, imageCount - 1)) +} + +export function usePointerHandlers({ + flatListRef, + itemWidthsRef, + currentIndexRef, + scrollTo, + onSettle, + imageCount, +}: { + flatListRef: React.RefObject + itemWidthsRef: React.RefObject> + currentIndexRef: React.RefObject + scrollTo: (offset: number) => void + onSettle: (index: number) => void + imageCount: number +}) { + useEffect(() => { + if (imageCount <= 1) return + + const el = + flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null + if (!el) return + + let isDragging = false + let isMouseDown = false + let startX = 0 + let dragScrollLeft = 0 + let delta = 0 + let prevDelta = 0 + let velo = 0 + let t = 0 + let stopTween: (() => void) | null = null + let localIndex = currentIndexRef.current + let overscrollX = 0 + + el.style.cursor = 'grab' + + const clearOverscroll = () => { + overscrollX = 0 + el.style.transform = '' + } + + const onMouseDown = (e: MouseEvent) => { + e.preventDefault() // prevent native image drag + + // Cancel any in-progress tween + if (stopTween) { + stopTween() + stopTween = null + } + clearOverscroll() + + isMouseDown = true + isDragging = false + localIndex = currentIndexRef.current + startX = e.pageX + dragScrollLeft = el.scrollLeft + delta = 0 + prevDelta = 0 + velo = 0 + t = e.timeStamp + } + + const onMouseMove = (e: MouseEvent) => { + if (!isMouseDown) return + + const x = e.pageX - startX + + // Require minimum movement before starting drag + if (!isDragging && Math.abs(x) < DRAG_THRESHOLD) return + + if (!isDragging) { + isDragging = true + el.style.cursor = 'grabbing' + el.style.userSelect = 'none' + + // Blur focused element within the gallery + if (el.contains(document.activeElement)) { + ;(document.activeElement as HTMLElement)?.blur?.() + } + } + + e.preventDefault() + + // Track velocity + const elapsed = e.timeStamp - t || 1 + prevDelta = delta + delta = x + velo = (delta - prevDelta) / (elapsed * FRAME_MS) + t = e.timeStamp + + const desiredScroll = dragScrollLeft - delta + const maxScroll = el.scrollWidth - el.clientWidth + + if (desiredScroll < 0) { + // Overscroll at start — rubber band + scrollTo(0) + overscrollX = desiredScroll * OVERSCROLL_RESISTANCE + el.style.transform = `translateX(${-overscrollX}px)` + } else if (desiredScroll > maxScroll) { + // Overscroll at end — rubber band + scrollTo(maxScroll) + overscrollX = (desiredScroll - maxScroll) * OVERSCROLL_RESISTANCE + el.style.transform = `translateX(${-overscrollX}px)` + } else { + // Normal scroll range + scrollTo(desiredScroll) + if (overscrollX !== 0) clearOverscroll() + } + + // Update local index from scroll position (only in normal range) + if (overscrollX === 0) { + const offsetX = desiredScroll + let accumulated = 0 + for (let i = 0; i < imageCount; i++) { + const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP + if (offsetX < accumulated + w / 2) { + localIndex = i + break + } + accumulated += w + if (i === imageCount - 1) localIndex = i + } + } + } + + const onMouseUp = () => { + if (!isMouseDown) return + + const wasDragging = isDragging + isMouseDown = false + isDragging = false + + el.style.cursor = 'grab' + el.style.userSelect = '' + + if (wasDragging) { + // Suppress the click that follows mouseup after a drag + el.addEventListener('click', e => e.stopPropagation(), { + once: true, + capture: true, + }) + + if (overscrollX !== 0) { + // Bounce back from overscroll + const targetIndex = overscrollX > 0 ? imageCount - 1 : 0 + const fromOverscroll = overscrollX + + stopTween = tween( + fromOverscroll, + 0, + BOUNCE_DURATION, + )( + v => { + el.style.transform = `translateX(${-v}px)` + }, + () => { + stopTween = null + clearOverscroll() + onSettle(targetIndex) + }, + ) + } else { + // Normal flick settle + let v = Math.abs(velo) + let restingDistance = 0 + while (v > FLICK_MIN_VELOCITY) { + v *= FLICK_DECAY + restingDistance += v + } + + const direction: -1 | 1 = delta < 0 ? -1 : 1 + const totalDistance = Math.abs(delta) + restingDistance + + const targetIndex = whichByDistance( + itemWidthsRef.current, + localIndex, + totalDistance, + direction, + imageCount, + ) + + const from = el.scrollLeft + const to = getOffsetForIndex(itemWidthsRef.current, targetIndex) + + if (from === to) { + onSettle(targetIndex) + return + } + + stopTween = tween( + from, + to, + SETTLE_DURATION, + )( + v => { + scrollTo(v) + }, + () => { + stopTween = null + onSettle(targetIndex) + }, + ) + } + } + } + + el.addEventListener('mousedown', onMouseDown) + window.addEventListener('mousemove', onMouseMove) + window.addEventListener('mouseup', onMouseUp) + + return () => { + el.removeEventListener('mousedown', onMouseDown) + window.removeEventListener('mousemove', onMouseMove) + window.removeEventListener('mouseup', onMouseUp) + if (stopTween) stopTween() + clearOverscroll() + el.style.cursor = '' + el.style.userSelect = '' + } + }, [ + flatListRef, + itemWidthsRef, + currentIndexRef, + scrollTo, + onSettle, + imageCount, + ]) +} diff --git a/src/components/images/Gallery/utils.ts b/src/components/images/Gallery/utils.ts new file mode 100644 index 0000000000..8f5fe481aa --- /dev/null +++ b/src/components/images/Gallery/utils.ts @@ -0,0 +1,22 @@ +import {ITEM_GAP} from '#/components/images/Gallery/const' + +export function getOffsetForIndex( + itemWidths: Map, + index: number, +): number { + let offset = 0 + for (let i = 0; i < index; i++) { + offset += (itemWidths.get(i) ?? 0) + ITEM_GAP + } + return offset +} + +export function getAspectRatio({ + width, + height, +}: {width?: number; height?: number} = {}) { + if (width && width > 0 && height && height > 0) { + return width / height + } + return undefined +} diff --git a/src/components/images/ImageLayoutGrid.tsx b/src/components/images/ImageLayoutGrid.tsx index 54ee1e0121..320dba70a5 100644 --- a/src/components/images/ImageLayoutGrid.tsx +++ b/src/components/images/ImageLayoutGrid.tsx @@ -6,7 +6,7 @@ import {type AppBskyEmbedImages} from '@atproto/api' import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' import {atoms as a, useBreakpoints} from '#/alf' import {PostEmbedViewContext} from '#/components/Post/Embed/types' -import {GalleryItem} from './Gallery' +import {GalleryItem} from './ImageLayoutGridItem' interface ImageLayoutGridProps { images: AppBskyEmbedImages.ViewImage[] diff --git a/src/components/images/Gallery.tsx b/src/components/images/ImageLayoutGridItem.tsx similarity index 100% rename from src/components/images/Gallery.tsx rename to src/components/images/ImageLayoutGridItem.tsx diff --git a/src/components/verification/VerificationCreatePrompt.tsx b/src/components/verification/VerificationCreatePrompt.tsx index b0a87f476e..ea374128ce 100644 --- a/src/components/verification/VerificationCreatePrompt.tsx +++ b/src/components/verification/VerificationCreatePrompt.tsx @@ -10,8 +10,8 @@ import {useVerificationCreateMutation} from '#/state/queries/verification/useVer import {atoms as a, useBreakpoints} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {type DialogControlProps} from '#/components/Dialog' import * as Dialog from '#/components/Dialog' +import {type DialogControlProps} from '#/components/Dialog' import {VerifiedCheck} from '#/components/icons/VerifiedCheck' import {Loader} from '#/components/Loader' import * as ProfileCard from '#/components/ProfileCard' diff --git a/src/features/liveEvents/preferences.ts b/src/features/liveEvents/preferences.ts index f3a05bbb5e..6fb2ac6d11 100644 --- a/src/features/liveEvents/preferences.ts +++ b/src/features/liveEvents/preferences.ts @@ -8,8 +8,8 @@ import { } from '#/state/queries/preferences' import {useAgent} from '#/state/session' import {useAnalytics} from '#/analytics' -import {IS_WEB} from '#/env' import * as env from '#/env' +import {IS_WEB} from '#/env' import { type LiveEventFeed, type LiveEventFeedMetricContext, diff --git a/src/lib/hooks/useAnimatedValue.ts b/src/lib/hooks/useAnimatedValue.ts index 9ae14dab62..eb567c02b1 100644 --- a/src/lib/hooks/useAnimatedValue.ts +++ b/src/lib/hooks/useAnimatedValue.ts @@ -1,12 +1,12 @@ -import * as React from 'react' +import {useRef} from 'react' import {Animated} from 'react-native' export function useAnimatedValue(initialValue: number) { - const lazyRef = React.useRef(undefined) + const lazyRef = useRef(undefined) if (lazyRef.current === undefined) { lazyRef.current = new Animated.Value(initialValue) } - return lazyRef.current as Animated.Value + return lazyRef.current } diff --git a/src/lib/hooks/useTimer.ts b/src/lib/hooks/useTimer.ts index b14a9f24fd..8793ac5475 100644 --- a/src/lib/hooks/useTimer.ts +++ b/src/lib/hooks/useTimer.ts @@ -1,13 +1,13 @@ -import * as React from 'react' +import {useCallback, useEffect, useRef} from 'react' /** * Helper hook to run persistent timers on views */ export function useTimer(time: number, handler: () => void) { - const timer = React.useRef(undefined) + const timer = useRef(undefined) // function to restart the timer - const reset = React.useCallback(() => { + const reset = useCallback(() => { if (timer.current) { clearTimeout(timer.current) } @@ -15,7 +15,7 @@ export function useTimer(time: number, handler: () => void) { }, [time, timer, handler]) // function to cancel the timer - const cancel = React.useCallback(() => { + const cancel = useCallback(() => { if (timer.current) { clearTimeout(timer.current) timer.current = undefined @@ -23,7 +23,7 @@ export function useTimer(time: number, handler: () => void) { }, [timer]) // start the timer immediately - React.useEffect(() => { + useEffect(() => { reset() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/src/lib/media/video/upload.shared.ts b/src/lib/media/video/upload.shared.ts index 1ab8439e6f..fd46e27868 100644 --- a/src/lib/media/video/upload.shared.ts +++ b/src/lib/media/video/upload.shared.ts @@ -30,7 +30,7 @@ export async function getServiceAuthToken({ return serviceAuth.token } -export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) { +export async function getVideoUploadLimits(agent: BskyAgent, i18n: I18n) { const token = await getServiceAuthToken({ agent, lxm: 'app.bsky.video.getUploadLimits', @@ -52,7 +52,7 @@ export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) { throw new UploadLimitError(limits.message) } else { throw new UploadLimitError( - _( + i18n._( msg`You have temporarily reached the limit for video uploads. Please try again later.`, ), ) diff --git a/src/lib/media/video/upload.ts b/src/lib/media/video/upload.ts index b7df3be52f..503577a76a 100644 --- a/src/lib/media/video/upload.ts +++ b/src/lib/media/video/upload.ts @@ -16,19 +16,19 @@ export async function uploadVideo({ did, setProgress, signal, - _, + i18n, }: { video: CompressedVideo agent: BskyAgent did: string setProgress: (progress: number) => void signal: AbortSignal - _: I18n['_'] + i18n: I18n }) { if (signal.aborted) { throw new AbortError() } - await getVideoUploadLimits(agent, _) + await getVideoUploadLimits(agent, i18n) const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { did, @@ -69,7 +69,9 @@ export async function uploadVideo({ const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus if (!responseBody.jobId) { - throw new ServerError(responseBody.error || _(msg`Failed to upload video`)) + throw new ServerError( + responseBody.error || i18n._(msg`Failed to upload video`), + ) } if (signal.aborted) { diff --git a/src/lib/media/video/upload.web.ts b/src/lib/media/video/upload.web.ts index 2e78ec6d38..98d329a709 100644 --- a/src/lib/media/video/upload.web.ts +++ b/src/lib/media/video/upload.web.ts @@ -15,19 +15,19 @@ export async function uploadVideo({ did, setProgress, signal, - _, + i18n, }: { video: CompressedVideo agent: BskyAgent did: string setProgress: (progress: number) => void signal: AbortSignal - _: I18n['_'] + i18n: I18n }) { if (signal.aborted) { throw new AbortError() } - await getVideoUploadLimits(agent, _) + await getVideoUploadLimits(agent, i18n) const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { did, @@ -70,11 +70,11 @@ export async function uploadVideo({ ) as AppBskyVideoDefs.JobStatus resolve(uploadRes) } else { - reject(new ServerError(_(msg`Failed to upload video`))) + reject(new ServerError(i18n._(msg`Failed to upload video`))) } } xhr.onerror = () => { - reject(new ServerError(_(msg`Failed to upload video`))) + reject(new ServerError(i18n._(msg`Failed to upload video`))) } xhr.open('POST', uri) xhr.setRequestHeader('Content-Type', video.mimeType) @@ -84,7 +84,7 @@ export async function uploadVideo({ ) if (!res.jobId) { - throw new ServerError(res.error || _(msg`Failed to upload video`)) + throw new ServerError(res.error || i18n._(msg`Failed to upload video`)) } if (signal.aborted) { diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 5bb7265709..e87aad55c3 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -73,6 +73,7 @@ export type CommonNavigatorParams = { Hashtag: {tag: string; author?: string} Topic: {topic: string} MessagesConversation: {conversation: string; embed?: string; accept?: true} + MessagesConversationSettings: {conversation: string} MessagesSettings: undefined MessagesInbox: undefined NotificationsActivityList: {posts: string} diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index e1ab07863c..6e77671483 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -13,12 +13,18 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +#: src/screens/Messages/ConversationSettings.tsx:861 +#: src/screens/Messages/ConversationSettings.tsx:871 +#: src/screens/Messages/ConversationSettings.tsx:948 +msgid "…" +msgstr "…" + #. Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is currently selected. #: src/components/InterestTabs.tsx:330 msgid "\"{interestsDisplayName}\" category (active)" msgstr "" -#: src/screens/Messages/components/ChatListItem.tsx:159 +#: src/screens/Messages/components/ChatListItem.tsx:320 msgid "(contains embedded content)" msgstr "" @@ -76,6 +82,12 @@ msgstr "" msgid "{0, plural, one {# month} other {# months}}" msgstr "" +#. placeholder {0}: reactions.length +#. placeholder {1}: groupedReactions.map(g => g.value).join(' ') +#: src/components/dms/MessageItem.tsx:242 +msgid "{0, plural, one {# person} other {# people}} reacted – {1}" +msgstr "{0, plural, one {# person} other {# people}} reacted – {1}" + #. placeholder {0}: quoteCount ?? 0 #: src/screens/Post/PostQuotes.tsx:44 msgid "{0, plural, one {# quote} other {# quotes}}" @@ -155,6 +167,12 @@ msgstr "" msgid "{0} (Account)" msgstr "" +#. placeholder {0}: reaction.value +#. placeholder {1}: reaction.count +#: src/components/dms/MessageItem.tsx:710 +msgid "{0} {1}" +msgstr "{0} {1}" + #. Pattern: {wordValue} in tags #. placeholder {0}: word.value #: src/components/dialogs/MutedWords.tsx:495 @@ -206,14 +224,14 @@ msgstr "" #. placeholder {0}: sanitizeDisplayName( sender.displayName || sender.handle, ) #. placeholder {1}: reaction.value -#: src/components/dms/MessageItem.tsx:143 +#: src/components/dms/MessageItem.tsx:235 msgid "{0} reacted {1}" msgstr "" #. placeholder {0}: sanitizeDisplayName( sender.displayName || sender.handle, ) #. placeholder {1}: convo.lastReaction.reaction.value #. placeholder {2}: lastMessageText ? `"${convo.lastReaction.message.text}"` : fallbackMessage -#: src/screens/Messages/components/ChatListItem.tsx:230 +#: src/screens/Messages/components/ChatListItem.tsx:385 msgid "{0} reacted {1} to {2}" msgstr "" @@ -237,6 +255,13 @@ msgstr "" msgid "{0}'s avatar" msgstr "" +#. placeholder {0}: groupOwner.handle +#. placeholder {0}: profile.handle +#: src/components/dms/MessagesListHeader.tsx:121 +#: src/screens/Messages/components/ChatListItem.tsx:224 +msgid "{0}'s group chat" +msgstr "{0}'s group chat" + #. How many days have passed, displayed in a narrow form #. placeholder {0}: diff.value #: src/lib/hooks/useTimeAgo.ts:171 @@ -265,6 +290,10 @@ msgstr "" msgid "{count, plural, one {# unread item} other {# unread items}}" msgstr "" +#: src/components/dms/DateDivider.tsx:68 +msgid "{date} at {time}" +msgstr "{date} at {time}" + #: src/lib/generate-starterpack.ts:104 #: src/screens/StarterPack/Wizard/index.tsx:200 msgid "{displayName}'s Starter Pack" @@ -473,6 +502,10 @@ msgstr "{MAX_DISPLAY_NAME, plural, other {Display name is too long. The maximum msgid "{MAX_HIDDEN_REPLIES, plural, other {You can hide a maximum of # replies.}}" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:231 +msgid "{memberCount}/{MEMBER_LIMIT}" +msgstr "{memberCount}/{MEMBER_LIMIT}" + #: src/screens/Signup/StepInfo/index.tsx:312 msgid "{MIN_ACCESS_AGE, plural, other {You must be # years of age or older to create an account.}}" msgstr "" @@ -510,6 +543,10 @@ msgstr "" msgid "{rank}." msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:238 +msgid "{requestCount, plural, one {# request} other {# requests}}" +msgstr "{requestCount, plural, one {# request} other {# requests}}" + #. trending topic time spent trending. should be as short as possible to fit in a pill #: src/screens/Search/modules/ExploreTrendingTopics.tsx:191 msgid "{type}h ago" @@ -564,28 +601,28 @@ msgstr "" #. Like count display, the <0> tags enclose the number of likes in bold (will never be 0) #. placeholder {0}: formatPostStatCount(post.likeCount) #. placeholder {1}: post.likeCount -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:486 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:490 msgid "<0>{0} {1, plural, one {like} other {likes}}" msgstr "" #. Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0) #. placeholder {0}: formatPostStatCount(post.quoteCount) #. placeholder {1}: post.quoteCount -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:468 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:471 msgid "<0>{0} {1, plural, one {quote} other {quotes}}" msgstr "" #. Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0) #. placeholder {0}: formatPostStatCount(post.repostCount) #. placeholder {1}: post.repostCount -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:448 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:450 msgid "<0>{0} {1, plural, one {repost} other {reposts}}" msgstr "" #. Save count display, the <0> tags enclose the number of saves in bold (will never be 0) #. placeholder {0}: formatPostStatCount(post.bookmarkCount) #. placeholder {1}: post.bookmarkCount -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:499 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:508 msgid "<0>{0} {1, plural, one {save} other {saves}}" msgstr "" @@ -607,10 +644,6 @@ msgstr "" msgid "<0>{0} members" msgstr "" -#: src/components/dms/DateDivider.tsx:70 -msgid "<0>{date} at {time}" -msgstr "" - #: src/screens/Hashtag.tsx:239 #: src/screens/Search/SearchResults.tsx:315 msgid "<0>Sign in<1> or <2>create an account<3> <4>to search for news, sports, politics, and everything else happening on Bluesky." @@ -652,7 +685,7 @@ msgid "A collection of popular feeds you can find on Bluesky, including News, Bo msgstr "" #. If last message does not contain text, fall back to "{user} reacted to {a message}" -#: src/screens/Messages/components/ChatListItem.tsx:209 +#: src/screens/Messages/components/ChatListItem.tsx:368 msgid "a message" msgstr "" @@ -692,7 +725,7 @@ msgstr "" msgid "A screenshot of the post composer with a new button next to the post button that says \"Drafts\", with a rainbow firework effect. Below, the text in the composer reads \"Hey, did you hear the news? Bluesky has drafts now!!!\"." msgstr "" -#: src/Navigation.tsx:544 +#: src/Navigation.tsx:545 #: src/screens/Settings/AboutSettings.tsx:74 #: src/screens/Settings/Settings.tsx:255 #: src/screens/Settings/Settings.tsx:258 @@ -727,11 +760,11 @@ msgstr "" msgid "Accessibility" msgstr "" -#: src/Navigation.tsx:387 +#: src/Navigation.tsx:388 msgid "Accessibility Settings" msgstr "" -#: src/Navigation.tsx:403 +#: src/Navigation.tsx:404 #: src/screens/Login/LoginForm.tsx:192 #: src/screens/Settings/AccountSettings.tsx:56 #: src/screens/Settings/Settings.tsx:173 @@ -741,6 +774,7 @@ msgstr "" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:426 #: src/screens/Messages/components/RequestButtons.tsx:101 +#: src/screens/Messages/ConversationSettings.tsx:528 #: src/view/com/profile/ProfileMenu.tsx:188 msgctxt "toast" msgid "Account blocked" @@ -786,6 +820,7 @@ msgstr "" msgid "Account removed from quick access" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:515 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:88 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:310 #: src/view/com/profile/ProfileMenu.tsx:176 @@ -814,7 +849,7 @@ msgstr "" msgid "Activity from others" msgstr "" -#: src/Navigation.tsx:512 +#: src/Navigation.tsx:513 msgid "Activity notifications" msgstr "" @@ -875,11 +910,11 @@ msgstr "" msgid "Add another account" msgstr "" -#: src/view/com/composer/Composer.tsx:1331 +#: src/view/com/composer/Composer.tsx:1329 msgid "Add another post" msgstr "" -#: src/view/com/composer/Composer.tsx:1997 +#: src/view/com/composer/Composer.tsx:1987 msgid "Add another post to thread" msgstr "" @@ -910,6 +945,10 @@ msgstr "" msgid "Add media to post" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:297 +msgid "Add members" +msgstr "Add members" + #: src/components/moderation/ReportDialog/index.tsx:532 #: src/components/moderation/ReportDialog/index.tsx:536 msgid "Add more details (optional)" @@ -1006,6 +1045,11 @@ msgstr "" msgid "Additional details (limit 300 characters)" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:343 +#: src/screens/Messages/ConversationSettings.tsx:566 +msgid "Admin" +msgstr "Admin" + #: src/view/com/composer/labels/LabelsBtn.tsx:155 msgid "Adult" msgstr "" @@ -1065,6 +1109,7 @@ msgid "alice@example.com" msgstr "" #. the default tab in the interests tab bar +#: src/components/dms/MessageItem.tsx:628 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:199 #: src/view/screens/Notifications.tsx:88 msgid "All" @@ -1159,7 +1204,8 @@ msgid "Already signed in as @{0}" msgstr "" #: src/components/images/AutoSizedImage.tsx:190 -#: src/components/images/Gallery.tsx:120 +#: src/components/images/Gallery/index.tsx:514 +#: src/components/images/ImageLayoutGridItem.tsx:120 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:94 #: src/view/com/composer/GifAltText.tsx:100 #: src/view/com/composer/photos/Gallery.tsx:214 @@ -1279,13 +1325,21 @@ msgstr "" msgid "An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts." msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:1016 +msgid "An invite link lets people join this group chat without being added directly. You control who can use the link and whether they need your approval. You can disable the link at any time. Your name, avatar, and the name of the group chat will be visible to everyone" +msgstr "An invite link lets people join this group chat without being added directly. You control who can use the link and whether they need your approval. You can disable the link at any time. Your name, avatar, and the name of the group chat will be visible to everyone" + #: src/components/moderation/ReportDialog/utils/useReportOptions.ts:239 msgid "An issue not included in these options" msgstr "" -#: src/components/dms/dialogs/NewChatDialog.tsx:41 -msgid "An issue occurred starting the chat" -msgstr "" +#: src/components/dms/dialogs/NewChatDialog.tsx:56 +msgid "An issue occurred creating the group chat, please try again" +msgstr "An issue occurred creating the group chat, please try again" + +#: src/components/dms/dialogs/NewChatDialog.tsx:42 +msgid "An issue occurred starting the chat, please try again" +msgstr "An issue occurred starting the chat, please try again" #: src/components/dms/dialogs/ShareViaChatDialog.tsx:50 msgid "An issue occurred while trying to open the chat" @@ -1355,7 +1409,7 @@ msgstr "" msgid "Anyone who follows me" msgstr "" -#: src/Navigation.tsx:552 +#: src/Navigation.tsx:553 #: src/screens/Settings/AppIconSettings/index.tsx:65 #: src/screens/Settings/AppIconSettings/SettingsListItem.tsx:19 #: src/screens/Settings/AppIconSettings/SettingsListItem.tsx:24 @@ -1393,7 +1447,7 @@ msgstr "" msgid "App passwords" msgstr "" -#: src/Navigation.tsx:355 +#: src/Navigation.tsx:356 #: src/screens/Settings/AppPasswords.tsx:51 msgid "App Passwords" msgstr "" @@ -1435,7 +1489,7 @@ msgstr "" msgid "Appeal this decision" msgstr "" -#: src/Navigation.tsx:395 +#: src/Navigation.tsx:396 #: src/screens/Settings/AppearanceSettings.tsx:73 #: src/screens/Settings/Settings.tsx:225 #: src/screens/Settings/Settings.tsx:228 @@ -1453,12 +1507,12 @@ msgid "Apply Pull Request" msgstr "" #. placeholder {0}: niceDate(i18n, createdAt, 'medium') -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:620 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:630 msgid "Archived from {0}" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:591 -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:629 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:601 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:639 msgid "Archived post" msgstr "" @@ -1471,9 +1525,9 @@ msgstr "" msgid "Are you sure you want to delete the app password \"{0}\"?" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:206 -msgid "Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant." -msgstr "" +#: src/components/dms/MessageContextMenu.tsx:204 +msgid "Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants." +msgstr "Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants." #: src/screens/StarterPack/StarterPackScreen.tsx:686 msgid "Are you sure you want to delete this starter pack?" @@ -1496,7 +1550,7 @@ msgstr "" msgid "Are you sure you want to remove this from your feeds?" msgstr "" -#: src/view/com/composer/Composer.tsx:1470 +#: src/view/com/composer/Composer.tsx:1469 msgid "Are you sure you'd like to discard this post?" msgstr "" @@ -1540,7 +1594,7 @@ msgstr "Automated account" msgid "Automation label" msgstr "Automation label" -#: src/Navigation.tsx:411 +#: src/Navigation.tsx:412 #: src/screens/Settings/AutomationLabelSettings.tsx:103 msgid "Automation Label" msgstr "Automation Label" @@ -1616,10 +1670,11 @@ msgstr "" msgid "Before you can get notifications for {name}'s posts, you must first verify your email." msgstr "" -#: src/components/dms/dialogs/NewChatDialog.tsx:66 +#: src/components/dms/dialogs/NewChatDialog.tsx:85 #: src/components/dms/MessageProfileButton.tsx:60 #: src/screens/Messages/ChatList.tsx:376 -#: src/screens/Messages/Conversation.tsx:230 +#: src/screens/Messages/Conversation.tsx:247 +#: src/screens/Messages/ConversationSettings.tsx:505 msgid "Before you can message another user, you must first verify your email." msgstr "" @@ -1648,11 +1703,17 @@ msgid "Birthday" msgstr "" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:853 +#: src/screens/Messages/ConversationSettings.tsx:627 +#: src/screens/Messages/ConversationSettings.tsx:1060 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:207 #: src/view/com/profile/ProfileMenu.tsx:563 msgid "Block" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:623 +msgid "Block {displayName}" +msgstr "Block {displayName}" + #: src/components/dms/ConvoMenu.tsx:275 #: src/components/dms/ConvoMenu.tsx:278 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:747 @@ -1664,6 +1725,10 @@ msgstr "" msgid "Block account" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:1057 +msgid "Block account?" +msgstr "Block account?" + #: src/components/PostControls/PostMenu/PostMenuItems.tsx:850 #: src/view/com/profile/ProfileMenu.tsx:546 msgid "Block Account?" @@ -1703,7 +1768,7 @@ msgstr "" msgid "Block user and/or delete this conversation" msgstr "" -#: src/components/Post/Embed/index.tsx:186 +#: src/components/Post/Embed/index.tsx:187 msgid "Blocked" msgstr "" @@ -1711,12 +1776,13 @@ msgstr "" msgid "Blocked accounts" msgstr "" -#: src/Navigation.tsx:196 +#: src/Navigation.tsx:197 #: src/view/screens/ModerationBlockedAccounts.tsx:104 msgid "Blocked Accounts" msgstr "" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:851 +#: src/screens/Messages/ConversationSettings.tsx:1058 #: src/view/com/profile/ProfileMenu.tsx:558 msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." msgstr "" @@ -1746,7 +1812,7 @@ msgstr "" msgid "Bluesky" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:645 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:655 msgid "Bluesky cannot confirm the authenticity of the claimed date." msgstr "" @@ -1951,9 +2017,11 @@ msgstr "" #: src/features/liveNow/components/GoLiveDialog.tsx:254 #: src/lib/media/picker.tsx:38 #: src/screens/Deactivated.tsx:150 +#: src/screens/Messages/ConversationSettings.tsx:1018 +#: src/screens/Messages/ConversationSettings.tsx:1039 #: src/screens/Profile/Header/EditProfileDialog.tsx:215 #: src/screens/Profile/Header/EditProfileDialog.tsx:223 -#: src/screens/Search/Shell.tsx:397 +#: src/screens/Search/Shell.tsx:399 #: src/screens/Settings/AppIconSettings/index.tsx:42 #: src/screens/Settings/AppIconSettings/index.tsx:228 #: src/screens/Settings/components/ChangeHandleDialog.tsx:80 @@ -1964,7 +2032,7 @@ msgstr "" #: src/screens/Takendown.tsx:102 #: src/screens/Takendown.tsx:105 #: src/view/com/composer/Composer.tsx:1547 -#: src/view/com/composer/Composer.tsx:1559 +#: src/view/com/composer/Composer.tsx:1557 #: src/view/com/composer/photos/EditImageDialog.web.tsx:44 #: src/view/com/composer/photos/EditImageDialog.web.tsx:53 #: src/view/shell/desktop/LeftNav.tsx:215 @@ -1979,7 +2047,7 @@ msgstr "" msgid "Cancel reactivation and sign out" msgstr "" -#: src/screens/Search/Shell.tsx:388 +#: src/screens/Search/Shell.tsx:390 msgid "Cancel search" msgstr "" @@ -1999,6 +2067,10 @@ msgstr "" msgid "Captions & alt text" msgstr "" +#: src/components/images/Gallery/index.tsx:251 +msgid "carousel" +msgstr "carousel" + #: src/components/RichTextTag.tsx:53 msgid "Cashtag {tag}" msgstr "" @@ -2075,7 +2147,7 @@ msgid "Changes to the starter pack will not be reflected in the list after creat msgstr "" #: src/lib/hooks/useNotificationHandler.ts:102 -#: src/Navigation.tsx:569 +#: src/Navigation.tsx:570 #: src/view/shell/bottom-bar/BottomBar.tsx:224 #: src/view/shell/desktop/LeftNav.tsx:611 #: src/view/shell/Drawer.tsx:454 @@ -2101,7 +2173,7 @@ msgctxt "toast" msgid "Chat muted" msgstr "" -#: src/Navigation.tsx:579 +#: src/Navigation.tsx:585 #: src/screens/Messages/components/InboxPreview.tsx:23 msgid "Chat request inbox" msgstr "" @@ -2113,7 +2185,7 @@ msgid "Chat requests" msgstr "" #: src/components/dms/ConvoMenu.tsx:84 -#: src/Navigation.tsx:574 +#: src/Navigation.tsx:580 #: src/screens/Messages/ChatList.tsx:82 #: src/screens/Messages/ChatList.tsx:86 #: src/screens/Messages/ChatList.tsx:385 @@ -2260,7 +2332,7 @@ msgstr "" msgid "Click to open tag menu for {0}" msgstr "" -#: src/components/dms/MessageItem.tsx:318 +#: src/components/dms/MessageItem.tsx:480 msgid "Click to retry failed message" msgstr "" @@ -2298,7 +2370,7 @@ msgstr "" #: src/components/NewskieDialog.tsx:169 #: src/components/NewskieDialog.tsx:175 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:107 -#: src/components/ProgressGuide/FollowDialog.tsx:445 +#: src/components/ProgressGuide/FollowDialog.tsx:460 #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:122 #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:128 #: src/components/verification/VerificationsDialog.tsx:146 @@ -2378,7 +2450,7 @@ msgstr "" msgid "Closes password update alert" msgstr "" -#: src/view/com/composer/Composer.tsx:1556 +#: src/view/com/composer/Composer.tsx:1555 msgid "Closes post composer and discards post draft" msgstr "" @@ -2417,7 +2489,7 @@ msgid "Comics" msgstr "" #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:46 -#: src/Navigation.tsx:345 +#: src/Navigation.tsx:346 #: src/view/screens/CommunityGuidelines.tsx:38 msgid "Community Guidelines" msgstr "" @@ -2437,7 +2509,7 @@ msgid "Compose new post" msgstr "" #. placeholder {0}: MAX_GRAPHEME_LENGTH || 0 -#: src/view/com/composer/Composer.tsx:1434 +#: src/view/com/composer/Composer.tsx:1431 msgid "Compose posts up to {0, plural, other {# characters}} in length" msgstr "" @@ -2445,11 +2517,11 @@ msgstr "" msgid "Compose reply" msgstr "" -#: src/view/com/composer/Composer.tsx:2393 +#: src/view/com/composer/Composer.tsx:2383 msgid "Compressing GIF..." msgstr "" -#: src/view/com/composer/Composer.tsx:2395 +#: src/view/com/composer/Composer.tsx:2385 msgid "Compressing video..." msgstr "" @@ -2541,7 +2613,7 @@ msgstr "" msgid "Content and media" msgstr "" -#: src/Navigation.tsx:528 +#: src/Navigation.tsx:529 msgid "Content and Media" msgstr "" @@ -2619,11 +2691,11 @@ msgstr "Continue to group name" msgid "Continue to next step" msgstr "" -#: src/screens/Messages/Conversation.tsx:60 +#: src/screens/Messages/Conversation.tsx:64 msgid "Conversation" msgstr "" -#: src/screens/Messages/components/ChatListItem.tsx:195 +#: src/screens/Messages/components/ChatListItem.tsx:355 msgid "Conversation deleted" msgstr "" @@ -2637,7 +2709,7 @@ msgstr "" msgid "Copied build version to clipboard" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:61 +#: src/components/dms/MessageContextMenu.tsx:62 #: src/components/PostControls/DiscoverDebug.tsx:36 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:272 #: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:77 @@ -2718,8 +2790,8 @@ msgstr "" msgid "Copy link to starter pack" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:150 -#: src/components/dms/MessageContextMenu.tsx:153 +#: src/components/dms/MessageContextMenu.tsx:151 +#: src/components/dms/MessageContextMenu.tsx:154 msgid "Copy message text" msgstr "" @@ -2743,7 +2815,7 @@ msgstr "" #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:41 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:108 -#: src/Navigation.tsx:350 +#: src/Navigation.tsx:351 #: src/view/screens/CopyrightPolicy.tsx:35 msgid "Copyright Policy" msgstr "" @@ -2790,6 +2862,10 @@ msgstr "" msgid "Could not mute chat" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:690 +msgid "Could not mute group chat" +msgstr "Could not mute group chat" + #: src/view/com/composer/videos/VideoPreview.web.tsx:66 msgid "Could not process your video" msgstr "" @@ -2840,7 +2916,7 @@ msgstr "" #: src/components/StarterPack/ProfileStarterPacks.tsx:207 #: src/components/StarterPack/ProfileStarterPacks.tsx:316 -#: src/Navigation.tsx:609 +#: src/Navigation.tsx:615 msgid "Create a starter pack" msgstr "" @@ -2883,6 +2959,10 @@ msgstr "" msgid "Create an avatar instead" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:800 +msgid "Create an invite link for this group chat" +msgstr "Create an invite link for this group chat" + #: src/components/StarterPack/ProfileStarterPacks.tsx:214 msgid "Create another" msgstr "" @@ -3010,7 +3090,7 @@ msgstr "" msgid "Default icons" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:208 +#: src/components/dms/MessageContextMenu.tsx:205 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:803 #: src/screens/Messages/components/ChatStatusInfo.tsx:55 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:275 @@ -3063,7 +3143,7 @@ msgstr "" msgid "Delete Conversation" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:164 +#: src/components/dms/MessageContextMenu.tsx:165 msgid "Delete for me" msgstr "" @@ -3072,11 +3152,11 @@ msgstr "" msgid "Delete list" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:204 +#: src/components/dms/MessageContextMenu.tsx:203 msgid "Delete message" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:162 +#: src/components/dms/MessageContextMenu.tsx:163 msgid "Delete message for me" msgstr "" @@ -3086,7 +3166,7 @@ msgstr "" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:787 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:789 -#: src/view/com/composer/Composer.tsx:1444 +#: src/view/com/composer/Composer.tsx:1443 msgid "Delete post" msgstr "" @@ -3107,12 +3187,14 @@ msgstr "" msgid "Delete this post?" msgstr "" -#: src/components/Post/Embed/index.tsx:179 +#: src/components/Post/Embed/index.tsx:180 msgid "Deleted" msgstr "" -#: src/components/dms/MessagesListHeader.tsx:117 -#: src/screens/Messages/components/ChatListItem.tsx:128 +#: src/components/dms/MessagesListHeader.tsx:123 +#: src/screens/Messages/components/ChatListItem.tsx:163 +#: src/screens/Messages/ConversationSettings.tsx:333 +#: src/screens/Messages/ConversationSettings.tsx:552 msgid "Deleted Account" msgstr "" @@ -3215,9 +3297,9 @@ msgstr "" #: src/components/dialogs/lists/CreateOrEditListDialog.tsx:101 #: src/screens/Profile/Header/EditProfileDialog.tsx:79 -#: src/view/com/composer/Composer.tsx:1229 -#: src/view/com/composer/Composer.tsx:1277 -#: src/view/com/composer/Composer.tsx:1477 +#: src/view/com/composer/Composer.tsx:1231 +#: src/view/com/composer/Composer.tsx:1275 +#: src/view/com/composer/Composer.tsx:1476 #: src/view/com/composer/drafts/DraftItem.tsx:242 #: src/view/com/composer/drafts/DraftsButton.tsx:131 msgid "Discard" @@ -3228,14 +3310,14 @@ msgstr "" msgid "Discard changes?" msgstr "" -#: src/view/com/composer/Composer.tsx:1227 +#: src/view/com/composer/Composer.tsx:1229 #: src/view/com/composer/drafts/DraftItem.tsx:239 #: src/view/com/composer/drafts/DraftsButton.tsx:98 msgid "Discard draft?" msgstr "" -#: src/view/com/composer/Composer.tsx:1244 -#: src/view/com/composer/Composer.tsx:1469 +#: src/view/com/composer/Composer.tsx:1246 +#: src/view/com/composer/Composer.tsx:1468 msgid "Discard post?" msgstr "" @@ -3270,7 +3352,7 @@ msgstr "" msgid "Dismiss banner" msgstr "" -#: src/view/com/composer/Composer.tsx:2314 +#: src/view/com/composer/Composer.tsx:2304 msgid "Dismiss error" msgstr "" @@ -3377,7 +3459,7 @@ msgctxt "action" msgid "Done" msgstr "" -#: src/components/dms/MessageItem.tsx:161 +#: src/components/dms/MessageItem.tsx:369 msgid "Double tap or long press the message to add a reaction" msgstr "" @@ -3490,6 +3572,11 @@ msgstr "" msgid "Edit Feeds" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:972 +#: src/screens/Messages/ConversationSettings.tsx:977 +msgid "Edit group name" +msgstr "Edit group name" + #: src/view/com/composer/photos/EditImageDialog.web.tsx:86 #: src/view/com/composer/photos/EditImageDialog.web.tsx:90 #: src/view/com/composer/photos/Gallery.tsx:221 @@ -3520,11 +3607,15 @@ msgstr "" msgid "Edit moderation list" msgstr "" -#: src/Navigation.tsx:360 +#: src/Navigation.tsx:361 #: src/view/screens/Feeds.tsx:519 msgid "Edit My Feeds" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:794 +msgid "Edit name" +msgstr "Edit name" + #. placeholder {0}: createSanitizedDisplayName( profile, ) #: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:246 msgid "Edit notifications from {0}" @@ -3555,6 +3646,10 @@ msgstr "" msgid "Edit starter pack" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:793 +msgid "Edit this group chat’s name" +msgstr "Edit this group chat’s name" + #: src/components/dialogs/lists/CreateOrEditListDialog.tsx:363 msgid "Edit user list" msgstr "" @@ -3563,7 +3658,7 @@ msgstr "" msgid "Edit who can reply" msgstr "" -#: src/Navigation.tsx:614 +#: src/Navigation.tsx:620 msgid "Edit your starter pack" msgstr "" @@ -3771,7 +3866,7 @@ msgstr "" msgid "Entertainment" msgstr "" -#: src/view/com/composer/Composer.tsx:2413 +#: src/view/com/composer/Composer.tsx:2403 #: src/view/com/util/error/ErrorScreen.tsx:43 msgid "Error" msgstr "" @@ -3896,8 +3991,8 @@ msgstr "" msgid "Explicit sexual images." msgstr "" -#: src/Navigation.tsx:818 -#: src/screens/Search/Shell.tsx:354 +#: src/Navigation.tsx:824 +#: src/screens/Search/Shell.tsx:356 #: src/view/shell/desktop/LeftNav.tsx:691 #: src/view/shell/Drawer.tsx:402 msgid "Explore" @@ -3932,7 +4027,7 @@ msgstr "" msgid "External media may allow websites to collect information about you and your device. No information is sent or requested until you press the \"play\" button." msgstr "" -#: src/Navigation.tsx:379 +#: src/Navigation.tsx:380 #: src/screens/Settings/ExternalMediaPreferences.tsx:34 msgid "External Media Preferences" msgstr "" @@ -3947,7 +4042,7 @@ msgid "Failed to accept chat" msgstr "" #: src/components/dms/ActionsWrapper.web.tsx:64 -#: src/components/dms/MessageContextMenu.tsx:103 +#: src/components/dms/MessageContextMenu.tsx:102 msgid "Failed to add emoji reaction" msgstr "" @@ -3964,6 +4059,7 @@ msgid "Failed to create app password. Please try again." msgstr "" #: src/components/dms/MessageProfileButton.tsx:38 +#: src/screens/Messages/ConversationSettings.tsx:482 msgid "Failed to create conversation" msgstr "" @@ -3978,7 +4074,7 @@ msgctxt "toast" msgid "Failed to delete chat" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:85 +#: src/components/dms/MessageContextMenu.tsx:84 msgid "Failed to delete message" msgstr "" @@ -4047,7 +4143,7 @@ msgstr "" msgid "Failed to load notification settings." msgstr "" -#: src/screens/Messages/components/MessageListError.tsx:23 +#: src/screens/Messages/components/MessageListError.tsx:22 msgid "Failed to load past messages" msgstr "" @@ -4090,7 +4186,7 @@ msgid "Failed to remove data. {0}" msgstr "" #: src/components/dms/ActionsWrapper.web.tsx:60 -#: src/components/dms/MessageContextMenu.tsx:99 +#: src/components/dms/MessageContextMenu.tsx:98 msgid "Failed to remove emoji reaction" msgstr "" @@ -4129,10 +4225,6 @@ msgctxt "toast" msgid "Failed to save your interests." msgstr "" -#: src/components/dms/MessageItem.tsx:311 -msgid "Failed to send" -msgstr "" - #: src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx:123 #: src/components/dialogs/EmailDialog/screens/Verify.tsx:137 msgid "Failed to send email, please try again." @@ -4173,7 +4265,7 @@ msgstr "" msgid "Failed to update settings" msgstr "" -#: src/lib/media/video/upload.ts:72 +#: src/lib/media/video/upload.ts:73 #: src/lib/media/video/upload.web.ts:73 #: src/lib/media/video/upload.web.ts:77 #: src/lib/media/video/upload.web.ts:87 @@ -4196,7 +4288,7 @@ msgstr "" msgid "False information about elections" msgstr "" -#: src/Navigation.tsx:295 +#: src/Navigation.tsx:296 msgid "Feed" msgstr "" @@ -4241,7 +4333,7 @@ msgctxt "toast" msgid "Feedback sent to feed operator" msgstr "" -#: src/Navigation.tsx:594 +#: src/Navigation.tsx:600 #: src/screens/SavedFeeds.tsx:120 #: src/screens/SavedFeeds.tsx:318 #: src/screens/Search/SearchResults.tsx:80 @@ -4320,8 +4412,8 @@ msgstr "" msgid "Find accounts to follow" msgstr "" -#: src/Navigation.tsx:435 -#: src/Navigation.tsx:636 +#: src/Navigation.tsx:436 +#: src/Navigation.tsx:642 msgid "Find Contacts" msgstr "" @@ -4342,12 +4434,12 @@ msgstr "" #. Starter packs suggested to the user for them to follow #: src/components/ProgressGuide/FollowDialog.tsx:72 #: src/components/ProgressGuide/FollowDialog.tsx:80 -#: src/components/ProgressGuide/FollowDialog.tsx:431 +#: src/components/ProgressGuide/FollowDialog.tsx:446 #: src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx:43 msgid "Find people to follow" msgstr "" -#: src/screens/Search/Shell.tsx:530 +#: src/screens/Search/Shell.tsx:532 msgid "Find posts, users, and feeds on Bluesky" msgstr "" @@ -4492,7 +4584,7 @@ msgid "Followed by <0>{0}, <1>{1}, and {2, plural, one {# other} other { msgstr "" #. placeholder {0}: route.params.name -#: src/Navigation.tsx:249 +#: src/Navigation.tsx:250 msgid "Followers of @{0} that you know" msgstr "" @@ -4538,7 +4630,7 @@ msgstr "" msgid "Following feed preferences" msgstr "" -#: src/Navigation.tsx:366 +#: src/Navigation.tsx:367 #: src/screens/Settings/FollowingFeedPreferences.tsx:57 msgid "Following Feed Preferences" msgstr "" @@ -4579,6 +4671,7 @@ msgstr "" msgid "For the best experience, we recommend using the theme font." msgstr "" +#: src/components/ProgressGuide/FollowDialog.tsx:131 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:346 #: src/screens/Search/modules/ExploreSuggestedAccounts.tsx:88 msgid "For You" @@ -4708,6 +4801,7 @@ msgstr "" #: src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx:77 #: src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx:87 +#: src/screens/Messages/ConversationSettings.tsx:1017 msgid "Get started" msgstr "" @@ -4716,7 +4810,7 @@ msgstr "" msgid "GIF" msgstr "" -#: src/view/com/composer/Composer.tsx:2418 +#: src/view/com/composer/Composer.tsx:2408 msgid "GIF uploaded" msgstr "" @@ -4810,7 +4904,7 @@ msgid "Go to account settings" msgstr "" #. placeholder {0}: profile.handle -#: src/screens/Messages/components/ChatListItem.tsx:360 +#: src/screens/Messages/components/ChatListItem.tsx:182 msgid "Go to conversation with {0}" msgstr "" @@ -4823,11 +4917,16 @@ msgid "Go to next" msgstr "" #: src/components/dms/ConvoMenu.tsx:255 +#: src/screens/Messages/ConversationSettings.tsx:603 #: src/view/shell/desktop/LeftNav.tsx:319 #: src/view/shell/desktop/LeftNav.tsx:325 msgid "Go to profile" msgstr "" +#: src/screens/Messages/components/ChatListItem.tsx:231 +msgid "Go to the group chat named \"{chatName}\"" +msgstr "Go to the group chat named \"{chatName}\"" + #: src/components/dms/ConvoMenu.tsx:252 msgid "Go to user's profile" msgstr "" @@ -4856,8 +4955,24 @@ msgstr "" msgid "Grooming or predatory behavior" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:684 +msgctxt "toast" +msgid "Group chat muted" +msgstr "Group chat muted" + +#: src/Navigation.tsx:575 +#: src/screens/Messages/ConversationSettings.tsx:102 +msgid "Group chat settings" +msgstr "Group chat settings" + +#: src/screens/Messages/ConversationSettings.tsx:686 +msgctxt "toast" +msgid "Group chat unmuted" +msgstr "Group chat unmuted" + #: src/components/dms/InitiateChatFlow.tsx:227 #: src/components/dms/InitiateChatFlow.tsx:545 +#: src/screens/Messages/ConversationSettings.tsx:978 msgid "Group name" msgstr "Group name" @@ -4909,7 +5024,7 @@ msgstr "" msgid "Harming or endangering minors" msgstr "" -#: src/Navigation.tsx:559 +#: src/Navigation.tsx:560 msgid "Hashtag" msgstr "" @@ -5115,8 +5230,8 @@ msgstr "" msgid "Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!" msgstr "" -#: src/Navigation.tsx:813 -#: src/Navigation.tsx:833 +#: src/Navigation.tsx:819 +#: src/Navigation.tsx:839 #: src/view/shell/bottom-bar/BottomBar.tsx:179 #: src/view/shell/desktop/LeftNav.tsx:673 #: src/view/shell/Drawer.tsx:428 @@ -5242,16 +5357,26 @@ msgstr "" msgid "If you're trying to change your handle or email, do so before you deactivate." msgstr "" -#: src/components/images/Gallery.tsx:76 +#: src/components/images/ImageLayoutGridItem.tsx:76 msgid "Image" msgstr "" +#. placeholder {0}: index + 1 +#: src/components/images/Gallery/index.tsx:424 +msgid "Image {0}" +msgstr "Image {0}" + #. placeholder {0}: index + 1 #. placeholder {1}: imgs.length #: src/view/com/lightbox/Lightbox.web.tsx:248 msgid "Image {0} of {1}" msgstr "" +#. placeholder {0}: index + 1 +#: src/components/images/Gallery/index.tsx:415 +msgid "Image {0} of {imageCount}" +msgstr "Image {0} of {imageCount}" + #: src/screens/Settings/AboutSettings.tsx:63 msgid "Image cache cleared" msgstr "" @@ -5262,6 +5387,11 @@ msgstr "" msgid "Image cache cleared, freed {0}" msgstr "" +#. placeholder {0}: images.length +#: src/components/images/Gallery/index.tsx:252 +msgid "Image gallery, {0} images" +msgstr "Image gallery, {0} images" + #. Image has been moderated and user has the option of showing it temporarily #: src/features/liveNow/components/LiveStatusDialog.tsx:299 msgid "Image is hidden due to your moderation settings." @@ -5446,6 +5576,11 @@ msgstr "" msgid "Invite friends <0/>" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:801 +#: src/screens/Messages/ConversationSettings.tsx:1015 +msgid "Invite link" +msgstr "Invite link" + #: src/components/StarterPack/ShareDialog.tsx:83 msgid "Invite people to this starter pack!" msgstr "" @@ -5454,6 +5589,10 @@ msgstr "" msgid "Invite your friends to follow your favorite feeds and people" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:566 +msgid "Invited" +msgstr "Invited" + #: src/screens/StarterPack/Wizard/StepDetails.tsx:34 msgid "Invites, but personal" msgstr "" @@ -5481,7 +5620,7 @@ msgid "It's just you right now! Add more people to your starter pack by searchin msgstr "" #. placeholder {0}: videoState.jobId -#: src/view/com/composer/Composer.tsx:2333 +#: src/view/com/composer/Composer.tsx:2323 msgid "Job ID: {0}" msgstr "" @@ -5506,7 +5645,7 @@ msgstr "" msgid "Journalism" msgstr "" -#: src/view/com/composer/Composer.tsx:1281 +#: src/view/com/composer/Composer.tsx:1279 #: src/view/com/composer/drafts/DraftsButton.tsx:135 msgid "Keep editing" msgstr "" @@ -5549,7 +5688,7 @@ msgstr "" msgid "Labels on your content" msgstr "" -#: src/Navigation.tsx:222 +#: src/Navigation.tsx:223 msgid "Language Settings" msgstr "" @@ -5663,6 +5802,7 @@ msgid "Learn more." msgstr "" #: src/components/dms/LeaveConvoPrompt.tsx:52 +#: src/screens/Messages/ConversationSettings.tsx:829 msgid "Leave" msgstr "" @@ -5679,6 +5819,10 @@ msgstr "" msgid "Leave conversation" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:828 +msgid "Leave this group chat" +msgstr "Leave this group chat" + #: src/components/dialogs/LinkWarning.tsx:83 #: src/components/dialogs/LinkWarning.tsx:91 msgid "Leaving Bluesky" @@ -5731,7 +5875,7 @@ msgstr "" msgid "Like 10 posts to train the Discover feed" msgstr "" -#: src/Navigation.tsx:472 +#: src/Navigation.tsx:473 msgid "Like notifications" msgstr "" @@ -5743,8 +5887,8 @@ msgstr "" msgid "Like this labeler" msgstr "" -#: src/Navigation.tsx:300 -#: src/Navigation.tsx:305 +#: src/Navigation.tsx:301 +#: src/Navigation.tsx:306 msgid "Liked by" msgstr "" @@ -5781,11 +5925,11 @@ msgstr "" msgid "Likes of your reposts" msgstr "" -#: src/Navigation.tsx:496 +#: src/Navigation.tsx:497 msgid "Likes of your reposts notifications" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:482 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:486 msgid "Likes on this post" msgstr "" @@ -5794,7 +5938,7 @@ msgstr "" msgid "Linear" msgstr "" -#: src/Navigation.tsx:255 +#: src/Navigation.tsx:256 msgid "List" msgstr "" @@ -5880,7 +6024,7 @@ msgctxt "toast" msgid "List unmuted" msgstr "" -#: src/Navigation.tsx:176 +#: src/Navigation.tsx:177 #: src/view/screens/Lists.tsx:68 #: src/view/screens/Profile.tsx:234 #: src/view/screens/Profile.tsx:242 @@ -5969,7 +6113,31 @@ msgstr "" msgid "Loading..." msgstr "" -#: src/Navigation.tsx:325 +#: src/screens/Messages/ConversationSettings.tsx:936 +msgid "Loading…" +msgstr "Loading…" + +#: src/screens/Messages/ConversationSettings.tsx:811 +msgid "Lock" +msgstr "Lock" + +#: src/screens/Messages/ConversationSettings.tsx:1038 +msgid "Lock group chat" +msgstr "Lock group chat" + +#: src/screens/Messages/ConversationSettings.tsx:1036 +msgid "Lock group chat?" +msgstr "Lock group chat?" + +#: src/screens/Messages/ConversationSettings.tsx:809 +msgid "Lock this group chat" +msgstr "Lock this group chat" + +#: src/screens/Messages/ConversationSettings.tsx:811 +msgid "Locked" +msgstr "Locked" + +#: src/Navigation.tsx:326 msgid "Log" msgstr "" @@ -6075,7 +6243,15 @@ msgstr "" msgid "Media that may be disturbing or inappropriate for some audiences." msgstr "" -#: src/Navigation.tsx:456 +#: src/screens/Messages/ConversationSettings.tsx:224 +msgid "Members" +msgstr "Members" + +#: src/screens/Messages/ConversationSettings.tsx:1037 +msgid "Members can still read chat history but can’t send new messages." +msgstr "Members can still read chat history but can’t send new messages." + +#: src/Navigation.tsx:457 msgid "Mention notifications" msgstr "" @@ -6095,7 +6271,9 @@ msgid "Menu" msgstr "" #: src/screens/Messages/components/MessageComposer.tsx:198 -#: src/screens/Messages/components/MessageInput.tsx:174 +#: src/screens/Messages/components/MessageInput.tsx:173 +#: src/screens/Messages/components/MessageInput.web.tsx:212 +#: src/screens/Messages/ConversationSettings.tsx:611 msgid "Message" msgstr "Message" @@ -6104,7 +6282,11 @@ msgstr "Message" msgid "Message {0}" msgstr "" -#: src/screens/Messages/components/ChatListItem.tsx:196 +#: src/screens/Messages/ConversationSettings.tsx:608 +msgid "Message {displayName}" +msgstr "Message {displayName}" + +#: src/screens/Messages/components/ChatListItem.tsx:356 msgid "Message deleted" msgstr "" @@ -6113,6 +6295,10 @@ msgctxt "toast" msgid "Message deleted" msgstr "" +#: src/components/dms/MessageItem.tsx:474 +msgid "Message failed to send." +msgstr "Message failed to send." + #. placeholder {0}: sender?.handle ?? 'unknown' #. placeholder {1}: message.text #: src/components/dms/MessageContextMenu.tsx:131 @@ -6125,12 +6311,12 @@ msgid "Message from server: {0}" msgstr "" #: src/screens/Messages/components/MessageComposer.tsx:197 -#: src/screens/Messages/components/MessageInput.tsx:172 +#: src/screens/Messages/components/MessageInput.tsx:171 msgid "Message input field" msgstr "" -#: src/screens/Messages/components/MessageInput.tsx:85 -#: src/screens/Messages/components/MessageInput.web.tsx:60 +#: src/screens/Messages/components/MessageInput.tsx:84 +#: src/screens/Messages/components/MessageInput.web.tsx:59 msgid "Message is too long" msgstr "" @@ -6138,11 +6324,11 @@ msgstr "" msgid "Message is too long ({graphemeCount}/{MAX_DM_GRAPHEME_LENGTH})" msgstr "Message is too long ({graphemeCount}/{MAX_DM_GRAPHEME_LENGTH})" -#: src/components/dms/MessageContextMenu.tsx:129 +#: src/components/dms/MessageContextMenu.tsx:130 msgid "Message options" msgstr "" -#: src/Navigation.tsx:828 +#: src/Navigation.tsx:834 msgid "Messages" msgstr "" @@ -6155,7 +6341,7 @@ msgstr "" msgid "Minor harassment or bullying" msgstr "" -#: src/Navigation.tsx:520 +#: src/Navigation.tsx:521 msgid "Miscellaneous notifications" msgstr "" @@ -6167,7 +6353,7 @@ msgstr "" msgid "Missing media" msgstr "" -#: src/Navigation.tsx:181 +#: src/Navigation.tsx:182 #: src/screens/Moderation/index.tsx:102 msgid "Moderation" msgstr "" @@ -6211,7 +6397,7 @@ msgstr "" msgid "Moderation lists" msgstr "" -#: src/Navigation.tsx:186 +#: src/Navigation.tsx:187 #: src/view/screens/ModerationModlists.tsx:68 msgid "Moderation Lists" msgstr "" @@ -6220,7 +6406,7 @@ msgstr "" msgid "moderation settings" msgstr "" -#: src/Navigation.tsx:315 +#: src/Navigation.tsx:316 msgid "Moderation states" msgstr "" @@ -6266,6 +6452,10 @@ msgstr "" msgid "Music" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:787 +msgid "Mute" +msgstr "Mute" + #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:171 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx:97 msgctxt "video" @@ -6307,6 +6497,10 @@ msgstr "" msgid "Mute these accounts?" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:785 +msgid "Mute this group chat" +msgstr "Mute this group chat" + #: src/components/dialogs/MutedWords.tsx:194 msgid "Mute this word for 24 hours" msgstr "" @@ -6341,11 +6535,15 @@ msgstr "" msgid "Mute words & tags" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:787 +msgid "Muted" +msgstr "Muted" + #: src/screens/Moderation/index.tsx:323 msgid "Muted accounts" msgstr "" -#: src/Navigation.tsx:191 +#: src/Navigation.tsx:192 #: src/view/screens/ModerationMutedAccounts.tsx:116 msgid "Muted Accounts" msgstr "" @@ -6441,8 +6639,8 @@ msgstr "" msgid "New {postsCount, plural, one {post} other {posts}} from {firstAuthorName}" msgstr "" -#: src/components/dms/dialogs/NewChatDialog.tsx:79 -#: src/components/dms/dialogs/NewChatDialog.tsx:89 +#: src/components/dms/dialogs/NewChatDialog.tsx:98 +#: src/components/dms/dialogs/NewChatDialog.tsx:108 #: src/screens/Messages/ChatList.tsx:408 #: src/screens/Messages/ChatList.tsx:415 msgid "New chat" @@ -6459,7 +6657,7 @@ msgstr "" msgid "New Feature" msgstr "" -#: src/Navigation.tsx:488 +#: src/Navigation.tsx:489 msgid "New follower notifications" msgstr "" @@ -6638,7 +6836,7 @@ msgstr "" msgid "No media yet" msgstr "" -#: src/screens/Messages/components/ChatListItem.tsx:138 +#: src/screens/Messages/components/ChatListItem.tsx:300 msgid "No messages yet" msgstr "" @@ -6699,7 +6897,7 @@ msgstr "" #: src/components/dialogs/SearchablePeopleList.tsx:224 #: src/components/dms/InitiateChatFlow.tsx:334 -#: src/components/ProgressGuide/FollowDialog.tsx:209 +#: src/components/ProgressGuide/FollowDialog.tsx:221 msgid "No results" msgstr "" @@ -6788,7 +6986,7 @@ msgstr "" msgid "Not followed by anyone you're following" msgstr "" -#: src/Navigation.tsx:171 +#: src/Navigation.tsx:172 #: src/view/screens/Profile.tsx:133 msgid "Not Found" msgstr "" @@ -6818,8 +7016,8 @@ msgstr "" msgid "Nothing saved yet" msgstr "" -#: src/Navigation.tsx:442 -#: src/Navigation.tsx:589 +#: src/Navigation.tsx:443 +#: src/Navigation.tsx:595 #: src/view/screens/Notifications.tsx:136 msgid "Notification settings" msgstr "" @@ -6832,8 +7030,8 @@ msgstr "" msgid "Notification Sounds" msgstr "" -#: src/Navigation.tsx:584 -#: src/Navigation.tsx:823 +#: src/Navigation.tsx:590 +#: src/Navigation.tsx:829 #: src/screens/Notifications/ActivityList.tsx:31 #: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:93 #: src/screens/Settings/NotificationSettings/index.tsx:93 @@ -6863,10 +7061,6 @@ msgstr "" msgid "now" msgstr "" -#: src/components/dms/MessageItem.tsx:275 -msgid "Now" -msgstr "" - #: src/view/com/composer/labels/LabelsBtn.tsx:146 #: src/view/com/composer/labels/LabelsBtn.tsx:149 msgid "Nudity" @@ -6900,7 +7094,7 @@ msgstr "" #: src/components/BotAccountAlert.tsx:52 #: src/components/BotAccountAlert.tsx:57 #: src/screens/Login/PasswordUpdatedForm.tsx:37 -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:651 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:661 msgid "Okay" msgstr "" @@ -6923,11 +7117,11 @@ msgstr "" msgid "Onboarding reset" msgstr "" -#: src/view/com/composer/Composer.tsx:774 +#: src/view/com/composer/Composer.tsx:772 msgid "One or more GIFs is missing alt text." msgstr "" -#: src/view/com/composer/Composer.tsx:771 +#: src/view/com/composer/Composer.tsx:769 msgid "One or more images is missing alt text." msgstr "" @@ -6939,11 +7133,11 @@ msgstr "" msgid "One or more of your selected files are too large. Maximum size is 100 MB." msgstr "" -#: src/view/com/composer/Composer.tsx:575 +#: src/view/com/composer/Composer.tsx:574 msgid "One or more posts are too long to save as a draft. {MAX_DRAFT_GRAPHEME_LENGTH, plural, one {The maximum number of characters is # character.} other {The maximum number of characters is # characters.}}" msgstr "" -#: src/view/com/composer/Composer.tsx:781 +#: src/view/com/composer/Composer.tsx:779 msgid "One or more videos is missing alt text." msgstr "" @@ -6987,8 +7181,12 @@ msgstr "" msgid "Open camera" msgstr "" -#: src/screens/Messages/components/ChatListItem.tsx:370 -#: src/screens/Messages/components/ChatListItem.tsx:374 +#: src/screens/Messages/ConversationSettings.tsx:561 +msgid "Open chat member options for {displayName}" +msgstr "Open chat member options for {displayName}" + +#: src/screens/Messages/components/ChatListItem.tsx:514 +#: src/screens/Messages/components/ChatListItem.tsx:518 msgid "Open conversation options" msgstr "" @@ -7001,8 +7199,8 @@ msgid "Open drawer menu" msgstr "" #: src/screens/Messages/components/MessageComposer.tsx:177 -#: src/screens/Messages/components/MessageInput.web.tsx:180 -#: src/view/com/composer/Composer.tsx:1982 +#: src/screens/Messages/components/MessageInput.web.tsx:179 +#: src/view/com/composer/Composer.tsx:1972 msgid "Open emoji picker" msgstr "" @@ -7023,11 +7221,15 @@ msgstr "" msgid "Open Germ DM" msgstr "" +#: src/components/dms/MessagesListHeader.tsx:204 +msgid "Open group chat settings" +msgstr "Open group chat settings" + #: src/components/Post/Embed/ExternalEmbed/index.tsx:79 msgid "Open link to {niceUrl}" msgstr "" -#: src/components/dms/ActionsWrapper.tsx:35 +#: src/components/dms/ActionsWrapper.tsx:34 msgid "Open message options" msgstr "" @@ -7120,7 +7322,7 @@ msgstr "" msgid "Opens device gallery to select up to {MAX_IMAGES, plural, other {# images}}, or a single video or GIF." msgstr "" -#: src/view/com/composer/Composer.tsx:1983 +#: src/view/com/composer/Composer.tsx:1973 msgid "Opens emoji picker" msgstr "" @@ -7134,6 +7336,10 @@ msgstr "" msgid "Opens flow to sign in to your existing Bluesky account" msgstr "" +#: src/components/images/Gallery/index.tsx:425 +msgid "Opens full image" +msgstr "Opens full image" + #: src/view/com/composer/photos/SelectGifBtn.tsx:37 msgid "Opens GIF select dialog" msgstr "" @@ -7313,12 +7519,12 @@ msgid "People" msgstr "" #. placeholder {0}: route.params.name -#: src/Navigation.tsx:242 +#: src/Navigation.tsx:243 msgid "People followed by @{0}" msgstr "" #. placeholder {0}: route.params.name -#: src/Navigation.tsx:235 +#: src/Navigation.tsx:236 msgid "People following @{0}" msgstr "" @@ -7611,7 +7817,7 @@ msgstr "" msgid "Porn" msgstr "" -#: src/view/com/composer/Composer.tsx:1631 +#: src/view/com/composer/Composer.tsx:1621 msgctxt "action" msgid "Post" msgstr "" @@ -7631,7 +7837,7 @@ msgstr "" msgid "Post a video" msgstr "" -#: src/view/com/composer/Composer.tsx:1629 +#: src/view/com/composer/Composer.tsx:1619 msgctxt "action" msgid "Post All" msgstr "" @@ -7641,10 +7847,10 @@ msgid "Post blocked" msgstr "" #. placeholder {0}: route.params.name -#: src/Navigation.tsx:268 -#: src/Navigation.tsx:275 -#: src/Navigation.tsx:282 -#: src/Navigation.tsx:289 +#: src/Navigation.tsx:269 +#: src/Navigation.tsx:276 +#: src/Navigation.tsx:283 +#: src/Navigation.tsx:290 msgid "Post by @{0}" msgstr "" @@ -7657,9 +7863,9 @@ msgstr "" msgid "Post failed to upload. Please check your Internet connection and try again." msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:131 -#: src/screens/PostThread/components/ThreadItemPost.tsx:113 -#: src/screens/PostThread/components/ThreadItemTreePost.tsx:109 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:132 +#: src/screens/PostThread/components/ThreadItemPost.tsx:117 +#: src/screens/PostThread/components/ThreadItemTreePost.tsx:110 #: src/screens/VideoFeed/index.tsx:551 msgid "Post has been deleted" msgstr "" @@ -7678,7 +7884,7 @@ msgstr "" msgid "Post interaction settings" msgstr "" -#: src/Navigation.tsx:202 +#: src/Navigation.tsx:203 #: src/screens/ModerationInteractionSettings/index.tsx:35 msgid "Post Interaction Settings" msgstr "" @@ -7740,13 +7946,13 @@ msgstr "" msgid "Preferred language" msgstr "" -#: src/screens/Messages/components/MessageListError.tsx:19 +#: src/screens/Messages/components/MessageListError.tsx:18 msgid "Press to attempt reconnection" msgstr "" #: src/components/Error.tsx:61 #: src/components/Lists.tsx:104 -#: src/screens/Messages/components/MessageListError.tsx:24 +#: src/screens/Messages/components/MessageListError.tsx:23 #: src/screens/Signup/BackNextButtons.tsx:48 msgid "Press to retry" msgstr "" @@ -7778,8 +7984,8 @@ msgstr "" msgid "Privacy and security" msgstr "" -#: src/Navigation.tsx:419 -#: src/Navigation.tsx:427 +#: src/Navigation.tsx:420 +#: src/Navigation.tsx:428 #: src/screens/Settings/ActivityPrivacySettings.tsx:41 #: src/screens/Settings/PrivacyAndSecuritySettings.tsx:45 msgid "Privacy and Security" @@ -7792,7 +7998,7 @@ msgstr "" #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:36 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:103 -#: src/Navigation.tsx:335 +#: src/Navigation.tsx:336 #: src/screens/Settings/AboutSettings.tsx:91 #: src/screens/Settings/AboutSettings.tsx:94 #: src/view/screens/PrivacyPolicy.tsx:35 @@ -7805,11 +8011,11 @@ msgstr "" msgid "Privacy violation of a minor" msgstr "" -#: src/view/com/composer/Composer.tsx:2407 +#: src/view/com/composer/Composer.tsx:2397 msgid "Processing GIF..." msgstr "" -#: src/view/com/composer/Composer.tsx:2409 +#: src/view/com/composer/Composer.tsx:2399 msgid "Processing video..." msgstr "" @@ -7855,22 +8061,22 @@ msgid "Public, sharable lists of users to mute or block in bulk." msgstr "" #. Accessibility label for button to publish a single post -#: src/view/com/composer/Composer.tsx:1614 +#: src/view/com/composer/Composer.tsx:1605 msgid "Publish post" msgstr "" #. Accessibility label for button to publish multiple posts in a thread -#: src/view/com/composer/Composer.tsx:1607 +#: src/view/com/composer/Composer.tsx:1600 msgid "Publish posts" msgstr "" #. Accessibility label for button to publish multiple replies in a thread -#: src/view/com/composer/Composer.tsx:1592 +#: src/view/com/composer/Composer.tsx:1589 msgid "Publish replies" msgstr "" #. Accessibility label for button to publish a single reply -#: src/view/com/composer/Composer.tsx:1599 +#: src/view/com/composer/Composer.tsx:1594 msgid "Publish reply" msgstr "" @@ -7902,7 +8108,7 @@ msgstr "" msgid "QR code saved to your camera roll!" msgstr "" -#: src/Navigation.tsx:464 +#: src/Navigation.tsx:465 msgid "Quote notifications" msgstr "" @@ -7936,7 +8142,7 @@ msgstr "" msgid "Quotes" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:464 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:467 msgid "Quotes of this post" msgstr "" @@ -7957,6 +8163,11 @@ msgstr "" msgid "React with {emoji}" msgstr "" +#: src/components/dms/MessageItem.tsx:535 +#: src/components/dms/MessageItem.tsx:545 +msgid "Reactions" +msgstr "Reactions" + #: src/screens/Deactivated.tsx:133 msgid "Reactivate your account" msgstr "" @@ -8039,7 +8250,7 @@ msgstr "" msgid "Recommended" msgstr "" -#: src/screens/Messages/components/MessageListError.tsx:20 +#: src/screens/Messages/components/MessageListError.tsx:19 msgid "Reconnect" msgstr "" @@ -8080,6 +8291,10 @@ msgstr "Remove {displayName} from group chat" msgid "Remove {displayName} from starter pack" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:634 +msgid "Remove {displayName} from this group chat" +msgstr "Remove {displayName} from this group chat" + #: src/screens/Search/components/SearchHistory.tsx:105 msgid "Remove {historyItem}" msgstr "" @@ -8126,6 +8341,10 @@ msgstr "" msgid "Remove feed?" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:637 +msgid "Remove from chat" +msgstr "Remove from chat" + #: src/screens/Profile/components/ProfileFeedHeader.tsx:326 #: src/screens/Profile/components/ProfileFeedHeader.tsx:332 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:176 @@ -8202,11 +8421,11 @@ msgstr "" msgid "Remove your verification for this account?" msgstr "" -#: src/components/Post/Embed/index.tsx:214 +#: src/components/Post/Embed/index.tsx:215 msgid "Removed by author" msgstr "" -#: src/components/Post/Embed/index.tsx:212 +#: src/components/Post/Embed/index.tsx:213 msgid "Removed by you" msgstr "" @@ -8297,7 +8516,7 @@ msgstr "" msgid "Replies to this post are disabled." msgstr "" -#: src/view/com/composer/Composer.tsx:1627 +#: src/view/com/composer/Composer.tsx:1617 msgctxt "action" msgid "Reply" msgstr "" @@ -8318,7 +8537,7 @@ msgstr "" msgid "Reply Hidden by You" msgstr "" -#: src/Navigation.tsx:448 +#: src/Navigation.tsx:449 msgid "Reply notifications" msgstr "" @@ -8339,10 +8558,11 @@ msgstr "" msgid "Reply was successfully hidden" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:172 +#: src/components/dms/MessageContextMenu.tsx:173 #: src/components/dms/MessagesListBlockedFooter.tsx:86 #: src/components/dms/MessagesListBlockedFooter.tsx:93 #: src/features/liveNow/components/LiveStatusDialog.tsx:266 +#: src/screens/Messages/ConversationSettings.tsx:820 msgid "Report" msgstr "" @@ -8374,7 +8594,7 @@ msgstr "" msgid "Report list" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:170 +#: src/components/dms/MessageContextMenu.tsx:171 msgid "Report message" msgstr "" @@ -8401,6 +8621,10 @@ msgstr "" msgid "Report this feed" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:819 +msgid "Report this group chat" +msgstr "Report this group chat" + #: src/components/moderation/ReportDialog/copy.ts:31 msgid "Report this list" msgstr "" @@ -8440,7 +8664,7 @@ msgstr "" msgid "Repost ({0, plural, one {# repost} other {# reposts}})" msgstr "" -#: src/Navigation.tsx:480 +#: src/Navigation.tsx:481 msgid "Repost notifications" msgstr "" @@ -8471,7 +8695,7 @@ msgstr "" msgid "Reposts" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:444 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:446 msgid "Reposts of this post" msgstr "" @@ -8481,7 +8705,7 @@ msgstr "" msgid "Reposts of your reposts" msgstr "" -#: src/Navigation.tsx:504 +#: src/Navigation.tsx:505 msgid "Reposts of your reposts notifications" msgstr "" @@ -8572,7 +8796,6 @@ msgstr "" #: src/components/ageAssurance/AgeAssuranceErrors.tsx:31 #: src/components/contacts/screens/VerifyNumber.tsx:350 #: src/components/contacts/screens/VerifyNumber.tsx:355 -#: src/components/dms/MessageItem.tsx:322 #: src/components/Error.tsx:66 #: src/components/Lists.tsx:115 #: src/components/moderation/ReportDialog/index.tsx:299 @@ -8582,7 +8805,7 @@ msgstr "" #: src/screens/Login/LoginForm.tsx:329 #: src/screens/Login/LoginForm.tsx:335 #: src/screens/Messages/ChatList.tsx:297 -#: src/screens/Messages/components/MessageListError.tsx:25 +#: src/screens/Messages/components/MessageListError.tsx:24 #: src/screens/Messages/Inbox.tsx:220 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:265 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:268 @@ -8631,6 +8854,7 @@ msgstr "" #: src/components/StarterPack/QrCodeDialog.tsx:207 #: src/features/liveNow/components/EditLiveDialog.tsx:204 #: src/features/liveNow/components/EditLiveDialog.tsx:211 +#: src/screens/Messages/ConversationSettings.tsx:992 #: src/screens/Profile/Header/EditProfileDialog.tsx:233 #: src/screens/Profile/Header/EditProfileDialog.tsx:247 #: src/screens/SavedFeeds.tsx:132 @@ -8665,17 +8889,17 @@ msgstr "" msgid "Save changes" msgstr "" -#: src/view/com/composer/Composer.tsx:1239 +#: src/view/com/composer/Composer.tsx:1241 #: src/view/com/composer/drafts/DraftsButton.tsx:93 msgid "Save changes?" msgstr "" -#: src/view/com/composer/Composer.tsx:1270 +#: src/view/com/composer/Composer.tsx:1269 #: src/view/com/composer/drafts/DraftsButton.tsx:125 msgid "Save draft" msgstr "" -#: src/view/com/composer/Composer.tsx:1241 +#: src/view/com/composer/Composer.tsx:1243 #: src/view/com/composer/drafts/DraftsButton.tsx:95 msgid "Save draft?" msgstr "" @@ -8715,7 +8939,7 @@ msgid "Saved Feeds" msgstr "" #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:144 -#: src/Navigation.tsx:628 +#: src/Navigation.tsx:634 #: src/screens/Bookmarks/index.tsx:62 msgid "Saved Posts" msgstr "" @@ -8755,20 +8979,20 @@ msgstr "" #: src/components/dialogs/SearchablePeopleList.tsx:515 #: src/components/forms/SearchInput.tsx:51 #: src/components/forms/SearchInput.tsx:53 -#: src/screens/Search/Shell.tsx:354 -#: src/screens/Search/Shell.tsx:518 +#: src/screens/Search/Shell.tsx:356 +#: src/screens/Search/Shell.tsx:520 #: src/view/shell/bottom-bar/BottomBar.tsx:199 msgid "Search" msgstr "" #. placeholder {0}: profile.handle #. placeholder {0}: route.params.name -#: src/Navigation.tsx:261 +#: src/Navigation.tsx:262 #: src/screens/Profile/ProfileSearch.tsx:37 msgid "Search @{0}'s posts" msgstr "" -#: src/components/ProgressGuide/FollowDialog.tsx:685 +#: src/components/ProgressGuide/FollowDialog.tsx:700 msgid "Search by name or interest" msgstr "" @@ -8781,12 +9005,12 @@ msgid "Search feeds" msgstr "" #. Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is not currently active and can be selected. -#: src/components/ProgressGuide/FollowDialog.tsx:488 +#: src/components/ProgressGuide/FollowDialog.tsx:503 msgid "Search for \"{interestsDisplayName}\"" msgstr "" #. Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is currently selected. -#: src/components/ProgressGuide/FollowDialog.tsx:483 +#: src/components/ProgressGuide/FollowDialog.tsx:498 msgid "Search for \"{interestsDisplayName}\" (active)" msgstr "" @@ -8810,7 +9034,7 @@ msgstr "" msgid "Search for people" msgstr "Search for people" -#: src/screens/Search/Shell.tsx:380 +#: src/screens/Search/Shell.tsx:382 msgid "Search for posts, users, or feeds" msgstr "" @@ -8839,7 +9063,7 @@ msgstr "" #: src/components/dialogs/SearchablePeopleList.tsx:535 #: src/components/dms/InitiateChatFlow.tsx:1002 -#: src/components/ProgressGuide/FollowDialog.tsx:704 +#: src/components/ProgressGuide/FollowDialog.tsx:719 msgid "Search profiles" msgstr "" @@ -8853,7 +9077,7 @@ msgstr "" #: src/components/dialogs/SearchablePeopleList.tsx:536 #: src/components/dms/InitiateChatFlow.tsx:1003 -#: src/components/ProgressGuide/FollowDialog.tsx:705 +#: src/components/ProgressGuide/FollowDialog.tsx:720 msgid "Searches for profiles" msgstr "" @@ -9105,8 +9329,8 @@ msgid "Send feedback" msgstr "" #: src/screens/Messages/components/MessageComposer.tsx:264 -#: src/screens/Messages/components/MessageInput.tsx:228 -#: src/screens/Messages/components/MessageInput.web.tsx:234 +#: src/screens/Messages/components/MessageInput.tsx:227 +#: src/screens/Messages/components/MessageInput.web.tsx:233 msgid "Send message" msgstr "" @@ -9176,7 +9400,7 @@ msgstr "" msgid "Sets email for password reset" msgstr "" -#: src/Navigation.tsx:217 +#: src/Navigation.tsx:218 #: src/screens/Settings/Settings.tsx:98 #: src/view/shell/desktop/LeftNav.tsx:806 #: src/view/shell/Drawer.tsx:597 @@ -9321,7 +9545,7 @@ msgstr "" msgid "Share your favorite feed!" msgstr "" -#: src/Navigation.tsx:320 +#: src/Navigation.tsx:321 msgid "Shared Preferences Tester" msgstr "" @@ -9440,7 +9664,7 @@ msgstr "" msgid "Show when you’re live" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:592 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:602 msgid "Shows information about when this post was created" msgstr "" @@ -9567,6 +9791,10 @@ msgstr "" msgid "Skip to next step" msgstr "" +#: src/components/images/Gallery/index.tsx:414 +msgid "slide" +msgstr "slide" + #: src/screens/Settings/AppearanceSettings.tsx:152 msgid "Smaller" msgstr "" @@ -9604,13 +9832,13 @@ msgid "Some people can reply" msgstr "" #. placeholder {0}: reaction.value -#: src/components/dms/MessageItem.tsx:148 +#: src/components/dms/MessageItem.tsx:239 msgid "Someone reacted {0}" msgstr "" #. placeholder {0}: convo.lastReaction.reaction.value #. placeholder {1}: lastMessageText ? `"${convo.lastReaction.message.text}"` : fallbackMessage -#: src/screens/Messages/components/ChatListItem.tsx:240 +#: src/screens/Messages/components/ChatListItem.tsx:393 msgid "Someone reacted {0} to {1}" msgstr "" @@ -9618,7 +9846,8 @@ msgstr "" msgid "Something wasn't quite right with the data you're trying to report. Please contact support." msgstr "" -#: src/screens/Messages/Conversation.tsx:144 +#: src/screens/Messages/Conversation.tsx:153 +#: src/screens/Messages/ConversationSettings.tsx:177 msgid "Something went wrong" msgstr "" @@ -9694,7 +9923,7 @@ msgstr "" msgid "Start a conversation, and it will appear here." msgstr "" -#: src/components/dms/dialogs/NewChatDialog.tsx:95 +#: src/components/dms/dialogs/NewChatDialog.tsx:114 msgid "Start a new chat" msgstr "" @@ -9717,8 +9946,8 @@ msgstr "Start chat" msgid "Start chat with {displayName}" msgstr "" -#: src/Navigation.tsx:599 -#: src/Navigation.tsx:604 +#: src/Navigation.tsx:605 +#: src/Navigation.tsx:610 #: src/screens/StarterPack/Wizard/index.tsx:208 msgid "Starter Pack" msgstr "" @@ -9780,7 +10009,7 @@ msgstr "" msgid "Stored as part of a secure code for matching with others" msgstr "" -#: src/Navigation.tsx:310 +#: src/Navigation.tsx:311 #: src/screens/Settings/Settings.tsx:456 msgid "Storybook" msgstr "" @@ -9882,7 +10111,7 @@ msgctxt "Name of app icon variant" msgid "Sunset" msgstr "" -#: src/Navigation.tsx:330 +#: src/Navigation.tsx:331 #: src/view/screens/Support.tsx:35 #: src/view/screens/Support.tsx:38 msgid "Support" @@ -9958,6 +10187,23 @@ msgstr "" msgid "Tap to dismiss" msgstr "" +#: src/components/dms/MessageItem.tsx:484 +msgid "Tap to retry" +msgstr "Tap to retry" + +#. placeholder {0}: reaction.value +#: src/components/dms/MessageItem.tsx:692 +msgid "Tap to show {0} reactions" +msgstr "Tap to show {0} reactions" + +#: src/components/dms/MessageItem.tsx:691 +msgid "Tap to show all reactions " +msgstr "Tap to show all reactions " + +#: src/components/dms/MessageItem.tsx:262 +msgid "Tap to view reactions" +msgstr "Tap to view reactions" + #: src/components/moderation/ReportDialog/utils/useReportOptions.ts:107 msgid "Targeted harassment" msgstr "" @@ -9998,7 +10244,7 @@ msgstr "" #: src/components/dialogs/BirthDateSettings.tsx:181 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:31 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:98 -#: src/Navigation.tsx:340 +#: src/Navigation.tsx:341 #: src/screens/Settings/AboutSettings.tsx:83 #: src/screens/Settings/AboutSettings.tsx:86 #: src/view/screens/TermsOfService.tsx:35 @@ -10240,6 +10486,9 @@ msgstr "" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:431 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:454 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:474 +#: src/screens/Messages/ConversationSettings.tsx:520 +#: src/screens/Messages/ConversationSettings.tsx:533 +#: src/screens/Messages/ConversationSettings.tsx:713 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:117 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:130 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:93 @@ -10332,7 +10581,7 @@ msgstr "" msgid "This button lets others open the Germ DM app to send you a message. You can manage its visibility from the Germ DM app, or you can disconnect your Bluesky account from Germ DM altogether by clicking the button below." msgstr "" -#: src/screens/Messages/components/MessageListError.tsx:18 +#: src/screens/Messages/components/MessageListError.tsx:17 msgid "This chat was disconnected" msgstr "" @@ -10366,7 +10615,7 @@ msgstr "" msgid "This content is not viewable without a Bluesky account." msgstr "" -#: src/screens/Messages/components/ChatListItem.tsx:362 +#: src/screens/Messages/components/ChatListItem.tsx:183 msgid "This conversation is with a deleted or a deactivated account. Press for options" msgstr "" @@ -10472,7 +10721,7 @@ msgstr "" #. placeholder {0}: niceDate(i18n, createdAt) #. placeholder {1}: niceDate(i18n, indexedAt) -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:632 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:642 msgid "This post claims to have been created on <0>{0}, but was first seen by Bluesky on <1>{1}." msgstr "" @@ -10492,7 +10741,7 @@ msgstr "" msgid "This post will be hidden from feeds and threads. This cannot be undone." msgstr "" -#: src/view/com/composer/Composer.tsx:902 +#: src/view/com/composer/Composer.tsx:898 msgid "This post's author has disabled quote posts." msgstr "" @@ -10600,7 +10849,7 @@ msgstr "" msgid "Threaded" msgstr "" -#: src/Navigation.tsx:373 +#: src/Navigation.tsx:374 msgid "Threads Preferences" msgstr "" @@ -10631,7 +10880,7 @@ msgstr "To log out, <0>click here. Or if you’d prefer, you can <1>delete y msgid "To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue." msgstr "" -#: src/components/dms/DateDivider.tsx:45 +#: src/components/dms/DateDivider.tsx:43 msgid "Today" msgstr "" @@ -10668,12 +10917,12 @@ msgstr "" msgid "Top replies first" msgstr "" -#: src/Navigation.tsx:564 +#: src/Navigation.tsx:565 msgid "Topic" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:143 -#: src/components/dms/MessageContextMenu.tsx:145 +#: src/components/dms/MessageContextMenu.tsx:144 +#: src/components/dms/MessageContextMenu.tsx:146 #: src/components/Post/Translated/index.tsx:150 #: src/components/Post/Translated/index.tsx:157 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:553 @@ -10746,7 +10995,7 @@ msgstr "" msgid "Two-factor authentication (2FA)" msgstr "" -#: src/screens/Messages/components/MessageInput.tsx:173 +#: src/screens/Messages/components/MessageInput.tsx:172 msgid "Type your message here" msgstr "" @@ -10818,6 +11067,10 @@ msgctxt "action" msgid "Unblock" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:622 +msgid "Unblock {displayName}" +msgstr "Unblock {displayName}" + #: src/components/dms/ConvoMenu.tsx:275 #: src/components/dms/ConvoMenu.tsx:278 #: src/view/com/profile/ProfileMenu.tsx:468 @@ -10889,6 +11142,14 @@ msgstr "" msgid "Unfortunately, your declared age indicates that you are not old enough to access Bluesky in your region." msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:647 +msgid "Uninvite" +msgstr "Uninvite" + +#: src/screens/Messages/ConversationSettings.tsx:644 +msgid "Uninvite {displayName} from this group chat" +msgstr "Uninvite {displayName} from this group chat" + #: src/components/verification/VerificationsDialog.tsx:209 msgid "Unknown verifier" msgstr "" @@ -10911,6 +11172,10 @@ msgstr "" msgid "Unlike ({0, plural, one {# like} other {# likes}})" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:809 +msgid "Unlock this group chat" +msgstr "Unlock this group chat" + #: src/screens/ProfileList/components/Header.tsx:180 #: src/screens/ProfileList/components/Header.tsx:187 msgid "Unmute" @@ -10944,6 +11209,10 @@ msgstr "" msgid "Unmute list" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:785 +msgid "Unmute this group chat" +msgstr "Unmute this group chat" + #: src/components/PostControls/PostMenu/PostMenuItems.tsx:621 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:624 msgid "Unmute thread" @@ -11019,7 +11288,7 @@ msgstr "" msgid "Unsupported clipboard content" msgstr "Unsupported clipboard content" -#: src/view/com/composer/Composer.tsx:1372 +#: src/view/com/composer/Composer.tsx:1370 msgid "Unsupported video type: {mimeType}" msgstr "" @@ -11085,7 +11354,7 @@ msgstr "" msgid "Upload from Library" msgstr "" -#: src/view/com/composer/Composer.tsx:2400 +#: src/view/com/composer/Composer.tsx:2390 msgid "Uploading GIF..." msgstr "" @@ -11098,7 +11367,7 @@ msgstr "" msgid "Uploading link thumbnail..." msgstr "" -#: src/view/com/composer/Composer.tsx:2402 +#: src/view/com/composer/Composer.tsx:2392 msgid "Uploading video..." msgstr "" @@ -11235,7 +11504,7 @@ msgstr "" msgid "Verification settings" msgstr "" -#: src/Navigation.tsx:210 +#: src/Navigation.tsx:211 #: src/screens/Moderation/VerificationSettings.tsx:34 msgid "Verification Settings" msgstr "" @@ -11342,7 +11611,7 @@ msgstr "" msgid "Video failed to process" msgstr "" -#: src/Navigation.tsx:620 +#: src/Navigation.tsx:626 msgid "Video Feed" msgstr "" @@ -11377,7 +11646,7 @@ msgstr "" msgid "Video settings" msgstr "" -#: src/view/com/composer/Composer.tsx:2420 +#: src/view/com/composer/Composer.tsx:2410 msgid "Video uploaded" msgstr "" @@ -11394,7 +11663,7 @@ msgstr "" msgid "Videos must be less than 3 minutes long." msgstr "" -#: src/view/com/composer/Composer.tsx:994 +#: src/view/com/composer/Composer.tsx:990 msgctxt "Action to view the post the user just created" msgid "View" msgstr "" @@ -11419,10 +11688,14 @@ msgstr "" msgid "View {0}’s profile" msgstr "View {0}’s profile" -#: src/components/dms/MessagesListHeader.tsx:138 +#: src/components/dms/MessagesListHeader.tsx:164 msgid "View {displayName}'s profile" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:598 +msgid "View {displayName}’s profile" +msgstr "View {displayName}’s profile" + #: src/components/ProfileHoverCard/index.web.tsx:479 msgid "View blocked user's profile" msgstr "" @@ -11444,6 +11717,10 @@ msgstr "" msgid "View full thread" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:235 +msgid "View incoming group chat requests" +msgstr "View incoming group chat requests" + #: src/components/moderation/LabelsOnMe.tsx:56 msgid "View information about these labels" msgstr "" @@ -11459,7 +11736,7 @@ msgstr "" msgid "View more trending videos" msgstr "" -#: src/view/com/composer/Composer.tsx:989 +#: src/view/com/composer/Composer.tsx:985 msgid "View post" msgstr "" @@ -11591,10 +11868,14 @@ msgstr "" msgid "We couldn't find any results for that topic." msgstr "" -#: src/screens/Messages/Conversation.tsx:145 +#: src/screens/Messages/Conversation.tsx:154 msgid "We couldn't load this conversation" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:178 +msgid "We couldn’t load this conversation’s settings" +msgstr "We couldn’t load this conversation’s settings" + #: src/components/contacts/screens/GetContacts.tsx:244 msgid "We delete hashes after matches are made" msgstr "" @@ -11697,7 +11978,7 @@ msgid "We're having issues initializing the age assurance process for your accou msgstr "" #: src/components/dialogs/SearchablePeopleList.tsx:108 -#: src/components/ProgressGuide/FollowDialog.tsx:183 +#: src/components/ProgressGuide/FollowDialog.tsx:195 msgid "We're having network issues, try again" msgstr "" @@ -11735,7 +12016,7 @@ msgstr "We’re sorry, but your search could not be completed. Please try again msgid "We're sorry, you cannot access this screen at this time." msgstr "" -#: src/view/com/composer/Composer.tsx:899 +#: src/view/com/composer/Composer.tsx:896 msgid "We're sorry! The post you are replying to has been deleted." msgstr "" @@ -11786,7 +12067,7 @@ msgid "What do you want to call your starter pack?" msgstr "" #: src/view/com/auth/SplashScreen.web.tsx:104 -#: src/view/com/composer/Composer.tsx:1332 +#: src/view/com/composer/Composer.tsx:1330 #: src/view/com/feeds/ComposerPrompt.tsx:193 msgid "What's up?" msgstr "" @@ -11863,25 +12144,21 @@ msgstr "" msgid "Would you like to save this as a draft before viewing your drafts?" msgstr "" -#: src/view/com/composer/Composer.tsx:1255 +#: src/view/com/composer/Composer.tsx:1257 msgid "Would you like to save this as a draft to edit later?" msgstr "" -#: src/screens/Messages/components/MessageInput.web.tsx:213 -msgid "Write a message" -msgstr "" - #: src/view/screens/Profile.tsx:436 #: src/view/screens/Profile.tsx:437 msgid "Write a post" msgstr "" -#: src/view/com/composer/Composer.tsx:1432 +#: src/view/com/composer/Composer.tsx:1430 msgid "Write post" msgstr "" #: src/screens/PostThread/components/ThreadComposePrompt.tsx:91 -#: src/view/com/composer/Composer.tsx:1330 +#: src/view/com/composer/Composer.tsx:1328 msgid "Write your reply" msgstr "" @@ -11928,7 +12205,7 @@ msgstr "" msgid "Yes, reactivate my account" msgstr "" -#: src/components/dms/DateDivider.tsx:47 +#: src/components/dms/DateDivider.tsx:45 msgid "Yesterday" msgstr "" @@ -12028,7 +12305,7 @@ msgstr "" msgid "You can now sign in with your new password." msgstr "" -#: src/view/com/composer/Composer.tsx:1260 +#: src/view/com/composer/Composer.tsx:1262 msgid "You can only save drafts up to 1000 characters." msgstr "" @@ -12167,7 +12444,7 @@ msgstr "" msgid "You have temporarily reached the limit for video uploads. Please try again later." msgstr "" -#: src/view/com/composer/Composer.tsx:1250 +#: src/view/com/composer/Composer.tsx:1252 msgid "You have unsaved changes to this draft, would you like to save them?" msgstr "" @@ -12255,13 +12532,13 @@ msgid "You probably want to restart the app now." msgstr "" #. placeholder {0}: reaction.value -#: src/components/dms/MessageItem.tsx:135 +#: src/components/dms/MessageItem.tsx:230 msgid "You reacted {0}" msgstr "" #. placeholder {0}: convo.lastReaction.reaction.value #. placeholder {1}: lastMessageText ? `"${convo.lastReaction.message.text}"` : fallbackMessage -#: src/screens/Messages/components/ChatListItem.tsx:217 +#: src/screens/Messages/components/ChatListItem.tsx:374 msgid "You reacted {0} to {1}" msgstr "" @@ -12293,15 +12570,15 @@ msgid "You will receive an email with a \"reset code.\" Enter that code here, th msgstr "" #. placeholder {0}: convo.lastMessage.text -#: src/screens/Messages/components/ChatListItem.tsx:153 +#: src/screens/Messages/components/ChatListItem.tsx:315 msgid "You: {0}" msgstr "" -#: src/screens/Messages/components/ChatListItem.tsx:182 +#: src/screens/Messages/components/ChatListItem.tsx:342 msgid "You: {defaultEmbeddedContentMessage}" msgstr "" -#: src/screens/Messages/components/ChatListItem.tsx:175 +#: src/screens/Messages/components/ChatListItem.tsx:335 msgid "You: {short}" msgstr "" @@ -12492,7 +12769,7 @@ msgstr "" msgid "Your full handle will be <0>@{0}" msgstr "" -#: src/Navigation.tsx:536 +#: src/Navigation.tsx:537 #: src/screens/Search/modules/ExploreInterestsCard.tsx:68 #: src/screens/Settings/ContentAndMediaSettings.tsx:94 #: src/screens/Settings/ContentAndMediaSettings.tsx:97 @@ -12529,11 +12806,11 @@ msgstr "" msgid "Your password must be at least 8 characters long." msgstr "" -#: src/view/com/composer/Composer.tsx:985 +#: src/view/com/composer/Composer.tsx:981 msgid "Your post was sent" msgstr "" -#: src/view/com/composer/Composer.tsx:982 +#: src/view/com/composer/Composer.tsx:978 msgid "Your posts were sent" msgstr "" @@ -12554,7 +12831,7 @@ msgstr "" msgid "Your profile, posts, feeds, and lists will no longer be visible to other Bluesky users. You can reactivate your account at any time by logging in." msgstr "" -#: src/view/com/composer/Composer.tsx:984 +#: src/view/com/composer/Composer.tsx:980 msgid "Your reply was sent" msgstr "" diff --git a/src/routes.ts b/src/routes.ts index 75eda461c8..387b1ca410 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -85,6 +85,7 @@ export const router = new Router({ MessagesSettings: '/messages/settings', MessagesInbox: '/messages/inbox', MessagesConversation: '/messages/:conversation', + MessagesConversationSettings: '/messages/:conversation/settings', // starter packs Start: '/start/:name/:rkey', StarterPackEdit: '/starter-pack/edit/:rkey', diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 509907b331..785ce04195 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -1,11 +1,15 @@ import {useCallback, useEffect, useMemo, useState} from 'react' -import {View} from 'react-native' +import {type LayoutChangeEvent, View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import { type AppBskyActorDefs, moderateProfile, type ModerationDecision, } from '@atproto/api' -import {ScrollEdgeEffectProvider} from '@bsky.app/expo-scroll-edge-effect' +import { + ScrollEdgeEffect, + ScrollEdgeEffectProvider, +} from '@bsky.app/expo-scroll-edge-effect' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' @@ -45,7 +49,7 @@ import {MessagesListHeader} from '#/components/dms/MessagesListHeader' import {Error} from '#/components/Error' import * as Layout from '#/components/Layout' import {Loader} from '#/components/Loader' -import {IS_WEB} from '#/env' +import {IS_LIQUID_GLASS, IS_WEB} from '#/env' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -83,7 +87,10 @@ export function MessagesConversationScreenInner({route}: Props) { ) return ( - + @@ -98,10 +105,11 @@ function Inner() { const convoState = useConvo() const {_} = useLingui() const isFocused = useIsFocused() + const {top: topInset} = useSafeAreaInsets() const moderationOpts = useModerationOpts() const {data: recipientUnshadowed} = useProfileQuery({ - did: convoState.recipients?.[0].did, + did: convoState.getPrimaryMember?.()?.did, }) const recipient = useMaybeProfileShadow(recipientUnshadowed) @@ -133,9 +141,10 @@ function Inner() { if (convoState.status === ConvoStatus.Error) { return ( <> - + {moderation ? ( - + ) : ( )} @@ -154,12 +163,15 @@ function Inner() { {/* MessagesList does not use the body scroll */} {isFocused && IS_WEB && } - {!readyToShow && - (moderation ? ( - - ) : ( - - ))} + {!readyToShow && ( + + {moderation ? ( + + ) : ( + + )} + + )} {moderation && recipient ? ( () + const {top: topInset} = useSafeAreaInsets() + const [headerHeight, setHeaderHeight] = useState(0) + const onHeaderLayout = (e: LayoutChangeEvent) => { + setHeaderHeight(e.nativeEvent.layout.height) + } const {params} = useRoute>() const {needsEmailVerification} = useEmail() @@ -248,15 +265,29 @@ function InnerReady({ maybeBlockForEmailVerification() }, [maybeBlockForEmailVerification]) + const header = ( + + ) + return ( <> - + {IS_LIQUID_GLASS ? ( + + {header} + + ) : ( + header + )} {isConvoActive(convoState) && ( + status: 'owner' | 'member' | 'invited' + } + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'MessagesConversationSettings' +> + +/** + * TODO This is just layout for now. + */ +export function MessagesConversationSettingsScreen({route}: Props) { + const {gtTablet} = useBreakpoints() + + const convoId = route.params.conversation + + return ( + + + + + + Group chat settings + + + + + + + + + ) +} + +function keyExtractor(item: Item) { + return item.type === 'CHAT_MEMBER' ? item.profile.did : item.type +} + +function SettingsInner() { + const {t: l} = useLingui() + + const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) + const bottomBarOffset = useBottomBarOffset() + + const convoState = useConvo() + const {currentAccount} = useSession() + const primaryMember = convoState?.getPrimaryMember?.() + + const data: bsky.profile.AnyProfileView[] = convoState.convo?.members ?? [] + const invites: string[] = [] + + const items = [ + { + type: 'MEMBERS_AND_REQUESTS', + }, + { + type: 'ADD_MEMBERS_LINK', + }, + ...[...data] + .sort((a, b) => { + const aIsAdmin = a.did === primaryMember?.did + const bIsAdmin = b.did === primaryMember?.did + const aIsSelf = a.did === currentAccount?.did + const bIsSelf = b.did === currentAccount?.did + if (aIsAdmin !== bIsAdmin) return aIsAdmin ? -1 : 1 + if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 + return 0 + }) + .map(profile => ({ + type: 'CHAT_MEMBER', + profile, + status: + primaryMember?.did === profile.did + ? 'owner' + : invites.includes(profile.did) + ? 'invited' + : 'member', + })), + ] + + function renderItem({item}: {item: Item}) { + switch (item.type) { + case 'MEMBERS_AND_REQUESTS': + return + case 'ADD_MEMBERS_LINK': + return + case 'CHAT_MEMBER': + return + default: + return null + } + } + + if (convoState.status === ConvoStatus.Error) { + return ( + <> + convoState.error.retry()} + sideBorders={false} + /> + + ) + } + + return ( + + ) : ( + + ) + } + renderItem={renderItem} + sideBorders={false} + windowSize={11} + onEndReachedThreshold={IS_NATIVE ? 1.5 : 0} + /> + ) +} + +function MembersAndRequests({ + memberCount, + requestCount, +}: { + memberCount: number + requestCount: number +}) { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + Members{' '} + + {l`${memberCount}/${MEMBER_LIMIT}`} + + {requestCount > 0 ? ( + + {l`${plural(requestCount, { + one: '# request', + other: '# requests', + })}`} + + ) : null} + + ) +} + +function AddMembersLink() { + const t = useTheme() + + return ( + + + [ + a.flex_row, + a.align_center, + a.justify_between, + pressed && web({outline: 'none'}), + ]}> + {({pressed}) => ( + <> + + + + + + + Add members + + + + + + )} + + + + ) +} + +function Member({ + profile, + status, +}: { + profile: Shadow + status: 'owner' | 'member' | 'invited' +}) { + const navigation = useNavigation() + const t = useTheme() + const {t: l} = useLingui() + + const {currentAccount} = useSession() + const moderationOpts = useModerationOpts() + const moderation = useMemo( + () => + moderationOpts ? moderateProfile(profile, moderationOpts) : undefined, + [profile, moderationOpts], + ) + + if (!moderation) return null + + const isDeletedAccount = profile.handle === 'missing.invalid' + const displayName = isDeletedAccount + ? l`Deleted Account` + : sanitizeDisplayName( + profile.displayName || profile.handle, + moderation.ui('displayName'), + ) + + let statusBadge: React.ReactNode | null = null + if (currentAccount?.did === profile.did) { + switch (status) { + case 'owner': + statusBadge = + break + } + } else { + statusBadge = + } + + return ( + + { + navigation.navigate('Profile', {name: profile.did}) + }}> + + + + + + {displayName} + + + {sanitizeHandle(profile.handle, '@')} + + + + {statusBadge} + + + + ) +} + +function StatusBadge({ + label, + style, +}: { + label: string + style?: StyleProp +}) { + const t = useTheme() + + return ( + + + {label} + + + ) +} + +function StatusButton({ + label, + style, + ...rest +}: { + label: string + style?: StyleProp +} & TriggerChildProps['props']) { + const t = useTheme() + + return ( + + + {label} + + + ) +} + +function MemberMenu({ + profile, + type, +}: { + profile: Shadow + type: 'owner' | 'member' | 'invited' +}) { + const navigation = useNavigation() + const t = useTheme() + const {t: l} = useLingui() + const ax = useAnalytics() + + const requireEmailVerification = useRequireEmailVerification() + const convoState = useConvo() + const {currentAccount} = useSession() + + const blockMemberPrompt = Prompt.usePromptControl() + + const isOwner = + currentAccount?.did == null + ? false + : convoState.getPrimaryMember?.()?.did === currentAccount.did + + const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) + const {mutate: initiateConvo} = useGetConvoForMembers({ + onSuccess: ({convo}) => { + ax.metric('chat:open', {logContext: 'ProfileHeader'}) + navigation.navigate('MessagesConversation', {conversation: convo.id}) + }, + onError: () => { + Toast.show(l`Failed to create conversation`) + }, + }) + const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) + + const messageMember = () => { + if (!convoAvailability?.canChat) { + return + } + + if (convoAvailability.convo) { + ax.metric('chat:open', {logContext: 'ProfileHeader'}) + navigation.navigate('MessagesConversation', { + conversation: convoAvailability.convo.id, + }) + } else { + ax.metric('chat:create', {logContext: 'ProfileHeader'}) + initiateConvo([profile.did]) + } + } + + const handleMessageMember = requireEmailVerification(messageMember, { + instructions: [ + + Before you can message another user, you must first verify your email. + , + ], + }) + + const handleBlockMember = async () => { + if (profile.viewer?.blocking) { + try { + await queueUnblock() + Toast.show(l({message: 'Account unblocked', context: 'toast'})) + } catch (err) { + const e = err as Error + if (e?.name !== 'AbortError') { + ax.logger.error('Failed to unblock account', {message: e}) + Toast.show(l`There was an issue! ${e.toString()}`, { + type: 'error', + }) + } + } + } else { + try { + await queueBlock() + Toast.show(l({message: 'Account blocked', context: 'toast'})) + } catch (err) { + const e = err as Error + if (e?.name !== 'AbortError') { + ax.logger.error('Failed to block account', {message: e}) + Toast.show(l`There was an issue! ${e.toString()}`, { + type: 'error', + }) + } + } + } + } + + const moderationOpts = useModerationOpts() + const moderation = useMemo( + () => + moderationOpts ? moderateProfile(profile, moderationOpts) : undefined, + [profile, moderationOpts], + ) + + if (!moderation) return null + + const isDeletedAccount = profile.handle === 'missing.invalid' + const displayName = isDeletedAccount + ? l`Deleted Account` + : sanitizeDisplayName( + profile.displayName || profile.handle, + moderation.ui('displayName'), + ) + + return ( + <> + + + {({props, state, control: menuControl}) => + type === 'owner' || type === 'invited' ? ( + + ) : ( + + + + ) + } + + + + { + navigation.navigate('Profile', {name: profile.did}) + }}> + + Go to profile + + + + + + Message + + + + + + + {type === 'owner' || type === 'member' ? ( + blockMemberPrompt.open()}> + + Block + + + + ) : null} + {isOwner ? ( + {}}> + + Remove from chat + + + + ) : null} + {isOwner && type === 'invited' ? ( + {}}> + + Uninvite + + + + ) : null} + + + + void handleBlockMember()} + /> + + ) +} + +function SettingsHeader({ + convo, + profiles, +}: { + convo: ChatBskyConvoDefs.ConvoView + profiles: bsky.profile.AnyProfileView[] +}) { + const t = useTheme() + const {t: l} = useLingui() + + const convoState = useConvo() + const {currentAccount} = useSession() + + const isOwner = + currentAccount?.did == null + ? false + : convoState.getPrimaryMember?.()?.did === currentAccount.did + + const {mutate: muteConvo} = useMuteConvo(convo.id, { + onSuccess: data => { + if (data.convo.muted) { + Toast.show(l({message: 'Group chat muted', context: 'toast'})) + } else { + Toast.show(l({message: 'Group chat unmuted', context: 'toast'})) + } + }, + onError: () => { + Toast.show(l`Could not mute group chat`, { + type: 'error', + }) + }, + }) + + const editNamePrompt = Prompt.usePromptControl() + const inviteLinkPrompt = Prompt.usePromptControl() + const lockChatPrompt = Prompt.usePromptControl() + + const [groupName, setGroupName] = useState( + convoState.getGroupInfo?.()?.name ?? '', + ) + const [newGroupName, setNewGroupName] = useState(groupName) + + const [isLocked, setIsLocked] = useState(false) + + const handleToggleMute = () => { + try { + muteConvo({mute: !convo?.muted}) + } catch (err) { + const e = err as Error + logger.error('Failed to mute group chat', {message: e}) + Toast.show(l`There was an issue! ${e.toString()}`, {type: 'error'}) + } + } + + const handlePromptName = () => { + editNamePrompt.open() + } + + const handleEditName = () => { + setGroupName(newGroupName) + editNamePrompt.close() + } + + const handlePromptInviteLink = () => { + inviteLinkPrompt.open() + } + + const handleConfirmInviteLink = () => { + inviteLinkPrompt.close() + } + + const handlePromptLock = () => { + lockChatPrompt.open() + } + + const handleConfirmLock = () => { + setIsLocked(true) + } + + const handleUnlock = () => { + setIsLocked(false) + } + + return ( + <> + + + + + + {groupName} + + + Created April 2, 2026 + + + + {isOwner ? ( + + ) : null} + + {isOwner ? ( + + ) : null} + {isOwner ? null : ( + {}} + /> + )} + {isOwner ? null : ( + {}} + /> + )} + + + + + + + ) +} + +function SettingsHeaderPlaceholder() { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + + + {l`…`} + + + + + + + + + + + + ) +} + +function SettingsButton({ + color = 'secondary', + icon, + label, + text, + onPress, +}: { + color?: ButtonColor + icon: React.ComponentType + label: string + text: string + onPress: () => void +}) { + const t = useTheme() + + return ( + + + + {text} + + + ) +} + +function SettingsButtonPlaceholder() { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + {l`…`} + + + ) +} + +function EditNamePrompt({ + control, + value, + onChangeText, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + value: string + onChangeText: (value: string) => void + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + <> + + + Edit group name + + + + + + + + + + + + + + ) +} + +function InviteLinkPrompt({ + control, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} + +function LockChatPrompt({ + control, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} + +function BlockMemberPrompt({ + control, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} + +function SubtleHoverWrapper({children}: React.PropsWithChildren) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + + return ( + + + {children} + + ) +} diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index a57450e637..68e29620da 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -1,36 +1,40 @@ -import {memo, useCallback, useMemo, useState} from 'react' +import {useCallback, useMemo, useState} from 'react' import {type GestureResponderEvent, View} from 'react-native' import { AppBskyEmbedRecord, + ChatBskyActorDefs, ChatBskyConvoDefs, moderateProfile, + type ModerationDecision, type ModerationOpts, } from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {GestureActionView} from '#/lib/custom-animations/GestureActionView' import {useHaptics} from '#/lib/haptics' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {decrementBadgeCount} from '#/lib/notifications/notifications' import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import { postUriToRelativePath, toBskyAppUrl, toShortUrl, } from '#/lib/strings/url-helpers' -import {useProfileShadow} from '#/state/cache/profile-shadow' +import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import { precacheConvoQuery, useMarkAsReadMutation, } from '#/state/queries/messages/conversation' -import {precacheProfile} from '#/state/queries/profile' +import {unstableCacheProfileView} from '#/state/queries/profile' import {useSession} from '#/state/session' import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import * as tokens from '#/alf/tokens' +import {AvatarBubbles} from '#/components/AvatarBubbles' import {useDialogControl} from '#/components/Dialog' import {ConvoMenu} from '#/components/dms/ConvoMenu' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' @@ -45,11 +49,17 @@ import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' -import type * as bsky from '#/types/bsky' +import * as bsky from '#/types/bsky' export const ChatListItemPortal = createPortalGroup() -export let ChatListItem = ({ +/** + * IMPORTANT NOTE: THIS IS CURRENTLY JANKY AF AND PROBABLY BROKEN, JUST WANTED TO ADD GROUPCHAT SUPPPORT + * + * TAKE A SECOND PASS PLEASE -sfn + */ + +export function ChatListItem({ convo, showMenu = true, children, @@ -57,32 +67,77 @@ export let ChatListItem = ({ convo: ChatBskyConvoDefs.ConvoView showMenu?: boolean children?: React.ReactNode -}): React.ReactNode => { +}) { const {currentAccount} = useSession() const moderationOpts = useModerationOpts() - const otherUser = convo.members.find( - member => member.did !== currentAccount?.did, - ) - - if (!otherUser || !moderationOpts) { + if (!moderationOpts) { return null } - return ( - - {children} - - ) + if ( + bsky.dangerousIsType( + convo.kind, + ChatBskyConvoDefs.isGroupConvo, + ) + ) { + const owner = convo.members.find(r => { + if ( + bsky.dangerousIsType( + r.kind, + ChatBskyActorDefs.isGroupConvoMember, + ) + ) { + return r.kind.role === 'owner' + } else { + throw new Error( + 'Expected a GroupConvoMember, got an unknown kind of member', + ) + } + }) + if (!owner) { + // TODO: Determine if this is the right thing to do here. Throwing here so that + // if it turns out to be wrong it'll be very visible + throw new Error('Could not find the group owner in the group members') + } + + return ( + + ) + } else if ( + bsky.dangerousIsType( + convo.kind, + ChatBskyConvoDefs.isDirectConvo, + ) + ) { + const otherMember = convo.members.find( + member => member.did !== currentAccount?.did, + ) + + if (!otherMember) { + return null + } + return ( + + {children} + + ) + } else { + return null + } } -ChatListItem = memo(ChatListItem) - -function ChatListItemReady({ +function DirectChatItem({ convo, profile: profileUnshadowed, moderationOpts, @@ -95,25 +150,140 @@ function ChatListItemReady({ showMenu?: boolean children?: React.ReactNode }) { - const ax = useAnalytics() - const t = useTheme() - const {_} = useLingui() - const {currentAccount} = useSession() - const menuControl = useMenuControl() - const leaveConvoControl = useDialogControl() - const {gtMobile} = useBreakpoints() + const {t: l} = useLingui() const profile = useProfileShadow(profileUnshadowed) - const {mutate: markAsRead} = useMarkAsReadMutation() + const moderation = useMemo( () => moderateProfile(profile, moderationOpts), [profile, moderationOpts], ) + + const isDeletedAccount = profile.handle === 'missing.invalid' + const displayName = isDeletedAccount + ? l`Deleted Account` + : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) + + return ( + + } + primaryProfile={profile} + primaryProfileModeration={moderation} + title={displayName} + subtitle={isDeletedAccount ? undefined : sanitizeHandle(profile.handle)} + accessibilityHint={ + !isDeletedAccount + ? l`Go to conversation with ${profile.handle}` + : l`This conversation is with a deleted or a deactivated account. Press for options` + } + showMenu={showMenu} + isDeletedAccount={isDeletedAccount} + isBlockedAccount={moderation.blocked} + showProfileBadges + postAlerts={ + + }> + {children} + + ) +} + +function GroupChatItem({ + convo, + groupOwner: groupOwnerUnshadowed, + groupInfo, + moderationOpts, + showMenu, + children, +}: { + convo: ChatBskyConvoDefs.ConvoView + groupOwner: bsky.profile.AnyProfileView + groupInfo: ChatBskyConvoDefs.GroupConvo + moderationOpts: ModerationOpts + showMenu?: boolean + children?: React.ReactNode +}) { + const {t: l} = useLingui() + const groupOwner = useProfileShadow(groupOwnerUnshadowed) + + const moderation = useMemo( + () => moderateProfile(groupOwner, moderationOpts), + [groupOwner, moderationOpts], + ) + + const chatName = groupInfo.name ?? l`${groupOwner.handle}'s group chat` + + return ( + } + title={chatName} + accessibilityHint={l`Go to the group chat named "${chatName}"`} + primaryProfile={groupOwner} + primaryProfileModeration={moderation} + isBlockedAccount={false} + isDeletedAccount={false} + showProfileBadges={false} + showMenu={showMenu}> + {children} + + ) +} + +function BaseChatItem({ + convo, + avatar, + title, + subtitle, + accessibilityHint, + isDeletedAccount, + isBlockedAccount, + primaryProfile, + primaryProfileModeration, + showMenu, + showProfileBadges, + postAlerts, + children, +}: { + convo: ChatBskyConvoDefs.ConvoView + avatar: React.ReactNode + title: string + subtitle?: string + accessibilityHint: string + isDeletedAccount: boolean + isBlockedAccount: boolean + primaryProfile: Shadow + primaryProfileModeration: ModerationDecision + showMenu?: boolean + showProfileBadges: boolean + postAlerts?: React.ReactNode + children?: React.ReactNode +}) { + const ax = useAnalytics() + const t = useTheme() + const {t: l} = useLingui() + const {currentAccount} = useSession() + const menuControl = useMenuControl() + const leaveConvoControl = useDialogControl() + const {mutate: markAsRead} = useMarkAsReadMutation() + const {gtMobile} = useBreakpoints() + const playHaptic = useHaptics() const queryClient = useQueryClient() const isUnread = convo.unreadCount > 0 const blockInfo = useMemo(() => { - const modui = moderation.ui('profileView') + const modui = primaryProfileModeration.ui('profileView') const blocks = modui.alerts.filter(alert => alert.type === 'blocking') const listBlocks = blocks.filter(alert => alert.source.type === 'list') const userBlock = blocks.find(alert => alert.source.type === 'user') @@ -121,21 +291,13 @@ function ChatListItemReady({ listBlocks, userBlock, } - }, [moderation]) + }, [primaryProfileModeration]) - const isDeletedAccount = profile.handle === 'missing.invalid' - const displayName = isDeletedAccount - ? _(msg`Deleted Account`) - : sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) - - const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount + const isDimStyle = convo.muted || isBlockedAccount || isDeletedAccount const {lastMessage, lastMessageSentAt, latestReportableMessage} = useMemo(() => { - let lastMessage = _(msg`No messages yet`) + let lastMessage = l`No messages yet` let lastMessageSentAt: string | null = null @@ -150,14 +312,12 @@ function ChatListItemReady({ if (convo.lastMessage.text) { if (isFromMe) { - lastMessage = _(msg`You: ${convo.lastMessage.text}`) + lastMessage = l`You: ${convo.lastMessage.text}` } else { lastMessage = convo.lastMessage.text } } else if (convo.lastMessage.embed) { - const defaultEmbeddedContentMessage = _( - msg`(contains embedded content)`, - ) + const defaultEmbeddedContentMessage = l`(contains embedded content)` if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) { const embed = convo.lastMessage.embed @@ -172,14 +332,14 @@ function ChatListItemReady({ ? toShortUrl(href) : defaultEmbeddedContentMessage if (isFromMe) { - lastMessage = _(msg`You: ${short}`) + lastMessage = l`You: ${short}` } else { lastMessage = short } } } else { if (isFromMe) { - lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`) + lastMessage = l`You: ${defaultEmbeddedContentMessage}` } else { lastMessage = defaultEmbeddedContentMessage } @@ -192,8 +352,8 @@ function ChatListItemReady({ lastMessageSentAt = convo.lastMessage.sentAt lastMessage = isDeletedAccount - ? _(msg`Conversation deleted`) - : _(msg`Message deleted`) + ? l`Conversation deleted` + : l`Message deleted` } if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) { @@ -205,44 +365,36 @@ function ChatListItemReady({ const isFromMe = convo.lastReaction.reaction.sender.did === currentAccount?.did const lastMessageText = convo.lastReaction.message.text - const fallbackMessage = _( - msg({ - message: 'a message', - comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`, - }), - ) + const fallbackMessage = l({ + message: 'a message', + comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`, + }) if (isFromMe) { - lastMessage = _( - msg`You reacted ${convo.lastReaction.reaction.value} to ${ - lastMessageText - ? `"${convo.lastReaction.message.text}"` - : fallbackMessage - }`, - ) + lastMessage = l`You reacted ${convo.lastReaction.reaction.value} to ${ + lastMessageText + ? `"${convo.lastReaction.message.text}"` + : fallbackMessage + }` } else { const senderDid = convo.lastReaction.reaction.sender.did const sender = convo.members.find( member => member.did === senderDid, ) if (sender) { - lastMessage = _( - msg`${sanitizeDisplayName( - sender.displayName || sender.handle, - )} reacted ${convo.lastReaction.reaction.value} to ${ - lastMessageText - ? `"${convo.lastReaction.message.text}"` - : fallbackMessage - }`, - ) + lastMessage = l`${sanitizeDisplayName( + sender.displayName || sender.handle, + )} reacted ${convo.lastReaction.reaction.value} to ${ + lastMessageText + ? `"${convo.lastReaction.message.text}"` + : fallbackMessage + }` } else { - lastMessage = _( - msg`Someone reacted ${convo.lastReaction.reaction.value} to ${ - lastMessageText - ? `"${convo.lastReaction.message.text}"` - : fallbackMessage - }`, - ) + lastMessage = l`Someone reacted ${convo.lastReaction.reaction.value} to ${ + lastMessageText + ? `"${convo.lastReaction.message.text}"` + : fallbackMessage + }` } } } @@ -254,7 +406,7 @@ function ChatListItemReady({ latestReportableMessage, } }, [ - _, + l, convo.lastMessage, convo.lastReaction, currentAccount?.did, @@ -279,9 +431,11 @@ function ChatListItemReady({ const onPress = useCallback( (e: GestureResponderEvent) => { - precacheProfile(queryClient, profile) + for (const member of convo.members) { + unstableCacheProfileView(queryClient, member) + } precacheConvoQuery(queryClient, convo) - decrementBadgeCount(convo.unreadCount) + void decrementBadgeCount(convo.unreadCount) if (isDeletedAccount) { e.preventDefault() menuControl.open() @@ -290,7 +444,7 @@ function ChatListItemReady({ ax.metric('chat:open', {logContext: 'ChatsList'}) } }, - [ax, isDeletedAccount, menuControl, queryClient, profile, convo], + [ax, isDeletedAccount, menuControl, queryClient, convo], ) const onLongPress = useCallback(() => { @@ -345,33 +499,23 @@ function ChatListItemReady({ a.absolute, {top: tokens.space.md, left: tokens.space.lg}, ]}> - + {avatar} - {displayName} + {title} - + + {showProfileBadges && ( + + )} + {lastMessageSentAt && ( @@ -432,7 +580,7 @@ function ChatListItemReady({ )} - {(convo.muted || moderation.blocked) && ( + {(convo.muted || isBlockedAccount) && ( - {!isDeletedAccount && ( + {subtitle && ( - @{profile.handle} + {subtitle} )} @@ -474,11 +622,7 @@ function ChatListItemReady({ {lastMessage} - + {postAlerts} {children} @@ -509,7 +653,7 @@ function ChatListItemReady({ {showMenu && ( 0} @@ -529,6 +673,7 @@ function ChatListItemReady({ latestReportableMessage={latestReportableMessage} /> )} + void + onSendMessage: (message: string) => Promise | void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode openEmojiPicker?: (pos: EmojiPickerPosition) => void }) { - const {_} = useLingui() + const {t: l} = useLingui() const t = useTheme() const playHaptic = useHaptics() const {getDraft, clearDraft} = useMessageDraft() @@ -82,13 +81,13 @@ export function MessageInput({ return } if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { - Toast.show(_(msg`Message is too long`), { + Toast.show(l`Message is too long`, { type: 'error', }) return } clearDraft() - onSendMessage(message) + void onSendMessage(message) playHaptic() setEmbed(undefined) setMessage('') @@ -111,7 +110,7 @@ export function MessageInput({ playHaptic, setEmbed, inputRef, - _, + l, ]) useFocusedInputHandler( @@ -169,9 +168,9 @@ export function MessageInput({ fallbackStyle={[t.atoms.bg_contrast_50]}> { @@ -225,7 +224,7 @@ export function MessageInput({ }}> void }) { const {isMobile} = useWebMediaQueries() - const {_} = useLingui() + const {t: l} = useLingui() const t = useTheme() const {getDraft, clearDraft} = useMessageDraft() const [message, setMessage] = useState(getDraft) @@ -57,7 +56,7 @@ export function MessageInput({ return } if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { - Toast.show(_(msg`Message is too long`), { + Toast.show(l`Message is too long`, { type: 'error', }) return @@ -66,7 +65,7 @@ export function MessageInput({ onSendMessage(message) setMessage('') setEmbed(undefined) - }, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed]) + }, [message, onSendMessage, l, clearDraft, hasEmbed, setEmbed]) const onKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -177,7 +176,7 @@ export function MessageInput({ width: 30, }, ]} - label={_(msg`Open emoji picker`)}> + label={l`Open emoji picker`}> {state => ( { return { [ConvoItemError.FirehoseFailed]: { - description: _(msg`This chat was disconnected`), - help: _(msg`Press to attempt reconnection`), - cta: _(msg`Reconnect`), + description: l`This chat was disconnected`, + help: l`Press to attempt reconnection`, + cta: l`Reconnect`, }, [ConvoItemError.HistoryFailed]: { - description: _(msg`Failed to load past messages`), - help: _(msg`Press to retry`), - cta: _(msg`Retry`), + description: l`Failed to load past messages`, + help: l`Press to retry`, + cta: l`Retry`, }, }[item.code] - }, [_, item.code]) + }, [l, item.code]) return ( - + - {description} ·{' '} + {description} {item.retry && ( - { - e.preventDefault() - item.retry?.() - return false - }}> - {cta} - + <> + ·{' '} + { + item.retry?.() + })}> + {cta} + + )} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index eda3c593d9..d55a361def 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -5,7 +5,7 @@ import { type KeyboardChatScrollViewProps, KeyboardGestureArea, } from 'react-native-keyboard-controller' -import Animated, { +import { runOnJS, type ScrollEvent, type SharedValue, @@ -77,18 +77,6 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) { ) } -function renderItem({item}: {item: ConvoItem}) { - if (item.type === 'message' || item.type === 'pending-message') { - return - } else if (item.type === 'deleted-message') { - return Deleted message - } else if (item.type === 'error') { - return - } - - return null -} - function keyExtractor(item: ConvoItem) { return item.key } @@ -103,12 +91,14 @@ export function MessagesList({ blocked, footer, hasAcceptOverride, + transparentHeaderHeight, }: { hasScrolled: boolean setHasScrolled: React.Dispatch> blocked?: boolean footer?: React.ReactNode hasAcceptOverride?: boolean + transparentHeaderHeight?: number }) { const ax = useAnalytics() const convoState = useConvoActive() @@ -155,6 +145,16 @@ export function MessagesList({ const prevContentHeight = useRef(0) const prevItemCount = useRef(0) + // Tracks whether the initial scroll-to-bottom has been triggered. Separated from isAtBottom so that contentInset + // (which causes an early onScroll with negative offset) can't prevent the first scroll. + // Reset when hasScrolled goes back to false (e.g. convo re-initialization after backgrounding). + const hasInitiallyScrolled = useRef(false) + const prevHasScrolled = useRef(hasScrolled) + if (prevHasScrolled.current && !hasScrolled) { + hasInitiallyScrolled.current = false + } + prevHasScrolled.current = hasScrolled + // -- Keep track of background state and positioning for new pill const layoutHeight = useSharedValue(0) const didBackground = useRef(false) @@ -187,8 +187,25 @@ export function MessagesList({ }) } - // This number _must_ be the height of the MaybeLoader component - if (height > 50 && isAtBottom.get()) { + // Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset + // can cause an early onScroll with a negative offset that sets isAtBottom to false before we get here. + if (!hasInitiallyScrolled.current && convoState.items.length > 0) { + hasInitiallyScrolled.current = true + flatListRef.current?.scrollToOffset({offset: height, animated: false}) + // If history is already done loading, mark ready after a frame for the scroll to settle. + // Otherwise, the footer sentinel's onLayout will handle it when history finishes. + if (!convoState.isFetchingHistory) { + requestAnimationFrame(() => { + setHasScrolled(true) + }) + } + prevContentHeight.current = height + prevItemCount.current = convoState.items.length + return + } + + // Subsequent: auto-scroll only if user is at the bottom + if (isAtBottom.get()) { // If the size of the content is changing by more than the height of the screen, then we don't // want to scroll further than the start of all the new content. Since we are storing the previous offset, // we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill @@ -212,17 +229,6 @@ export function MessagesList({ offset: height, animated: hasScrolled && height > prevContentHeight.current, }) - - // HACK Unfortunately, we need to call `setHasScrolled` after a brief delay, - // because otherwise there is too much of a delay between the time the content - // scrolls and the time the screen appears, causing a flicker. - // We cannot actually use a synchronous scroll here, because `onContentSizeChange` - // is actually async itself - all the info has to come across the bridge first. - if (!hasScrolled && !convoState.isFetchingHistory) { - setTimeout(() => { - setHasScrolled(true) - }, 100) - } } } @@ -369,6 +375,40 @@ export function MessagesList({ setEmojiPickerState({isOpen: true, pos}) }, []) + const renderItem = ({item}: {item: ConvoItem}) => { + if (item.type === 'message' || item.type === 'pending-message') { + return ( + member.did === item.message.sender.did, + )} + isGroupChat={convoState.getGroupInfo?.() != null} + /> + ) + } else if (item.type === 'deleted-message') { + return Deleted message + } else if (item.type === 'error') { + return + } + + return null + } + + // Footer sentinel: when history is still loading during the initial scroll, the footer's onLayout fires each time + // new items are prepended (shifting its position). Once history finishes, this triggers setHasScrolled. + const onFooterLayout = useCallback(() => { + if ( + hasInitiallyScrolled.current && + !hasScrolled && + !convoState.isFetchingHistory + ) { + requestAnimationFrame(() => { + setHasScrolled(true) + }) + } + }, [hasScrolled, setHasScrolled, convoState.isFetchingHistory]) + const renderScrollComponent = useCallback( (props: ScrollViewProps) => ( @@ -382,7 +422,8 @@ export function MessagesList({ interpolator="ios" // HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419 offset={Math.round(inputHeightJS)} - textInputNativeID={textInputId} + // slightly too buggy unfortunately, enable when possible + // textInputNativeID={textInputId} style={[a.flex_1]}> {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} @@ -411,15 +452,27 @@ export function MessagesList({ } // native only (prop is not supported on web) renderScrollComponent={renderScrollComponent} - // pushes up the content under the input on web (renderScrollComponent handles it on native) - ListFooterComponent={web( - , - )} + contentContainerStyle={{ + paddingBottom: platform({ + // ios is slightly larger as the input has no top padding + ios: tokens.space.lg, + android: tokens.space.md, + web: 0, // web uses ListFooterComponent instead for scroll reasons + }), + }} + ListFooterComponent={ + + } style={web({ scrollbarWidth: 'thin', scrollbarColor: `${t.palette.contrast_100} transparent`, scrollbarGutter: 'stable both-edges', })} + contentInset={{top: transparentHeaderHeight}} + scrollIndicatorInsets={{top: transparentHeaderHeight}} /> + void onSendMessage(message) + } hasEmbed={!!embedUri} setEmbed={setEmbed}> @@ -518,12 +573,6 @@ function ChatScrollComponent({ ) } -function WebInputSpacer({inputHeight}: {inputHeight: number}) { - if (!IS_WEB) return null - - return -} - type FooterState = 'loading' | 'new-chat' | 'request' | 'standard' function getFooterState( diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 42574c7408..17c8e54be8 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -39,6 +39,7 @@ import {Button} from '#/components/Button' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {GalleryBleed} from '#/components/images/Gallery' import {Link} from '#/components/Link' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' @@ -308,234 +309,243 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ return ( <> - - - - - - - - - + + + + + + + + + + + + {sanitizeDisplayName( + post.author.displayName || + sanitizeHandle(post.author.handle), + moderation.ui('displayName'), + )} + + + + + + - {sanitizeDisplayName( - post.author.displayName || - sanitizeHandle(post.author.handle), - moderation.ui('displayName'), - )} + {sanitizeHandle(post.author.handle, '@')} - - - - - - - {sanitizeHandle(post.author.handle, '@')} - - - - - - - - - - - - - {richText?.text ? ( - - ) : undefined} - - {post.embed && ( - - + - )} - - - {post.repostCount !== 0 || - post.likeCount !== 0 || - post.quoteCount !== 0 || - post.bookmarkCount !== 0 ? ( - // Show this section unless we're *sure* it has no engagement. + + + + + + + + + + {richText?.text ? ( + + ) : undefined} + + {post.embed && ( + + + + )} + + + {post.repostCount !== 0 || + post.likeCount !== 0 || + post.quoteCount !== 0 || + post.bookmarkCount !== 0 ? ( + // Show this section unless we're *sure* it has no engagement. + + {post.repostCount != null && post.repostCount !== 0 ? ( + + + + + {formatPostStatCount(post.repostCount)} + {' '} + + + + + ) : null} + {post.quoteCount != null && + post.quoteCount !== 0 && + !post.viewer?.embeddingDisabled ? ( + + + + + {formatPostStatCount(post.quoteCount)} + {' '} + + + + + ) : null} + {post.likeCount != null && post.likeCount !== 0 ? ( + + + + + {formatPostStatCount(post.likeCount)} + {' '} + + + + + ) : null} + {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( + + + + {formatPostStatCount(post.bookmarkCount)} + {' '} + + + + ) : null} + + ) : null} - {post.repostCount != null && post.repostCount !== 0 ? ( - - - - - {formatPostStatCount(post.repostCount)} - {' '} - - - - - ) : null} - {post.quoteCount != null && - post.quoteCount !== 0 && - !post.viewer?.embeddingDisabled ? ( - - - - - {formatPostStatCount(post.quoteCount)} - {' '} - - - - - ) : null} - {post.likeCount != null && post.likeCount !== 0 ? ( - - - - - {formatPostStatCount(post.likeCount)} - {' '} - - - - - ) : null} - {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( - - - - {formatPostStatCount(post.bookmarkCount)} - {' '} - - - - ) : null} + + + - ) : null} - - - - + - - + ) }) diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx index 87aa551330..841c2af745 100644 --- a/src/screens/PostThread/components/ThreadItemPost.tsx +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -32,6 +32,10 @@ import {atoms as a, useTheme} from '#/alf' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {useInteractionState} from '#/components/hooks/useInteractionState' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import { + GalleryBleed, + maybeApplyGalleryOffsetStyles, +} from '#/components/images/Gallery' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' import {PostHider} from '#/components/moderation/PostHider' @@ -131,18 +135,20 @@ const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({ !item.ui.showParentReplyLine && overrides?.topBorder !== true return ( - - {children} - + + + {children} + + ) }) @@ -295,7 +301,14 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({ moderation={moderation} timestamp={post.indexedAt} postHref={postHref} - style={[a.pb_xs]} + style={[ + a.pb_xs, + maybeApplyGalleryOffsetStyles('meta', { + post, + modui: moderation.ui('contentList'), + additionalCauses: additionalPostAlerts, + }), + ]} /> {post.embed && ( - + - {Array.from(Array(indents)).map((_, n: number) => { - const isSkipped = item.ui.skippedIndentIndices.has(n) - return ( - + - ) - })} - {children} - + ], + ]}> + {Array.from(Array(indents)).map((_, n: number) => { + const isSkipped = item.ui.skippedIndentIndices.has(n) + return ( + + ) + })} + {children} + + ) }, ) diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx index ac0ad75483..9f6dc67d27 100644 --- a/src/screens/Search/Shell.tsx +++ b/src/screens/Search/Shell.tsx @@ -96,7 +96,12 @@ export function SearchScreenShell({ const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam)) // Query terms - const [searchText, setSearchText] = useState(queryParam) + const [searchText, _setSearchText] = useState(queryParam) + const searchTextRef = useRef(searchText) + const setSearchText = (text: string) => { + searchTextRef.current = text + _setSearchText(text) + } const {data: autocompleteData, isFetching: isAutocompleteFetching} = useActorAutocompleteQuery(searchText, true) @@ -227,15 +232,12 @@ export function SearchScreenShell({ } }, [setShowAutocomplete, setSearchText, navigation, route.params, route.name]) - const onSubmit = useCallback( - (source: 'typed' | 'autocomplete') => () => { - ax.metric('search:query', { - source, - }) - navigateToItem(searchText) - }, - [ax, navigateToItem, searchText], - ) + const onSubmit = (source: 'typed' | 'autocomplete') => () => { + ax.metric('search:query', { + source, + }) + navigateToItem(searchTextRef.current) + } const onAutocompleteResultPress = useCallback(() => { if (IS_WEB) { diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx index 5acb50c4f5..c605a48f0b 100644 --- a/src/screens/Settings/AboutSettings.tsx +++ b/src/screens/Settings/AboutSettings.tsx @@ -21,8 +21,8 @@ import * as Layout from '#/components/Layout' import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {getDeviceId} from '#/analytics/identifiers' -import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env' import * as env from '#/env' +import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env' import {useDemoMode} from '#/storage/hooks/demo-mode' import {useDevMode} from '#/storage/hooks/dev-mode' import {OTAInfo} from './components/OTAInfo' diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index b6c8ee2f16..ef2b251cce 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -1,6 +1,6 @@ import { type AtpAgent, - type ChatBskyActorDefs, + ChatBskyActorDefs, ChatBskyConvoDefs, type ChatBskyConvoGetLog, type ChatBskyConvoSendMessage, @@ -37,6 +37,7 @@ import { import {type MessagesEventBus} from '#/state/messages/events/agent' import {type MessagesEventBusError} from '#/state/messages/events/types' import {IS_NATIVE} from '#/env' +import * as bsky from '#/types/bsky' const logger = Logger.create(Logger.Context.ConversationAgent) @@ -112,6 +113,9 @@ export class Convo { this.markConvoAccepted = this.markConvoAccepted.bind(this) this.addReaction = this.addReaction.bind(this) this.removeReaction = this.removeReaction.bind(this) + this.isGroup = this.isGroup.bind(this) + this.getGroupInfo = this.getGroupInfo.bind(this) + this.getPrimaryMember = this.getPrimaryMember.bind(this) } private commit() { @@ -155,6 +159,9 @@ export class Convo { markConvoAccepted: undefined, addReaction: undefined, removeReaction: undefined, + isGroup: this.isGroup, + getGroupInfo: this.getGroupInfo, + getPrimaryMember: this.getPrimaryMember, } } case ConvoStatus.Disabled: @@ -175,6 +182,9 @@ export class Convo { markConvoAccepted: this.markConvoAccepted, addReaction: this.addReaction, removeReaction: this.removeReaction, + isGroup: this.isGroup, + getGroupInfo: this.getGroupInfo, + getPrimaryMember: this.getPrimaryMember, } } case ConvoStatus.Error: { @@ -192,6 +202,9 @@ export class Convo { markConvoAccepted: undefined, addReaction: undefined, removeReaction: undefined, + isGroup: undefined, + getGroupInfo: undefined, + getPrimaryMember: undefined, } } default: { @@ -209,6 +222,9 @@ export class Convo { markConvoAccepted: undefined, addReaction: undefined, removeReaction: undefined, + isGroup: this.isGroup, + getGroupInfo: this.getGroupInfo, + getPrimaryMember: this.getPrimaryMember, } } } @@ -222,7 +238,7 @@ export class Convo { switch (action.event) { case ConvoDispatchEvent.Init: { this.status = ConvoStatus.Initializing - this.setup() + void this.setup() this.setupFirehose() this.requestPollInterval(ACTIVE_POLL_INTERVAL) break @@ -234,12 +250,12 @@ export class Convo { switch (action.event) { case ConvoDispatchEvent.Ready: { this.status = ConvoStatus.Ready - this.fetchMessageHistory() + void this.fetchMessageHistory() break } case ConvoDispatchEvent.Background: { this.status = ConvoStatus.Backgrounded - this.fetchMessageHistory() + void this.fetchMessageHistory() this.requestPollInterval(BACKGROUND_POLL_INTERVAL) break } @@ -258,7 +274,7 @@ export class Convo { } case ConvoDispatchEvent.Disable: { this.status = ConvoStatus.Disabled - this.fetchMessageHistory() // finish init + void this.fetchMessageHistory() // finish init this.cleanupFirehoseConnection?.() this.withdrawRequestedPollInterval() break @@ -269,7 +285,7 @@ export class Convo { case ConvoStatus.Ready: { switch (action.event) { case ConvoDispatchEvent.Resume: { - this.refreshConvo() + void this.refreshConvo() this.requestPollInterval(ACTIVE_POLL_INTERVAL) break } @@ -308,11 +324,11 @@ export class Convo { } else { if (this.convo) { this.status = ConvoStatus.Ready - this.refreshConvo() + void this.refreshConvo() this.maybeRecoverFromNetworkError() } else { this.status = ConvoStatus.Initializing - this.setup() + void this.setup() } this.requestPollInterval(ACTIVE_POLL_INTERVAL) } @@ -435,7 +451,7 @@ export class Convo { this.firehoseError = undefined this.commit() } else { - this.batchRetryPendingMessages() + void this.batchRetryPendingMessages() } if (this.fetchMessageHistoryError) { @@ -487,7 +503,8 @@ export class Convo { } else { this.dispatch({event: ConvoDispatchEvent.Ready}) } - } catch (e: any) { + } catch (err) { + const e = err as Error if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { logger.error('setup failed', { safeMessage: e.message, @@ -557,11 +574,7 @@ export class Convo { async fetchConvo() { if (this.pendingFetchConvo) return this.pendingFetchConvo - this.pendingFetchConvo = new Promise<{ - convo: ChatBskyConvoDefs.ConvoView - sender: ChatBskyActorDefs.ProfileViewBasic | undefined - recipients: ChatBskyActorDefs.ProfileViewBasic[] - }>(async (resolve, reject) => { + this.pendingFetchConvo = (async () => { try { const response = await networkRetry(2, () => { return this.agent.api.chat.bsky.convo.getConvo( @@ -574,17 +587,15 @@ export class Convo { const convo = response.data.convo - resolve({ + return { convo, sender: convo.members.find(m => m.did === this.senderUserDid), recipients: convo.members.filter(m => m.did !== this.senderUserDid), - }) - } catch (e) { - reject(e) + } } finally { this.pendingFetchConvo = undefined } - }) + })() return this.pendingFetchConvo } @@ -596,7 +607,8 @@ export class Convo { this.convo = convo || this.convo this.sender = sender || this.sender this.recipients = recipients || this.recipients - } catch (e: any) { + } catch (err) { + const e = err as Error if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { logger.error(`failed to refresh convo`, { safeMessage: e.message, @@ -664,7 +676,8 @@ export class Convo { this.pastMessages.set(message.id, message) } } - } catch (e: any) { + } catch (err) { + const e = err as Error if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { logger.error('failed to fetch message history', { safeMessage: e.message, @@ -673,7 +686,7 @@ export class Convo { this.fetchMessageHistoryError = { retry: () => { - this.fetchMessageHistory() + void this.fetchMessageHistory() }, } } finally { @@ -716,7 +729,7 @@ export class Convo { onFirehoseConnect() { this.firehoseError = undefined - this.batchRetryPendingMessages() + void this.batchRetryPendingMessages() this.commit() } @@ -761,8 +774,8 @@ export class Convo { /** * If this message is already in new messages, it was added by our * sending logic, and is based on client-ordering. When we receive - * the "commited" event from the log, we should replace this - * reference and re-insert in order to respect the order we receied + * the "committed" event from the log, we should replace this + * reference and re-insert in order to respect the order we received * from the log. */ if (this.newMessages.has(ev.message.id)) { @@ -836,7 +849,7 @@ export class Convo { this.commit() if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) { - this.processPendingMessages() + void this.processPendingMessages() } } @@ -912,7 +925,7 @@ export class Convo { } } - private handleSendMessageFailure(e: any) { + private handleSendMessageFailure(e: Error | XRPCError) { if (e instanceof XRPCError) { if (NETWORK_FAILURE_STATUSES.includes(e.status)) { this.pendingMessageFailure = 'recoverable' @@ -1026,7 +1039,8 @@ export class Convo { {encoding: 'application/json', headers: DM_SERVICE_HEADERS}, ) }) - } catch (e: any) { + } catch (err) { + const e = err as Error if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { logger.error(`failed to delete message`, { safeMessage: e.message, @@ -1334,4 +1348,46 @@ export class Convo { throw error } } + + // Group utilities + + isGroup(): boolean | undefined { + if (!this.convo) return undefined + const info = this.getGroupInfo() + return !!info + } + + getGroupInfo(): ChatBskyConvoDefs.GroupConvo | undefined { + if ( + this.convo && + bsky.dangerousIsType( + this.convo.kind, + ChatBskyConvoDefs.isGroupConvo, + ) + ) { + return this.convo.kind + } + return undefined + } + + getPrimaryMember(): ChatBskyActorDefs.ProfileViewBasic | undefined { + if (this.isGroup()) { + return this.recipients?.find(r => { + if ( + bsky.dangerousIsType( + r.kind, + ChatBskyActorDefs.isGroupConvoMember, + ) + ) { + return r.kind.role === 'owner' + } else { + throw new Error( + 'Expected a GroupConvoMember, got an unknown kind of member', + ) + } + }) + } else { + return this.recipients?.find(r => r.did !== this.senderUserDid) + } + } } diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index 7053877935..d7adb51c6d 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -144,6 +144,9 @@ type FetchMessageHistory = () => Promise type MarkConvoAccepted = () => void type AddReaction = (messageId: string, reaction: string) => Promise type RemoveReaction = (messageId: string, reaction: string) => Promise +type IsGroup = () => boolean | undefined +type GetGroupInfo = () => ChatBskyConvoDefs.GroupConvo | undefined +type GetPrimaryMember = () => ChatBskyActorDefs.ProfileViewBasic | undefined export type ConvoStateUninitialized = { status: ConvoStatus.Uninitialized @@ -159,6 +162,9 @@ export type ConvoStateUninitialized = { markConvoAccepted: undefined addReaction: undefined removeReaction: undefined + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateInitializing = { status: ConvoStatus.Initializing @@ -174,6 +180,9 @@ export type ConvoStateInitializing = { markConvoAccepted: undefined addReaction: undefined removeReaction: undefined + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateReady = { status: ConvoStatus.Ready @@ -189,6 +198,9 @@ export type ConvoStateReady = { markConvoAccepted: MarkConvoAccepted addReaction: AddReaction removeReaction: RemoveReaction + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateBackgrounded = { status: ConvoStatus.Backgrounded @@ -204,6 +216,9 @@ export type ConvoStateBackgrounded = { markConvoAccepted: MarkConvoAccepted addReaction: AddReaction removeReaction: RemoveReaction + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateSuspended = { status: ConvoStatus.Suspended @@ -219,6 +234,9 @@ export type ConvoStateSuspended = { markConvoAccepted: MarkConvoAccepted addReaction: AddReaction removeReaction: RemoveReaction + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateError = { status: ConvoStatus.Error @@ -234,6 +252,9 @@ export type ConvoStateError = { markConvoAccepted: undefined addReaction: undefined removeReaction: undefined + isGroup: undefined + getGroupInfo: undefined + getPrimaryMember: undefined } export type ConvoStateDisabled = { status: ConvoStatus.Disabled @@ -249,6 +270,9 @@ export type ConvoStateDisabled = { markConvoAccepted: MarkConvoAccepted addReaction: AddReaction removeReaction: RemoveReaction + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoState = | ConvoStateUninitialized diff --git a/src/state/queries/messages/create-group-chat.ts b/src/state/queries/messages/create-group-chat.ts new file mode 100644 index 0000000000..9f8aadc7d0 --- /dev/null +++ b/src/state/queries/messages/create-group-chat.ts @@ -0,0 +1,37 @@ +import {type ChatBskyGroupCreateGroup} from '@atproto/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {DM_SERVICE_HEADERS} from '#/lib/constants' +import {logger} from '#/logger' +import {useAgent} from '#/state/session' +import {precacheConvoQuery} from './conversation' + +export function useCreateGroupChat({ + onSuccess, + onError, +}: { + onSuccess?: (data: ChatBskyGroupCreateGroup.OutputSchema) => void + onError?: (error: Error) => void +}) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation({ + mutationFn: async ({name, members}: {name: string; members: string[]}) => { + const {data} = await agent.chat.bsky.group.createGroup( + {name, members}, + {headers: DM_SERVICE_HEADERS}, + ) + + return data + }, + onSuccess: data => { + precacheConvoQuery(queryClient, data.convo) + onSuccess?.(data) + }, + onError: error => { + logger.error(error) + onError?.(error) + }, + }) +} diff --git a/src/state/queries/messages/mute-conversation.ts b/src/state/queries/messages/mute-conversation.ts index 08878d7fb5..d90ebb1b55 100644 --- a/src/state/queries/messages/mute-conversation.ts +++ b/src/state/queries/messages/mute-conversation.ts @@ -31,13 +31,13 @@ export function useMuteConvo( mutationFn: async ({mute}: {mute: boolean}) => { if (!convoId) throw new Error('No convoId provided') if (mute) { - const {data} = await agent.api.chat.bsky.convo.muteConvo( + const {data} = await agent.chat.bsky.convo.muteConvo( {convoId}, {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, ) return data } else { - const {data} = await agent.api.chat.bsky.convo.unmuteConvo( + const {data} = await agent.chat.bsky.convo.unmuteConvo( {convoId}, {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, ) diff --git a/src/state/queries/trending/useGetSuggestedUsersForDiscoverQuery.ts b/src/state/queries/trending/useGetSuggestedUsersForDiscoverQuery.ts index efd02a1dce..74c8886693 100644 --- a/src/state/queries/trending/useGetSuggestedUsersForDiscoverQuery.ts +++ b/src/state/queries/trending/useGetSuggestedUsersForDiscoverQuery.ts @@ -19,9 +19,9 @@ export type QueryProps = { export const getSuggestedUsersForDiscoverQueryKeyRoot = 'unspecced-suggested-users-for-explore' -export const createGetSuggestedUsersForDiscoverQueryKey = ( - props: QueryProps, -) => [getSuggestedUsersForDiscoverQueryKeyRoot, props.limit] +export const createGetSuggestedUsersForDiscoverQueryKey = (props: { + limit?: number +}) => [getSuggestedUsersForDiscoverQueryKeyRoot, props.limit] export function useGetSuggestedUsersForDiscoverQuery(props: QueryProps = {}) { const agent = useAgent() @@ -29,7 +29,7 @@ export function useGetSuggestedUsersForDiscoverQuery(props: QueryProps = {}) { return useQuery({ staleTime: STALE.MINUTES.THREE, - queryKey: createGetSuggestedUsersForDiscoverQueryKey(props), + queryKey: createGetSuggestedUsersForDiscoverQueryKey({limit: props.limit}), queryFn: async () => { const contentLangs = getContentLanguages().join(',') const userInterests = aggregateUserInterests(preferences) diff --git a/src/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery.ts b/src/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery.ts index eec392d4c9..e816f0cedb 100644 --- a/src/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery.ts +++ b/src/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery.ts @@ -16,21 +16,27 @@ import {useAgent} from '#/state/session' export type QueryProps = { category?: string | null limit?: number + enabled?: boolean } export const getSuggestedUsersForSeeMoreQueryKeyRoot = 'unspecced-suggested-users-for-explore' -export const createGetSuggestedUsersForSeeMoreQueryKey = ( - props: QueryProps, -) => [getSuggestedUsersForSeeMoreQueryKeyRoot, props.category, props.limit] +export const createGetSuggestedUsersForSeeMoreQueryKey = (props: { + category?: string | null + limit?: number +}) => [getSuggestedUsersForSeeMoreQueryKeyRoot, props.category, props.limit] export function useGetSuggestedUsersForSeeMoreQuery(props: QueryProps = {}) { const agent = useAgent() const {data: preferences} = usePreferencesQuery() return useQuery({ + enabled: props.enabled ?? true, staleTime: STALE.MINUTES.THREE, - queryKey: createGetSuggestedUsersForSeeMoreQueryKey(props), + queryKey: createGetSuggestedUsersForSeeMoreQueryKey({ + category: props.category, + limit: props.limit, + }), queryFn: async () => { const contentLangs = getContentLanguages().join(',') const userInterests = aggregateUserInterests(preferences) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index ceaa2fc395..f05ff232fb 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -54,9 +54,8 @@ import { type BskyAgent, type RichText, } from '@atproto/api' -import {msg, plural} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {plural} from '@lingui/core/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' @@ -197,12 +196,13 @@ export const ComposePost = ({ cancelRef?: React.RefObject }) => { const {currentAccount} = useSession() + const t = useTheme() const ax = useAnalytics() const agent = useAgent() const queryClient = useQueryClient() const currentDid = currentAccount!.did const {closeComposer} = useComposerControls() - const {_} = useLingui() + const {t: l, i18n} = useLingui() const requireAltTextEnabled = useRequireAltTextEnabled() const langPrefs = useLanguagePrefs() const setLangPrefs = useLanguagePrefsApi() @@ -313,7 +313,7 @@ export const ComposePost = ({ abortController, }, }) - processVideo( + void processVideo( asset, videoAction => { composerDispatch({ @@ -328,10 +328,10 @@ export const ComposePost = ({ agent, currentDid, abortController.signal, - _, + i18n, ) }, - [_, agent, currentDid, composerDispatch], + [i18n, agent, currentDid, composerDispatch], ) const onInitVideo = useNonReactiveCallback(() => { @@ -460,7 +460,7 @@ export const ComposePost = ({ } // Start video compression and upload - processVideo( + void processVideo( asset, videoAction => { composerDispatch({ @@ -475,7 +475,7 @@ export const ComposePost = ({ agent, currentDid, abortController.signal, - _, + i18n, ) } catch (e) { logger.error('Failed to restore video from draft', { @@ -484,7 +484,7 @@ export const ComposePost = ({ }) } }, - [_, agent, currentDid, composerDispatch], + [i18n, agent, currentDid, composerDispatch], ) const handleSelectDraft = useCallback( @@ -558,11 +558,11 @@ export const ComposePost = ({ const getDraftSaveError = useCallback( (e: unknown): string => { if (e instanceof AppBskyDraftCreateDraft.DraftLimitReachedError) { - return _(msg`You've reached the maximum number of drafts`) + return l`You've reached the maximum number of drafts` } - return _(msg`Failed to save draft`) + return l`Failed to save draft` }, - [_], + [l], ) const validateDraftTextOrError = useCallback((): boolean => { @@ -571,14 +571,12 @@ export const ComposePost = ({ ) if (tooLong) { setError( - _( - msg`One or more posts are too long to save as a draft. ${plural(MAX_DRAFT_GRAPHEME_LENGTH, {one: 'The maximum number of characters is # character.', other: 'The maximum number of characters is # characters.'})}`, - ), + l`One or more posts are too long to save as a draft. ${plural(MAX_DRAFT_GRAPHEME_LENGTH, {one: 'The maximum number of characters is # character.', other: 'The maximum number of characters is # characters.'})}`, ) return false } return true - }, [composerState.thread.posts, _]) + }, [composerState.thread.posts, l]) const handleSaveDraft = useCallback(async () => { setError('') @@ -768,21 +766,21 @@ export const ComposePost = ({ const media = thread.posts[i].embed.media if (media) { if (media.type === 'images' && media.images.some(img => !img.alt)) { - return _(msg`One or more images is missing alt text.`) + return l`One or more images is missing alt text.` } if (media.type === 'gif' && !media.alt) { - return _(msg`One or more GIFs is missing alt text.`) + return l`One or more GIFs is missing alt text.` } if ( media.type === 'video' && media.video.status !== 'error' && !media.video.altText ) { - return _(msg`One or more videos is missing alt text.`) + return l`One or more videos is missing alt text.` } } } - }, [thread, requireAltTextEnabled, _]) + }, [thread, requireAltTextEnabled, l]) const canPost = !missingAltError && @@ -895,11 +893,9 @@ export const ComposePost = ({ let err = cleanError(e.message) if (err.includes('not locate record')) { - err = _( - msg`We're sorry! The post you are replying to has been deleted.`, - ) + err = l`We're sorry! The post you are replying to has been deleted.` } else if (e instanceof EmbeddingDisabledError) { - err = _(msg`This post's author has disabled quote posts.`) + err = l`This post's author has disabled quote posts.` } setError(err) setIsPublishing(false) @@ -979,14 +975,14 @@ export const ComposePost = ({ {thread.posts.length > 1 - ? _(msg`Your posts were sent`) + ? l`Your posts were sent` : replyTo - ? _(msg`Your reply was sent`) - : _(msg`Your post was sent`)} + ? l`Your reply was sent` + : l`Your post was sent`} {postUri && ( { const {host: name, rkey} = new AtUri(postUri) navigation.navigate('PostThread', {name, rkey}) @@ -1001,7 +997,7 @@ export const ComposePost = ({ ) }, 500) }, [ - _, + l, ax, agent, thread, @@ -1026,7 +1022,7 @@ export const ComposePost = ({ // Preserves the referential identity passed to each post item. // Avoids re-rendering all posts on each keystroke. const onComposerPostPublish = useNonReactiveCallback(() => { - onPressPublish() + void onPressPublish() }) useEffect(() => { @@ -1047,7 +1043,7 @@ export const ComposePost = ({ setPublishOnUpload(false) } else if (uploadingVideos === 0) { setPublishOnUpload(false) - onPressPublish() + void onPressPublish() } } }, [thread.posts, onPressPublish, publishOnUpload]) @@ -1189,7 +1185,13 @@ export const ComposePost = ({ layout={native(LinearTransition)} onScroll={scrollHandler} contentContainerStyle={a.flex_grow} - style={a.flex_1} + style={[ + a.flex_1, + web({ + scrollbarGutter: 'stable', + scrollbarColor: `${t.palette.contrast_200} transparent`, + }), + ]} keyboardShouldPersistTaps="always" onContentSizeChange={onScrollViewContentSizeChange} onLayout={onScrollViewLayout}> @@ -1224,9 +1226,9 @@ export const ComposePost = ({ {replyTo ? ( @@ -1264,21 +1266,17 @@ export const ComposePost = ({ {allPostsWithinLimit && ( )} - + )} @@ -1320,16 +1318,16 @@ let ComposerPost = memo(function ComposerPost({ }) { const {currentAccount} = useSession() const currentDid = currentAccount!.did - const {_} = useLingui() + const {t: l} = useLingui() const {data: currentProfile} = useProfileQuery({did: currentDid}) const richtext = post.richtext const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media const forceMinHeight = IS_WEB && isTextOnly && isActive const selectTextInputPlaceholder = isReply ? isFirstPost - ? _(msg`Write your reply`) - : _(msg`Add another post`) - : _(msg`What's up?`) + ? l`Write your reply` + : l`Add another post` + : l`What's up?` const discardPromptControl = Prompt.usePromptControl() const dispatchPost = useCallback( @@ -1369,7 +1367,7 @@ let ComposerPost = memo(function ComposerPost({ if (IS_NATIVE) return // web only const [mimeType] = uri.slice('data:'.length).split(';') if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { - Toast.show(_(msg`Unsupported video type: ${mimeType}`), { + Toast.show(l`Unsupported video type: ${mimeType}`, { type: 'error', }) return @@ -1384,7 +1382,7 @@ let ComposerPost = memo(function ComposerPost({ onImageAdd([res]) } }, - [post.id, onSelectVideo, onImageAdd, _], + [post.id, onSelectVideo, onImageAdd, l], ) useHideKeyboardOnBackground() @@ -1429,19 +1427,20 @@ let ComposerPost = memo(function ComposerPost({ onError={onError} onPressPublish={onPublish} accessible={true} - accessibilityLabel={_(msg`Write post`)} - accessibilityHint={_( - msg`Compose posts up to ${plural(MAX_GRAPHEME_LENGTH || 0, { + accessibilityLabel={l`Write post`} + accessibilityHint={l`Compose posts up to ${plural( + MAX_GRAPHEME_LENGTH || 0, + { other: '# characters', - })} in length`, - )} + }, + )} in length`} /> {canRemovePost && isActive && ( <>