From 59a2d19c26830981f5b7e227c2559ea9d247dece Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 14 Apr 2026 17:42:43 -0500 Subject: [PATCH 01/26] Fix stale searchText in search submit handler (#10251) Co-authored-by: Claude Opus 4.6 (1M context) --- src/screens/Search/Shell.tsx | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) 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) { From 8d3eba238141557df82070f02251d64affdb7837 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Tue, 14 Apr 2026 18:50:00 -0400 Subject: [PATCH 02/26] [ALG-77] include 'for you' in see more suggested users (#10241) --- src/components/ProgressGuide/FollowDialog.tsx | 47 ++++++++++++------- .../useGetSuggestedUsersForDiscoverQuery.ts | 8 ++-- .../useGetSuggestedUsersForSeeMoreQuery.ts | 14 ++++-- 3 files changed, 45 insertions(+), 24 deletions(-) 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/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) From bcbc11418979c44fddcfea2c65bf7a8d22adf26f Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Wed, 15 Apr 2026 03:14:26 +0000 Subject: [PATCH 03/26] Nightly source-language update --- src/locale/locales/en/messages.po | 33 ++++++++++++++++--------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index e1ab07863c..ebc04f79ae 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -1953,7 +1953,7 @@ msgstr "" #: src/screens/Deactivated.tsx:150 #: 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 @@ -1979,7 +1979,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 "" @@ -2298,7 +2298,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 @@ -3897,7 +3897,7 @@ msgid "Explicit sexual images." msgstr "" #: src/Navigation.tsx:818 -#: src/screens/Search/Shell.tsx:354 +#: src/screens/Search/Shell.tsx:356 #: src/view/shell/desktop/LeftNav.tsx:691 #: src/view/shell/Drawer.tsx:402 msgid "Explore" @@ -4342,12 +4342,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 "" @@ -4579,6 +4579,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" @@ -6699,7 +6700,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 "" @@ -8755,8 +8756,8 @@ 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 "" @@ -8768,7 +8769,7 @@ msgstr "" 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 +8782,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 +8811,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 +8840,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 +8854,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 "" @@ -11697,7 +11698,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 "" From ecc78efb1212db215775dec996e203ff60a3be42 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 15 Apr 2026 07:39:51 -0700 Subject: [PATCH 04/26] Stop passing `_` around in composer functions (#10256) --- src/lib/media/video/upload.shared.ts | 4 +- src/lib/media/video/upload.ts | 10 +- src/lib/media/video/upload.web.ts | 12 +- src/view/com/composer/Composer.tsx | 202 +++++++++++++-------------- src/view/com/composer/state/video.ts | 38 ++--- 5 files changed, 129 insertions(+), 137 deletions(-) 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/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 && ( <> )} @@ -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/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/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/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/yarn.lock b/yarn.lock index d3cb6da5eb..1a160ff2a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20,14 +20,14 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" -"@atproto/api@^0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.8.tgz#ae847abece43f0108535c6305780079e8782ab29" - integrity sha512-b79kuI3AzEmpLLi9afRNq6T0KFEEVL4d+vHFAtWxeDwS7lfwUOIIngMjAVvwmwC5nJRZIrK8L9d4y7LD8zdvsg== +"@atproto/api@^0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.9.tgz#f09ed8412159d6878eeaf25a0a8b4445c62fa9eb" + integrity sha512-+sUYNuiA1Rv8HemMCURHwRkMp2D7cq6nNquefjosu6UB54IzkD0MLK3YY383poLRShiApouOxRse2OKK25dbQw== dependencies: - "@atproto/common-web" "^0.4.20" + "@atproto/common-web" "^0.4.21" "@atproto/lexicon" "^0.6.2" - "@atproto/syntax" "^0.5.3" + "@atproto/syntax" "^0.5.4" "@atproto/xrpc" "^0.7.7" await-lock "^2.2.2" multiformats "^9.9.0" @@ -44,14 +44,14 @@ "@atproto/syntax" "^0.5.1" zod "^3.23.8" -"@atproto/common-web@^0.4.20": - version "0.4.20" - resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.20.tgz#bb455868e674d45ed1044c68ccccae3c08168d47" - integrity sha512-RcsYT28yQgVi/Glb/hHPGpqpzIlKrbMLeldEd7PmmMLWDaJL2j3lb92qytvxjl1yhi2Ssq2TEuMZ2NlWaAbpow== +"@atproto/common-web@^0.4.21": + version "0.4.21" + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.21.tgz#2198583f842a000f495f1caec6f7e4eda207191b" + integrity sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw== dependencies: "@atproto/lex-data" "^0.0.15" - "@atproto/lex-json" "^0.0.15" - "@atproto/syntax" "^0.5.3" + "@atproto/lex-json" "^0.0.16" + "@atproto/syntax" "^0.5.4" zod "^3.23.8" "@atproto/lex-data@^0.0.14": @@ -82,10 +82,10 @@ "@atproto/lex-data" "^0.0.14" tslib "^2.8.1" -"@atproto/lex-json@^0.0.15": - version "0.0.15" - resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.15.tgz#34d300e5dfd8a0ec76ca7363f264a488e17c1bd9" - integrity sha512-kCLdP629H6GhgPjBTpZibUoqlpmW0hnVfZVwcD4s4Jch1KAqY/QcfL24Ih8wrW0Ok1YvtMIhjk98evdTA2OJcw== +"@atproto/lex-json@^0.0.16": + version "0.0.16" + resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.16.tgz#c99b5147560310f9f7f74405c57858a12c3e365a" + integrity sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg== dependencies: "@atproto/lex-data" "^0.0.15" tslib "^2.8.1" @@ -108,10 +108,10 @@ dependencies: tslib "^2.8.1" -"@atproto/syntax@^0.5.3": - version "0.5.3" - resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.3.tgz#4331d01f63fe56c374dcf95d4432a22b62271a17" - integrity sha512-gzhlHOJHm5KXdCc17fXi1fXM81ccs5jJfNgCui84ay9JGvczxegpYHNqdMlv+iBuhtBzFIjgx6ChjRxN/kO8kQ== +"@atproto/syntax@^0.5.4": + version "0.5.4" + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.4.tgz#89842eb8b8ab181752b04ed840cc6b100e296b00" + integrity sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw== dependencies: tslib "^2.8.1" From e804546809d5788c467697e79f859dc6cb11d8a5 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Wed, 15 Apr 2026 18:53:01 -0400 Subject: [PATCH 09/26] [APP-1934] replace image grid layout with carousel (#10157) Co-authored-by: RetroSunstar <57507616+RetroSunstar@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Eric Bailey --- src/analytics/features/types.ts | 1 + src/analytics/metrics/types.ts | 15 + src/components/Post/Embed/ImageEmbed.tsx | 17 + src/components/Post/Embed/index.tsx | 80 +-- src/components/images/Gallery/const.ts | 3 + src/components/images/Gallery/index.tsx | 531 ++++++++++++++++++ .../Gallery/maybeApplyGalleryOffsetStyles.ts | 102 ++++ src/components/images/Gallery/tween.ts | 40 ++ .../images/Gallery/useKeyboardHandlers.ts | 8 + .../images/Gallery/useKeyboardHandlers.web.ts | 91 +++ .../images/Gallery/usePointerHandlers.ts | 8 + .../images/Gallery/usePointerHandlers.web.ts | 270 +++++++++ src/components/images/Gallery/utils.ts | 22 + src/components/images/ImageLayoutGrid.tsx | 2 +- .../{Gallery.tsx => ImageLayoutGridItem.tsx} | 0 .../components/ThreadItemAnchor.tsx | 436 +++++++------- .../PostThread/components/ThreadItemPost.tsx | 49 +- .../components/ThreadItemTreePost.tsx | 55 +- src/view/com/post/Post.tsx | 181 +++--- src/view/com/posts/PostFeedItem.tsx | 297 +++++----- 20 files changed, 1697 insertions(+), 511 deletions(-) create mode 100644 src/components/images/Gallery/const.ts create mode 100644 src/components/images/Gallery/index.tsx create mode 100644 src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts create mode 100644 src/components/images/Gallery/tween.ts create mode 100644 src/components/images/Gallery/useKeyboardHandlers.ts create mode 100644 src/components/images/Gallery/useKeyboardHandlers.web.ts create mode 100644 src/components/images/Gallery/usePointerHandlers.ts create mode 100644 src/components/images/Gallery/usePointerHandlers.web.ts create mode 100644 src/components/images/Gallery/utils.ts rename src/components/images/{Gallery.tsx => ImageLayoutGridItem.tsx} (100%) diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index b86c62e5ba..70cb8fc38d 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -13,6 +13,7 @@ export enum Features { ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled', GroupChatsEnable = 'group_chats:enable', DmsNewMessageComposerEnable = 'dms:new_message_composer:enable', + PostGalleryEmbedEnable = 'post_gallery_embed:enable', AATest = 'aa-test', } diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index ccb256e219..1d74e1c5b8 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -1046,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/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/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/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/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index aef5457fd4..052de3bab3 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -27,6 +27,10 @@ import {Link} from '#/view/com/util/Link' import {PostMeta} from '#/view/com/util/PostMeta' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a} from '#/alf' +import { + GalleryBleed, + maybeApplyGalleryOffsetStyles, +} from '#/components/images/Gallery' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' @@ -155,89 +159,106 @@ function PostInner({ const [hover, setHover] = useState(false) return ( - { - setHover(true) - }} - onPointerLeave={() => { - setHover(false) - }}> - - {showReplyLine && } - - - - - - - {replyAuthorDid !== '' && ( - - )} - - - + { + setHover(true) + }} + onPointerLeave={() => { + setHover(false) + }}> + + {showReplyLine && } + + + - {richText.text ? ( - - - {limitLines && ( - - )} - - ) : undefined} - - {post.embed ? ( - + + + {replyAuthorDid !== '' && ( + + )} + + + - ) : null} - - + {richText.text ? ( + + + {limitLines && ( + + )} + + ) : undefined} + + {post.embed ? ( + + + + ) : null} + + + - - + + ) } diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 7184dbb569..c6a365e17c 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -34,6 +34,10 @@ import {Link} from '#/view/com/util/Link' import {PostMeta} from '#/view/com/util/PostMeta' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a} from '#/alf' +import { + GalleryBleed, + maybeApplyGalleryOffsetStyles, +} from '#/components/images/Gallery' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' @@ -163,6 +167,7 @@ let FeedItemInner = ({ const queryClient = useQueryClient() const {openComposer} = useOpenComposer() const pal = usePalette('default') + const {currentAccount} = useSession() const [hover, setHover] = useState(false) @@ -293,140 +298,6 @@ let FeedItemInner = ({ } }, [reason]) - return ( - { - setHover(true) - }} - onPointerLeave={() => { - setHover(false) - }}> - - - - {isThreadChild && ( - - )} - - - - {reason && ( - - )} - - - - - - - {isThreadParent && ( - - )} - - - - {showReplyTo && - (parentAuthor || isParentBlocked || isParentNotFound) && ( - - )} - - - - - - - - - ) -} -FeedItemInner = memo(FeedItemInner) - -let PostContent = ({ - post, - moderation, - richText, - postEmbed, - postAuthor, - onOpenEmbed, - threadgateRecord, -}: { - moderation: ModerationDecision - richText: RichTextAPI - postEmbed: AppBskyFeedDefs.PostView['embed'] - postAuthor: AppBskyFeedDefs.PostView['author'] - onOpenEmbed: () => void - post: AppBskyFeedDefs.PostView - threadgateRecord?: AppBskyFeedThreadgate.Record -}): React.ReactNode => { - const {currentAccount} = useSession() - const [limitLines, setLimitLines] = useState( - () => countLines(richText.text) >= MAX_POST_LINES, - ) const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ threadgateRecord, }) @@ -451,6 +322,150 @@ let PostContent = ({ : [] }, [post, currentAccount?.did, threadgateHiddenReplies]) + return ( + + { + setHover(true) + }} + onPointerLeave={() => { + setHover(false) + }}> + + + + {isThreadChild && ( + + )} + + + + {reason && ( + + )} + + + + + + + {isThreadParent && ( + + )} + + + + {showReplyTo && + (parentAuthor || isParentBlocked || isParentNotFound) && ( + + )} + + + + + + + + + + ) +} +FeedItemInner = memo(FeedItemInner) + +let PostContent = ({ + post, + moderation, + richText, + postEmbed, + postAuthor, + onOpenEmbed, + additionalPostAlerts, +}: { + moderation: ModerationDecision + richText: RichTextAPI + postEmbed: AppBskyFeedDefs.PostView['embed'] + postAuthor: AppBskyFeedDefs.PostView['author'] + onOpenEmbed: () => void + post: AppBskyFeedDefs.PostView + additionalPostAlerts?: AppModerationCause[] +}): React.ReactNode => { + const [limitLines, setLimitLines] = useState( + () => countLines(richText.text) >= MAX_POST_LINES, + ) + const record = useMemo( () => bsky.validate(post.record, AppBskyFeedPost.validateRecord) @@ -492,7 +507,15 @@ let PostContent = ({ ) : undefined} {record && } {postEmbed ? ( - + Date: Wed, 15 Apr 2026 20:43:01 -0500 Subject: [PATCH 10/26] Add gate debug code (#10264) --- src/analytics/PassiveAnalytics.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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]) From 6d8b4a2070b17ee264078d88bb583b6074084c9b Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Thu, 16 Apr 2026 03:15:06 +0000 Subject: [PATCH 11/26] Nightly source-language update --- src/locale/locales/en/messages.po | 762 ++++++++++++++++++++---------- 1 file changed, 519 insertions(+), 243 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index ebc04f79ae..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,6 +2017,8 @@ 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:399 @@ -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 @@ -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 "" @@ -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,7 +3991,7 @@ msgstr "" msgid "Explicit sexual images." msgstr "" -#: src/Navigation.tsx:818 +#: src/Navigation.tsx:824 #: src/screens/Search/Shell.tsx:356 #: src/view/shell/desktop/LeftNav.tsx:691 #: src/view/shell/Drawer.tsx:402 @@ -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 "" @@ -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 "" @@ -4709,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 "" @@ -4717,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 "" @@ -4811,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 "" @@ -4824,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 "" @@ -4857,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" @@ -4910,7 +5024,7 @@ msgstr "" msgid "Harming or endangering minors" msgstr "" -#: src/Navigation.tsx:559 +#: src/Navigation.tsx:560 msgid "Hashtag" msgstr "" @@ -5116,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 @@ -5243,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 "" @@ -5263,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." @@ -5447,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 "" @@ -5455,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 "" @@ -5482,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 "" @@ -5507,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 "" @@ -5550,7 +5688,7 @@ msgstr "" msgid "Labels on your content" msgstr "" -#: src/Navigation.tsx:222 +#: src/Navigation.tsx:223 msgid "Language Settings" msgstr "" @@ -5664,6 +5802,7 @@ msgid "Learn more." msgstr "" #: src/components/dms/LeaveConvoPrompt.tsx:52 +#: src/screens/Messages/ConversationSettings.tsx:829 msgid "Leave" msgstr "" @@ -5680,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" @@ -5732,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 "" @@ -5744,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 "" @@ -5782,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 "" @@ -5795,7 +5938,7 @@ msgstr "" msgid "Linear" msgstr "" -#: src/Navigation.tsx:255 +#: src/Navigation.tsx:256 msgid "List" msgstr "" @@ -5881,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 @@ -5970,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 "" @@ -6076,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 "" @@ -6096,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" @@ -6105,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 "" @@ -6114,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 @@ -6126,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 "" @@ -6139,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 "" @@ -6156,7 +6341,7 @@ msgstr "" msgid "Minor harassment or bullying" msgstr "" -#: src/Navigation.tsx:520 +#: src/Navigation.tsx:521 msgid "Miscellaneous notifications" msgstr "" @@ -6168,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 "" @@ -6212,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 "" @@ -6221,7 +6406,7 @@ msgstr "" msgid "moderation settings" msgstr "" -#: src/Navigation.tsx:315 +#: src/Navigation.tsx:316 msgid "Moderation states" msgstr "" @@ -6267,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" @@ -6308,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 "" @@ -6342,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 "" @@ -6442,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" @@ -6460,7 +6657,7 @@ msgstr "" msgid "New Feature" msgstr "" -#: src/Navigation.tsx:488 +#: src/Navigation.tsx:489 msgid "New follower notifications" msgstr "" @@ -6639,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 "" @@ -6789,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 "" @@ -6819,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 "" @@ -6833,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 @@ -6864,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" @@ -6901,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 "" @@ -6924,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 "" @@ -6940,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 "" @@ -6988,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 "" @@ -7002,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 "" @@ -7024,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 "" @@ -7121,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 "" @@ -7135,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 "" @@ -7314,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 "" @@ -7612,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 "" @@ -7632,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 "" @@ -7642,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 "" @@ -7658,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 "" @@ -7679,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 "" @@ -7741,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 "" @@ -7779,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" @@ -7793,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 @@ -7806,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 "" @@ -7856,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 "" @@ -7903,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 "" @@ -7937,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 "" @@ -7958,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 "" @@ -8040,7 +8250,7 @@ msgstr "" msgid "Recommended" msgstr "" -#: src/screens/Messages/components/MessageListError.tsx:20 +#: src/screens/Messages/components/MessageListError.tsx:19 msgid "Reconnect" msgstr "" @@ -8081,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 "" @@ -8127,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 @@ -8203,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 "" @@ -8298,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 "" @@ -8319,7 +8537,7 @@ msgstr "" msgid "Reply Hidden by You" msgstr "" -#: src/Navigation.tsx:448 +#: src/Navigation.tsx:449 msgid "Reply notifications" msgstr "" @@ -8340,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 "" @@ -8375,7 +8594,7 @@ msgstr "" msgid "Report list" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:170 +#: src/components/dms/MessageContextMenu.tsx:171 msgid "Report message" msgstr "" @@ -8402,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 "" @@ -8441,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 "" @@ -8472,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 "" @@ -8482,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 "" @@ -8573,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 @@ -8583,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 @@ -8632,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 @@ -8666,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 "" @@ -8716,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 "" @@ -8764,7 +8987,7 @@ 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 "" @@ -9106,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 "" @@ -9177,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 @@ -9322,7 +9545,7 @@ msgstr "" msgid "Share your favorite feed!" msgstr "" -#: src/Navigation.tsx:320 +#: src/Navigation.tsx:321 msgid "Shared Preferences Tester" msgstr "" @@ -9441,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 "" @@ -9568,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 "" @@ -9605,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 "" @@ -9619,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 "" @@ -9695,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 "" @@ -9718,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 "" @@ -9781,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 "" @@ -9883,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" @@ -9959,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 "" @@ -9999,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 @@ -10241,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 @@ -10333,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 "" @@ -10367,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 "" @@ -10473,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 "" @@ -10493,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 "" @@ -10601,7 +10849,7 @@ msgstr "" msgid "Threaded" msgstr "" -#: src/Navigation.tsx:373 +#: src/Navigation.tsx:374 msgid "Threads Preferences" msgstr "" @@ -10632,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 "" @@ -10669,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 @@ -10747,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 "" @@ -10819,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 @@ -10890,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 "" @@ -10912,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" @@ -10945,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" @@ -11020,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 "" @@ -11086,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 "" @@ -11099,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 "" @@ -11236,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 "" @@ -11343,7 +11611,7 @@ msgstr "" msgid "Video failed to process" msgstr "" -#: src/Navigation.tsx:620 +#: src/Navigation.tsx:626 msgid "Video Feed" msgstr "" @@ -11378,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 "" @@ -11395,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 "" @@ -11420,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 "" @@ -11445,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 "" @@ -11460,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 "" @@ -11592,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 "" @@ -11736,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 "" @@ -11787,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 "" @@ -11864,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 "" @@ -11929,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 "" @@ -12029,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 "" @@ -12168,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 "" @@ -12256,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 "" @@ -12294,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 "" @@ -12493,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 @@ -12530,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 "" @@ -12555,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 "" From 8c5899fc93f999159e72ade12a5ce6ab05cd1335 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 16 Apr 2026 10:06:47 -0500 Subject: [PATCH 12/26] Add warning log to gates init (#10268) --- src/analytics/features/index.ts | 10 +++++++++- src/logger/types.ts | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/analytics/features/index.ts b/src/analytics/features/index.ts index afd2d2b089..4644d7f1b4 100644 --- a/src/analytics/features/index.ts +++ b/src/analytics/features/index.ts @@ -2,11 +2,13 @@ import {MMKV} from '@bsky.app/react-native-mmkv' import {setPolyfills} from '@growthbook/growthbook' import {GrowthBook} from '@growthbook/growthbook-react' +import {Logger} from '#/logger' import {getNavigationMetadata, type Metadata} from '#/analytics/metadata' import * as env from '#/env' export {Features} from '#/analytics/features/types' +const logger = Logger.create(Logger.Context.Growthbook) const CACHE = new MMKV({id: 'bsky_features_cache'}) setPolyfills({ @@ -44,7 +46,13 @@ export const features = new GrowthBook({ * initialization completes. */ export const init = new Promise(async y => { - await features.init({timeout: TIMEOUT_INIT}) + const res = await features.init({timeout: TIMEOUT_INIT}) + if (!res.success) { + logger.warn('GrowthBook initialization failed or timed out', { + source: res.source, + safeMessage: res.error?.toString(), + }) + } y() }) diff --git a/src/logger/types.ts b/src/logger/types.ts index 826a1bcc1e..cc700dc58b 100644 --- a/src/logger/types.ts +++ b/src/logger/types.ts @@ -16,6 +16,7 @@ export enum LogContext { PolicyUpdate = 'policy-update', Geolocation = 'geolocation', Drafts = 'drafts', + Growthbook = 'growthbook', /** * METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this From cc861093c292eee75a21876eea82a88455c2a98e Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 16 Apr 2026 08:31:38 -0700 Subject: [PATCH 13/26] Make padding equal in the bottom bar (#10265) --- src/view/shell/bottom-bar/BottomBarStyles.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx index 2602862dcb..a7c76eb332 100644 --- a/src/view/shell/bottom-bar/BottomBarStyles.tsx +++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx @@ -1,6 +1,6 @@ import {StyleSheet} from 'react-native' -import {atoms as a} from '#/alf' +import {atoms as a, tokens} from '#/alf' export const styles = StyleSheet.create({ bottomBar: { @@ -10,8 +10,8 @@ export const styles = StyleSheet.create({ right: 0, flexDirection: 'row', borderTopWidth: StyleSheet.hairlineWidth, - paddingLeft: 5, - paddingRight: 10, + paddingLeft: tokens.space.sm, + paddingRight: tokens.space.sm, }, bottomBarWeb: a.fixed, ctrl: { From cc8f22887f1ee91351d49cbb5e8b8436b9b4cf02 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 16 Apr 2026 11:37:03 -0500 Subject: [PATCH 14/26] [APP-2031] DM composer fast-follows (#10269) --- package.json | 4 ++-- src/components/Autocomplete/Autocomplete.tsx | 12 +++++++++++- yarn.lock | 16 ++++++++-------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 1623c5c139..08e3e22bea 100644 --- a/package.json +++ b/package.json @@ -89,8 +89,8 @@ "@bsky.app/expo-scroll-edge-effect": "^0.1.4", "@bsky.app/expo-translate-text": "^0.2.9", "@bsky.app/react-native-mmkv": "2.12.5", - "@bsky.app/sift": "^0.3.2", - "@bsky.app/tapper": "^0.5.0", + "@bsky.app/sift": "^0.3.3", + "@bsky.app/tapper": "^0.5.1", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx index daee24847d..f8cbfd6dd4 100644 --- a/src/components/Autocomplete/Autocomplete.tsx +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -58,7 +58,17 @@ export function Autocomplete({ data={data} onSelect={onSelect} onDismiss={onDismiss} - style={[ + outerStyle={[ + a.rounded_md, + a.w_full, + t.atoms.shadow_lg, + IS_WEB + ? { + maxWidth: 300, + } + : {}, + ]} + innerStyle={[ a.overflow_hidden, a.rounded_md, a.border, diff --git a/yarn.lock b/yarn.lock index 1a160ff2a2..ff02962745 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2444,15 +2444,15 @@ resolved "https://registry.yarnpkg.com/@bsky.app/react-native-mmkv/-/react-native-mmkv-2.12.5.tgz#eb17d31a6158c74393f617a1763ac223ff3f83a6" integrity sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg== -"@bsky.app/sift@^0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@bsky.app/sift/-/sift-0.3.2.tgz#04122bf665fad3a7eb90e1d689028ec9a209c56f" - integrity sha512-u48gzcA5QNkvfWzH+gnouXxLulrgA6yIc7NwSBnEZt9XwZfClLfvEDjRk/VxU4AJlEwQRhY2ZC593H0oo+5/bQ== +"@bsky.app/sift@^0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@bsky.app/sift/-/sift-0.3.3.tgz#71e7e34b2c6a6ef681b045575bf1aa94ece40783" + integrity sha512-EhBg6KR+G67gnXZDzDTAXhuMez/Xok5l1Q3FwWkSw4HXZ5uDy1vrwb86F6YvalTMUZ9bys7b5Lo3rOF5jNXdRQ== -"@bsky.app/tapper@^0.5.0": - version "0.5.0" - resolved "https://registry.yarnpkg.com/@bsky.app/tapper/-/tapper-0.5.0.tgz#39f3814a063cc0e8ee58c05e09be3d5cb8638f22" - integrity sha512-Fb7L2CruOA/k/FgKDOGChr+JKXsf+geAOTZXDevs9oqbSYTrXuI8KrRgaDwPS+FVCp1vAYHC/3esuVv+lbUtnw== +"@bsky.app/tapper@^0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@bsky.app/tapper/-/tapper-0.5.1.tgz#7c72e1903435290a29be9f33fe0fcba95bbfa554" + integrity sha512-roGmW6Fk9qE8N0u9d74XzX9+MUIr04PElOhfIg0pXtZ1buaORpasLBBC+i6WytxDP2p29CuFdzBXDaBhmcI/ow== "@crowdin/cli@^4.14.1": version "4.14.1" From f51602b3fe50af7b9d1c758e7a49b1d8326e8552 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 16 Apr 2026 09:55:30 -0700 Subject: [PATCH 15/26] Support group chats across other surfaces (#10266) --- src/components/AvatarBubbles.tsx | 20 +- .../PostControls/ShareMenu/RecentChats.tsx | 78 ++++--- .../dialogs/SearchablePeopleList.tsx | 217 +++++++++++++++--- src/components/dms/dialogs/NewChatDialog.tsx | 17 +- .../dms/dialogs/ShareViaChatDialog.tsx | 15 +- src/components/dms/dialogs/TextInput.tsx | 1 - src/components/dms/dialogs/TextInput.web.tsx | 1 - src/components/dms/util.ts | 101 +++++++- .../Messages/components/ChatListItem.tsx | 114 +++------ .../Messages/components/RequestListItem.tsx | 27 ++- .../queries/messages/list-conversations.tsx | 6 +- 11 files changed, 436 insertions(+), 161 deletions(-) delete mode 100644 src/components/dms/dialogs/TextInput.tsx delete mode 100644 src/components/dms/dialogs/TextInput.web.tsx diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx index 2dd2f3b203..44fd7f4e90 100644 --- a/src/components/AvatarBubbles.tsx +++ b/src/components/AvatarBubbles.tsx @@ -18,7 +18,7 @@ import type * as bsky from '#/types/bsky' type Props = { animate?: boolean profiles: bsky.profile.AnyProfileView[] - size?: 'small' | 'medium' | 'large' + size?: 'small' | 'medium' | 'large' | number } export function AvatarBubbles({ @@ -28,8 +28,22 @@ export function AvatarBubbles({ }: 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 containerSize = + typeof size === 'number' + ? size + : size === 'small' + ? 40 + : size === 'medium' + ? 56 + : 120 + const scale = + typeof size === 'number' + ? size / 120 + : size === 'small' + ? 40 / 120 + : size === 'medium' + ? 56 / 120 + : 1 const marginOffset = size === 'small' || size === 'medium' ? -2 : 0 const initialValue = animate ? 0 : 1 diff --git a/src/components/PostControls/ShareMenu/RecentChats.tsx b/src/components/PostControls/ShareMenu/RecentChats.tsx index 24fcc87b3a..5e3cecf077 100644 --- a/src/components/PostControls/ShareMenu/RecentChats.tsx +++ b/src/components/PostControls/ShareMenu/RecentChats.tsx @@ -6,21 +6,21 @@ import {Trans} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {type NavigationProp} from '#/lib/routes/types' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {useSession} from '#/state/session' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, tokens, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' import {Button} from '#/components/Button' import {useDialogContext} from '#/components/Dialog' +import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util' import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' -import type * as bsky from '#/types/bsky' export function RecentChats({ postUri, @@ -60,23 +60,24 @@ export function RecentChats({ showsHorizontalScrollIndicator={false} nestedScrollEnabled> {convos && convos.length > 0 ? ( - convos.map(convo => { - const otherMember = convo.members.find( - member => member.did !== currentAccount?.did, - ) + convos.map(c => { + const convo = parseConvoView(c, currentAccount?.did) + + if (!convo) return null if ( - !otherMember || - otherMember.handle === 'missing.invalid' || - convo.muted - ) + (convo.kind === 'direct' && + convo.primaryMember.handle === 'missing.invalid') || + convo.view.muted + ) { return null + } return ( onSelectChat(convo.id)} + key={convo.view.id} + convo={convo} + onPress={() => onSelectChat(convo.view.id)} moderationOpts={moderationOpts} /> ) @@ -99,26 +100,33 @@ export function RecentChats({ const WIDTH = 80 function RecentChatItem({ - profile: profileUnshadowed, onPress, moderationOpts, + convo, }: { - profile: bsky.profile.AnyProfileView onPress: () => void moderationOpts: ModerationOpts + convo: ConvoWithDetails }) { const {_} = useLingui() const t = useTheme() - const profile = useProfileShadow(profileUnshadowed) + const primaryProfile = useProfileShadow(convo.primaryMember) - const moderation = moderateProfile(profile, moderationOpts) - const name = sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), - ) + const moderation = moderateProfile(primaryProfile, moderationOpts) + const name = + convo.kind === 'group' + ? convo.details.name + : createSanitizedDisplayName( + primaryProfile, + true, + moderation.ui('displayName'), + ) - if (isBlockedOrBlocking(profile) || isMuted(profile)) { + if ( + convo.kind === 'direct' && + (isBlockedOrBlocking(primaryProfile) || isMuted(primaryProfile)) + ) { return null } @@ -133,12 +141,16 @@ function RecentChatItem({ a.justify_start, a.align_center, ]}> - + {convo.kind === 'group' ? ( + + ) : ( + + )} {name} - + {convo.kind === 'direct' && ( + + )} ) diff --git a/src/components/dialogs/SearchablePeopleList.tsx b/src/components/dialogs/SearchablePeopleList.tsx index 9471d48c04..83b59580f3 100644 --- a/src/components/dialogs/SearchablePeopleList.tsx +++ b/src/components/dialogs/SearchablePeopleList.tsx @@ -8,11 +8,9 @@ import { } from 'react' import {TextInput, View} from 'react-native' import {moderateProfile, type ModerationOpts} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Plural, Trans, useLingui} from '@lingui/react/macro' -import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {sanitizeHandle} from '#/lib/strings/handles' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' @@ -23,7 +21,11 @@ import {type ListMethods} from '#/view/com/util/List' import {android, atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {canBeMessaged} from '#/components/dms/util' +import { + canBeMessaged, + type ConvoWithDetails, + parseConvoView, +} from '#/components/dms/util' import {useInteractionState} from '#/components/hooks/useInteractionState' import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' @@ -31,6 +33,9 @@ import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' import {IS_WEB} from '#/env' import type * as bsky from '#/types/bsky' +import {AvatarBubbles} from '../AvatarBubbles' +import {Error} from '../Error' +import {ProfileBadges} from '../ProfileBadges' export type ProfileItem = { type: 'profile' @@ -38,6 +43,12 @@ export type ProfileItem = { profile: bsky.profile.AnyProfileView } +type ExistingChatItem = { + type: 'existingChat' + key: string + convo: ConvoWithDetails +} + type EmptyItem = { type: 'empty' key: string @@ -54,7 +65,12 @@ type ErrorItem = { key: string } -type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem +type Item = + | ProfileItem + | ExistingChatItem + | EmptyItem + | PlaceholderItem + | ErrorItem export function SearchablePeopleList({ title, @@ -72,12 +88,14 @@ export function SearchablePeopleList({ onSelectChat?: undefined } | { - onSelectChat: (did: string) => void + onSelectChat: ( + chat: {kind: 'user'; did: string} | {kind: 'convo'; id: string}, + ) => void renderProfileCard?: undefined } )) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const moderationOpts = useModerationOpts() const control = Dialog.useDialogContext() const [headerHeight, setHeaderHeight] = useState(0) @@ -105,7 +123,7 @@ export function SearchablePeopleList({ _items.push({ type: 'empty', key: 'empty', - message: _(msg`We're having network issues, try again`), + message: l`We're having network issues, try again`, }) } else if (searchText.length) { if (results?.length) { @@ -139,20 +157,27 @@ export function SearchablePeopleList({ const usedDids = new Set() for (const page of convos.pages) { - for (const convo of page.convos) { - const profiles = convo.members.filter( - m => m.did !== currentAccount?.did, - ) + for (const convoView of page.convos) { + const convo = parseConvoView(convoView, currentAccount?.did) - for (const profile of profiles) { - if (usedDids.has(profile.did)) continue + if (!convo) continue - usedDids.add(profile.did) + if (convo.kind === 'group') { + _items.push({ + type: 'existingChat', + key: convo.view.id, + convo, + }) + } else { + if (convo.primaryMember.handle === 'missing.invalid') continue + if (usedDids.has(convo.primaryMember.did)) continue + + usedDids.add(convo.primaryMember.did) _items.push({ - type: 'profile', - key: profile.did, - profile, + type: 'existingChat', + key: convo.view.id, + convo: convo, }) } } @@ -209,7 +234,7 @@ export function SearchablePeopleList({ return _items }, [ - _, + l, searchText, results, isError, @@ -221,12 +246,27 @@ export function SearchablePeopleList({ ]) if (searchText && !isFetching && !items.length && !isError) { - items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) + items.push({type: 'empty', key: 'empty', message: l`No results`}) } const renderItems = useCallback( ({item}: {item: Item}) => { switch (item.type) { + case 'existingChat': { + if (renderProfileCard) { + // should be unreachable + return null + } else { + return ( + onSelectChat({kind: 'convo', id})} + /> + ) + } + } case 'profile': { if (renderProfileCard) { return {renderProfileCard(item)} @@ -236,7 +276,7 @@ export function SearchablePeopleList({ key={item.key} profile={item.profile} moderationOpts={moderationOpts!} - onPress={onSelectChat} + onPress={did => onSelectChat({kind: 'user', did})} /> ) } @@ -247,11 +287,14 @@ export function SearchablePeopleList({ case 'empty': { return } + case 'error': { + return + } default: return null } }, - [moderationOpts, onSelectChat, renderProfileCard], + [moderationOpts, onSelectChat, renderProfileCard, l], ) useLayoutEffect(() => { @@ -293,7 +336,7 @@ export function SearchablePeopleList({ {IS_WEB ? ( + ) +} + function ProfileCardSkeleton() { const t = useTheme() @@ -488,7 +639,7 @@ function SearchInput({ inputRef: React.RefObject }) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const { state: hovered, onIn: onMouseEnter, @@ -512,7 +663,7 @@ function SearchInput({ ) diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index f0861baf45..6686a6ffe2 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -77,6 +77,15 @@ export function NewChat({ [control, createGroupChat], ) + const onSelectExistingChat = useCallback( + (chatId: string) => { + control.close(() => { + onNewChat(chatId) + }) + }, + [control, onNewChat], + ) + const onPress = useCallback(() => { control.open() }, [control]) @@ -112,7 +121,13 @@ export function NewChat({ ) : ( { + if (chat.kind === 'user') { + onCreateChat(chat.did) + } else { + onSelectExistingChat(chat.id) + } + }} sortByMessageDeclaration /> )} diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx index faf3545519..30cd80862d 100644 --- a/src/components/dms/dialogs/ShareViaChatDialog.tsx +++ b/src/components/dms/dialogs/ShareViaChatDialog.tsx @@ -53,6 +53,13 @@ function SendViaChatDialogInner({ }, }) + const onSelectExistingChat = useCallback( + (chatId: string) => { + control.close(() => onSelectChat(chatId)) + }, + [control, onSelectChat], + ) + const onCreateChat = useCallback( (did: string) => { control.close(() => createChat([did])) @@ -63,7 +70,13 @@ function SendViaChatDialogInner({ return ( { + if (chat.kind === 'user') { + onCreateChat(chat.did) + } else { + onSelectExistingChat(chat.id) + } + }} showRecentConvos sortByMessageDeclaration /> diff --git a/src/components/dms/dialogs/TextInput.tsx b/src/components/dms/dialogs/TextInput.tsx deleted file mode 100644 index b4e77e3e07..0000000000 --- a/src/components/dms/dialogs/TextInput.tsx +++ /dev/null @@ -1 +0,0 @@ -export {BottomSheetTextInput as TextInput} from '@discord/bottom-sheet/src' diff --git a/src/components/dms/dialogs/TextInput.web.tsx b/src/components/dms/dialogs/TextInput.web.tsx deleted file mode 100644 index 5371a534f1..0000000000 --- a/src/components/dms/dialogs/TextInput.web.tsx +++ /dev/null @@ -1 +0,0 @@ -export {TextInput} from 'react-native' diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts index 2bcc9c3bdf..491023cf2f 100644 --- a/src/components/dms/util.ts +++ b/src/components/dms/util.ts @@ -1,7 +1,8 @@ -import {type ChatBskyConvoDefs} from '@atproto/api' +import {type $Typed, ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' import {EMOJI_REACTION_LIMIT} from '#/lib/constants' -import type * as bsky from '#/types/bsky' +import {logger} from '#/logger' +import * as bsky from '#/types/bsky' export function canBeMessaged(profile: bsky.profile.AnyProfileView) { switch (profile.associated?.chat?.allowIncoming) { @@ -54,3 +55,99 @@ export function hasReachedReactionLimit( ) return myReactions.length >= EMOJI_REACTION_LIMIT } + +type GroupConvoMember = ChatBskyActorDefs.ProfileViewBasic & { + // can be missing if account deleted + kind?: $Typed +} + +type DirectConvoMember = ChatBskyActorDefs.ProfileViewBasic & { + kind: $Typed +} + +export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & ( + | { + kind: 'group' + details: ChatBskyConvoDefs.GroupConvo + primaryMember: GroupConvoMember // the owner + members: Array + } + | { + kind: 'direct' + details: ChatBskyConvoDefs.DirectConvo + primaryMember: DirectConvoMember // the other user + members: Array + } +) + +/** + * Converts a raw convoView into something easier to use (i.e. extracts chat owner) + * and enforces the correct type for convo members. + */ +export function parseConvoView( + convoView: ChatBskyConvoDefs.ConvoView, + ownDid: string | undefined, +): ConvoWithDetails | null { + if ( + bsky.dangerousIsType( + convoView.kind, + ChatBskyConvoDefs.isGroupConvo, + ) + ) { + let owner: GroupConvoMember | undefined = undefined + + for (const member of convoView.members) { + if ( + bsky.dangerousIsType( + member.kind, + ChatBskyActorDefs.isGroupConvoMember, + ) + ) { + if (member.kind.role === 'owner') { + // have to do a type assertion here + // this works: {...member, kind: member.kind} + // however that's creating a new object for no good reason + owner = member as GroupConvoMember + } + } else { + throw new Error( + 'Expected a GroupConvoMember, got an unknown kind of member', + ) + } + } + + if (!owner) { + throw new Error('No owner found in group convo') + } + + return { + view: convoView, + kind: 'group', + details: convoView.kind, + primaryMember: owner, + members: convoView.members as Array, + } + } else if ( + bsky.dangerousIsType( + convoView.kind, + ChatBskyConvoDefs.isDirectConvo, + ) + ) { + const otherUser = convoView.members.find(m => m.did !== ownDid) + + if (!otherUser) { + throw new Error('No other user found in direct convo') + } + + return { + view: convoView, + kind: 'direct', + details: convoView.kind, + primaryMember: otherUser as DirectConvoMember, + members: convoView.members as Array, + } + } else { + logger.warn('Unknown convo kind: ' + JSON.stringify(convoView.kind)) + return null + } +} diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index 68e29620da..1f51656b1c 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -2,7 +2,6 @@ import {useCallback, useMemo, useState} from 'react' import {type GestureResponderEvent, View} from 'react-native' import { AppBskyEmbedRecord, - ChatBskyActorDefs, ChatBskyConvoDefs, moderateProfile, type ModerationDecision, @@ -38,6 +37,7 @@ import {AvatarBubbles} from '#/components/AvatarBubbles' import {useDialogControl} from '#/components/Dialog' import {ConvoMenu} from '#/components/dms/ConvoMenu' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' +import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util' import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpen} from '#/components/icons/EnveopeOpen' import {Trash_Stroke2_Corner0_Rounded} from '#/components/icons/Trash' @@ -49,7 +49,7 @@ import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' -import * as bsky from '#/types/bsky' +import type * as bsky from '#/types/bsky' export const ChatListItemPortal = createPortalGroup() @@ -60,7 +60,7 @@ export const ChatListItemPortal = createPortalGroup() */ export function ChatListItem({ - convo, + convo: convoView, showMenu = true, children, }: { @@ -75,83 +75,47 @@ export function ChatListItem({ return null } - 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') + const convo = parseConvoView(convoView, currentAccount?.did) + + switch (convo?.kind) { + case 'direct': { + return ( + + {children} + + ) } - - return ( - - ) - } else if ( - bsky.dangerousIsType( - convo.kind, - ChatBskyConvoDefs.isDirectConvo, - ) - ) { - const otherMember = convo.members.find( - member => member.did !== currentAccount?.did, - ) - - if (!otherMember) { + case 'group': { + return ( + + ) + } + default: { return null } - return ( - - {children} - - ) - } else { - return null } } function DirectChatItem({ convo, - profile: profileUnshadowed, moderationOpts, showMenu, children, }: { - convo: ChatBskyConvoDefs.ConvoView - profile: bsky.profile.AnyProfileView + convo: Extract moderationOpts: ModerationOpts showMenu?: boolean children?: React.ReactNode }) { const {t: l} = useLingui() - const profile = useProfileShadow(profileUnshadowed) + const profile = useProfileShadow(convo.primaryMember) const moderation = useMemo( () => moderateProfile(profile, moderationOpts), @@ -165,7 +129,7 @@ function DirectChatItem({ return ( moderationOpts: ModerationOpts showMenu?: boolean children?: React.ReactNode }) { const {t: l} = useLingui() - const groupOwner = useProfileShadow(groupOwnerUnshadowed) + const groupOwner = useProfileShadow(convo.primaryMember) const moderation = useMemo( () => moderateProfile(groupOwner, moderationOpts), [groupOwner, moderationOpts], ) - const chatName = groupInfo.name ?? l`${groupOwner.handle}'s group chat` + const chatName = convo.details.name return ( } title={chatName} accessibilityHint={l`Go to the group chat named "${chatName}"`} @@ -507,7 +469,7 @@ function BaseChatItem({ label={title} accessibilityHint={accessibilityHint} accessibilityActions={ - IS_NATIVE + showMenu && IS_NATIVE ? [ { name: 'magicTap', @@ -521,8 +483,8 @@ function BaseChatItem({ : undefined } onPress={onPress} - onLongPress={IS_NATIVE ? onLongPress : undefined} - onAccessibilityAction={onLongPress}> + onLongPress={showMenu && IS_NATIVE ? onLongPress : undefined} + onAccessibilityAction={showMenu ? onLongPress : undefined}> {({hovered, pressed, focused}) => ( member.did !== currentAccount?.did, - ) + const convo = parseConvoView(convoView, currentAccount?.did) - if (!otherUser || !moderationOpts) { + if (!convo || !moderationOpts) { return null } - const isDeletedAccount = otherUser.handle === 'missing.invalid' + const isDeletedAccount = convo.primaryMember.handle === 'missing.invalid' return ( - + {!isDeletedAccount ? ( <> - + ) : ( <> - + )} diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index c5457d1cb8..4c21bbbfeb 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -24,17 +24,20 @@ export const RQKEY_ROOT = 'convo-list' export const RQKEY = ( status: 'accepted' | 'request' | 'all', readState: 'all' | 'unread' = 'all', -) => [RQKEY_ROOT, status, readState] + kind: 'all' | 'group' | 'direct' = 'all', +) => [RQKEY_ROOT, status, readState, kind] type RQPageParam = string | undefined export function useListConvosQuery({ enabled, status, readState = 'all', + kind = 'all', }: { enabled?: boolean status?: 'request' | 'accepted' readState?: 'all' | 'unread' + kind?: 'all' | 'group' | 'direct' } = {}) { const agent = useAgent() @@ -47,6 +50,7 @@ export function useListConvosQuery({ limit: 20, cursor: pageParam, readState: readState === 'unread' ? 'unread' : undefined, + kind: kind === 'all' ? undefined : kind, status, }, {headers: DM_SERVICE_HEADERS}, From a9e170b6d0962600a68f291e964ba437bbaee260 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:15:59 -0700 Subject: [PATCH 16/26] Integrate more group chat settings (#10253) --- src/screens/Messages/ConversationSettings.tsx | 73 +++++++++++++++---- 1 file changed, 58 insertions(+), 15 deletions(-) diff --git a/src/screens/Messages/ConversationSettings.tsx b/src/screens/Messages/ConversationSettings.tsx index e846e1c231..1e240623d0 100644 --- a/src/screens/Messages/ConversationSettings.tsx +++ b/src/screens/Messages/ConversationSettings.tsx @@ -3,7 +3,7 @@ import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' import {type ChatBskyConvoDefs, moderateProfile} from '@atproto/api' import {plural} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro' -import {useNavigation} from '@react-navigation/native' +import {StackActions, useNavigation} from '@react-navigation/native' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' @@ -22,6 +22,7 @@ import {ConvoStatus} from '#/state/messages/convo/types' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' +import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' import {useMuteConvo} from '#/state/queries/messages/mute-conversation' import {useProfileBlockMutationQueue} from '#/state/queries/profile' import {useSession} from '#/state/session' @@ -186,9 +187,7 @@ function SettingsInner() { return ( () const convoState = useConvo() const {currentAccount} = useSession() @@ -686,8 +686,21 @@ function SettingsHeader({ Toast.show(l({message: 'Group chat unmuted', context: 'toast'})) } }, - onError: () => { - Toast.show(l`Could not mute group chat`, { + onError: e => { + logger.error('Failed to mute group chat', {message: e}) + Toast.show(l`Failed to mute group chat`, { + type: 'error', + }) + }, + }) + + const {mutate: leaveConvo} = useLeaveConvo(convo.id, { + onMutate: () => { + navigation.dispatch(StackActions.pop(2)) + }, + onError: e => { + logger.error('Failed to leave group chat', {message: e}) + Toast.show(l({message: 'Failed to leave group chat', context: 'toast'}), { type: 'error', }) }, @@ -696,6 +709,7 @@ function SettingsHeader({ const editNamePrompt = Prompt.usePromptControl() const inviteLinkPrompt = Prompt.usePromptControl() const lockChatPrompt = Prompt.usePromptControl() + const leaveChatPrompt = Prompt.usePromptControl() const [groupName, setGroupName] = useState( convoState.getGroupInfo?.()?.name ?? '', @@ -705,15 +719,15 @@ function SettingsHeader({ 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'}) - } + muteConvo({mute: !convo?.muted}) } + const handleLeaveChat = () => { + leaveChatPrompt.open() + } + + const handleReportChat = () => {} + const handlePromptName = () => { editNamePrompt.open() } @@ -818,7 +832,7 @@ function SettingsHeader({ icon={FlagIcon} label={l`Report this group chat`} text={l`Report`} - onPress={() => {}} + onPress={handleReportChat} /> )} {isOwner ? null : ( @@ -827,7 +841,7 @@ function SettingsHeader({ icon={ArrowBoxLeftIcon} label={l`Leave this group chat`} text={l`Leave`} - onPress={() => {}} + onPress={handleLeaveChat} /> )} @@ -843,6 +857,11 @@ function SettingsHeader({ onConfirm={handleConfirmInviteLink} /> + ) } @@ -1042,6 +1061,30 @@ function LockChatPrompt({ ) } +function LeaveChatPrompt({ + control, + groupName, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + groupName: string + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} + function BlockMemberPrompt({ control, onConfirm, From e10c05d735adcfab3baae7cb82ee4b4127871189 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:17:01 -0700 Subject: [PATCH 17/26] Reveal chat timestamp on tap (#10262) --- src/components/ContextMenu/index.tsx | 21 +++- src/components/ContextMenu/types.ts | 8 ++ src/components/dms/ActionsWrapper.tsx | 5 +- src/components/dms/ActionsWrapper.web.tsx | 23 ++-- src/components/dms/DateDivider.tsx | 2 +- src/components/dms/DateDividerToggle.tsx | 44 +++++++ src/components/dms/MessageContextMenu.tsx | 5 +- src/components/dms/MessageItem.tsx | 115 ++++++++++++------ .../Messages/components/MessagesList.tsx | 5 +- 9 files changed, 178 insertions(+), 50 deletions(-) create mode 100644 src/components/dms/DateDividerToggle.tsx diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index cce4332dc4..ea4badde82 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -235,7 +235,13 @@ export function Root({children}: {children: React.ReactNode}) { return {children} } -export function Trigger({children, label, contentLabel, style}: TriggerProps) { +export function Trigger({ + children, + label, + contentLabel, + style, + onTap, +}: TriggerProps) { const context = useContextMenuContext() const playHaptic = useHaptics() const insets = useSafeAreaInsets() @@ -294,6 +300,17 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { } }, [context, insets]) + const tapGesture = useMemo(() => { + const gesture = Gesture.Tap() + .numberOfTaps(1) + .cancelsTouchesInView(false) + .runOnJS(true) + if (onTap) { + gesture.onEnd(() => void onTap()) + } + return gesture + }, [onTap]) + const doubleTapGesture = useMemo(() => { return Gesture.Tap() .numberOfTaps(2) @@ -346,8 +363,10 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { }) }, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV]) + // Order matters here: doubleTapGesture must come before tapGesture. const composedGestures = Gesture.Exclusive( doubleTapGesture, + tapGesture, pressAndHoldGesture, ) diff --git a/src/components/ContextMenu/types.ts b/src/components/ContextMenu/types.ts index 260d95e85c..e2f1522d1e 100644 --- a/src/components/ContextMenu/types.ts +++ b/src/components/ContextMenu/types.ts @@ -84,6 +84,14 @@ export type TriggerProps = { hint?: string role?: AccessibilityRole style?: StyleProp + /** + * Callback for single taps. Composed with the double-tap and + * press-and-hold gestures via `Gesture.Exclusive`, so a double tap + * does not also fire this handler. + * + * @platform ios, android + */ + onTap?: () => void } export type TriggerChildProps = | { diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index 3ed704f99d..1e4b40206e 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -9,15 +9,18 @@ export function ActionsWrapper({ message, isFromSelf, children, + onTap, }: { message: ChatBskyConvoDefs.MessageView + hasReactions?: boolean isFromSelf: boolean children: React.ReactNode + onTap?: () => void }) { const {t: l} = useLingui() return ( - + {trigger => // will always be true, since this file is platform split trigger.IS_NATIVE && ( diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx index beb6577e0f..05df7b0324 100644 --- a/src/components/dms/ActionsWrapper.web.tsx +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -1,8 +1,7 @@ import {useCallback, useRef, useState} from 'react' import {Pressable, 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 {useConvoActive} from '#/state/messages/convo' import {useSession} from '#/state/session' @@ -16,16 +15,20 @@ import {hasReachedReactionLimit} from './util' export function ActionsWrapper({ message, + hasReactions, isFromSelf, children, + onTap, }: { message: ChatBskyConvoDefs.MessageView + hasReactions?: boolean isFromSelf: boolean children: React.ReactNode + onTap?: () => void }) { const viewRef = useRef(null) const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const convo = useConvoActive() const {currentAccount} = useSession() @@ -57,17 +60,17 @@ export function ActionsWrapper({ ) { 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], ) return ( @@ -87,6 +90,7 @@ export function ActionsWrapper({ isFromSelf ? [a.mr_xs, {marginLeft: 'auto'}, a.flex_row_reverse] : [a.ml_xs, {marginRight: 'auto'}], + hasReactions ? [a.mb_2xl] : undefined, ]}> {({props, state, IS_NATIVE, control}) => { @@ -133,10 +137,13 @@ export function ActionsWrapper({ }} - {children} - + ) } diff --git a/src/components/dms/DateDivider.tsx b/src/components/dms/DateDivider.tsx index 0a54de39fc..21724ba3ea 100644 --- a/src/components/dms/DateDivider.tsx +++ b/src/components/dms/DateDivider.tsx @@ -27,8 +27,8 @@ const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, { }) let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { - const {t: l} = useLingui() const t = useTheme() + const {t: l} = useLingui() let date: string const time = timeFormatter.format(new Date(dateStr)) diff --git a/src/components/dms/DateDividerToggle.tsx b/src/components/dms/DateDividerToggle.tsx new file mode 100644 index 0000000000..97489f8401 --- /dev/null +++ b/src/components/dms/DateDividerToggle.tsx @@ -0,0 +1,44 @@ +import {createContext, useCallback, useContext, useState} from 'react' + +type DateDividerToggleContextType = { + isDividerToggled: (id: string) => boolean + toggleDivider: (id: string) => void +} + +const DateDividerToggleContext = createContext({ + isDividerToggled: () => false, + toggleDivider: () => {}, +}) + +export function DateDividerToggleProvider({ + children, +}: { + children: React.ReactNode +}) { + const [toggledIds, setToggledIds] = useState(new Set()) + + const toggleDivider = useCallback((id: string) => { + setToggledIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + const isDividerToggled = useCallback( + (id: string) => toggledIds.has(id), + [toggledIds], + ) + + return ( + + {children} + + ) +} + +export function useDateDividerToggle() { + return useContext(DateDividerToggleContext) +} diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index 2460aa585d..3a923133f1 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -31,9 +31,11 @@ import {hasReachedReactionLimit} from './util' export let MessageContextMenu = ({ message, children, + onTap, }: { message: ChatBskyConvoDefs.MessageView children: TriggerProps['children'] + onTap?: () => void }): React.ReactNode => { const {t: l} = useLingui() const ax = useAnalytics() @@ -130,7 +132,8 @@ export let MessageContextMenu = ({ label={l`Message options`} contentLabel={l`Message from @${ sender?.handle ?? 'unknown' // should always be defined - }: ${message.text}`}> + }: ${message.text}`} + onTap={onTap}> {children} diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index adfc3e67b1..7a000c8dc5 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -1,6 +1,7 @@ -import {memo, useCallback, useMemo, useState} from 'react' +import {memo, useCallback, useEffect, useMemo, useState} from 'react' import { type GestureResponderEvent, + LayoutAnimation, Pressable, type StyleProp, type TextStyle, @@ -11,7 +12,9 @@ import Animated, { FadeOut, LayoutAnimationConfig, LinearTransition, + useAnimatedStyle, useSharedValue, + withTiming, ZoomIn, ZoomOut, } from 'react-native-reanimated' @@ -43,6 +46,7 @@ import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import type * as bsky from '#/types/bsky' import {DateDivider} from './DateDivider' +import {useDateDividerToggle} from './DateDividerToggle' import {MessageItemEmbed} from './MessageItemEmbed' const AVATAR_SIZE = 28 @@ -158,17 +162,25 @@ let MessageItem = ({ new Date(prevMessage.sentAt).getTime() > MESSAGE_GAP_THRESHOLD_MS + const {isDividerToggled, toggleDivider} = useDateDividerToggle() + const isDateDividerToggled = isDividerToggled(message.id) + const isNextDateDividerToggled = + nextMessage != null && isDividerToggled(nextMessage.id) const showDateDivider = hasLargeGapFromPrev - const isInCluster = !(isFirstInCluster && isLastInCluster) + const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled + const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled + const isInCluster = !(effectiveFirstInCluster && effectiveLastInCluster) const isInMiddleOfCluster = - isInCluster && !isFirstInCluster && !isLastInCluster + isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster const hasReactions = message.reactions && message.reactions.length > 0 const squaredBottomCorner = - !hasReactions && isInCluster && (isInMiddleOfCluster || isFirstInCluster) + !hasReactions && + isInCluster && + (isInMiddleOfCluster || effectiveFirstInCluster) const squaredTopCorner = - isInCluster && (isInMiddleOfCluster || isLastInCluster) + isInCluster && (isInMiddleOfCluster || effectiveLastInCluster) const pendingColor = t.palette.primary_300 @@ -179,6 +191,45 @@ let MessageItem = ({ const hasEmbedAndText = AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 + const targetBottomRadius = + squaredBottomCorner || hasEmbedAndText + ? SQUARED_BORDER_RADIUS + : BORDER_RADIUS + const targetTopRadius = squaredTopCorner + ? SQUARED_BORDER_RADIUS + : BORDER_RADIUS + + const bottomRadiusSV = useSharedValue(targetBottomRadius) + const topRadiusSV = useSharedValue(targetTopRadius) + + const showDisplayName = + isGroupChat && + !isFromSelf && + effectiveFirstInCluster && + !isDateDividerToggled && + !isOnlyEmoji(message.text) + const showAvatar = isGroupChat && !isFromSelf && isLastInCluster + + useEffect(() => { + bottomRadiusSV.set(withTiming(targetBottomRadius, {duration: 300})) + }, [targetBottomRadius, bottomRadiusSV]) + + useEffect(() => { + topRadiusSV.set(withTiming(targetTopRadius, {duration: 300})) + }, [targetTopRadius, topRadiusSV]) + + const borderRadiusStyle = useAnimatedStyle(() => + isFromSelf + ? { + borderBottomRightRadius: bottomRadiusSV.get(), + borderTopRightRadius: topRadiusSV.get(), + } + : { + borderBottomLeftRadius: bottomRadiusSV.get(), + borderTopLeftRadius: topRadiusSV.get(), + }, + ) + const avatar = profile ? ( - {showDateDivider && ( + {(showDateDivider || isDateDividerToggled) && ( @@ -330,10 +381,12 @@ let MessageItem = ({ - {isGroupChat && !isFromSelf && isLastInCluster ? ( + {showAvatar ? ( {avatar} @@ -346,10 +399,7 @@ let MessageItem = ({ paddingLeft: AVATAR_SIZE, }, ]}> - {isGroupChat && - !isFromSelf && - isFirstInCluster && - !isOnlyEmoji(message.text) ? ( + {showDisplayName ? ( ) : null} - + { + if (!hasLargeGapFromPrev) { + LayoutAnimation.configureNext( + LayoutAnimation.Presets.easeInEaseOut, + ) + toggleDivider(message.id) + } + }}> {rt.text.length > 0 && ( - - + )} {AppBskyEmbedRecord.isView(message.embed) && ( - {isLastInCluster && ( + {effectiveLastInCluster && ( + } - + ) } From 9f3c21e298ab6a2f61327c55ddbcfc1ffcb5b9bb Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:48:30 -0700 Subject: [PATCH 18/26] Add header to group clip clops (#10254) Co-authored-by: Samuel Newman --- src/screens/Messages/ChatList.tsx | 8 +- src/screens/Messages/Conversation.tsx | 2 +- .../Messages/components/MessagesList.tsx | 31 ++++-- .../components/MessagesListInfoPanel.tsx | 95 +++++++++++++++++++ src/state/messages/convo/agent.ts | 16 ++++ src/state/messages/convo/types.ts | 7 ++ 6 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 src/screens/Messages/components/MessagesListInfoPanel.tsx diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx index 9a59352953..3463b04ed0 100644 --- a/src/screens/Messages/ChatList.tsx +++ b/src/screens/Messages/ChatList.tsx @@ -242,7 +242,7 @@ export function MessagesScreenInner({navigation, route}: Props) { if (!isScreenFocused) { return } - return listenSoftReset(onSoftReset) + return listenSoftReset(() => void onSoftReset()) }, [onSoftReset, isScreenFocused]) // NOTE(APiligrim) @@ -292,7 +292,7 @@ export function MessagesScreenInner({navigation, route}: Props) { size="small" color="secondary_inverted" variant="solid" - onPress={() => refetch()}> + onPress={() => void refetch()}> Retry @@ -342,8 +342,8 @@ export function MessagesScreenInner({navigation, route}: Props) { renderItem={renderItem} keyExtractor={keyExtractor} refreshing={isPTRing} - onRefresh={onRefresh} - onEndReached={onEndReached} + onRefresh={() => void onRefresh()} + onEndReached={() => void onEndReached()} ListFooterComponent={ + style={[a.w_full, IS_LIQUID_GLASS && {paddingTop: topInset}]}> {moderation ? ( ) : ( diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index ca6280ec22..8ffb382704 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -1,4 +1,11 @@ -import {useCallback, useEffect, useId, useRef, useState} from 'react' +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from 'react' import {type LayoutChangeEvent, type ScrollViewProps, View} from 'react-native' import { KeyboardChatScrollView, @@ -62,6 +69,7 @@ import {useAnalytics} from '#/analytics' import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env' import {ChatStatusInfo} from './ChatStatusInfo' import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' +import {MessagesListInfoPanel} from './MessagesListInfoPanel' import {KeyboardStickyView} from './vendor/KeyboardStickyView' function MaybeLoader({isLoading}: {isLoading: boolean}) { @@ -151,10 +159,12 @@ export function MessagesList({ // 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 + useLayoutEffect(() => { + if (prevHasScrolled.current && !hasScrolled) { + hasInitiallyScrolled.current = false + } + prevHasScrolled.current = hasScrolled + }, [hasScrolled]) // -- Keep track of background state and positioning for new pill const layoutHeight = useSharedValue(0) @@ -384,7 +394,7 @@ export function MessagesList({ profile={convoState.convo.members.find( member => member.did === item.message.sender.did, )} - isGroupChat={convoState.getGroupInfo?.() != null} + isGroupChat={convoState.isGroup()} /> ) } else if (item.type === 'deleted-message') { @@ -417,6 +427,8 @@ export function MessagesList({ [inputHeightUI], ) + console.log('DEBUG >>>', 'convoState.hasAllHistory', convoState.hasAllHistory) + return ( + <> + + {convoState.isGroup() && convoState.hasAllHistory ? ( + + ) : null} + } // native only (prop is not supported on web) renderScrollComponent={renderScrollComponent} diff --git a/src/screens/Messages/components/MessagesListInfoPanel.tsx b/src/screens/Messages/components/MessagesListInfoPanel.tsx new file mode 100644 index 0000000000..9f904e1eb3 --- /dev/null +++ b/src/screens/Messages/components/MessagesListInfoPanel.tsx @@ -0,0 +1,95 @@ +import {View} from 'react-native' +import {Plural, Trans, useLingui} from '@lingui/react/macro' + +import {type ConvoState} from '#/state/messages/convo/types' +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' +import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' +import {Text} from '#/components/Typography' + +export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) { + const t = useTheme() + const {t: l} = useLingui() + + const {currentAccount} = useSession() + + const groupName = convoState.getGroupInfo?.()?.name + + const members = (convoState?.convo?.members ?? []).filter( + profile => profile.did !== currentAccount?.did, + ) + + let names: React.ReactNode | null = null + if (members.length === 1) { + names = New chat with {members[0].displayName} + } + if (members.length === 2) { + names = ( + + New chat with {members[0].displayName} and {members[1].displayName} + + ) + } + if (members.length > 2) { + names = ( + + New chat with {members[0].displayName}, {members[1].displayName}, and{' '} + + . + + ) + } + + return ( + + + {groupName ? ( + + {groupName} + + ) : null} + {names ? ( + + {names} + + ) : null} + + + + + + ) +} diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index ef2b251cce..9f0693c4a3 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -153,6 +153,8 @@ export class Convo { sender: this.sender, recipients: this.recipients, isFetchingHistory: this.isFetchingHistory, + // Explicit null check since the value is initially undefined. + hasAllHistory: this.oldestRev === null, deleteMessage: undefined, sendMessage: undefined, fetchMessageHistory: undefined, @@ -176,6 +178,8 @@ export class Convo { sender: this.sender!, recipients: this.recipients!, isFetchingHistory: this.isFetchingHistory, + // Explicit null check since the value is initially undefined. + hasAllHistory: this.oldestRev === null, deleteMessage: this.deleteMessage, sendMessage: this.sendMessage, fetchMessageHistory: this.fetchMessageHistory, @@ -196,6 +200,7 @@ export class Convo { sender: undefined, recipients: undefined, isFetchingHistory: false, + hasAllHistory: false, deleteMessage: undefined, sendMessage: undefined, fetchMessageHistory: undefined, @@ -216,6 +221,8 @@ export class Convo { sender: this.sender, recipients: this.recipients, isFetchingHistory: false, + // Explicit null check since the value is initially undefined. + hasAllHistory: this.oldestRev === null, deleteMessage: undefined, sendMessage: undefined, fetchMessageHistory: undefined, @@ -627,6 +634,7 @@ export class Convo { /* * If oldestRev is null, we've fetched all history. + * Needs to explicitly check for `null` since this is initially `undefined`. */ if (this.oldestRev === null) return @@ -660,6 +668,14 @@ export class Convo { this.oldestRev = cursor ?? null + /* + * If the response contained fewer messages than the limit, we know + * there are no more pages, regardless of whether a cursor was returned. + */ + if (messages.length < (IS_NATIVE ? 30 : 60)) { + this.oldestRev = null + } + for (const message of messages) { if ( ChatBskyConvoDefs.isMessageView(message) || diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index d7adb51c6d..f27610053a 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -156,6 +156,7 @@ export type ConvoStateUninitialized = { sender: ChatBskyActorDefs.ProfileViewBasic | undefined recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined isFetchingHistory: false + hasAllHistory: boolean deleteMessage: undefined sendMessage: undefined fetchMessageHistory: undefined @@ -174,6 +175,7 @@ export type ConvoStateInitializing = { sender: ChatBskyActorDefs.ProfileViewBasic | undefined recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined isFetchingHistory: boolean + hasAllHistory: boolean deleteMessage: undefined sendMessage: undefined fetchMessageHistory: undefined @@ -192,6 +194,7 @@ export type ConvoStateReady = { sender: ChatBskyActorDefs.ProfileViewBasic recipients: ChatBskyActorDefs.ProfileViewBasic[] isFetchingHistory: boolean + hasAllHistory: boolean deleteMessage: DeleteMessage sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory @@ -210,6 +213,7 @@ export type ConvoStateBackgrounded = { sender: ChatBskyActorDefs.ProfileViewBasic recipients: ChatBskyActorDefs.ProfileViewBasic[] isFetchingHistory: boolean + hasAllHistory: boolean deleteMessage: DeleteMessage sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory @@ -228,6 +232,7 @@ export type ConvoStateSuspended = { sender: ChatBskyActorDefs.ProfileViewBasic recipients: ChatBskyActorDefs.ProfileViewBasic[] isFetchingHistory: boolean + hasAllHistory: boolean deleteMessage: DeleteMessage sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory @@ -246,6 +251,7 @@ export type ConvoStateError = { sender: undefined recipients: undefined isFetchingHistory: false + hasAllHistory: false deleteMessage: undefined sendMessage: undefined fetchMessageHistory: undefined @@ -264,6 +270,7 @@ export type ConvoStateDisabled = { sender: ChatBskyActorDefs.ProfileViewBasic recipients: ChatBskyActorDefs.ProfileViewBasic[] isFetchingHistory: boolean + hasAllHistory: boolean deleteMessage: DeleteMessage sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory From 36c95d7dc6c38a16a8e200b70bfaa8e71d2ce204 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:57:48 -0700 Subject: [PATCH 19/26] Remove console log (#10273) --- src/screens/Messages/components/MessagesList.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 8ffb382704..90953b44aa 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -427,8 +427,6 @@ export function MessagesList({ [inputHeightUI], ) - console.log('DEBUG >>>', 'convoState.hasAllHistory', convoState.hasAllHistory) - return ( Date: Thu, 16 Apr 2026 11:16:43 -0700 Subject: [PATCH 20/26] Remove own reaction from reactions dialog on tap (#10231) Co-authored-by: Samuel Newman --- .../src/BottomSheetNativeComponent.tsx | 13 +- src/components/Dialog/context.ts | 1 + src/components/Dialog/index.tsx | 15 +- src/components/Dialog/index.web.tsx | 2 + src/components/Dialog/types.ts | 1 + src/components/Error.tsx | 6 +- src/components/dms/MessageItem.tsx | 439 +++++------------- src/components/dms/ReactionsDialog.tsx | 391 ++++++++++++++++ 8 files changed, 532 insertions(+), 336 deletions(-) create mode 100644 src/components/dms/ReactionsDialog.tsx diff --git a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx index 2604e0c0b8..0c3c700e90 100644 --- a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx +++ b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx @@ -1,4 +1,4 @@ -import * as React from 'react' +import {Component, createRef} from 'react' import { Dimensions, type LayoutChangeEvent, @@ -39,14 +39,14 @@ const IS_IOS15 = const IS_NON_E2E_ANDROID = Platform.OS === 'android' && Number(Platform.Version) < 35 -export class BottomSheetNativeComponent extends React.Component< +export class BottomSheetNativeComponent extends Component< BottomSheetViewProps, { open: boolean viewHeight?: number } > { - ref = React.createRef() + ref = createRef() static contextType = PortalContext @@ -129,6 +129,7 @@ export class BottomSheetNativeComponent extends React.Component< function BottomSheetNativeComponentInner({ children, backgroundColor, + maxHeight, onLayout, onStateChange, nativeViewRef, @@ -156,6 +157,7 @@ function BottomSheetNativeComponentInner({ return ( - + {children} diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts index b7e3c78d5e..5c36af6ae5 100644 --- a/src/components/Dialog/context.ts +++ b/src/components/Dialog/context.ts @@ -23,6 +23,7 @@ export const Context = createContext({ disableDrag: false, setDisableDrag: () => {}, isWithinDialog: false, + isHeightConstrained: false, }) Context.displayName = 'DialogContext' diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 3b1048240b..af724a4ab2 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -157,6 +157,8 @@ export function Outer({ [open, close], ) + const isHeightConstrained = nativeOptions?.maxHeight != null + const context = useMemo( () => ({ close, @@ -165,8 +167,9 @@ export function Outer({ disableDrag, setDisableDrag, isWithinDialog: true, + isHeightConstrained, }), - [close, snapPoint, disableDrag, setDisableDrag], + [close, snapPoint, disableDrag, setDisableDrag, isHeightConstrained], ) return ( @@ -180,7 +183,9 @@ export function Outer({ onStateChange={onStateChange} disableDrag={disableDrag}> - + {children} @@ -213,10 +218,11 @@ export function Inner({children, style, header}: DialogInnerProps) { export const ScrollableInner = forwardRef( function ScrollableInner( - {children, contentContainerStyle, header, ...props}, + {children, contentContainerStyle, header, style, ...props}, ref, ) { - const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() + const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} = + useDialogContext() const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full const insets = useSafeAreaInsets() const [keyboardHeight, setKeyboardHeight] = useState(() => @@ -243,6 +249,7 @@ export const ScrollableInner = forwardRef( return ( {}, isWithinDialog: true, + isHeightConstrained: false, }), [close], ) @@ -196,6 +197,7 @@ export function Inner({ a.border, t.atoms.bg, { + cursor: 'default', // The overlay applies `cursor: 'pointer'` to all children. maxWidth: 600, borderColor: t.palette.contrast_200, shadowColor: t.palette.black, diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index 938d7c744d..865083d501 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -45,6 +45,7 @@ export type DialogContextProps = { setDisableDrag: React.Dispatch> // in the event that the hook is used outside of a dialog isWithinDialog: boolean + isHeightConstrained: boolean } export type DialogControlOpenOptions = { diff --git a/src/components/Error.tsx b/src/components/Error.tsx index 04f4034c06..77aacdb451 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -60,8 +60,7 @@ export function Error({ color="primary" label={_(msg`Press to retry`)} onPress={onRetry} - size="large" - style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> + size="large"> Retry @@ -73,8 +72,7 @@ export function Error({ color={onRetry ? 'secondary' : 'primary'} label={_(msg`Return to previous page`)} onPress={goBack} - size="large" - style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> + size="large"> Go Back diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 7a000c8dc5..d8ffa2debf 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -1,4 +1,4 @@ -import {memo, useCallback, useEffect, useMemo, useState} from 'react' +import {memo, useCallback, useEffect, useMemo} from 'react' import { type GestureResponderEvent, LayoutAnimation, @@ -6,6 +6,7 @@ import { type StyleProp, type TextStyle, View, + type ViewStyle, } from 'react-native' import Animated, { FadeIn, @@ -25,22 +26,21 @@ import { } from '@atproto/api' import {plural} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro' +import {useQueryClient} from '@tanstack/react-query' -import {HITSLOP_10} from '#/lib/constants' +import {makeProfileLink} from '#/lib/routes/links' 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 {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {useSession} from '#/state/session' -import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' -import {UserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, native, useTheme, web} from '#/alf' +import {atoms as a, native, platform, useTheme} 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 {InlineLinkText, Link} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' @@ -48,6 +48,7 @@ import type * as bsky from '#/types/bsky' import {DateDivider} from './DateDivider' import {useDateDividerToggle} from './DateDividerToggle' import {MessageItemEmbed} from './MessageItemEmbed' +import {ReactionsDialog} from './ReactionsDialog' const AVATAR_SIZE = 28 const CLUSTERED_MESSAGE_GAP = 2 @@ -55,19 +56,9 @@ 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, @@ -112,6 +103,7 @@ let MessageItem = ({ const {t: l} = useLingui() const {convo} = useConvoActive() const moderationOpts = useModerationOpts() + const queryClient = useQueryClient() const reactionsControl = useDialogControl() @@ -203,11 +195,7 @@ let MessageItem = ({ const topRadiusSV = useSharedValue(targetTopRadius) const showDisplayName = - isGroupChat && - !isFromSelf && - effectiveFirstInCluster && - !isDateDividerToggled && - !isOnlyEmoji(message.text) + isGroupChat && !isFromSelf && isFirstInCluster && !isOnlyEmoji(message.text) const showAvatar = isGroupChat && !isFromSelf && isLastInCluster useEffect(() => { @@ -231,12 +219,23 @@ let MessageItem = ({ ) const avatar = profile ? ( - + unstableCacheProfileView(queryClient, profile)}> + + ) : ( ) @@ -299,78 +298,88 @@ let MessageItem = ({ const appliedReactions = ( {hasReactions ? ( - <> - + - - isGroupChat ? reactionsControl.open() : undefined - }> - {groupedReactions.map(group => ( - 1 && native(ZoomOut.delay(200)) - } - layout={native(LinearTransition.delay(300))} - key={group.value} - style={[a.p_2xs]}> - - {group.value} - - - ))} - {groupedReactions.length !== reactions.length && - reactions.length > 1 ? ( - - - {reactions.length} - - - ) : null} - - - - + a.flex_row, + a.gap_2xs, + a.px_xs, + isFromSelf ? a.justify_end : a.justify_start, + a.flex_wrap, + a.rounded_lg, + a.border, + t.atoms.border_contrast_low, + t.atoms.bg_contrast_25, + t.atoms.shadow_sm, + { + paddingTop: platform({android: 2, default: 3}), + paddingBottom: platform({android: 2, default: 3}), + transform: [{translateY: -8}], + }, + ]} + onPress={() => (isGroupChat ? reactionsControl.open() : undefined)}> + {groupedReactions.map(group => ( + 1 && native(ZoomOut.delay(200)) + } + layout={native(LinearTransition.delay(300))} + key={group.value} + style={[a.py_2xs]}> + + {group.value} + + + ))} + {groupedReactions.length !== reactions.length && + reactions.length > 1 ? ( + + + {reactions.length} + + + ) : null} + + ) : null} + ) + const messageInset = platform({ + ios: isFromSelf ? a.mr_md : isGroupChat ? a.ml_md : a.ml_sm, + android: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined, + web: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined, + }) + return ( <> {(showDateDivider || isDateDividerToggled) && ( @@ -379,25 +388,25 @@ let MessageItem = ({ )} + style={[messageInset, isFirstInCluster && !showDateDivider && a.mt_sm]}> {showAvatar ? ( - + {avatar} ) : null} {showDisplayName ? ( { - 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/ReactionsDialog.tsx b/src/components/dms/ReactionsDialog.tsx new file mode 100644 index 0000000000..f040e234e2 --- /dev/null +++ b/src/components/dms/ReactionsDialog.tsx @@ -0,0 +1,391 @@ +import {useRef, useState} from 'react' +import { + LayoutAnimation, + Pressable, + type ScrollView, + useWindowDimensions, + View, +} from 'react-native' +import Animated from 'react-native-reanimated' +import {type ChatBskyConvoDefs} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' + +import {HITSLOP_10} from '#/lib/constants' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {sanitizeHandle} from '#/lib/strings/handles' +import {type ActiveConvoStates, useConvoActive} from '#/state/messages/convo' +import {useSession} from '#/state/session' +import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme, web} from '#/alf' +import * as Dialog from '#/components/Dialog' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import {IS_NATIVE, IS_WEB} from '#/env' +import type * as bsky from '#/types/bsky' + +type Reaction = { + key: string + value: string + senders: ChatBskyConvoDefs.ReactionViewSender[] + count: number +} + +export function ReactionsDialog({ + control, + members, + message, + reactions, + groupedReactions, +}: { + control: Dialog.DialogControlProps + members: bsky.profile.AnyProfileView[] + message: ChatBskyConvoDefs.MessageView + reactions?: ChatBskyConvoDefs.ReactionView[] + groupedReactions?: Reaction[] +}) { + const {t: l} = useLingui() + + const {height: screenHeight} = useWindowDimensions() + const {currentAccount} = useSession() + const convo = useConvoActive() + + const [selected, setSelected] = useState('all') + + const handleFilter = (value: string) => { + setSelected(value) + } + + const filteredReactions = reactions?.filter( + r => selected === 'all' || r.value === selected, + ) + + const header = ( + <> + + + Reactions + + + + + + ) + + return ( + setSelected('all')} + nativeOptions={{ + preventExpansion: true, + minHeight: screenHeight / 2, + maxHeight: screenHeight / 2, + }}> + + {IS_NATIVE ? header : null} + + {filteredReactions + ?.sort((a, b) => { + if (a.sender.did === currentAccount?.did) return -1 + if (b.sender.did === currentAccount?.did) return 1 + return 0 + }) + .map(reaction => { + const sender = members.find(m => m.did === reaction.sender.did) + if (!sender) return null + return ( + + ) + })} + + + ) +} + +function ReactionRow({ + control, + convo, + currentAccount, + message, + profile, + reaction, + allReactions, + selected, + setSelected, +}: { + control: Dialog.DialogControlProps + convo: ActiveConvoStates + currentAccount?: bsky.profile.AnyProfileView + message: ChatBskyConvoDefs.MessageView + profile: bsky.profile.AnyProfileView + reaction: ChatBskyConvoDefs.ReactionView + allReactions: ChatBskyConvoDefs.ReactionView[] + selected: string + setSelected: React.Dispatch> +}) { + const t = useTheme() + const {t: l} = useLingui() + + const isFromSelf = currentAccount?.did === profile.did + + const displayName = createSanitizedDisplayName(profile, true) + const handle = sanitizeHandle(profile?.handle ?? '', '@') + + const handleOnPress = () => { + const remainingReactions = + allReactions?.filter( + r => + !(r.value === reaction.value && r.sender.did === currentAccount?.did), + ) ?? [] + + if (remainingReactions.length === 0) { + control.close() + } else if ( + selected !== 'all' && + !remainingReactions.some(r => r.value === reaction.value) + ) { + // tab no longer exists + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setSelected('all') + } + + convo + .removeReaction(message.id, reaction.value) + .catch(() => Toast.show(l`Failed to remove emoji reaction`)) + } + + const inner = ( + <> + + + + + {displayName} + + + {isFromSelf ? l`Tap to remove` : handle} + + + + + + {reaction.value} + + + + ) + + if (isFromSelf) { + return ( + + {inner} + + ) + } + + return ( + + {inner} + + ) +} + +function ReactionTabs({ + groupedReactions, + selected, + totalReactions, + onFilter, +}: { + groupedReactions?: Reaction[] + selected: string + totalReactions: number + onFilter: (value: string) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + const scrollViewRef = useRef(null) + const scrollState = useRef({x: 0, width: 0}) + const tabLayouts = useRef>(new Map()) + + const handlePress = (value: string) => { + onFilter(value) + + // Scroll a partially-visible tab fully into view. + const layout = tabLayouts.current.get(value) + if (layout && scrollViewRef.current && scrollState.current.width > 0) { + const tabLeft = layout.x + const tabRight = layout.x + layout.width + const viewLeft = scrollState.current.x + const viewRight = viewLeft + scrollState.current.width + + if (tabLeft < viewLeft) { + scrollViewRef.current.scrollTo({ + x: Math.max(0, tabLeft - 24), + animated: true, + }) + } else if (tabRight > viewRight) { + scrollViewRef.current.scrollTo({ + x: tabRight - scrollState.current.width + 24, + animated: true, + }) + } + } + } + + const handleTabLayout = (key: string, layout: {x: number; width: number}) => { + tabLayouts.current.set(key, layout) + } + + const tabs = [ + { + key: 'all', + value: l`All`, + senders: [], + count: totalReactions, + } as Reaction, + ...(groupedReactions ?? []), + ] + + return ( + + { + scrollState.current = { + x: e.nativeEvent.contentOffset.x, + width: e.nativeEvent.layoutMeasurement.width, + } + }} + onLayout={e => { + scrollState.current.width = e.nativeEvent.layout.width + }}> + + {tabs?.map((reaction, index) => ( + + ))} + + + + ) +} + +function ReactionTab({ + index, + reaction, + selected, + total, + onPress, + onTabLayout, +}: { + index: number + reaction: Reaction + selected: string + total: number + onPress: (value: string) => void + onTabLayout: (key: string, layout: {x: number; width: number}) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + return ( + { + onTabLayout(reaction.key, { + x: e.nativeEvent.layout.x, + width: e.nativeEvent.layout.width, + }) + }} + onPress={() => onPress(reaction.key)}> + + {l`${reaction.value} ${reaction.count}`} + + + ) +} From 6e3c9c3a9f293993d681fa846f132a8dc694099f Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:55:19 -0700 Subject: [PATCH 21/26] Add flow for adding people to a group clip clop (#10255) --- src/components/dms/AddMembersFlow.tsx | 470 ++++++++++++++++++ src/components/dms/InitiateChatFlow.tsx | 174 +------ .../dms/components/EmptyMemberList.tsx | 16 + .../dms/components/GroupChatProfileCard.tsx | 65 +++ .../dms/components/ProfileCardSkeleton.tsx | 21 + src/components/dms/components/UserLabel.tsx | 15 + .../dms/components/UserSearchInput.tsx | 68 +++ src/screens/Messages/ConversationSettings.tsx | 158 +++--- .../components/MessagesListInfoPanel.tsx | 124 +++-- 9 files changed, 849 insertions(+), 262 deletions(-) create mode 100644 src/components/dms/AddMembersFlow.tsx create mode 100644 src/components/dms/components/EmptyMemberList.tsx create mode 100644 src/components/dms/components/GroupChatProfileCard.tsx create mode 100644 src/components/dms/components/ProfileCardSkeleton.tsx create mode 100644 src/components/dms/components/UserLabel.tsx create mode 100644 src/components/dms/components/UserSearchInput.tsx diff --git a/src/components/dms/AddMembersFlow.tsx b/src/components/dms/AddMembersFlow.tsx new file mode 100644 index 0000000000..103ad0aca4 --- /dev/null +++ b/src/components/dms/AddMembersFlow.tsx @@ -0,0 +1,470 @@ +import { + useCallback, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react' +import {LayoutAnimation, type TextInput, View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' +import {useProfileFollowsQuery} from '#/state/queries/profile-follows' +import {useSession} from '#/state/session' +import {type ListMethods} from '#/view/com/util/List' +import {android, atoms as a, native, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {canBeMessaged} from '#/components/dms/util' +import * as Toggle from '#/components/forms/Toggle' +import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import {Text} from '#/components/Typography' +import {IS_NATIVE, IS_WEB} from '#/env' +import type * as bsky from '#/types/bsky' +import {ChatProfileTabs} from './ChatProfileTabs' +import {EmptyMemberList} from './components/EmptyMemberList' +import {GroupChatProfileCard} from './components/GroupChatProfileCard' +import {ProfileCardSkeleton} from './components/ProfileCardSkeleton' +import {UserLabel} from './components/UserLabel' +import {UserSearchInput} from './components/UserSearchInput' + +type LabelItem = { + type: 'label' + key: string + message: string +} + +type ProfileItem = { + type: 'profile' + key: string + profile: bsky.profile.AnyProfileView +} + +type EmptyItem = { + type: 'empty' + key: string + message: string +} + +type PlaceholderItem = { + type: 'placeholder' + key: string +} + +type ErrorItem = { + type: 'error' + key: string +} + +type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem + +export type State = { + groupChatDids: string[] + groupChatProfiles: bsky.profile.AnyProfileView[] +} + +export type Action = + | { + type: 'setDids' + groupChatDids: string[] + groupChatProfiles: bsky.profile.AnyProfileView[] + } + | { + type: 'removeDids' + groupChatDids: string[] + groupChatProfiles: bsky.profile.AnyProfileView[] + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setDids': { + return { + ...state, + groupChatDids: action.groupChatDids, + groupChatProfiles: action.groupChatProfiles, + } + } + case 'removeDids': { + return { + ...state, + groupChatDids: action.groupChatDids, + groupChatProfiles: action.groupChatProfiles, + } + } + } +} + +export function AddMembersFlow({ + title, + onAddMembers, +}: { + title: string + onAddMembers: (dids: string[]) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const moderationOpts = useModerationOpts() + const control = Dialog.useDialogContext() + const [headerHeight, setHeaderHeight] = useState(0) + const [footerHeight, setFooterHeight] = useState(0) + const listRef = useRef(null) + const {currentAccount} = useSession() + const inputRef = useRef(null) + + const [searchText, setSearchText] = useState('') + + const { + data: results, + isError, + isFetching, + } = useActorAutocompleteQuery(searchText, true, 12) + const {data: follows} = useProfileFollowsQuery(currentAccount?.did) + + const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, { + groupChatDids: [], + groupChatProfiles: [], + }) + + const onRemoveDid = useCallback( + (did: string) => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + dispatch({ + type: 'removeDids', + groupChatDids: groupChatDids.filter(d => d !== did), + groupChatProfiles: groupChatProfiles.filter( + profile => profile.did !== did, + ), + }) + }, + [groupChatDids, groupChatProfiles], + ) + + const items = useMemo(() => { + let _items: Item[] = [] + + if (isError) { + _items.push({ + type: 'empty', + key: 'empty', + message: l`We’re having network issues, try again`, + }) + } else if (searchText.length) { + if (results?.length) { + for (const profile of results) { + if (profile.did === currentAccount?.did) continue + _items.push({ + type: 'profile', + key: profile.did, + profile, + }) + } + + _items = _items.sort(item => { + return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 + }) + } + } else { + const placeholders: Item[] = Array(10) + .fill(0) + .map((__, i) => ({ + type: 'placeholder', + key: i + '', + })) + + if (follows) { + for (const page of follows.pages) { + for (const profile of page.follows) { + _items.push({ + type: 'profile', + key: profile.did, + profile, + }) + } + } + + _items = _items.sort(item => { + return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 + }) + } else { + _items.push(...placeholders) + } + } + + if (searchText === '') { + _items.unshift({ + type: 'label', + key: 'suggested', + message: l`Suggested`, + }) + } + + return _items + }, [isError, searchText, l, results, currentAccount?.did, follows]) + + if (searchText && !isFetching && !items.length && !isError) { + items.push({type: 'empty', key: 'empty', message: l`No results`}) + } + + const handlePressBack = useCallback(() => { + control.close() + }, [control]) + + const handlePressAdd = useCallback(() => { + onAddMembers(groupChatDids) + }, [groupChatDids, onAddMembers]) + + const renderItems = useCallback( + ({item}: {item: Item}) => { + switch (item.type) { + case 'label': { + return + } + case 'profile': { + return ( + + ) + } + case 'placeholder': { + return + } + case 'empty': { + return + } + default: + return null + } + }, + [moderationOpts], + ) + + useLayoutEffect(() => { + if (IS_WEB) { + setImmediate(() => { + inputRef?.current?.focus() + }) + } + }, []) + + let buttonLabel = l`Continue to group name` + let buttonText = l`Next` + let showButton = groupChatProfiles.length > 0 + let isButtonDisabled = !showButton + + const showChatProfileTabs = groupChatProfiles.length > 0 + + const listHeader = useMemo( + () => ( + setHeaderHeight(evt.nativeEvent.layout.height)}> + + + {IS_NATIVE ? ( + + ) : null} + + {title} + + {IS_WEB ? ( + + ) : showButton ? ( + + ) : null} + + + { + setSearchText(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + onEscape={control.close} + /> + + + {showChatProfileTabs ? ( + + + + ) : null} + + ), + [ + buttonLabel, + control, + groupChatProfiles, + handlePressAdd, + handlePressBack, + isButtonDisabled, + l, + onRemoveDid, + searchText, + showButton, + showChatProfileTabs, + t.atoms.bg, + t.atoms.border_contrast_low, + t.atoms.text_contrast_high, + title, + ], + ) + + const setGroupChatMembers = (dids: string[]) => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + + const added = dids.filter(d => !groupChatDids.includes(d)) + const removed = groupChatDids.filter(d => !dids.includes(d)) + const newDids = [ + ...groupChatDids.filter(d => !removed.includes(d)), + ...added, + ] + + const kept = groupChatProfiles.filter(p => dids.includes(p.did)) + const keptDids = new Set(kept.map(p => p.did)) + const addedProfiles = items + .filter( + (item): item is ProfileItem => + item.type === 'profile' && + dids.includes(item.profile.did) && + !keptDids.has(item.profile.did), + ) + .map(item => item.profile) + .sort((a, b) => dids.indexOf(a.did) - dids.indexOf(b.did)) + + dispatch({ + type: 'setDids', + groupChatDids: newDids, + groupChatProfiles: [...kept, ...addedProfiles], + }) + } + + return ( + + item.key} + style={[ + web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), + native({height: '100%'}), + ]} + webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]} + webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} + scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} + keyboardDismissMode="on-drag" + footer={ + IS_WEB ? ( + setFooterHeight(evt.nativeEvent.layout.height)}> + + + + + + ) : null + } + /> + + ) +} diff --git a/src/components/dms/InitiateChatFlow.tsx b/src/components/dms/InitiateChatFlow.tsx index 4b171d964f..bc4824f47c 100644 --- a/src/components/dms/InitiateChatFlow.tsx +++ b/src/components/dms/InitiateChatFlow.tsx @@ -6,7 +6,7 @@ import { useRef, useState, } from 'react' -import {LayoutAnimation, TextInput, View} from 'react-native' +import {LayoutAnimation, type TextInput, View} from 'react-native' import {moderateProfile, type ModerationOpts} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' @@ -23,13 +23,11 @@ import * as Dialog from '#/components/Dialog' import {canBeMessaged} from '#/components/dms/util' import * as TextField from '#/components/forms/TextField' import * as Toggle from '#/components/forms/Toggle' -import {useInteractionState} from '#/components/hooks/useInteractionState' import { ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon, ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon, } from '#/components/icons/Arrow' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' -import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import * as ProfileCard from '#/components/ProfileCard' @@ -37,6 +35,11 @@ import {Text} from '#/components/Typography' import {IS_NATIVE, IS_WEB} from '#/env' import type * as bsky from '#/types/bsky' import {ChatProfileTabs} from './ChatProfileTabs' +import {EmptyMemberList} from './components/EmptyMemberList' +import {GroupChatProfileCard} from './components/GroupChatProfileCard' +import {ProfileCardSkeleton} from './components/ProfileCardSkeleton' +import {UserLabel} from './components/UserLabel' +import {UserSearchInput} from './components/UserSearchInput' type NewGroupChatItem = { type: 'newGroupChat' @@ -49,7 +52,7 @@ type LabelItem = { message: string } -export type ProfileItem = { +type ProfileItem = { type: 'profile' key: string profile: bsky.profile.AnyProfileView @@ -184,6 +187,7 @@ function reducer(state: State, action: Action): State { } } } + export function InitiateChatFlow({ title, onSelectChat, @@ -382,7 +386,7 @@ export function InitiateChatFlow({ ) } case 'label': { - return ) : ( - { @@ -813,59 +817,6 @@ function DefaultProfileCard({ ) } -function GroupChatProfileCard({ - profile, - moderationOpts, -}: { - profile: bsky.profile.AnyProfileView - moderationOpts: ModerationOpts -}) { - const t = useTheme() - const enabled = canBeMessaged(profile) - const moderation = moderateProfile(profile, moderationOpts) - const handle = sanitizeHandle(profile.handle, '@') - const displayName = sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), - ) - - return ( - - - - - - - {enabled ? ( - - ) : ( - - {handle} can’t be messaged - - )} - - - - {enabled ? : null} - - ) -} - function GroupChatMemberProfileCard({ profile, moderationOpts, @@ -902,106 +853,3 @@ function GroupChatMemberProfileCard({ ) } - -function ProfileCardSkeleton() { - return ( - - - - - ) -} - -function Label({message}: {message: string}) { - const t = useTheme() - return ( - - - {message} - - - ) -} - -function Empty({message}: {message: string}) { - const t = useTheme() - return ( - - - {message} - - - (╯°□°)╯︵ ┻━┻ - - ) -} - -function SearchInput({ - value, - onChangeText, - onEscape, - inputRef, -}: { - value: string - onChangeText: (text: string) => void - onEscape: () => void - inputRef: React.RefObject -}) { - const t = useTheme() - const {t: l} = useLingui() - const { - state: hovered, - onIn: onMouseEnter, - onOut: onMouseLeave, - } = useInteractionState() - const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() - const interacted = hovered || focused - - return ( - - - { - if (nativeEvent.key === 'Escape') { - onEscape() - } - }} - autoCorrect={false} - autoComplete="off" - autoCapitalize="none" - autoFocus - accessibilityLabel={l`Search profiles`} - accessibilityHint={l`Searches for profiles`} - /> - - ) -} diff --git a/src/components/dms/components/EmptyMemberList.tsx b/src/components/dms/components/EmptyMemberList.tsx new file mode 100644 index 0000000000..4fbe980a46 --- /dev/null +++ b/src/components/dms/components/EmptyMemberList.tsx @@ -0,0 +1,16 @@ +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +export function EmptyMemberList({message}: {message: string}) { + const t = useTheme() + return ( + + + {message} + + (╯°□°)╯︵ ┻━┻ + + ) +} diff --git a/src/components/dms/components/GroupChatProfileCard.tsx b/src/components/dms/components/GroupChatProfileCard.tsx new file mode 100644 index 0000000000..49ef5cc5b0 --- /dev/null +++ b/src/components/dms/components/GroupChatProfileCard.tsx @@ -0,0 +1,65 @@ +import {View} from 'react-native' +import {moderateProfile, type ModerationOpts} from '@atproto/api' +import {Trans} from '@lingui/react/macro' + +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {atoms as a, useTheme} from '#/alf' +import {canBeMessaged} from '#/components/dms/util' +import * as Toggle from '#/components/forms/Toggle' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +export function GroupChatProfileCard({ + profile, + moderationOpts, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts +}) { + const t = useTheme() + const enabled = canBeMessaged(profile) + const moderation = moderateProfile(profile, moderationOpts) + const handle = sanitizeHandle(profile.handle, '@') + const displayName = sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + ) + + return ( + + + + + + + {enabled ? ( + + ) : ( + + {handle} can’t be messaged + + )} + + + + {enabled ? : null} + + ) +} diff --git a/src/components/dms/components/ProfileCardSkeleton.tsx b/src/components/dms/components/ProfileCardSkeleton.tsx new file mode 100644 index 0000000000..60e7228ab6 --- /dev/null +++ b/src/components/dms/components/ProfileCardSkeleton.tsx @@ -0,0 +1,21 @@ +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import * as ProfileCard from '#/components/ProfileCard' + +export function ProfileCardSkeleton() { + return ( + + + + + ) +} diff --git a/src/components/dms/components/UserLabel.tsx b/src/components/dms/components/UserLabel.tsx new file mode 100644 index 0000000000..ebee466789 --- /dev/null +++ b/src/components/dms/components/UserLabel.tsx @@ -0,0 +1,15 @@ +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +export function UserLabel({message}: {message: string}) { + const t = useTheme() + return ( + + + {message} + + + ) +} diff --git a/src/components/dms/components/UserSearchInput.tsx b/src/components/dms/components/UserSearchInput.tsx new file mode 100644 index 0000000000..c83a48bb18 --- /dev/null +++ b/src/components/dms/components/UserSearchInput.tsx @@ -0,0 +1,68 @@ +import {TextInput, View} from 'react-native' +import {useLingui} from '@lingui/react/macro' + +import {atoms as a, useTheme, web} from '#/alf' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' + +export function UserSearchInput({ + value, + onChangeText, + onEscape, + inputRef, +}: { + value: string + onChangeText: (text: string) => void + onEscape: () => void + inputRef: React.RefObject +}) { + const t = useTheme() + const {t: l} = useLingui() + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const interacted = hovered || focused + + return ( + + + { + if (nativeEvent.key === 'Escape') { + onEscape() + } + }} + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + autoFocus + accessibilityLabel={l`Search profiles`} + accessibilityHint={l`Searches for profiles`} + /> + + ) +} diff --git a/src/screens/Messages/ConversationSettings.tsx b/src/screens/Messages/ConversationSettings.tsx index 1e240623d0..892e2e2c45 100644 --- a/src/screens/Messages/ConversationSettings.tsx +++ b/src/screens/Messages/ConversationSettings.tsx @@ -31,7 +31,8 @@ import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {AvatarBubbles} from '#/components/AvatarBubbles' import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' -import type * as Dialog from '#/components/Dialog' +import * as Dialog from '#/components/Dialog' +import {AddMembersFlow} from '#/components/dms/AddMembersFlow' import {Error} from '#/components/Error' import * as TextField from '#/components/forms/TextField' import {useInteractionState} from '#/components/hooks/useInteractionState' @@ -138,11 +139,11 @@ function SettingsInner() { }, ...[...data] .sort((a, b) => { - const aIsAdmin = a.did === primaryMember?.did - const bIsAdmin = b.did === primaryMember?.did + const aIsOwner = a.did === primaryMember?.did + const bIsOwner = b.did === primaryMember?.did const aIsSelf = a.did === currentAccount?.did const bIsSelf = b.did === currentAccount?.did - if (aIsAdmin !== bIsAdmin) return aIsAdmin ? -1 : 1 + if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1 if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 return 0 }) @@ -216,6 +217,14 @@ function MembersAndRequests({ const t = useTheme() const {t: l} = useLingui() + const convoState = useConvo() + const {currentAccount} = useSession() + + const isOwner = + currentAccount?.did == null + ? false + : convoState.getPrimaryMember?.()?.did === currentAccount.did + return ( @@ -229,7 +238,7 @@ function MembersAndRequests({ {color: t.palette.contrast_500}, ]}>{l`${memberCount}/${MEMBER_LIMIT}`} - {requestCount > 0 ? ( + {isOwner && requestCount > 0 ? ( - - [ - a.flex_row, - a.align_center, - a.justify_between, - pressed && web({outline: 'none'}), + <> + + - {({pressed}) => ( - <> - - - - + [ + a.flex_row, + a.align_center, + a.justify_between, + pressed && web({outline: 'none'}), + ]} + onPress={() => addMembersControl.open()}> + {({pressed}) => ( + <> + + + + + + + Add members + - - Add members - - - - - )} - - - + + + )} + + + + + + + { + // TODO Add members here + addMembersControl.close() + }} + /> + + ) } diff --git a/src/screens/Messages/components/MessagesListInfoPanel.tsx b/src/screens/Messages/components/MessagesListInfoPanel.tsx index 9f904e1eb3..9f1a82dbc4 100644 --- a/src/screens/Messages/components/MessagesListInfoPanel.tsx +++ b/src/screens/Messages/components/MessagesListInfoPanel.tsx @@ -6,6 +6,8 @@ import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import {AvatarBubbles} from '#/components/AvatarBubbles' import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {AddMembersFlow} from '#/components/dms/AddMembersFlow' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' import {Text} from '#/components/Typography' @@ -14,8 +16,17 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) { const t = useTheme() const {t: l} = useLingui() + const addMembersControl = Dialog.useDialogControl() + const {currentAccount} = useSession() + const isOwner = + currentAccount?.did == null + ? false + : convoState.getPrimaryMember?.()?.did === currentAccount.did + // TODO Get this from @api/atproto - dsb + const isLinkEnabled = false + const groupName = convoState.getGroupInfo?.()?.name const members = (convoState?.convo?.members ?? []).filter( @@ -47,49 +58,78 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) { ) } + const showButtons = isOwner || isLinkEnabled + return ( - - - {groupName ? ( - - {groupName} - - ) : null} - {names ? ( - - {names} - - ) : null} - - - + <> + + + {groupName ? ( + + {groupName} + + ) : null} + {names ? ( + + {names} + + ) : null} + {showButtons ? ( + + {isOwner ? ( + + ) : null} + {isOwner || isLinkEnabled ? ( + + ) : null} + + ) : null} - + + + { + // TODO Add members here + addMembersControl.close() + }} + /> + + ) } From 3358e1947b607da0123e2a6e01b226c73ca88cda Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 16 Apr 2026 12:24:11 -0700 Subject: [PATCH 22/26] Lazily measure lightbox thumbnails (#10270) --- src/components/Post/Embed/ImageEmbed.tsx | 41 +++----- src/screens/Profile/Header/Shell.tsx | 22 ++--- src/state/lightbox.tsx | 47 ++++++++-- .../com/lightbox/ImageViewing/@types/index.ts | 6 +- src/view/com/lightbox/ImageViewing/index.tsx | 93 +++++++++++++++---- src/view/com/profile/ProfileSubpageHeader.tsx | 36 ++----- 6 files changed, 144 insertions(+), 101 deletions(-) diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index fba3256d56..6ca166ff10 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -1,11 +1,5 @@ import {InteractionManager, View} from 'react-native' -import { - type AnimatedRef, - measure, - type MeasuredDimensions, - runOnJS, - runOnUI, -} from 'react-native-reanimated' +import {type AnimatedRef} from 'react-native-reanimated' import {Image} from 'expo-image' import {useLightboxControls} from '#/state/lightbox' @@ -37,34 +31,21 @@ export function ImageEmbed({ alt: img.alt, dimensions: img.aspectRatio ?? null, })) - const _openLightbox = ( - index: number, - thumbRects: (MeasuredDimensions | null)[], - fetchedDims: (Dimensions | null)[], - ) => { - openLightbox({ - images: items.map((item, i) => ({ - ...item, - thumbRect: thumbRects[i] ?? null, - thumbDimensions: fetchedDims[i] ?? null, - type: 'image', - })), - index, - }) - } const onPress = ( index: number, refs: AnimatedRef[], fetchedDims: (Dimensions | null)[], ) => { - runOnUI(() => { - 'worklet' - const rects: (MeasuredDimensions | null)[] = [] - for (const r of refs) { - rects.push(measure(r)) - } - runOnJS(_openLightbox)(index, rects, fetchedDims) - })() + openLightbox({ + images: items.map((item, i) => ({ + ...item, + thumbRect: null, + thumbRef: refs[i] ?? null, + thumbDimensions: fetchedDims[i] ?? null, + type: 'image', + })), + index, + }) } const onPressIn = (_: number) => { InteractionManager.runAfterInteractions(() => { diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index b050ea9ef8..1d33539e25 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -1,10 +1,7 @@ import {memo, useCallback, useEffect, useMemo} from 'react' import {Pressable, View} from 'react-native' import Animated, { - measure, - type MeasuredDimensions, - runOnJS, - runOnUI, + type AnimatedRef, useAnimatedRef, } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' @@ -76,7 +73,7 @@ let ProfileHeaderShell = ({ const _openLightbox = useCallback( ( uri: string, - thumbRect: MeasuredDimensions | null, + thumbRef: AnimatedRef, type: 'circle-avi' | 'rect-avi' | 'image' = 'circle-avi', ) => { openLightbox({ @@ -84,7 +81,8 @@ let ProfileHeaderShell = ({ { uri, thumbUri: uri, - thumbRect, + thumbRect: null, + thumbRef, dimensions: type === 'circle-avi' || type === 'rect-avi' ? { @@ -130,11 +128,7 @@ let ProfileHeaderShell = ({ const avatar = profile.avatar const type = profile.associated?.labeler ? 'rect-avi' : 'circle-avi' if (avatar && !(modui.blur && modui.noOverride)) { - runOnUI(() => { - 'worklet' - const rect = measure(aviRef) - runOnJS(_openLightbox)(avatar, rect, type) - })() + _openLightbox(avatar, aviRef, type) } } }, [ @@ -152,11 +146,7 @@ let ProfileHeaderShell = ({ const modui = moderation.ui('banner') const banner = profile.banner if (banner && !(modui.blur && modui.noOverride)) { - runOnUI(() => { - 'worklet' - const rect = measure(bannerRef) - runOnJS(_openLightbox)(banner, rect, 'image') - })() + _openLightbox(banner, bannerRef, 'image') } }, [profile.banner, moderation, _openLightbox, bannerRef]) diff --git a/src/state/lightbox.tsx b/src/state/lightbox.tsx index 1e22cc98a4..7d688dab20 100644 --- a/src/state/lightbox.tsx +++ b/src/state/lightbox.tsx @@ -1,4 +1,10 @@ import {createContext, useContext, useEffect, useMemo, useState} from 'react' +import { + measure, + type MeasuredDimensions, + runOnJS, + runOnUI, +} from 'react-native-reanimated' import {nanoid} from 'nanoid/non-secure' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -39,17 +45,42 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } }, [activeLightbox, disableScope, enableScope]) + const doOpen = useNonReactiveCallback((lightbox: Omit) => { + setActiveLightbox(prevLightbox => { + if (prevLightbox) { + // Ignore duplicate open requests. If it's already open, + // the user has to explicitly close the previous one first. + return prevLightbox + } else { + return {...lightbox, id: nanoid()} + } + }) + }) + const openLightbox = useNonReactiveCallback( (lightbox: Omit) => { - setActiveLightbox(prevLightbox => { - if (prevLightbox) { - // Ignore duplicate open requests. If it's already open, - // the user has to explicitly close the previous one first. - return prevLightbox - } else { - return {...lightbox, id: nanoid()} + const thumbRef = lightbox.images[lightbox.index]?.thumbRef + if (thumbRef) { + // Measure the tapped image on the UI thread, then open with + // the rect baked in so it's available from the first render. + // Only the rect (plain data) goes through runOnJS — AnimatedRef + // objects can't survive serialization across threads. + const openWithRect = (rect: MeasuredDimensions | null) => { + doOpen({ + ...lightbox, + images: lightbox.images.map((img, i) => + i === lightbox.index ? {...img, thumbRect: rect} : img, + ), + }) } - }) + runOnUI(() => { + 'worklet' + const rect = measure(thumbRef) + runOnJS(openWithRect)(rect) + })() + } else { + doOpen(lightbox) + } }, ) diff --git a/src/view/com/lightbox/ImageViewing/@types/index.ts b/src/view/com/lightbox/ImageViewing/@types/index.ts index 55b3db8fea..8435513af0 100644 --- a/src/view/com/lightbox/ImageViewing/@types/index.ts +++ b/src/view/com/lightbox/ImageViewing/@types/index.ts @@ -7,7 +7,10 @@ */ import {type TransformsStyle} from 'react-native' -import {type MeasuredDimensions} from 'react-native-reanimated' +import { + type AnimatedRef, + type MeasuredDimensions, +} from 'react-native-reanimated' export type Dimensions = { width: number @@ -25,6 +28,7 @@ export type ImageSource = { thumbUri: string thumbDimensions: Dimensions | null thumbRect: MeasuredDimensions | null + thumbRef?: AnimatedRef | null alt?: string type: 'image' | 'circle-avi' | 'rect-avi' } diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index f68900971e..82d1ce27a5 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -23,8 +23,10 @@ import Animated, { cancelAnimation, interpolate, measure, + type MeasuredDimensions, ReduceMotion, runOnJS, + runOnUI, type SharedValue, useAnimatedReaction, useAnimatedRef, @@ -73,12 +75,11 @@ const FAST_SPRING: WithSpringConfig = { } function canAnimate(lightbox: Lightbox): boolean { - return ( - !PlatformInfo.getIsReducedMotionEnabled() && - lightbox.images.every( - img => img.thumbRect && (img.dimensions || img.thumbDimensions), - ) - ) + if (PlatformInfo.getIsReducedMotionEnabled()) { + return false + } + const img = lightbox.images[lightbox.index] + return !!img.thumbRect && !!(img.dimensions || img.thumbDimensions) } export default function ImageViewRoot({ @@ -99,6 +100,9 @@ export default function ImageViewRoot({ 'portrait', ) const openProgress = useSharedValue(0) + const thumbRects = useSharedValue>( + {}, + ) if (!activeLightbox && nextLightbox) { setActiveLightbox(nextLightbox) @@ -109,6 +113,12 @@ export default function ImageViewRoot({ return } + const initial: Record = {} + nextLightbox.images.forEach((img, i) => { + initial[i] = img.thumbRect ?? null + }) + thumbRects.set(initial) + const isAnimated = canAnimate(nextLightbox) // https://github.com/software-mansion/react-native-reanimated/issues/6677 @@ -125,13 +135,21 @@ export default function ImageViewRoot({ ) }) } - }, [nextLightbox, openProgress]) + }, [nextLightbox, openProgress, thumbRects]) + + const onFullyClosed = useCallback(() => { + setActiveLightbox(null) + runOnUI(() => { + 'worklet' + thumbRects.set({}) + })() + }, [thumbRects]) useAnimatedReaction( () => openProgress.get() === 0, (isGone, wasGone) => { if (isGone && !wasGone) { - runOnJS(setActiveLightbox)(null) + runOnJS(onFullyClosed)() } }, ) @@ -184,6 +202,7 @@ export default function ImageViewRoot({ onFlyAway={onFlyAway} safeAreaRef={ref} openProgress={openProgress} + thumbRects={thumbRects} /> )} @@ -200,6 +219,7 @@ function ImageView({ onFlyAway, safeAreaRef, openProgress, + thumbRects, }: { lightbox: Lightbox orientation: 'portrait' | 'landscape' @@ -209,6 +229,7 @@ function ImageView({ onFlyAway: () => void safeAreaRef: AnimatedRef openProgress: SharedValue + thumbRects: SharedValue> }) { const {images, index: initialImageIndex} = lightbox const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox]) @@ -216,7 +237,7 @@ function ImageView({ const [isDragging, setIsDragging] = useState(false) const [imageIndex, setImageIndex] = useState(initialImageIndex) const [showControls, setShowControls] = useState(true) - const [isAltExpanded, setAltExpanded] = useState(false) + const [isAltExpanded, setIsAltExpanded] = useState(false) const dismissSwipeTranslateY = useSharedValue(0) const isFlyingAway = useSharedValue(false) @@ -287,6 +308,24 @@ function ImageView({ } }) + const handleRequestClose = useCallback(() => { + const activeRef = images[imageIndex]?.thumbRef + if (isAnimated && activeRef) { + runOnUI(() => { + 'worklet' + const rect = measure(activeRef) + thumbRects.modify(rects => { + 'worklet' + rects[imageIndex] = rect + return rects + }) + runOnJS(onRequestClose)() + })() + } else { + onRequestClose() + } + }, [isAnimated, images, imageIndex, thumbRects, onRequestClose]) + const onTap = useCallback(() => { setShowControls(show => !show) }, []) @@ -355,7 +394,7 @@ function ImageView({ onTap={onTap} onZoom={onZoom} imageSrc={imageSrc} - onRequestClose={onRequestClose} + onRequestClose={handleRequestClose} isScrollViewBeingDragged={isDragging} showControls={showControls} safeAreaRef={safeAreaRef} @@ -364,6 +403,8 @@ function ImageView({ isActive={i === imageIndex} dismissSwipeTranslateY={dismissSwipeTranslateY} openProgress={openProgress} + thumbRects={thumbRects} + imageIndex={i} /> ))} @@ -372,7 +413,7 @@ function ImageView({ - + setAltExpanded(e => !e)} + toggleAltExpanded={() => setIsAltExpanded(e => !e)} onPressSave={onPressSave} onPressShare={onPressShare} /> @@ -404,6 +445,8 @@ function LightboxImage({ safeAreaRef, openProgress, dismissSwipeTranslateY, + thumbRects, + imageIndex, }: { imageSrc: ImageSource onRequestClose: () => void @@ -417,6 +460,8 @@ function LightboxImage({ safeAreaRef: AnimatedRef openProgress: SharedValue dismissSwipeTranslateY: SharedValue + thumbRects: SharedValue> + imageIndex: number }) { const [fetchedDims, setFetchedDims] = useState(null) const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions @@ -449,7 +494,7 @@ function LightboxImage({ return safeArea }, [safeAreaRef, heightDelayedForJSThreadOnly, widthDelayedForJSThreadOnly]) - const {thumbRect} = imageSrc + const {thumbRect: thumbRectJS} = imageSrc const transforms = useDerivedValue(() => { 'worklet' const safeArea = measureSafeArea() @@ -467,13 +512,21 @@ function LightboxImage({ } } - if (isActive && thumbRect && imageAspect && openProgressValue < 1) { - return interpolateTransform( - openProgressValue, - thumbRect, - safeArea, - imageAspect, - ) + if (isActive && imageAspect && openProgressValue < 1) { + let thumbRect + if (_WORKLET) { + thumbRect = thumbRects.get()[imageIndex] + } else { + thumbRect = thumbRectJS + } + if (thumbRect) { + return interpolateTransform( + openProgressValue, + thumbRect, + safeArea, + imageAspect, + ) + } } return { isHidden: false, diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index 41ce2b2f7d..1c0bf40843 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -1,12 +1,6 @@ import {useCallback} from 'react' import {Pressable, View} from 'react-native' -import Animated, { - measure, - type MeasuredDimensions, - runOnJS, - runOnUI, - useAnimatedRef, -} from 'react-native-reanimated' +import Animated, {useAnimatedRef} from 'react-native-reanimated' import {type AppBskyGraphDefs} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' @@ -60,14 +54,17 @@ export function ProfileSubpageHeader({ const canGoBack = navigation.canGoBack() const aviRef = useAnimatedRef() - const _openLightbox = useCallback( - (uri: string, thumbRect: MeasuredDimensions | null) => { + const onPressAvi = useCallback(() => { + if ( + avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) + ) { openLightbox({ images: [ { - uri, - thumbUri: uri, - thumbRect, + uri: avatar, + thumbUri: avatar, + thumbRect: null, + thumbRef: aviRef, dimensions: { // It's fine if it's actually smaller but we know it's 1:1. height: 1000, @@ -79,21 +76,8 @@ export function ProfileSubpageHeader({ ], index: 0, }) - }, - [openLightbox], - ) - - const onPressAvi = useCallback(() => { - if ( - avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) - ) { - runOnUI(() => { - 'worklet' - const rect = measure(aviRef) - runOnJS(_openLightbox)(avatar, rect) - })() } - }, [_openLightbox, avatar, aviRef]) + }, [openLightbox, avatar, aviRef]) return ( <> From 524cbc514d6e32405e14fd615e7e0b9c85d1fa3e Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:06:09 -0700 Subject: [PATCH 23/26] Don't toggle the date divider when tapping a reaction (#10274) --- src/components/dms/MessageItem.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index d8ffa2debf..b1c288de78 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -1,4 +1,4 @@ -import {memo, useCallback, useEffect, useMemo} from 'react' +import {memo, useCallback, useEffect, useMemo, useRef} from 'react' import { type GestureResponderEvent, LayoutAnimation, @@ -106,6 +106,7 @@ let MessageItem = ({ const queryClient = useQueryClient() const reactionsControl = useDialogControl() + const reactionTapRef = useRef(false) const {message, nextMessage, prevMessage} = item const isPending = item.type === 'pending-message' @@ -328,6 +329,16 @@ let MessageItem = ({ transform: [{translateY: -8}], }, ]} + onPressIn={() => { + // Don't toggle the date divider when tapping a reaction. + reactionTapRef.current = true + }} + onPressOut={() => { + // Include a delay here to account for tap-and-drag before release. + setTimeout(() => { + reactionTapRef.current = false + }, 100) + }} onPress={() => (isGroupChat ? reactionsControl.open() : undefined)}> {groupedReactions.map(group => ( { + if (reactionTapRef.current) return if (!hasLargeGapFromPrev) { LayoutAnimation.configureNext( LayoutAnimation.Presets.easeInEaseOut, From 8f56fca82cbba82f518ae727ec22ce339af443f2 Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Fri, 17 Apr 2026 03:14:49 +0000 Subject: [PATCH 24/26] Nightly source-language update --- src/locale/locales/en/messages.po | 478 ++++++++++++++++++------------ 1 file changed, 288 insertions(+), 190 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 6e77671483..16fde51ee6 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -13,9 +13,9 @@ 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 +#: src/screens/Messages/ConversationSettings.tsx:924 +#: src/screens/Messages/ConversationSettings.tsx:934 +#: src/screens/Messages/ConversationSettings.tsx:1011 msgid "…" msgstr "…" @@ -24,7 +24,7 @@ msgstr "…" msgid "\"{interestsDisplayName}\" category (active)" msgstr "" -#: src/screens/Messages/components/ChatListItem.tsx:320 +#: src/screens/Messages/components/ChatListItem.tsx:282 msgid "(contains embedded content)" msgstr "" @@ -72,6 +72,11 @@ msgstr "" msgid "{0, plural, one {# like} other {# likes}}" msgstr "" +#. placeholder {0}: convo.members.length +#: src/components/dialogs/SearchablePeopleList.tsx:553 +msgid "{0, plural, one {# member} other {# members}}" +msgstr "{0, plural, one {# member} other {# members}}" + #. placeholder {0}: diff.value #: src/lib/hooks/useTimeAgo.ts:149 msgid "{0, plural, one {# minute} other {# minutes}}" @@ -84,7 +89,7 @@ msgstr "" #. placeholder {0}: reactions.length #. placeholder {1}: groupedReactions.map(g => g.value).join(' ') -#: src/components/dms/MessageItem.tsx:242 +#: src/components/dms/MessageItem.tsx:293 msgid "{0, plural, one {# person} other {# people}} reacted – {1}" msgstr "{0, plural, one {# person} other {# people}} reacted – {1}" @@ -169,7 +174,7 @@ msgstr "" #. placeholder {0}: reaction.value #. placeholder {1}: reaction.count -#: src/components/dms/MessageItem.tsx:710 +#: src/components/dms/ReactionsDialog.tsx:387 msgid "{0} {1}" msgstr "{0} {1}" @@ -224,14 +229,14 @@ msgstr "" #. placeholder {0}: sanitizeDisplayName( sender.displayName || sender.handle, ) #. placeholder {1}: reaction.value -#: src/components/dms/MessageItem.tsx:235 +#: src/components/dms/MessageItem.tsx:286 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:385 +#: src/screens/Messages/components/ChatListItem.tsx:347 msgid "{0} reacted {1} to {2}" msgstr "" @@ -255,10 +260,13 @@ msgstr "" msgid "{0}'s avatar" msgstr "" -#. placeholder {0}: groupOwner.handle +#. placeholder {0}: sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), ) +#: src/components/dms/MessageItem.tsx:224 +msgid "{0}’s avatar" +msgstr "{0}’s avatar" + #. 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" @@ -463,13 +471,13 @@ msgstr "" msgid "{following} following" msgstr "" -#: src/components/dialogs/SearchablePeopleList.tsx:414 +#: src/components/dialogs/SearchablePeopleList.tsx:458 msgid "{handle} can't be messaged" msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:805 -#: src/components/dms/InitiateChatFlow.tsx:858 -#: src/components/dms/InitiateChatFlow.tsx:897 +#: src/components/dms/components/GroupChatProfileCard.tsx:56 +#: src/components/dms/InitiateChatFlow.tsx:809 +#: src/components/dms/InitiateChatFlow.tsx:848 msgid "{handle} can’t be messaged" msgstr "{handle} can’t be messaged" @@ -502,7 +510,7 @@ 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 +#: src/screens/Messages/ConversationSettings.tsx:239 msgid "{memberCount}/{MEMBER_LIMIT}" msgstr "{memberCount}/{MEMBER_LIMIT}" @@ -543,7 +551,7 @@ msgstr "" msgid "{rank}." msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:238 +#: src/screens/Messages/ConversationSettings.tsx:246 msgid "{requestCount, plural, one {# request} other {# requests}}" msgstr "{requestCount, plural, one {# request} other {# requests}}" @@ -685,7 +693,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:368 +#: src/screens/Messages/components/ChatListItem.tsx:330 msgid "a message" msgstr "" @@ -746,7 +754,7 @@ msgid "Accept chat request" msgstr "" #. Accept a chat request -#: src/screens/Messages/components/RequestListItem.tsx:42 +#: src/screens/Messages/components/RequestListItem.tsx:45 msgid "Accept Request" msgstr "" @@ -774,7 +782,7 @@ msgstr "" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:426 #: src/screens/Messages/components/RequestButtons.tsx:101 -#: src/screens/Messages/ConversationSettings.tsx:528 +#: src/screens/Messages/ConversationSettings.tsx:571 #: src/view/com/profile/ProfileMenu.tsx:188 msgctxt "toast" msgid "Account blocked" @@ -820,7 +828,7 @@ msgstr "" msgid "Account removed from quick access" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:515 +#: src/screens/Messages/ConversationSettings.tsx:558 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:88 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:310 #: src/view/com/profile/ProfileMenu.tsx:176 @@ -857,6 +865,7 @@ msgstr "" #: src/components/dialogs/MutedWords.tsx:337 #: src/components/dialogs/StarterPackDialog.tsx:376 #: src/components/dialogs/StarterPackDialog.tsx:388 +#: src/components/dms/AddMembersFlow.tsx:341 #: src/view/com/modals/UserAddRemoveLists.tsx:236 msgid "Add" msgstr "" @@ -936,6 +945,10 @@ msgstr "Add automation label to account" msgid "Add emoji reaction" msgstr "" +#: src/components/dms/AddMembersFlow.tsx:422 +msgid "Add group chat members" +msgstr "Add group chat members" + #: src/view/com/feeds/ComposerPrompt.tsx:224 msgid "Add image" msgstr "" @@ -945,7 +958,8 @@ msgstr "" msgid "Add media to post" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:297 +#: src/screens/Messages/ConversationSettings.tsx:325 +#: src/screens/Messages/ConversationSettings.tsx:342 msgid "Add members" msgstr "Add members" @@ -967,6 +981,8 @@ msgstr "" msgid "Add muted words and tags" msgstr "" +#: src/screens/Messages/components/MessagesListInfoPanel.tsx:101 +#: src/screens/Messages/components/MessagesListInfoPanel.tsx:126 #: src/screens/ProfileList/AboutSection.tsx:72 #: src/screens/ProfileList/AboutSection.tsx:90 msgid "Add people" @@ -1045,8 +1061,8 @@ msgstr "" msgid "Additional details (limit 300 characters)" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:343 -#: src/screens/Messages/ConversationSettings.tsx:566 +#: src/screens/Messages/ConversationSettings.tsx:386 +#: src/screens/Messages/ConversationSettings.tsx:609 msgid "Admin" msgstr "Admin" @@ -1109,7 +1125,7 @@ msgid "alice@example.com" msgstr "" #. the default tab in the interests tab bar -#: src/components/dms/MessageItem.tsx:628 +#: src/components/dms/ReactionsDialog.tsx:289 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:199 #: src/view/screens/Notifications.tsx:88 msgid "All" @@ -1325,7 +1341,7 @@ 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 +#: src/screens/Messages/ConversationSettings.tsx:1079 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" @@ -1525,7 +1541,7 @@ msgstr "" msgid "Are you sure you want to delete the app password \"{0}\"?" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:204 +#: src/components/dms/MessageContextMenu.tsx:207 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." @@ -1538,6 +1554,10 @@ msgstr "" msgid "Are you sure you want to discard your changes?" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:1122 +msgid "Are you sure you want to leave {groupName}?" +msgstr "Are you sure you want to leave {groupName}?" + #: src/components/dms/LeaveConvoPrompt.tsx:50 msgid "Are you sure you want to leave this conversation?" msgstr "" @@ -1609,9 +1629,12 @@ msgstr "" msgid "Available" msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:485 -#: src/components/dms/InitiateChatFlow.tsx:670 -#: src/components/dms/InitiateChatFlow.tsx:677 +#: src/components/dms/AddMembersFlow.tsx:290 +#: src/components/dms/AddMembersFlow.tsx:445 +#: src/components/dms/AddMembersFlow.tsx:452 +#: src/components/dms/InitiateChatFlow.tsx:489 +#: src/components/dms/InitiateChatFlow.tsx:674 +#: src/components/dms/InitiateChatFlow.tsx:681 #: src/components/moderation/LabelsOnMeDialog.tsx:334 #: src/components/moderation/LabelsOnMeDialog.tsx:335 #: src/screens/Login/ChooseAccountForm.tsx:98 @@ -1624,7 +1647,7 @@ msgstr "" #: src/screens/Login/SetNewPasswordForm.tsx:178 #: src/screens/Messages/components/ChatDisabled.tsx:148 #: src/screens/Messages/components/ChatDisabled.tsx:149 -#: src/screens/Profile/Header/Shell.tsx:184 +#: src/screens/Profile/Header/Shell.tsx:174 #: src/screens/Settings/components/ChangePasswordDialog.tsx:273 #: src/screens/Settings/components/ChangePasswordDialog.tsx:282 #: src/screens/Signup/BackNextButtons.tsx:42 @@ -1670,11 +1693,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:85 +#: src/components/dms/dialogs/NewChatDialog.tsx:94 #: src/components/dms/MessageProfileButton.tsx:60 #: src/screens/Messages/ChatList.tsx:376 #: src/screens/Messages/Conversation.tsx:247 -#: src/screens/Messages/ConversationSettings.tsx:505 +#: src/screens/Messages/ConversationSettings.tsx:548 msgid "Before you can message another user, you must first verify your email." msgstr "" @@ -1703,14 +1726,14 @@ msgid "Birthday" msgstr "" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:853 -#: src/screens/Messages/ConversationSettings.tsx:627 -#: src/screens/Messages/ConversationSettings.tsx:1060 +#: src/screens/Messages/ConversationSettings.tsx:670 +#: src/screens/Messages/ConversationSettings.tsx:1147 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:207 #: src/view/com/profile/ProfileMenu.tsx:563 msgid "Block" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:623 +#: src/screens/Messages/ConversationSettings.tsx:666 msgid "Block {displayName}" msgstr "Block {displayName}" @@ -1725,7 +1748,7 @@ msgstr "Block {displayName}" msgid "Block account" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:1057 +#: src/screens/Messages/ConversationSettings.tsx:1144 msgid "Block account?" msgstr "Block account?" @@ -1782,7 +1805,7 @@ msgid "Blocked Accounts" msgstr "" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:851 -#: src/screens/Messages/ConversationSettings.tsx:1058 +#: src/screens/Messages/ConversationSettings.tsx:1145 #: src/view/com/profile/ProfileMenu.tsx:558 msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." msgstr "" @@ -2017,8 +2040,9 @@ 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/Messages/ConversationSettings.tsx:1081 +#: src/screens/Messages/ConversationSettings.tsx:1102 +#: src/screens/Messages/ConversationSettings.tsx:1126 #: src/screens/Profile/Header/EditProfileDialog.tsx:215 #: src/screens/Profile/Header/EditProfileDialog.tsx:223 #: src/screens/Search/Shell.tsx:399 @@ -2302,6 +2326,10 @@ msgstr "" msgid "click here" msgstr "" +#: src/screens/Messages/components/MessagesListInfoPanel.tsx:97 +msgid "Click here to add people to this group chat" +msgstr "Click here to add people to this group chat" + #: src/ageAssurance/components/NoAccessScreen.tsx:129 msgid "Click here to contact our support team" msgstr "" @@ -2327,15 +2355,23 @@ msgstr "" msgid "Click here to update your email" msgstr "" +#: src/screens/Messages/components/MessagesListInfoPanel.tsx:109 +msgid "Click here to view or create an invite link for this group chat" +msgstr "Click here to view or create an invite link for this group chat" + #. placeholder {0}: isCashtag ? tag : `#${tag}` #: src/components/RichTextTag.tsx:56 msgid "Click to open tag menu for {0}" msgstr "" -#: src/components/dms/MessageItem.tsx:480 +#: src/components/dms/MessageItem.tsx:544 msgid "Click to retry failed message" msgstr "" +#: src/components/dms/ActionsWrapper.web.tsx:142 +msgid "Click to view the date and time" +msgstr "Click to view the date and time" + #: src/components/dms/ChatEmptyPill.tsx:39 msgid "Clip 🐴 clop 🐴" msgstr "" @@ -2359,14 +2395,15 @@ msgstr "" #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:185 #: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:191 #: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:199 -#: src/components/dialogs/SearchablePeopleList.tsx:296 +#: src/components/dialogs/SearchablePeopleList.tsx:339 #: src/components/dialogs/StarterPackDialog.tsx:187 +#: src/components/dms/AddMembersFlow.tsx:315 #: src/components/dms/AfterReportDialog.tsx:93 #: src/components/dms/AfterReportDialog.tsx:98 #: src/components/dms/AfterReportDialog.tsx:212 #: src/components/dms/AfterReportDialog.tsx:217 #: src/components/dms/EmojiPopup.android.tsx:59 -#: src/components/dms/InitiateChatFlow.tsx:510 +#: src/components/dms/InitiateChatFlow.tsx:514 #: src/components/NewskieDialog.tsx:169 #: src/components/NewskieDialog.tsx:175 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:107 @@ -2386,8 +2423,8 @@ msgstr "" msgid "Close" msgstr "" -#: src/components/Dialog/index.web.tsx:126 -#: src/components/Dialog/index.web.tsx:311 +#: src/components/Dialog/index.web.tsx:127 +#: src/components/Dialog/index.web.tsx:313 msgid "Close active dialog" msgstr "" @@ -2679,7 +2716,8 @@ msgstr "" msgid "Continue thread..." msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:437 +#: src/components/dms/AddMembersFlow.tsx:255 +#: src/components/dms/InitiateChatFlow.tsx:441 msgid "Continue to group name" msgstr "Continue to group name" @@ -2695,7 +2733,7 @@ msgstr "" msgid "Conversation" msgstr "" -#: src/screens/Messages/components/ChatListItem.tsx:355 +#: src/screens/Messages/components/ChatListItem.tsx:317 msgid "Conversation deleted" msgstr "" @@ -2709,7 +2747,7 @@ msgstr "" msgid "Copied build version to clipboard" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:62 +#: src/components/dms/MessageContextMenu.tsx:64 #: src/components/PostControls/DiscoverDebug.tsx:36 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:272 #: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:77 @@ -2790,8 +2828,8 @@ msgstr "" msgid "Copy link to starter pack" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:151 #: src/components/dms/MessageContextMenu.tsx:154 +#: src/components/dms/MessageContextMenu.tsx:157 msgid "Copy message text" msgstr "" @@ -2862,10 +2900,6 @@ 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 "" @@ -2896,7 +2930,7 @@ msgstr "" #. Text on button to create a new starter pack #: src/components/dialogs/StarterPackDialog.tsx:113 #: src/components/dialogs/StarterPackDialog.tsx:210 -#: src/components/dms/InitiateChatFlow.tsx:446 +#: src/components/dms/InitiateChatFlow.tsx:450 #: src/components/StarterPack/ProfileStarterPacks.tsx:329 msgid "Create" msgstr "" @@ -2959,7 +2993,7 @@ msgstr "" msgid "Create an avatar instead" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:800 +#: src/screens/Messages/ConversationSettings.tsx:858 msgid "Create an invite link for this group chat" msgstr "Create an invite link for this group chat" @@ -2967,7 +3001,7 @@ msgstr "Create an invite link for this group chat" msgid "Create another" msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:445 +#: src/components/dms/InitiateChatFlow.tsx:449 msgid "Create group chat" msgstr "Create group chat" @@ -3090,7 +3124,7 @@ msgstr "" msgid "Default icons" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:205 +#: src/components/dms/MessageContextMenu.tsx:208 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:803 #: src/screens/Messages/components/ChatStatusInfo.tsx:55 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:275 @@ -3143,7 +3177,7 @@ msgstr "" msgid "Delete Conversation" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:165 +#: src/components/dms/MessageContextMenu.tsx:168 msgid "Delete for me" msgstr "" @@ -3152,11 +3186,11 @@ msgstr "" msgid "Delete list" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:203 +#: src/components/dms/MessageContextMenu.tsx:206 msgid "Delete message" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:163 +#: src/components/dms/MessageContextMenu.tsx:166 msgid "Delete message for me" msgstr "" @@ -3192,9 +3226,9 @@ msgid "Deleted" msgstr "" #: 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 +#: src/screens/Messages/components/ChatListItem.tsx:127 +#: src/screens/Messages/ConversationSettings.tsx:376 +#: src/screens/Messages/ConversationSettings.tsx:595 msgid "Deleted Account" msgstr "" @@ -3344,7 +3378,7 @@ msgstr "" msgid "Discover New Feeds" msgstr "" -#: src/components/Dialog/index.tsx:401 +#: src/components/Dialog/index.tsx:408 msgid "Dismiss" msgstr "" @@ -3459,11 +3493,11 @@ msgctxt "action" msgid "Done" msgstr "" -#: src/components/dms/MessageItem.tsx:369 +#: src/components/dms/MessageItem.tsx:451 msgid "Double tap or long press the message to add a reaction" msgstr "" -#: src/components/Dialog/index.tsx:402 +#: src/components/Dialog/index.tsx:409 msgid "Double tap to close the dialog" msgstr "" @@ -3572,8 +3606,8 @@ msgstr "" msgid "Edit Feeds" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:972 -#: src/screens/Messages/ConversationSettings.tsx:977 +#: src/screens/Messages/ConversationSettings.tsx:1035 +#: src/screens/Messages/ConversationSettings.tsx:1040 msgid "Edit group name" msgstr "Edit group name" @@ -3612,7 +3646,7 @@ msgstr "" msgid "Edit My Feeds" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:794 +#: src/screens/Messages/ConversationSettings.tsx:852 msgid "Edit name" msgstr "Edit name" @@ -3646,7 +3680,7 @@ msgstr "" msgid "Edit starter pack" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:793 +#: src/screens/Messages/ConversationSettings.tsx:851 msgid "Edit this group chat’s name" msgstr "Edit this group chat’s name" @@ -4041,8 +4075,8 @@ msgctxt "toast" msgid "Failed to accept chat" msgstr "" -#: src/components/dms/ActionsWrapper.web.tsx:64 -#: src/components/dms/MessageContextMenu.tsx:102 +#: src/components/dms/ActionsWrapper.web.tsx:67 +#: src/components/dms/MessageContextMenu.tsx:104 msgid "Failed to add emoji reaction" msgstr "" @@ -4059,7 +4093,7 @@ msgid "Failed to create app password. Please try again." msgstr "" #: src/components/dms/MessageProfileButton.tsx:38 -#: src/screens/Messages/ConversationSettings.tsx:482 +#: src/screens/Messages/ConversationSettings.tsx:525 msgid "Failed to create conversation" msgstr "" @@ -4074,7 +4108,7 @@ msgctxt "toast" msgid "Failed to delete chat" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:84 +#: src/components/dms/MessageContextMenu.tsx:86 msgid "Failed to delete message" msgstr "" @@ -4107,6 +4141,11 @@ msgstr "" msgid "Failed to launch SMS app" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:747 +msgctxt "toast" +msgid "Failed to leave group chat" +msgstr "Failed to leave group chat" + #: src/screens/Messages/ChatList.tsx:287 #: src/screens/Messages/Inbox.tsx:210 msgid "Failed to load conversations" @@ -4151,6 +4190,10 @@ msgstr "" msgid "Failed to load preference." msgstr "" +#: src/components/dialogs/SearchablePeopleList.tsx:291 +msgid "Failed to load profiles" +msgstr "Failed to load profiles" + #: src/screens/Search/Explore.tsx:481 #: src/screens/Search/Explore.tsx:536 #: src/screens/Search/Explore.tsx:581 @@ -4167,6 +4210,10 @@ msgstr "" msgid "Failed to mark all requests as read" msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:735 +msgid "Failed to mute group chat" +msgstr "Failed to mute group chat" + #: src/state/queries/pinned-post.ts:75 msgid "Failed to pin post" msgstr "" @@ -4185,8 +4232,9 @@ msgstr "" msgid "Failed to remove data. {0}" msgstr "" -#: src/components/dms/ActionsWrapper.web.tsx:60 -#: src/components/dms/MessageContextMenu.tsx:98 +#: src/components/dms/ActionsWrapper.web.tsx:63 +#: src/components/dms/MessageContextMenu.tsx:100 +#: src/components/dms/ReactionsDialog.tsx:174 msgid "Failed to remove emoji reaction" msgstr "" @@ -4801,7 +4849,7 @@ msgstr "" #: src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx:77 #: src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx:87 -#: src/screens/Messages/ConversationSettings.tsx:1017 +#: src/screens/Messages/ConversationSettings.tsx:1080 msgid "Get started" msgstr "" @@ -4841,7 +4889,7 @@ msgstr "" msgid "Go back" msgstr "" -#: src/components/Error.tsx:79 +#: src/components/Error.tsx:77 #: src/screens/List/ListHiddenScreen.tsx:228 #: src/screens/Profile/ErrorState.tsx:63 #: src/screens/Profile/ErrorState.tsx:67 @@ -4904,7 +4952,7 @@ msgid "Go to account settings" msgstr "" #. placeholder {0}: profile.handle -#: src/screens/Messages/components/ChatListItem.tsx:182 +#: src/screens/Messages/components/ChatListItem.tsx:148 msgid "Go to conversation with {0}" msgstr "" @@ -4917,13 +4965,13 @@ msgid "Go to next" msgstr "" #: src/components/dms/ConvoMenu.tsx:255 -#: src/screens/Messages/ConversationSettings.tsx:603 +#: src/screens/Messages/ConversationSettings.tsx:646 #: 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 +#: src/screens/Messages/components/ChatListItem.tsx:193 msgid "Go to the group chat named \"{chatName}\"" msgstr "Go to the group chat named \"{chatName}\"" @@ -4955,24 +5003,28 @@ msgstr "" msgid "Grooming or predatory behavior" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:684 +#: src/screens/Messages/ConversationSettings.tsx:728 msgctxt "toast" msgid "Group chat muted" msgstr "Group chat muted" #: src/Navigation.tsx:575 -#: src/screens/Messages/ConversationSettings.tsx:102 +#: src/screens/Messages/ConversationSettings.tsx:104 msgid "Group chat settings" msgstr "Group chat settings" -#: src/screens/Messages/ConversationSettings.tsx:686 +#: src/screens/Messages/ConversationSettings.tsx:730 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 +#: src/components/dialogs/SearchablePeopleList.tsx:563 +msgid "Group is locked" +msgstr "Group is locked" + +#: src/components/dms/InitiateChatFlow.tsx:231 +#: src/components/dms/InitiateChatFlow.tsx:549 +#: src/screens/Messages/ConversationSettings.tsx:1041 msgid "Group name" msgstr "Group name" @@ -5576,8 +5628,9 @@ msgstr "" msgid "Invite friends <0/>" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:801 -#: src/screens/Messages/ConversationSettings.tsx:1015 +#: src/screens/Messages/components/MessagesListInfoPanel.tsx:113 +#: src/screens/Messages/ConversationSettings.tsx:859 +#: src/screens/Messages/ConversationSettings.tsx:1078 msgid "Invite link" msgstr "Invite link" @@ -5589,7 +5642,7 @@ msgstr "" msgid "Invite your friends to follow your favorite feeds and people" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:566 +#: src/screens/Messages/ConversationSettings.tsx:609 msgid "Invited" msgstr "Invited" @@ -5802,7 +5855,7 @@ msgid "Learn more." msgstr "" #: src/components/dms/LeaveConvoPrompt.tsx:52 -#: src/screens/Messages/ConversationSettings.tsx:829 +#: src/screens/Messages/ConversationSettings.tsx:887 msgid "Leave" msgstr "" @@ -5819,7 +5872,11 @@ msgstr "" msgid "Leave conversation" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:828 +#: src/screens/Messages/ConversationSettings.tsx:1124 +msgid "Leave group chat" +msgstr "Leave group chat" + +#: src/screens/Messages/ConversationSettings.tsx:886 msgid "Leave this group chat" msgstr "Leave this group chat" @@ -5958,11 +6015,11 @@ msgstr "" msgid "List by {0}" msgstr "" -#: src/view/com/profile/ProfileSubpageHeader.tsx:161 +#: src/view/com/profile/ProfileSubpageHeader.tsx:145 msgid "List by <0/>" msgstr "" -#: src/view/com/profile/ProfileSubpageHeader.tsx:159 +#: src/view/com/profile/ProfileSubpageHeader.tsx:143 msgid "List by you" msgstr "" @@ -6113,27 +6170,27 @@ msgstr "" msgid "Loading..." msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:936 +#: src/screens/Messages/ConversationSettings.tsx:999 msgid "Loading…" msgstr "Loading…" -#: src/screens/Messages/ConversationSettings.tsx:811 +#: src/screens/Messages/ConversationSettings.tsx:869 msgid "Lock" msgstr "Lock" -#: src/screens/Messages/ConversationSettings.tsx:1038 +#: src/screens/Messages/ConversationSettings.tsx:1101 msgid "Lock group chat" msgstr "Lock group chat" -#: src/screens/Messages/ConversationSettings.tsx:1036 +#: src/screens/Messages/ConversationSettings.tsx:1099 msgid "Lock group chat?" msgstr "Lock group chat?" -#: src/screens/Messages/ConversationSettings.tsx:809 +#: src/screens/Messages/ConversationSettings.tsx:867 msgid "Lock this group chat" msgstr "Lock this group chat" -#: src/screens/Messages/ConversationSettings.tsx:811 +#: src/screens/Messages/ConversationSettings.tsx:869 msgid "Locked" msgstr "Locked" @@ -6243,11 +6300,11 @@ msgstr "" msgid "Media that may be disturbing or inappropriate for some audiences." msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:224 +#: src/screens/Messages/ConversationSettings.tsx:232 msgid "Members" msgstr "Members" -#: src/screens/Messages/ConversationSettings.tsx:1037 +#: src/screens/Messages/ConversationSettings.tsx:1100 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." @@ -6273,7 +6330,7 @@ msgstr "" #: src/screens/Messages/components/MessageComposer.tsx:198 #: src/screens/Messages/components/MessageInput.tsx:173 #: src/screens/Messages/components/MessageInput.web.tsx:212 -#: src/screens/Messages/ConversationSettings.tsx:611 +#: src/screens/Messages/ConversationSettings.tsx:654 msgid "Message" msgstr "Message" @@ -6282,26 +6339,26 @@ msgstr "Message" msgid "Message {0}" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:608 +#: src/screens/Messages/ConversationSettings.tsx:651 msgid "Message {displayName}" msgstr "Message {displayName}" -#: src/screens/Messages/components/ChatListItem.tsx:356 +#: src/screens/Messages/components/ChatListItem.tsx:318 msgid "Message deleted" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:83 +#: src/components/dms/MessageContextMenu.tsx:85 msgctxt "toast" msgid "Message deleted" msgstr "" -#: src/components/dms/MessageItem.tsx:474 +#: src/components/dms/MessageItem.tsx:538 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 +#: src/components/dms/MessageContextMenu.tsx:133 msgid "Message from @{0}: {1}" msgstr "" @@ -6324,7 +6381,7 @@ 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:130 +#: src/components/dms/MessageContextMenu.tsx:132 msgid "Message options" msgstr "" @@ -6374,12 +6431,12 @@ msgstr "" msgid "Moderation list by {0}" msgstr "" -#: src/view/com/profile/ProfileSubpageHeader.tsx:174 +#: src/view/com/profile/ProfileSubpageHeader.tsx:158 msgid "Moderation list by <0/>" msgstr "" #: src/view/com/modals/UserAddRemoveLists.tsx:221 -#: src/view/com/profile/ProfileSubpageHeader.tsx:172 +#: src/view/com/profile/ProfileSubpageHeader.tsx:156 msgid "Moderation list by you" msgstr "" @@ -6452,7 +6509,7 @@ msgstr "" msgid "Music" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:787 +#: src/screens/Messages/ConversationSettings.tsx:845 msgid "Mute" msgstr "Mute" @@ -6497,7 +6554,7 @@ msgstr "" msgid "Mute these accounts?" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:785 +#: src/screens/Messages/ConversationSettings.tsx:843 msgid "Mute this group chat" msgstr "Mute this group chat" @@ -6535,7 +6592,7 @@ msgstr "" msgid "Mute words & tags" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:787 +#: src/screens/Messages/ConversationSettings.tsx:845 msgid "Muted" msgstr "Muted" @@ -6639,13 +6696,33 @@ msgstr "" msgid "New {postsCount, plural, one {post} other {posts}} from {firstAuthorName}" msgstr "" -#: src/components/dms/dialogs/NewChatDialog.tsx:98 -#: src/components/dms/dialogs/NewChatDialog.tsx:108 +#: src/components/dms/dialogs/NewChatDialog.tsx:107 +#: src/components/dms/dialogs/NewChatDialog.tsx:117 #: src/screens/Messages/ChatList.tsx:408 #: src/screens/Messages/ChatList.tsx:415 msgid "New chat" msgstr "" +#. placeholder {0}: members[0].displayName +#: src/screens/Messages/components/MessagesListInfoPanel.tsx:38 +msgid "New chat with {0}" +msgstr "New chat with {0}" + +#. placeholder {0}: members[0].displayName +#. placeholder {1}: members[1].displayName +#: src/screens/Messages/components/MessagesListInfoPanel.tsx:42 +msgid "New chat with {0} and {1}" +msgstr "New chat with {0} and {1}" + +#. placeholder {0}: members[0].displayName +#. placeholder {1}: members[1].displayName +#. placeholder {2}: members.length - 2 +#. placeholder {3}: members.length - 2 +#. placeholder {4}: members.length - 2 +#: src/screens/Messages/components/MessagesListInfoPanel.tsx:49 +msgid "New chat with {0}, {1}, and {2, plural, one {{3} more} other {{4} more}}." +msgstr "New chat with {0}, {1}, and {2, plural, one {{3} more} other {{4} more}}." + #: src/components/dialogs/EmailDialog/screens/Update.tsx:223 msgid "New email address" msgstr "" @@ -6667,13 +6744,13 @@ msgstr "" msgid "New followers" msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:226 -#: src/components/dms/InitiateChatFlow.tsx:709 -#: src/components/dms/InitiateChatFlow.tsx:737 +#: src/components/dms/InitiateChatFlow.tsx:230 +#: src/components/dms/InitiateChatFlow.tsx:713 +#: src/components/dms/InitiateChatFlow.tsx:741 msgid "New group chat" msgstr "New group chat" -#: src/components/dms/InitiateChatFlow.tsx:261 +#: src/components/dms/InitiateChatFlow.tsx:265 msgid "New group chat with:" msgstr "New group chat with:" @@ -6743,7 +6820,8 @@ msgstr "" #: src/components/contacts/screens/ViewMatches.tsx:395 #: src/components/contacts/screens/ViewMatches.tsx:410 -#: src/components/dms/InitiateChatFlow.tsx:438 +#: src/components/dms/AddMembersFlow.tsx:256 +#: src/components/dms/InitiateChatFlow.tsx:442 #: src/screens/Login/ForgotPasswordForm.tsx:149 #: src/screens/Login/ForgotPasswordForm.tsx:156 #: src/screens/Login/SetNewPasswordForm.tsx:186 @@ -6836,7 +6914,7 @@ msgstr "" msgid "No media yet" msgstr "" -#: src/screens/Messages/components/ChatListItem.tsx:300 +#: src/screens/Messages/components/ChatListItem.tsx:262 msgid "No messages yet" msgstr "" @@ -6895,8 +6973,9 @@ msgstr "" msgid "No result" msgstr "" -#: src/components/dialogs/SearchablePeopleList.tsx:224 -#: src/components/dms/InitiateChatFlow.tsx:334 +#: src/components/dialogs/SearchablePeopleList.tsx:249 +#: src/components/dms/AddMembersFlow.tsx:208 +#: src/components/dms/InitiateChatFlow.tsx:338 #: src/components/ProgressGuide/FollowDialog.tsx:221 msgid "No results" msgstr "" @@ -7181,12 +7260,12 @@ msgstr "" msgid "Open camera" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:561 +#: src/screens/Messages/ConversationSettings.tsx:604 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 +#: src/screens/Messages/components/ChatListItem.tsx:476 +#: src/screens/Messages/components/ChatListItem.tsx:480 msgid "Open conversation options" msgstr "" @@ -7229,7 +7308,7 @@ msgstr "Open group chat settings" msgid "Open link to {niceUrl}" msgstr "" -#: src/components/dms/ActionsWrapper.tsx:34 +#: src/components/dms/ActionsWrapper.tsx:37 msgid "Open message options" msgstr "" @@ -7377,6 +7456,7 @@ msgstr "" msgid "Opens this draft in the composer" msgstr "" +#: src/components/dms/MessageItem.tsx:227 #: src/view/com/notifications/NotificationFeedItem.tsx:1021 #: src/view/com/util/UserAvatar.tsx:599 msgid "Opens this profile" @@ -8035,7 +8115,7 @@ msgstr "" msgid "Profile" msgstr "" -#: src/screens/Profile/Header/Shell.tsx:174 +#: src/screens/Profile/Header/Shell.tsx:164 msgid "Profile banner placeholder" msgstr "Profile banner placeholder" @@ -8163,8 +8243,8 @@ msgstr "" msgid "React with {emoji}" msgstr "" -#: src/components/dms/MessageItem.tsx:535 -#: src/components/dms/MessageItem.tsx:545 +#: src/components/dms/ReactionsDialog.tsx:67 +#: src/components/dms/ReactionsDialog.tsx:92 msgid "Reactions" msgstr "Reactions" @@ -8291,7 +8371,7 @@ msgstr "Remove {displayName} from group chat" msgid "Remove {displayName} from starter pack" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:634 +#: src/screens/Messages/ConversationSettings.tsx:677 msgid "Remove {displayName} from this group chat" msgstr "Remove {displayName} from this group chat" @@ -8341,7 +8421,7 @@ msgstr "" msgid "Remove feed?" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:637 +#: src/screens/Messages/ConversationSettings.tsx:680 msgid "Remove from chat" msgstr "Remove from chat" @@ -8558,11 +8638,11 @@ msgstr "" msgid "Reply was successfully hidden" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:173 +#: src/components/dms/MessageContextMenu.tsx:176 #: 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 +#: src/screens/Messages/ConversationSettings.tsx:878 msgid "Report" msgstr "" @@ -8594,7 +8674,7 @@ msgstr "" msgid "Report list" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:171 +#: src/components/dms/MessageContextMenu.tsx:174 msgid "Report message" msgstr "" @@ -8621,7 +8701,7 @@ msgstr "" msgid "Report this feed" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:819 +#: src/screens/Messages/ConversationSettings.tsx:877 msgid "Report this group chat" msgstr "Report this group chat" @@ -8796,7 +8876,7 @@ msgstr "" #: src/components/ageAssurance/AgeAssuranceErrors.tsx:31 #: src/components/contacts/screens/VerifyNumber.tsx:350 #: src/components/contacts/screens/VerifyNumber.tsx:355 -#: src/components/Error.tsx:66 +#: src/components/Error.tsx:65 #: src/components/Lists.tsx:115 #: src/components/moderation/ReportDialog/index.tsx:299 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:56 @@ -8825,7 +8905,7 @@ msgstr "" msgid "Retry loading report options" msgstr "" -#: src/components/Error.tsx:74 +#: src/components/Error.tsx:73 #: src/screens/List/ListHiddenScreen.tsx:223 #: src/screens/StarterPack/StarterPackScreen.tsx:803 msgid "Return to previous page" @@ -8854,7 +8934,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/Messages/ConversationSettings.tsx:1055 #: src/screens/Profile/Header/EditProfileDialog.tsx:233 #: src/screens/Profile/Header/EditProfileDialog.tsx:247 #: src/screens/SavedFeeds.tsx:132 @@ -8869,7 +8949,7 @@ msgstr "" msgid "Save" msgstr "" -#: src/view/com/lightbox/ImageViewing/index.tsx:611 +#: src/view/com/lightbox/ImageViewing/index.tsx:664 msgctxt "action" msgid "Save" msgstr "" @@ -8976,7 +9056,7 @@ msgstr "" msgid "Scroll to top" msgstr "" -#: src/components/dialogs/SearchablePeopleList.tsx:515 +#: src/components/dialogs/SearchablePeopleList.tsx:666 #: src/components/forms/SearchInput.tsx:51 #: src/components/forms/SearchInput.tsx:53 #: src/screens/Search/Shell.tsx:356 @@ -9030,7 +9110,7 @@ msgstr "" msgid "Search for more feeds" msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:982 +#: src/components/dms/components/UserSearchInput.tsx:43 msgid "Search for people" msgstr "Search for people" @@ -9061,8 +9141,8 @@ msgstr "" msgid "Search posts" msgstr "" -#: src/components/dialogs/SearchablePeopleList.tsx:535 -#: src/components/dms/InitiateChatFlow.tsx:1002 +#: src/components/dialogs/SearchablePeopleList.tsx:686 +#: src/components/dms/components/UserSearchInput.tsx:63 #: src/components/ProgressGuide/FollowDialog.tsx:719 msgid "Search profiles" msgstr "" @@ -9075,8 +9155,8 @@ msgstr "" msgid "Search..." msgstr "" -#: src/components/dialogs/SearchablePeopleList.tsx:536 -#: src/components/dms/InitiateChatFlow.tsx:1003 +#: src/components/dialogs/SearchablePeopleList.tsx:687 +#: src/components/dms/components/UserSearchInput.tsx:64 #: src/components/ProgressGuide/FollowDialog.tsx:720 msgid "Searches for profiles" msgstr "" @@ -9190,6 +9270,10 @@ msgstr "" msgid "Select caption file (.vtt)" msgstr "Select caption file (.vtt)" +#: src/components/dialogs/SearchablePeopleList.tsx:499 +msgid "Select chat \"{name}\"" +msgstr "Select chat \"{name}\"" + #: src/screens/Settings/LanguageSettings.tsx:178 #: src/screens/Settings/LanguageSettings.tsx:216 msgid "Select content languages" @@ -9220,7 +9304,7 @@ msgstr "" msgid "Select GIF \"{0}\"" msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:645 +#: src/components/dms/InitiateChatFlow.tsx:649 msgid "Select group chat members" msgstr "Select group chat members" @@ -9334,11 +9418,11 @@ msgstr "" msgid "Send message" msgstr "" -#: src/components/PostControls/ShareMenu/RecentChats.tsx:128 +#: src/components/PostControls/ShareMenu/RecentChats.tsx:136 msgid "Send post to {name}" msgstr "" -#: src/components/dms/dialogs/ShareViaChatDialog.tsx:65 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:72 msgid "Send post to..." msgstr "" @@ -9471,7 +9555,7 @@ msgstr "" msgid "Share" msgstr "" -#: src/view/com/lightbox/ImageViewing/index.tsx:620 +#: src/view/com/lightbox/ImageViewing/index.tsx:673 msgctxt "action" msgid "Share" msgstr "" @@ -9832,13 +9916,13 @@ msgid "Some people can reply" msgstr "" #. placeholder {0}: reaction.value -#: src/components/dms/MessageItem.tsx:239 +#: src/components/dms/MessageItem.tsx:290 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:393 +#: src/screens/Messages/components/ChatListItem.tsx:355 msgid "Someone reacted {0} to {1}" msgstr "" @@ -9847,7 +9931,7 @@ msgid "Something wasn't quite right with the data you're trying to report. Pleas msgstr "" #: src/screens/Messages/Conversation.tsx:153 -#: src/screens/Messages/ConversationSettings.tsx:177 +#: src/screens/Messages/ConversationSettings.tsx:179 msgid "Something went wrong" msgstr "" @@ -9919,11 +10003,11 @@ msgstr "" msgid "Sports" msgstr "" -#: src/components/PostControls/ShareMenu/RecentChats.tsx:206 +#: src/components/PostControls/ShareMenu/RecentChats.tsx:224 msgid "Start a conversation, and it will appear here." msgstr "" -#: src/components/dms/dialogs/NewChatDialog.tsx:114 +#: src/components/dms/dialogs/NewChatDialog.tsx:123 msgid "Start a new chat" msgstr "" @@ -9937,12 +10021,12 @@ msgstr "" msgid "Start adding people!" msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:646 +#: src/components/dms/InitiateChatFlow.tsx:650 msgid "Start chat" msgstr "Start chat" -#: src/components/dialogs/SearchablePeopleList.tsx:383 -#: src/components/dms/InitiateChatFlow.tsx:773 +#: src/components/dialogs/SearchablePeopleList.tsx:427 +#: src/components/dms/InitiateChatFlow.tsx:777 msgid "Start chat with {displayName}" msgstr "" @@ -9957,12 +10041,12 @@ msgstr "" msgid "Starter pack by {0}" msgstr "" -#: src/view/com/profile/ProfileSubpageHeader.tsx:187 +#: src/view/com/profile/ProfileSubpageHeader.tsx:171 msgid "Starter pack by <0/>" msgstr "" #: src/components/StarterPack/StarterPackCard.tsx:90 -#: src/view/com/profile/ProfileSubpageHeader.tsx:185 +#: src/view/com/profile/ProfileSubpageHeader.tsx:169 msgid "Starter pack by you" msgstr "" @@ -10082,7 +10166,8 @@ msgstr "" msgid "Successfully verified" msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:313 +#: src/components/dms/AddMembersFlow.tsx:200 +#: src/components/dms/InitiateChatFlow.tsx:317 msgid "Suggested" msgstr "Suggested" @@ -10187,20 +10272,29 @@ msgstr "" msgid "Tap to dismiss" msgstr "" -#: src/components/dms/MessageItem.tsx:484 +#: src/components/dms/ReactionsDialog.tsx:195 +msgid "Tap to remove" +msgstr "Tap to remove" + +#. placeholder {0}: reaction.value +#: src/components/dms/ReactionsDialog.tsx:211 +msgid "Tap to remove your {0} reaction" +msgstr "Tap to remove your {0} reaction" + +#: src/components/dms/MessageItem.tsx:548 msgid "Tap to retry" msgstr "Tap to retry" #. placeholder {0}: reaction.value -#: src/components/dms/MessageItem.tsx:692 +#: src/components/dms/ReactionsDialog.tsx:361 msgid "Tap to show {0} reactions" msgstr "Tap to show {0} reactions" -#: src/components/dms/MessageItem.tsx:691 +#: src/components/dms/ReactionsDialog.tsx:360 msgid "Tap to show all reactions " msgstr "Tap to show all reactions " -#: src/components/dms/MessageItem.tsx:262 +#: src/components/dms/MessageItem.tsx:313 msgid "Tap to view reactions" msgstr "Tap to view reactions" @@ -10391,7 +10485,7 @@ msgstr "" msgid "The starter pack that you are trying to view is invalid. You may delete this starter pack instead." msgstr "" -#: src/components/ContextMenu/index.tsx:478 +#: src/components/ContextMenu/index.tsx:497 msgid "The subject of the context menu" msgstr "" @@ -10486,9 +10580,8 @@ 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/Messages/ConversationSettings.tsx:563 +#: src/screens/Messages/ConversationSettings.tsx:576 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:117 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:130 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:93 @@ -10615,7 +10708,7 @@ msgstr "" msgid "This content is not viewable without a Bluesky account." msgstr "" -#: src/screens/Messages/components/ChatListItem.tsx:183 +#: src/screens/Messages/components/ChatListItem.tsx:149 msgid "This conversation is with a deleted or a deactivated account. Press for options" msgstr "" @@ -10921,8 +11014,8 @@ msgstr "" msgid "Topic" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:144 -#: src/components/dms/MessageContextMenu.tsx:146 +#: src/components/dms/MessageContextMenu.tsx:147 +#: src/components/dms/MessageContextMenu.tsx:149 #: src/components/Post/Translated/index.tsx:150 #: src/components/Post/Translated/index.tsx:157 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:553 @@ -11067,7 +11160,7 @@ msgctxt "action" msgid "Unblock" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:622 +#: src/screens/Messages/ConversationSettings.tsx:665 msgid "Unblock {displayName}" msgstr "Unblock {displayName}" @@ -11142,11 +11235,11 @@ 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 +#: src/screens/Messages/ConversationSettings.tsx:690 msgid "Uninvite" msgstr "Uninvite" -#: src/screens/Messages/ConversationSettings.tsx:644 +#: src/screens/Messages/ConversationSettings.tsx:687 msgid "Uninvite {displayName} from this group chat" msgstr "Uninvite {displayName} from this group chat" @@ -11172,7 +11265,7 @@ msgstr "" msgid "Unlike ({0, plural, one {# like} other {# likes}})" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:809 +#: src/screens/Messages/ConversationSettings.tsx:867 msgid "Unlock this group chat" msgstr "Unlock this group chat" @@ -11209,7 +11302,7 @@ msgstr "" msgid "Unmute list" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:785 +#: src/screens/Messages/ConversationSettings.tsx:843 msgid "Unmute this group chat" msgstr "Unmute this group chat" @@ -11669,7 +11762,7 @@ msgid "View" msgstr "" #. placeholder {0}: profile.handle -#: src/screens/Profile/Header/Shell.tsx:267 +#: src/screens/Profile/Header/Shell.tsx:257 msgid "View {0}'s avatar" msgstr "" @@ -11692,7 +11785,7 @@ msgstr "View {0}’s profile" msgid "View {displayName}'s profile" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:598 +#: src/screens/Messages/ConversationSettings.tsx:641 msgid "View {displayName}’s profile" msgstr "View {displayName}’s profile" @@ -11717,7 +11810,7 @@ msgstr "" msgid "View full thread" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:235 +#: src/screens/Messages/ConversationSettings.tsx:243 msgid "View incoming group chat requests" msgstr "View incoming group chat requests" @@ -11749,11 +11842,11 @@ msgstr "" msgid "View profile" msgstr "" -#: src/screens/Profile/Header/Shell.tsx:173 +#: src/screens/Profile/Header/Shell.tsx:163 msgid "View profile banner" msgstr "View profile banner" -#: src/view/com/profile/ProfileSubpageHeader.tsx:124 +#: src/view/com/profile/ProfileSubpageHeader.tsx:108 msgid "View the avatar" msgstr "" @@ -11872,7 +11965,7 @@ msgstr "" msgid "We couldn't load this conversation" msgstr "" -#: src/screens/Messages/ConversationSettings.tsx:178 +#: src/screens/Messages/ConversationSettings.tsx:180 msgid "We couldn’t load this conversation’s settings" msgstr "We couldn’t load this conversation’s settings" @@ -11977,12 +12070,13 @@ msgstr "" msgid "We're having issues initializing the age assurance process for your account. Please <0>contact support for assistance." msgstr "" -#: src/components/dialogs/SearchablePeopleList.tsx:108 +#: src/components/dialogs/SearchablePeopleList.tsx:126 #: src/components/ProgressGuide/FollowDialog.tsx:195 msgid "We're having network issues, try again" msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:250 +#: src/components/dms/AddMembersFlow.tsx:152 +#: src/components/dms/InitiateChatFlow.tsx:254 msgid "We’re having network issues, try again" msgstr "We’re having network issues, try again" @@ -12532,13 +12626,13 @@ msgid "You probably want to restart the app now." msgstr "" #. placeholder {0}: reaction.value -#: src/components/dms/MessageItem.tsx:230 +#: src/components/dms/MessageItem.tsx:281 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:374 +#: src/screens/Messages/components/ChatListItem.tsx:336 msgid "You reacted {0} to {1}" msgstr "" @@ -12569,16 +12663,20 @@ msgstr "" msgid "You will receive an email with a \"reset code.\" Enter that code here, then enter your new password." msgstr "" +#: src/screens/Messages/ConversationSettings.tsx:1123 +msgid "You won’t be able to rejoin unless you’re invited." +msgstr "You won’t be able to rejoin unless you’re invited." + #. placeholder {0}: convo.lastMessage.text -#: src/screens/Messages/components/ChatListItem.tsx:315 +#: src/screens/Messages/components/ChatListItem.tsx:277 msgid "You: {0}" msgstr "" -#: src/screens/Messages/components/ChatListItem.tsx:342 +#: src/screens/Messages/components/ChatListItem.tsx:304 msgid "You: {defaultEmbeddedContentMessage}" msgstr "" -#: src/screens/Messages/components/ChatListItem.tsx:335 +#: src/screens/Messages/components/ChatListItem.tsx:297 msgid "You: {short}" msgstr "" From a97b15b204d8df1f9276794c209b401c32a4b881 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 17 Apr 2026 02:42:10 -0700 Subject: [PATCH 25/26] =?UTF-8?q?=E2=9C=A8=20`EmojiPicker`=20component=20(?= =?UTF-8?q?#10249)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/EmojiPicker/index.tsx | 40 ++++ src/components/EmojiPicker/index.web.tsx | 150 +++++++++++++++ src/components/EmojiPicker/preload.ts | 7 + .../EmojiPicker/preload.web.ts} | 10 +- src/components/EmojiPicker/types.ts | 65 +++++++ .../dms/EmojiReactionPicker.web.tsx | 39 +--- .../Messages/components/MessageComposer.tsx | 122 ++++-------- .../Messages/components/MessageInput.tsx | 2 - .../Messages/components/MessageInput.web.tsx | 101 ++++------ .../Messages/components/MessagesList.tsx | 24 +-- src/state/shell/composer/index.tsx | 2 - src/view/com/composer/Composer.tsx | 66 +++---- src/view/com/composer/SelectMediaButton.tsx | 2 +- .../com/composer/text-input/TextInput.web.tsx | 2 +- .../composer/text-input/web/EmojiPicker.tsx | 37 ---- .../text-input/web/EmojiPicker.web.tsx | 180 ------------------ src/view/shell/Composer.web.tsx | 27 --- 17 files changed, 397 insertions(+), 479 deletions(-) create mode 100644 src/components/EmojiPicker/index.tsx create mode 100644 src/components/EmojiPicker/index.web.tsx create mode 100644 src/components/EmojiPicker/preload.ts rename src/{view/com/composer/text-input/web/useWebPreloadEmoji.ts => components/EmojiPicker/preload.web.ts} (52%) create mode 100644 src/components/EmojiPicker/types.ts delete mode 100644 src/view/com/composer/text-input/web/EmojiPicker.tsx delete mode 100644 src/view/com/composer/text-input/web/EmojiPicker.web.tsx diff --git a/src/components/EmojiPicker/index.tsx b/src/components/EmojiPicker/index.tsx new file mode 100644 index 0000000000..facc131275 --- /dev/null +++ b/src/components/EmojiPicker/index.tsx @@ -0,0 +1,40 @@ +import {type PickerProps, type RootProps, type TriggerProps} from './types' + +export * from './types' + +/** + * Provides emoji picker context and wraps children in a {@link Menu.Root}. + * + * On emoji select, fires a `textInputWebEmitter` event (for web text inputs + * that listen for emoji insertions) and forwards to the optional + * `onEmojiSelect` callback. + * + * @platform web + */ +export function Root(_props: RootProps): React.ReactNode { + throw new Error('EmojiPopup is not implemented on native') +} + +/** + * Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children + * pattern. + * + * @platform web + */ +export function Trigger(_props: TriggerProps): React.ReactNode { + throw new Error('EmojiPopup is not implemented on native') +} + +/** + * Renders the emoji picker inside a Radix `DropdownMenu.Portal`. + * + * Holding Shift while selecting an emoji keeps the picker open for + * multi-select. Otherwise the menu closes after each selection. + * + * Must be rendered inside a {@link Root}. + * + * @platform web + */ +export function Picker(_props: PickerProps): React.ReactNode { + throw new Error('EmojiPopup is not implemented on native') +} diff --git a/src/components/EmojiPicker/index.web.tsx b/src/components/EmojiPicker/index.web.tsx new file mode 100644 index 0000000000..8d5e7788ec --- /dev/null +++ b/src/components/EmojiPicker/index.web.tsx @@ -0,0 +1,150 @@ +import {createContext, useContext, useEffect, useMemo, useRef} from 'react' +import EmojiPicker from '@emoji-mart/react' +import {DropdownMenu} from 'radix-ui' + +import {useA11y} from '#/state/a11y' +import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' +import {atoms as a, flatten} from '#/alf' +import * as Menu from '../Menu' +import {useWebPreloadEmoji} from './preload' +import { + type Emoji, + type PickerProps, + type RootProps, + type TriggerProps, +} from './types' + +export * from './types' + +const EmojiPickerContext = createContext<{ + onEmojiSelect: (emoji: Emoji) => void + nextFocusRef: RootProps['nextFocusRef'] +} | null>(null) + +/** + * Provides emoji picker context and wraps children in a {@link Menu.Root}. + * + * On emoji select, fires a `textInputWebEmitter` event (for web text inputs + * that listen for emoji insertions) and forwards to the optional + * `onEmojiSelect` callback. + * + * @platform web + */ +export function Root({ + children, + control, + onEmojiSelect, + preloadOnMount = true, + nextFocusRef, +}: RootProps) { + useWebPreloadEmoji({immediate: preloadOnMount}) + + const value = useMemo( + () => ({ + onEmojiSelect: (emoji: Emoji) => { + textInputWebEmitter.emit('emoji-inserted', emoji) + + if (onEmojiSelect) onEmojiSelect(emoji) + }, + nextFocusRef, + }), + [onEmojiSelect, nextFocusRef], + ) + + return ( + + {children} + + ) +} + +/** + * Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children + * pattern. + * + * @platform web + */ +export function Trigger(props: TriggerProps) { + return +} + +/** + * Renders the emoji picker inside a Radix `DropdownMenu.Portal`. + * + * Holding Shift while selecting an emoji keeps the picker open for + * multi-select. Otherwise the menu closes after each selection. + * + * Must be rendered inside a {@link Root}. + * + * @platform web + */ +export function Picker({keepOpenWhenShiftHeld = true}: PickerProps) { + const {onEmojiSelect, nextFocusRef} = useEmojiPickerContext() + const {control} = Menu.useMenuContext() + const {reduceMotionEnabled} = useA11y() + const isShiftDown = useRef(false) + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + isShiftDown.current = true + } + } + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + isShiftDown.current = false + } + } + window.addEventListener('keydown', onKeyDown, true) + window.addEventListener('keyup', onKeyUp, true) + + return () => { + window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('keyup', onKeyUp, true) + } + }, []) + + return ( + + { + if (!nextFocusRef) return + let element = + nextFocusRef instanceof Function + ? nextFocusRef() + : nextFocusRef.current + if (element) { + evt.preventDefault() + element.focus() + } + }}> +
evt.stopPropagation()} + style={flatten([!reduceMotionEnabled && a.zoom_fade_in])}> + { + onEmojiSelect(emoji) + + if (!keepOpenWhenShiftHeld || !isShiftDown.current) { + control.close() + } + }} + /> +
+
+
+ ) +} + +function useEmojiPickerContext() { + const ctx = useContext(EmojiPickerContext) + if (!ctx) + throw new Error( + 'EmojiPicker.Picker must be used within an EmojiPicker.Root component', + ) + return ctx +} diff --git a/src/components/EmojiPicker/preload.ts b/src/components/EmojiPicker/preload.ts new file mode 100644 index 0000000000..d37216b168 --- /dev/null +++ b/src/components/EmojiPicker/preload.ts @@ -0,0 +1,7 @@ +/** + * Native no-op. Emoji data preloading is only needed on web where the picker + * uses `emoji-mart`. + */ +export function useWebPreloadEmoji({}: {immediate?: boolean} = {}) { + return () => Promise.resolve() +} diff --git a/src/view/com/composer/text-input/web/useWebPreloadEmoji.ts b/src/components/EmojiPicker/preload.web.ts similarity index 52% rename from src/view/com/composer/text-input/web/useWebPreloadEmoji.ts rename to src/components/EmojiPicker/preload.web.ts index 27636a14b4..4456153b71 100644 --- a/src/view/com/composer/text-input/web/useWebPreloadEmoji.ts +++ b/src/components/EmojiPicker/preload.web.ts @@ -7,8 +7,14 @@ import {init} from 'emoji-mart' let loadRequested = false /** - * Preload the emoji picker data to prevent flash. - * {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194} + * Preloads emoji-mart data so the picker renders instantly when opened. + * + * Returns a function that can be called manually to trigger preloading (e.g. + * on hover). When `immediate` is `true`, preloading starts on mount. + * + * Data is only fetched once per page load — subsequent calls are no-ops. + * + * @see {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194 | emoji-mart preloading docs} */ export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) { const preload = useCallback(async () => { diff --git a/src/components/EmojiPicker/types.ts b/src/components/EmojiPicker/types.ts new file mode 100644 index 0000000000..4b6300ffda --- /dev/null +++ b/src/components/EmojiPicker/types.ts @@ -0,0 +1,65 @@ +import {type DialogControlProps} from '../Dialog' +import {type TriggerProps as MenuTriggerProps} from '../Menu/types' + +/** + * Represents an emoji selected from the picker. Sourced from the `emoji-mart` + * library's selection data. + */ +export type Emoji = { + aliases?: string[] + emoticons: string[] + id: string + keywords: string[] + name: string + /** The native unicode character for the emoji, e.g. "😀" */ + native: string + shortcodes?: string + /** The unicode codepoint, e.g. "1f600" */ + unified: string + /** Skin tone variant (1–6), if applicable */ + skin?: number +} + +type FocusableElement = {focus: () => void} + +export interface RootProps { + children: React.ReactNode + control?: DialogControlProps + /** + * Called when the user selects an emoji. On web this fires in addition to + * the `textInputWebEmitter` event, so callers that only need the text + * insertion can omit this. + */ + onEmojiSelect?: (emoji: Emoji) => void + /** + * When `true` (default), preloads emoji data as soon as the component + * mounts so the picker opens instantly. Set to `false` to defer loading + * until the picker is actually opened. + */ + preloadOnMount?: boolean + /** + * Element to return focus to when the picker closes. Accepts either a ref + * or a getter function. + */ + nextFocusRef?: + | React.RefObject + | (() => FocusableElement | null | undefined) +} + +/** + * Props for the trigger button that opens the emoji picker. Extends + * {@link MenuTriggerProps} — accepts the same render-prop children pattern. + */ +export interface TriggerProps extends MenuTriggerProps {} + +/** + * Props for the picker panel itself. + */ +export interface PickerProps { + /** + * When `true`, the picker will remain open after selecting an emoji when the Shift key is held down. + * + * @default true + */ + keepOpenWhenShiftHeld?: boolean +} diff --git a/src/components/dms/EmojiReactionPicker.web.tsx b/src/components/dms/EmojiReactionPicker.web.tsx index 6be85efb4c..1a78a55458 100644 --- a/src/components/dms/EmojiReactionPicker.web.tsx +++ b/src/components/dms/EmojiReactionPicker.web.tsx @@ -1,18 +1,14 @@ import {useState} from 'react' import {Pressable, View} from 'react-native' import {type ChatBskyConvoDefs} from '@atproto/api' -import EmojiPicker from '@emoji-mart/react' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {DropdownMenu} from 'radix-ui' import {useSession} from '#/state/session' -import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker' -import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji' import {atoms as a, flatten, useTheme} from '#/alf' +import * as EmojiPicker from '#/components/EmojiPicker' import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' import * as Menu from '#/components/Menu' -import {type TriggerProps} from '#/components/Menu/types' import {Text} from '#/components/Typography' import {hasAlreadyReacted, hasReachedReactionLimit} from './util' @@ -22,19 +18,21 @@ export function EmojiReactionPicker({ onEmojiSelect, }: { message: ChatBskyConvoDefs.MessageView - children?: TriggerProps['children'] + children?: EmojiPicker.TriggerProps['children'] onEmojiSelect: (emoji: string) => void }) { if (!children) throw new Error('EmojiReactionPicker requires the children prop on web') - const {_} = useLingui() + const {t: l} = useLingui() return ( - - {children} + onEmojiSelect(emoji.native)}> + + {children} + - + ) } @@ -49,8 +47,6 @@ function MenuInner({ const {control} = Menu.useMenuContext() const {currentAccount} = useSession() - useWebPreloadEmoji({immediate: true}) - const [expanded, setExpanded] = useState(false) const [prevOpen, setPrevOpen] = useState(control.isOpen) @@ -62,10 +58,6 @@ function MenuInner({ } } - const handleEmojiPickerResponse = (emoji: Emoji) => { - handleEmojiSelect(emoji.native) - } - const handleEmojiSelect = (emoji: string) => { control.close() onEmojiSelect(emoji) @@ -74,18 +66,7 @@ function MenuInner({ const limitReacted = hasReachedReactionLimit(message, currentAccount?.did) return expanded ? ( - - -
evt.stopPropagation()}> - -
-
-
+ ) : ( diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index cb5da5628a..b0ec463979 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -1,4 +1,4 @@ -import {useEffect, useState} from 'react' +import {useState} from 'react' import {Pressable, View} from 'react-native' import { useKeyboardHandler, @@ -25,14 +25,9 @@ import { useMessageDraft, useSaveMessageDraft, } from '#/state/messages/message-drafts' -import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' -import { - type Emoji, - EmojiPicker, - type EmojiPickerState, -} from '#/view/com/composer/text-input/web/EmojiPicker' import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf' import {Composer, useComposerInternalApiRef} from '#/components/Composer' +import * as EmojiPicker from '#/components/EmojiPicker' import {GlassView} from '#/components/GlassView' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' @@ -60,10 +55,6 @@ export function MessageComposer({ const {needsEmailVerification} = useEmail() const editable = !needsEmailVerification const {getDraft, clearDraft} = useMessageDraft() - const [emojiPickerState, setEmojiPickerState] = useState({ - isOpen: false, - pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, - }) const composerInternalApiRef = useComposerInternalApiRef() const [text, setText] = useState(getDraft) @@ -85,10 +76,6 @@ export function MessageComposer({ const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0) - const openEmojiPicker = (pos: any) => { - setEmojiPickerState({isOpen: true, pos}) - } - const onSubmit = () => { if (!editable) return if (!hasEmbed && text.trim() === '') return @@ -112,16 +99,6 @@ export function MessageComposer({ } } - useEffect(() => { - function onEmojiInserted(emoji: Emoji) { - composerInternalApiRef.current?.insert(emoji.native) - } - textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) - return () => { - textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) - } - }, [composerInternalApiRef]) - return ( {children} @@ -142,54 +119,47 @@ export function MessageComposer({ tintColor={t.palette.contrast_50} fallbackStyle={[t.atoms.bg_contrast_50]}> {IS_WEB && ( - { - e.currentTarget.measure( - (_fx, _fy, _width, _height, px, py) => { - // TODO: rip this horrible system out - openEmojiPicker?.({ - top: py, - left: px - 400, - right: px - 400, - bottom: py, - nextFocusRef: { - current: - composerInternalApiRef.current?.input?.element, + + composerInternalApiRef.current?.insert(emoji.native) + } + nextFocusRef={() => + composerInternalApiRef.current?.input?.element + }> + + {({props, state, control}) => ( + - {state => ( - - )} - + ]}> + + + )} + + + )} - - {IS_WEB && ( - setEmojiPickerState(prev => ({...prev, isOpen: false}))} - /> - )} ) } diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx index e2bf735713..d545e6d7b5 100644 --- a/src/screens/Messages/components/MessageInput.tsx +++ b/src/screens/Messages/components/MessageInput.tsx @@ -25,7 +25,6 @@ import { useMessageDraft, useSaveMessageDraft, } from '#/state/messages/message-drafts' -import {type EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker' import {atoms as a, platform, tokens, useTheme} from '#/alf' import {GlassView} from '#/components/GlassView' import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' @@ -50,7 +49,6 @@ export function MessageInput({ hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode - openEmojiPicker?: (pos: EmojiPickerPosition) => void }) { const {t: l} = useLingui() const t = useTheme() diff --git a/src/screens/Messages/components/MessageInput.web.tsx b/src/screens/Messages/components/MessageInput.web.tsx index 5e71fb2ef0..1ba7619d85 100644 --- a/src/screens/Messages/components/MessageInput.web.tsx +++ b/src/screens/Messages/components/MessageInput.web.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef, useState} from 'react' +import {useCallback, useRef, useState} from 'react' import {Pressable, View} from 'react-native' import {useLingui} from '@lingui/react/macro' import {flushSync} from 'react-dom' @@ -11,13 +11,9 @@ import { useMessageDraft, useSaveMessageDraft, } from '#/state/messages/message-drafts' -import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' -import { - type Emoji, - type EmojiPickerPosition, -} from '#/view/com/composer/text-input/web/EmojiPicker' import {atoms as a, flatten, useTheme} from '#/alf' import {Button} from '#/components/Button' +import * as EmojiPicker from '#/components/EmojiPicker' import {useSharedInputStyles} from '#/components/forms/TextField' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' @@ -30,13 +26,11 @@ export function MessageInput({ hasEmbed, setEmbed, children, - openEmojiPicker, }: { onSendMessage: (message: string) => void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode - openEmojiPicker?: (pos: EmojiPickerPosition) => void }) { const {isMobile} = useWebMediaQueries() const {t: l} = useLingui() @@ -104,12 +98,11 @@ export function MessageInput({ }, []) const onEmojiInserted = useCallback( - (emoji: Emoji) => { + (emoji: EmojiPicker.Emoji) => { if (!textAreaRef.current) { return } const position = textAreaRef.current.selectionStart ?? 0 - textAreaRef.current.focus() flushSync(() => { setMessage( message => @@ -121,12 +114,6 @@ export function MessageInput({ }, [setMessage], ) - useEffect(() => { - textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) - return () => { - textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) - } - }, [onEmojiInserted]) useSaveMessageDraft(message) useExtractEmbedFromFacets(message, setEmbed) @@ -152,49 +139,45 @@ export function MessageInput({ // @ts-expect-error web only onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}> - + + + {({props, state}) => ( + + )} + + + ({ - isOpen: false, - pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, - }) - const inputHeightUI = useSharedValue(0) const [inputHeightJS, setInputHeightJS] = useState(0) @@ -382,10 +373,6 @@ export function MessagesList({ }) }, [flatListRef]) - const onOpenEmojiPicker = useCallback((pos: any) => { - setEmojiPickerState({isOpen: true, pos}) - }, []) - const renderItem = ({item}: {item: ConvoItem}) => { if (item.type === 'message' || item.type === 'pending-message') { return ( @@ -525,8 +512,7 @@ export function MessagesList({ textInputId={textInputId} onSendMessage={onSendMessage} hasEmbed={!!embedUri} - setEmbed={setEmbed} - openEmojiPicker={onOpenEmojiPicker}> + setEmbed={setEmbed}> )} @@ -535,14 +521,6 @@ export function MessagesList({ - {IS_WEB && ( - setEmojiPickerState(prev => ({...prev, isOpen: false}))} - /> - )} - {newMessagesPill.show && } ) diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx index eda5235523..c29fb41872 100644 --- a/src/state/shell/composer/index.tsx +++ b/src/state/shell/composer/index.tsx @@ -17,7 +17,6 @@ import { RQKEY_GIF_ROOT, RQKEY_LINK_ROOT, } from '#/state/queries/resolve-link' -import {type EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker' import * as Toast from '#/components/Toast' export interface ComposerOptsPostRef { @@ -51,7 +50,6 @@ export interface ComposerOpts { onPostSuccess?: (data: OnPostSuccessData) => void quote?: AppBskyFeedDefs.PostView mention?: string // handle of user to mention - openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void text?: string imageUris?: {uri: string; width: number; height: number; altText?: string}[] videoUri?: {uri: string; width: number; height: number} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index f05ff232fb..d12b3a0bea 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -72,7 +72,6 @@ import { } from '#/lib/constants' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {mimeToExt} from '#/lib/media/video/util' import {useCallOnce} from '#/lib/once' import {type NavigationProp} from '#/lib/routes/types' @@ -122,9 +121,10 @@ import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' import {UserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, native, useTheme, web} from '#/alf' +import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as EmojiPicker from '#/components/EmojiPicker' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' @@ -185,7 +185,6 @@ export const ComposePost = ({ onPostSuccess, quote: initQuote, mention: initMention, - openEmojiPicker, text: initText, imageUris: initImageUris, videoUri: initVideoUri, @@ -206,7 +205,7 @@ export const ComposePost = ({ const requireAltTextEnabled = useRequireAltTextEnabled() const langPrefs = useLanguagePrefs() const setLangPrefs = useLanguagePrefsApi() - const textInput = useRef(null) + const textInputRef = useRef(null) const discardPromptControl = Prompt.usePromptControl() const {mutateAsync: saveDraft, isPending: _isSavingDraft} = useSaveDraftMutation() @@ -708,7 +707,7 @@ export const ComposePost = ({ ) const onPressCancel = useCallback(() => { - if (textInput.current?.maybeClosePopup()) { + if (textInputRef.current?.maybeClosePopup()) { return } @@ -1064,17 +1063,6 @@ export const ComposePost = ({ } } - const onEmojiButtonPress = useCallback(() => { - const rect = textInput.current?.getCursorPosition() - if (rect) { - openEmojiPicker?.({ - ...rect, - nextFocusRef: - textInput as unknown as React.MutableRefObject, - }) - } - }, [openEmojiPicker]) - const scrollViewRef = useAnimatedRef() useEffect(() => { if (composerState.mutableNeedsFocusActive) { @@ -1082,7 +1070,7 @@ export const ComposePost = ({ // On Android, this risks getting the cursor stuck behind the keyboard. // Not worth it. if (!IS_ANDROID) { - textInput.current?.focus() + textInputRef.current?.focus() } } }, [composerState]) @@ -1123,7 +1111,6 @@ export const ComposePost = ({ !isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost)) } onError={setError} - onEmojiButtonPress={onEmojiButtonPress} onSelectVideo={selectVideo} onAddPost={() => { composerDispatch({ @@ -1133,6 +1120,7 @@ export const ComposePost = ({ currentLanguages={currentLanguages} onSelectLanguage={onSelectLanguage} openGallery={openGallery} + textInputRef={textInputRef} /> ) @@ -1201,7 +1189,7 @@ export const ComposePost = ({ 1} @@ -1288,7 +1276,7 @@ export const ComposePost = ({ let ComposerPost = memo(function ComposerPost({ post, dispatch, - textInput, + textInputRef, isActive, isReply, isFirstPost, @@ -1303,7 +1291,7 @@ let ComposerPost = memo(function ComposerPost({ }: { post: PostDraft dispatch: (action: ComposerAction) => void - textInput: React.Ref + textInputRef: React.RefObject | null isActive: boolean isReply: boolean isFirstPost: boolean @@ -1404,7 +1392,7 @@ let ComposerPost = memo(function ComposerPost({ style={[a.mt_xs]} /> void showAddButton: boolean - onEmojiButtonPress: () => void onError: (error: string) => void onSelectVideo: (postId: string, asset: ImagePickerAsset) => void onAddPost: () => void currentLanguages: string[] onSelectLanguage?: (language: string) => void openGallery?: boolean + textInputRef: React.RefObject }) { const t = useTheme() const {t: l} = useLingui() - const {isMobile} = useWebMediaQueries() + const {gtPhone} = useBreakpoints() /* * Once we've allowed a certain type of asset to be selected, we don't allow * other types of media to be selected. @@ -1965,17 +1953,23 @@ function ComposerFooter({ onAdd={onImageAdd} /> - {!isMobile ? ( - + {IS_WEB && gtPhone ? ( + + + {({props}) => ( + + )} + + + ) : null} )} diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx index 259d1304fd..8f44cfc3e7 100644 --- a/src/view/com/composer/SelectMediaButton.tsx +++ b/src/view/com/composer/SelectMediaButton.tsx @@ -32,7 +32,7 @@ export type SelectMediaButtonProps = { type: AssetType assets: ImagePickerAsset[] errors: string[] - }) => void + }) => void | Promise /** * If true, automatically open the media picker when the component mounts. */ diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index fe6fee2b3f..bb314f116c 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -32,11 +32,11 @@ import { import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import {atoms as a, useAlf} from '#/alf' import {normalizeTextStyles} from '#/alf/typography' +import {type Emoji} from '#/components/EmojiPicker' import {Portal} from '#/components/Portal' import {Text} from '#/components/Typography' import {type TextInputProps} from './TextInput.types' import {type AutocompleteRef, createSuggestion} from './web/Autocomplete' -import {type Emoji} from './web/EmojiPicker' import {LinkDecorator} from './web/LinkDecorator' import {TagDecorator} from './web/TagDecorator' diff --git a/src/view/com/composer/text-input/web/EmojiPicker.tsx b/src/view/com/composer/text-input/web/EmojiPicker.tsx deleted file mode 100644 index 5001753a5b..0000000000 --- a/src/view/com/composer/text-input/web/EmojiPicker.tsx +++ /dev/null @@ -1,37 +0,0 @@ -export type Emoji = { - aliases?: string[] - emoticons: string[] - id: string - keywords: string[] - name: string - native: string - shortcodes?: string - unified: string -} - -export interface EmojiPickerPosition { - top: number - left: number - right: number - bottom: number - nextFocusRef: React.MutableRefObject | null -} - -export interface EmojiPickerState { - isOpen: boolean - pos: EmojiPickerPosition -} - -interface IProps { - state: EmojiPickerState - close: () => void - /** - * If `true`, overrides position and ensures picker is pinned to the top of - * the target element. - */ - pinToTop?: boolean -} - -export function EmojiPicker(_opts: IProps) { - return null -} diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx deleted file mode 100644 index c5a7e6491d..0000000000 --- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import {useEffect, useMemo, useRef} from 'react' -import {Pressable, useWindowDimensions, View} from 'react-native' -import Picker from '@emoji-mart/react' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {DismissableLayer, FocusScope} from 'radix-ui/internal' - -import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' -import {atoms as a, flatten} from '#/alf' -import {Portal} from '#/components/Portal' - -const HEIGHT_OFFSET = 40 -const WIDTH_OFFSET = 100 -const PICKER_HEIGHT = 435 + HEIGHT_OFFSET -const PICKER_WIDTH = 350 + WIDTH_OFFSET - -export type Emoji = { - aliases?: string[] - emoticons: string[] - id: string - keywords: string[] - name: string - native: string - shortcodes?: string - unified: string -} - -export interface EmojiPickerPosition { - top: number - left: number - right: number - bottom: number - nextFocusRef: React.MutableRefObject | null -} - -export interface EmojiPickerState { - isOpen: boolean - pos: EmojiPickerPosition -} - -interface IProps { - state: EmojiPickerState - close: () => void - /** - * If `true`, overrides position and ensures picker is pinned to the top of - * the target element. - */ - pinToTop?: boolean -} - -export function EmojiPicker({state, close, pinToTop}: IProps) { - const {_} = useLingui() - const {height, width} = useWindowDimensions() - - const isShiftDown = useRef(false) - - const position = useMemo(() => { - if (pinToTop) { - return { - top: state.pos.top - PICKER_HEIGHT + HEIGHT_OFFSET - 10, - left: state.pos.left, - } - } - - const fitsBelow = state.pos.top + PICKER_HEIGHT < height - const fitsAbove = PICKER_HEIGHT < state.pos.top - const placeOnLeft = PICKER_WIDTH < state.pos.left - const screenYMiddle = height / 2 - PICKER_HEIGHT / 2 - - if (fitsBelow) { - return { - top: state.pos.top + HEIGHT_OFFSET, - } - } else if (fitsAbove) { - return { - bottom: height - state.pos.bottom + HEIGHT_OFFSET, - } - } else { - return { - top: screenYMiddle, - left: placeOnLeft ? state.pos.left - PICKER_WIDTH : undefined, - right: !placeOnLeft - ? width - state.pos.right - PICKER_WIDTH - : undefined, - } - } - }, [state.pos, height, width, pinToTop]) - - useEffect(() => { - if (!state.isOpen) return - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Shift') { - isShiftDown.current = true - } - } - const onKeyUp = (e: KeyboardEvent) => { - if (e.key === 'Shift') { - isShiftDown.current = false - } - } - window.addEventListener('keydown', onKeyDown, true) - window.addEventListener('keyup', onKeyUp, true) - - return () => { - window.removeEventListener('keydown', onKeyDown, true) - window.removeEventListener('keyup', onKeyUp, true) - } - }, [state.isOpen]) - - const onInsert = (emoji: Emoji) => { - textInputWebEmitter.emit('emoji-inserted', emoji) - - if (!isShiftDown.current) { - close() - } - } - - if (!state.isOpen) return null - - return ( - - { - const nextFocusRef = state.pos.nextFocusRef - const node = nextFocusRef?.current - if (node) { - e.preventDefault() - node.focus() - } - }}> - - - - - evt.preventDefault()} - onDismiss={close}> - { - return (await import('@emoji-mart/data')).default - }} - onEmojiSelect={onInsert} - autoFocus={true} - /> - - - - - - - - ) -} diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index d99e55aacb..d3232f492b 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -1,4 +1,3 @@ -import {useCallback, useState} from 'react' import {StyleSheet, View} from 'react-native' import {DismissableLayer, FocusGuards, FocusScope} from 'radix-ui/internal' import {RemoveScrollBar} from 'react-remove-scroll-bar' @@ -6,11 +5,6 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar' import {useA11y} from '#/state/a11y' import {useModals} from '#/state/modals' import {type ComposerOpts, useComposerState} from '#/state/shell/composer' -import { - EmojiPicker, - type EmojiPickerPosition, - type EmojiPickerState, -} from '#/view/com/composer/text-input/web/EmojiPicker' import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf' import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' @@ -41,25 +35,6 @@ function Inner({state}: {state: ComposerOpts}) { const t = useTheme() const {gtMobile} = useBreakpoints() const {reduceMotionEnabled} = useA11y() - const [pickerState, setPickerState] = useState({ - isOpen: false, - pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, - }) - - const onOpenPicker = useCallback((pos: EmojiPickerPosition | undefined) => { - if (!pos) return - setPickerState({ - isOpen: true, - pos, - }) - }, []) - - const onClosePicker = useCallback(() => { - setPickerState(prev => ({ - ...prev, - isOpen: false, - })) - }, []) FocusGuards.useFocusGuards() @@ -104,13 +79,11 @@ function Inner({state}: {state: ComposerOpts}) { onPost={state.onPost} onPostSuccess={state.onPostSuccess} mention={state.mention} - openEmojiPicker={onOpenPicker} text={state.text} imageUris={state.imageUris} openGallery={state.openGallery} />
- ) From a77b6e352505d8d3eca5f365bc7d7ee85aa95da7 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Fri, 17 Apr 2026 08:43:13 -0400 Subject: [PATCH 26/26] [APP-2066] Migrate from Tenor to KLIPY (#10240) Co-authored-by: Claude Opus 4.6 (1M context) --- __tests__/lib/string.test.ts | 65 +++++++++++ src/analytics/features/types.ts | 2 +- src/components/MediaPreview.tsx | 4 +- .../Post/Embed/ExternalEmbed/index.tsx | 5 +- src/components/dialogs/GifSelect.tsx | 76 ++++++------ src/lib/api/resolve.ts | 34 +++++- src/lib/constants.ts | 5 + src/lib/strings/embed-player.ts | 104 +++++++++++++++++ src/state/persisted/schema.ts | 1 + src/state/queries/klipy.ts | 108 ++++++++++++++++++ src/state/queries/tenor.ts | 52 +++++++-- src/view/com/composer/drafts/state/api.ts | 5 +- 12 files changed, 411 insertions(+), 50 deletions(-) create mode 100644 src/state/queries/klipy.ts diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 7453b8d203..38966c35cb 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -8,6 +8,7 @@ import { parseStarterPackUri, } from '#/lib/strings/starter-pack' import {messages} from '#/locale/locales/en/messages' +import {klipyUrlToBskyGifUrl} from '#/state/queries/klipy' import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor' import {cleanError} from '../../src/lib/strings/errors' import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles' @@ -450,6 +451,13 @@ describe('parseEmbedPlayerFromUrl', () => { 'https://sufjanstevens.bandcamp.com', 'https://bandcamp.com/', 'https://bandcamp.com', + + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300', + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300&mp4=videoSlugMp4&webm=videoSlugWebm', + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200', + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif', + 'https://static.klipy.com/other/path.gif?hh=200&ww=300', + 'https://static.klipy.com', ] const outputs = [ @@ -845,6 +853,35 @@ describe('parseEmbedPlayerFromUrl', () => { undefined, undefined, undefined, + + { + type: 'klipy_gif', + source: 'klipy', + isGif: true, + hideDetails: true, + playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif', + dimensions: { + width: 300, + height: 200, + }, + }, + // With video slug params — on native (test env), keeps gif filename, + // strips mp4/webm params. On web, would swap to video filename. + { + type: 'klipy_gif', + source: 'klipy', + isGif: true, + hideDetails: true, + playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif', + dimensions: { + width: 300, + height: 200, + }, + }, + undefined, + undefined, + undefined, + undefined, ] it('correctly grabs the correct id from uri', () => { @@ -1049,3 +1086,31 @@ describe('tenorUrlToBskyGifUrl', () => { }, ) }) + +describe('klipyUrlToBskyGifUrl', () => { + const inputs = [ + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif', + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300', + ] + + it.each(inputs)( + 'returns url with k.gifs.bsky.app as hostname for input url', + input => { + const out = klipyUrlToBskyGifUrl(input) + expect(out.startsWith('https://k.gifs.bsky.app/')).toEqual(true) + }, + ) + + it('preserves the path and query params when rewriting', () => { + const out = klipyUrlToBskyGifUrl( + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300', + ) + expect(out).toEqual( + 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif?hh=200&ww=300', + ) + }) + + it('returns empty string for invalid URLs', () => { + expect(klipyUrlToBskyGifUrl('not-a-url')).toEqual('') + }) +}) diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 70cb8fc38d..36a354bb49 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -13,7 +13,7 @@ export enum Features { ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled', 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/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index a6e30c820c..530c85ae66 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -3,7 +3,7 @@ import {Image} from 'expo-image' import {type AppBskyFeedDefs} from '@atproto/api' import {Trans} from '@lingui/react/macro' -import {isTenorGifUri} from '#/lib/strings/embed-player' +import {isGifEmbed} from '#/lib/strings/embed-player' import {atoms as a, useTheme} from '#/alf' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {Text} from '#/components/Typography' @@ -38,7 +38,7 @@ export function Embed({ ) } else if (e.type === 'link') { if (!e.view.external.thumb) return null - if (!isTenorGifUri(e.view.external.uri)) return null + if (!isGifEmbed(e.view.external.uri)) return null return ( diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx index dfa159b2fe..bc58cbf137 100644 --- a/src/components/dialogs/GifSelect.tsx +++ b/src/components/dialogs/GifSelect.tsx @@ -8,16 +8,18 @@ import { import {type TextInput, View} from 'react-native' import {useWindowDimensions} from 'react-native' import {Image} from 'expo-image' -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 {cleanError} from '#/lib/strings/errors' +import { + useFeaturedGifsQuery as useKlipyFeaturedGifsQuery, + useGifSearchQuery as useKlipyGifSearchQuery, +} from '#/state/queries/klipy' import { type Gif, - tenorUrlToBskyGifUrl, - useFeaturedGifsQuery, - useGifSearchQuery, + gifPreviewUrl, + useTenorFeaturedGifsQuery, + useTenorGifSearchQuery, } from '#/state/queries/tenor' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' @@ -85,7 +87,8 @@ function GifList({ control: Dialog.DialogControlProps onSelectGif: (gif: Gif) => void }) { - const {_} = useLingui() + const ax = useAnalytics() + const {t: l} = useLingui() const t = useTheme() const {gtMobile} = useBreakpoints() const textInputRef = useRef(null) @@ -93,11 +96,14 @@ function GifList({ const [undeferredSearch, setSearch] = useState('') const search = useThrottledValue(undeferredSearch, 500) const {height} = useWindowDimensions() + const klipyEnabled = ax.features.enabled(ax.features.KlipyGifProviderEnable) const isSearching = search.length > 0 - const trendingQuery = useFeaturedGifsQuery() - const searchQuery = useGifSearchQuery(search) + const klipyTrending = useKlipyFeaturedGifsQuery({enabled: klipyEnabled}) + const klipySearch = useKlipyGifSearchQuery(search, {enabled: klipyEnabled}) + const tenorTrending = useTenorFeaturedGifsQuery({enabled: !klipyEnabled}) + const tenorSearch = useTenorGifSearchQuery(search, {enabled: !klipyEnabled}) const { data, @@ -108,7 +114,13 @@ function GifList({ isPending, isError, refetch, - } = isSearching ? searchQuery : trendingQuery + } = klipyEnabled + ? isSearching + ? klipySearch + : klipyTrending + : isSearching + ? tenorSearch + : tenorTrending const flattenedData = useMemo(() => { return data?.pages.flatMap(page => page.results) || [] @@ -158,7 +170,7 @@ function GifList({ color="secondary" shape="round" onPress={() => control.close()} - label={_(msg`Close GIF dialog`)}> + label={l`Close GIF dialog`}> )} @@ -166,8 +178,8 @@ function GifList({ { setSearch(text) listRef.current?.scrollToOffset({offset: 0, animated: false}) @@ -185,7 +197,7 @@ function GifList({
) - }, [gtMobile, t.atoms.bg, _, control]) + }, [gtMobile, t.atoms.bg, l, control, klipyEnabled]) return ( <> @@ -212,14 +224,18 @@ function GifList({ emptyType="results" sideBorders={false} topBorder={false} - errorTitle={_(msg`Failed to load GIFs`)} - errorMessage={_(msg`There was an issue connecting to Tenor.`)} + errorTitle={l`Failed to load GIFs`} + errorMessage={ + klipyEnabled + ? l`There was an issue connecting to KLIPY.` + : l`There was an issue connecting to Tenor.` + } emptyMessage={ isSearching - ? _(msg`No search results found for "${search}".`) - : _( - msg`No featured GIFs found. There may be an issue with Tenor.`, - ) + ? l`No search results found for "${search}".` + : klipyEnabled + ? l`No featured GIFs found. There may be an issue with KLIPY.` + : l`No featured GIFs found. There may be an issue with Tenor.` } /> )} @@ -246,23 +262,19 @@ function GifList({ } function DialogError({details}: {details?: string}) { - const {_} = useLingui() + const {t: l} = useLingui() const control = Dialog.useDialogContext() return ( - +