From 153772b760ef1f67220efe40ca6b4c66d33af71b Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 4 Jun 2026 01:03:11 +0300 Subject: [PATCH 01/19] `expo-image` tweaks (#10710) --- src/components/ContextMenu/index.tsx | 1 + .../pager/ImageItem/ImageItem.ios.tsx | 1 + src/components/Lightbox/pager/ImagePager.tsx | 4 ++++ src/components/MediaPreview.tsx | 1 + .../Post/Embed/ExternalEmbed/index.tsx | 1 + .../Post/Embed/StandardSiteEmbed/index.tsx | 1 + .../contacts/FindContactsBannerNUX.tsx | 1 + .../contacts/components/HeroImage.tsx | 1 + .../dialogs/nuxs/ActivitySubscriptions.tsx | 1 + .../dialogs/nuxs/BookmarksAnnouncement.tsx | 1 + .../dialogs/nuxs/DraftsAnnouncement.tsx | 1 + .../dialogs/nuxs/FindContactsAnnouncement.tsx | 1 + .../nuxs/InitialVerificationAnnouncement.tsx | 2 ++ .../dialogs/nuxs/LiveNowBetaDialog.tsx | 1 + src/components/images/AutoSizedImage.tsx | 1 + src/components/images/Gallery/index.tsx | 1 + src/components/images/ImageLayoutGridItem.tsx | 1 + .../verification/VerifierDialog.tsx | 1 + .../components/LiveEventFeedCardCompact.tsx | 1 + .../components/LiveEventFeedCardWide.tsx | 1 + .../liveNow/components/LinkPreview.tsx | 1 + .../liveNow/components/LiveStatusDialog.tsx | 1 + .../StepFinished/ValuePropositionPager.tsx | 2 ++ src/screens/Profile/components/GermButton.tsx | 1 + src/view/com/composer/ComposerReplyTo.tsx | 20 +++++++++---------- src/view/com/composer/photos/Gallery.tsx | 1 + src/view/com/util/UserAvatar.tsx | 1 + src/view/com/util/UserBanner.tsx | 2 ++ 28 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index 8d3d39a749..51bf83309f 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -499,6 +499,7 @@ function TriggerClone({ accessibilityLabel={label} accessibilityHint={_(msg`The subject of the context menu`)} accessibilityIgnoresInvertColors={false} + cachePolicy="none" /> ) diff --git a/src/components/Lightbox/pager/ImageItem/ImageItem.ios.tsx b/src/components/Lightbox/pager/ImageItem/ImageItem.ios.tsx index c0bb99c89e..19373cbe1a 100644 --- a/src/components/Lightbox/pager/ImageItem/ImageItem.ios.tsx +++ b/src/components/Lightbox/pager/ImageItem/ImageItem.ios.tsx @@ -246,6 +246,7 @@ const ImageItem = ({ } } cachePolicy="memory" + useAppleWebpCodec /> diff --git a/src/components/Lightbox/pager/ImagePager.tsx b/src/components/Lightbox/pager/ImagePager.tsx index 1c8836a0fd..7b8815f276 100644 --- a/src/components/Lightbox/pager/ImagePager.tsx +++ b/src/components/Lightbox/pager/ImagePager.tsx @@ -32,6 +32,7 @@ import Animated, { withSpring, type WithSpringConfig, } from 'react-native-reanimated' +import {Image} from 'expo-image' import * as ScreenOrientation from 'expo-screen-orientation' import {type Dimensions} from '#/lib/media/types' @@ -136,6 +137,9 @@ export default function ImageViewRoot({ 'worklet' thumbRects.set({}) })() + requestIdleCallback(() => { + void Image.clearMemoryCache() + }) }, [thumbRects]) useAnimatedReaction( diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index 9aad6da864..b36705d840 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -129,6 +129,7 @@ export function ImageItem({ contentFit="cover" accessible={true} accessibilityIgnoresInvertColors + useAppleWebpCodec /> {children} diff --git a/src/components/Post/Embed/ExternalEmbed/index.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx index 43bfbb4747..fe0be2bd25 100644 --- a/src/components/Post/Embed/ExternalEmbed/index.tsx +++ b/src/components/Post/Embed/ExternalEmbed/index.tsx @@ -108,6 +108,7 @@ export const ExternalEmbed = ({ source={{uri: imageUri}} accessibilityIgnoresInvertColors loading="lazy" + useAppleWebpCodec /> ) : undefined} diff --git a/src/components/Post/Embed/StandardSiteEmbed/index.tsx b/src/components/Post/Embed/StandardSiteEmbed/index.tsx index 70fce8ad77..5f7fc1f5e7 100644 --- a/src/components/Post/Embed/StandardSiteEmbed/index.tsx +++ b/src/components/Post/Embed/StandardSiteEmbed/index.tsx @@ -166,6 +166,7 @@ export const StandardSiteEmbed = ({ source={{uri: imageUri}} accessibilityIgnoresInvertColors loading="lazy" + useAppleWebpCodec /> ) : undefined} diff --git a/src/components/contacts/FindContactsBannerNUX.tsx b/src/components/contacts/FindContactsBannerNUX.tsx index 2997e00d4e..417379fa2b 100644 --- a/src/components/contacts/FindContactsBannerNUX.tsx +++ b/src/components/contacts/FindContactsBannerNUX.tsx @@ -60,6 +60,7 @@ export function FindContactsBannerNUX() { a.self_end, a.mt_sm, ]} + useAppleWebpCodec /> ) diff --git a/src/components/dialogs/nuxs/ActivitySubscriptions.tsx b/src/components/dialogs/nuxs/ActivitySubscriptions.tsx index ed4306459c..318b130414 100644 --- a/src/components/dialogs/nuxs/ActivitySubscriptions.tsx +++ b/src/components/dialogs/nuxs/ActivitySubscriptions.tsx @@ -113,6 +113,7 @@ export function ActivitySubscriptionsNUX() { alt={_( msg`A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature.`, )} + useAppleWebpCodec /> diff --git a/src/components/dialogs/nuxs/BookmarksAnnouncement.tsx b/src/components/dialogs/nuxs/BookmarksAnnouncement.tsx index f7e4bf04c6..153130f8ec 100644 --- a/src/components/dialogs/nuxs/BookmarksAnnouncement.tsx +++ b/src/components/dialogs/nuxs/BookmarksAnnouncement.tsx @@ -124,6 +124,7 @@ export function BookmarksAnnouncement() { 'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.', }), )} + useAppleWebpCodec /> diff --git a/src/components/dialogs/nuxs/DraftsAnnouncement.tsx b/src/components/dialogs/nuxs/DraftsAnnouncement.tsx index 79488fe4d9..37b93bda70 100644 --- a/src/components/dialogs/nuxs/DraftsAnnouncement.tsx +++ b/src/components/dialogs/nuxs/DraftsAnnouncement.tsx @@ -101,6 +101,7 @@ export function DraftsAnnouncement() { 'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.', }), )} + useAppleWebpCodec /> diff --git a/src/components/dialogs/nuxs/FindContactsAnnouncement.tsx b/src/components/dialogs/nuxs/FindContactsAnnouncement.tsx index 8fd7d2d2c5..70f29ef157 100644 --- a/src/components/dialogs/nuxs/FindContactsAnnouncement.tsx +++ b/src/components/dialogs/nuxs/FindContactsAnnouncement.tsx @@ -78,6 +78,7 @@ export function FindContactsAnnouncement() { alt={_( msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`, )} + useAppleWebpCodec /> diff --git a/src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx b/src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx index 85bc29a48f..cbe2555aac 100644 --- a/src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx +++ b/src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx @@ -85,6 +85,7 @@ export function InitialVerificationAnnouncement() { alt={_( msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, )} + useAppleWebpCodec /> @@ -119,6 +120,7 @@ export function InitialVerificationAnnouncement() { alt={_( msg`An mockup of a iPhone showing the Bluesky app open to the profile of a verified user with a blue checkmark next to their display name.`, )} + useAppleWebpCodec /> diff --git a/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx b/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx index 49c43a1e41..e389e11175 100644 --- a/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx +++ b/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx @@ -150,6 +150,7 @@ export function LiveNowBetaDialog() { 'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.', }), )} + useAppleWebpCodec /> diff --git a/src/components/images/AutoSizedImage.tsx b/src/components/images/AutoSizedImage.tsx index 79b41311cb..182bfe661b 100644 --- a/src/components/images/AutoSizedImage.tsx +++ b/src/components/images/AutoSizedImage.tsx @@ -142,6 +142,7 @@ export function AutoSizedImage({ } }} loading="lazy" + useAppleWebpCodec /> diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index fe2b63e606..9ae677c2d2 100644 --- a/src/components/images/Gallery/index.tsx +++ b/src/components/images/Gallery/index.tsx @@ -480,6 +480,7 @@ function GalleryImage({ height: e.source.height, }) }} + useAppleWebpCodec /> {(hasAlt || isCropped) && !hideBadges ? ( diff --git a/src/components/images/ImageLayoutGridItem.tsx b/src/components/images/ImageLayoutGridItem.tsx index 0c349b6612..d6cbc0aded 100644 --- a/src/components/images/ImageLayoutGridItem.tsx +++ b/src/components/images/ImageLayoutGridItem.tsx @@ -106,6 +106,7 @@ export function GalleryItem({ } }} loading="lazy" + useAppleWebpCodec /> diff --git a/src/components/verification/VerifierDialog.tsx b/src/components/verification/VerifierDialog.tsx index 570441ecd0..e8944024ca 100644 --- a/src/components/verification/VerifierDialog.tsx +++ b/src/components/verification/VerifierDialog.tsx @@ -87,6 +87,7 @@ function Inner({ alt={_( msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, )} + useAppleWebpCodec /> diff --git a/src/features/liveEvents/components/LiveEventFeedCardCompact.tsx b/src/features/liveEvents/components/LiveEventFeedCardCompact.tsx index df2bb17f83..9d2f948eb6 100644 --- a/src/features/liveEvents/components/LiveEventFeedCardCompact.tsx +++ b/src/features/liveEvents/components/LiveEventFeedCardCompact.tsx @@ -69,6 +69,7 @@ export function LiveEventFeedCardCompact({ style={[a.absolute, a.inset_0, a.w_full, a.h_full]} contentFit="cover" placeholderContentFit="cover" + useAppleWebpCodec /> setImageLoadError(false)} onError={() => setImageLoadError(true)} + useAppleWebpCodec /> )} {linkMeta && (!linkMeta.image || imageLoadError) && ( diff --git a/src/features/liveNow/components/LiveStatusDialog.tsx b/src/features/liveNow/components/LiveStatusDialog.tsx index 545e8ab943..9e916678f2 100644 --- a/src/features/liveNow/components/LiveStatusDialog.tsx +++ b/src/features/liveNow/components/LiveStatusDialog.tsx @@ -147,6 +147,7 @@ export function LiveStatus({ contentFit="cover" style={[a.absolute, a.inset_0]} accessibilityIgnoresInvertColors + useAppleWebpCodec /> {page === 1 && ( {_(msg`Your )} diff --git a/src/screens/Profile/components/GermButton.tsx b/src/screens/Profile/components/GermButton.tsx index 0bef3a22c3..e37e57edbf 100644 --- a/src/screens/Profile/components/GermButton.tsx +++ b/src/screens/Profile/components/GermButton.tsx @@ -105,6 +105,7 @@ function GermLogo({size}: {size: 'small' | 'large'}) { source={require('../../../../assets/images/germ_logo.webp')} accessibilityIgnoresInvertColors={false} contentFit="cover" + useAppleWebpCodec style={[ a.rounded_full, size === 'large' ? {width: 32, height: 32} : {width: 16, height: 16}, diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index a6ef1a344c..bac3e74d31 100644 --- a/src/view/com/composer/ComposerReplyTo.tsx +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -151,8 +151,8 @@ function ComposerReplyToImages({ )) || (images.length === 2 && ( @@ -160,14 +160,14 @@ function ComposerReplyToImages({ )) || @@ -176,21 +176,21 @@ function ComposerReplyToImages({ @@ -201,28 +201,28 @@ function ComposerReplyToImages({ diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 7624b585f6..2cc000019f 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -245,6 +245,7 @@ const GalleryItem = ({ }} accessible={true} accessibilityIgnoresInvertColors + enforceEarlyResizing cachePolicy="none" autoplay={false} contentFit="cover" diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 420fd0270f..9db4914b21 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -330,6 +330,7 @@ let UserAvatar = ({ }} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} onLoad={onLoad} + useAppleWebpCodec /> )} {!noBorder && } diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 8146b7ad9d..033e8c14fe 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -129,6 +129,7 @@ export function UserBanner({ source={{uri: banner}} accessible={true} accessibilityIgnoresInvertColors + useAppleWebpCodec /> ) : ( ) : ( Date: Thu, 4 Jun 2026 03:21:17 +0000 Subject: [PATCH 02/19] Nightly source-language update --- src/locale/locales/en/messages.po | 424 +++++++++++++++--------------- 1 file changed, 212 insertions(+), 212 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index b1fd0337d7..5830797c52 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -93,7 +93,7 @@ msgstr "{0, plural, one {# person} other {# people}}" #. placeholder {0}: reactions.length #. placeholder {1}: groupedReactions.map(g => g.value).join(' ') -#: src/components/dms/MessageItem.tsx:297 +#: src/components/dms/MessageItem.tsx:273 msgid "{0, plural, one {# person} other {# people}} reacted – {1}" msgstr "{0, plural, one {# person} other {# people}} reacted – {1}" @@ -122,7 +122,7 @@ msgstr "" #. How long it takes to read an article, in minutes. Displayed in a short form, e.g. "5m" for 5 minutes. #. placeholder {0}: view.readingTime -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:232 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:233 msgid "{0, plural, one {#m} other {#m}}" msgstr "{0, plural, one {#m} other {#m}}" @@ -211,7 +211,7 @@ msgid "{0} following" msgstr "" #. placeholder {0}: sanitizeHandle(profile.handle, '@') -#: src/components/dms/MessageItem.tsx:640 +#: src/components/dms/MessageItem.tsx:609 msgid "{0} is blocking you" msgstr "{0} is blocking you" @@ -269,7 +269,7 @@ msgstr "" #. placeholder {0}: createSanitizedDisplayName(memberSender) #. placeholder {1}: reaction.value -#: src/components/dms/MessageItem.tsx:292 +#: src/components/dms/MessageItem.tsx:268 msgid "{0} reacted {1}" msgstr "" @@ -314,8 +314,8 @@ msgstr "" #. placeholder {0}: sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), ) #. placeholder {0}: sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), ) -#: src/view/com/util/UserAvatar.tsx:595 -#: src/view/com/util/UserAvatar.tsx:613 +#: src/view/com/util/UserAvatar.tsx:596 +#: src/view/com/util/UserAvatar.tsx:614 msgid "{0}'s avatar" msgstr "" @@ -839,7 +839,7 @@ msgstr "" msgid "A new code has been sent" msgstr "" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:93 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:94 msgid "A new form of verification" msgstr "" @@ -897,7 +897,7 @@ msgid "Accept join request" msgstr "Accept join request" #. Accept a chat request -#: src/screens/Messages/components/RequestListItem.tsx:48 +#: src/screens/Messages/components/RequestListItem.tsx:51 msgid "Accept Request" msgstr "" @@ -999,7 +999,7 @@ msgctxt "toast" msgid "Account unmuted" msgstr "" -#: src/components/verification/VerifierDialog.tsx:99 +#: src/components/verification/VerifierDialog.tsx:100 msgid "Accounts with a scalloped blue check mark <0><1/> can verify others. These trusted verifiers are selected by Bluesky." msgstr "" @@ -1085,7 +1085,7 @@ msgstr "" msgid "Add App Password" msgstr "" -#: src/screens/Settings/AutomationLabelSettings.tsx:166 +#: src/screens/Settings/AutomationLabelSettings.tsx:170 msgid "Add automation label to account" msgstr "Add automation label to account" @@ -1112,8 +1112,8 @@ msgstr "" msgid "Add members" msgstr "Add members" -#: src/components/moderation/ReportDialog/index.tsx:526 -#: src/components/moderation/ReportDialog/index.tsx:530 +#: src/components/moderation/ReportDialog/index.tsx:528 +#: src/components/moderation/ReportDialog/index.tsx:532 msgid "Add more details (optional)" msgstr "" @@ -1215,7 +1215,7 @@ msgstr "" msgid "Additional details (limit 1000 characters)" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:544 +#: src/components/moderation/ReportDialog/index.tsx:546 msgid "Additional details (limit 300 characters)" msgstr "" @@ -1283,7 +1283,7 @@ msgid "alice@example.com" msgstr "" #. the default tab in the interests tab bar -#: src/components/dms/ReactionsDialog.tsx:284 +#: src/components/dms/ReactionsDialog.tsx:288 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:201 #: src/view/screens/Notifications.tsx:86 msgid "All" @@ -1291,7 +1291,7 @@ msgstr "" #. Tab label showing the total count of reactions on a chat message. #. placeholder {0}: tab.count -#: src/components/dms/ReactionsDialog.tsx:381 +#: src/components/dms/ReactionsDialog.tsx:385 msgid "All {0}" msgstr "All {0}" @@ -1388,9 +1388,9 @@ msgstr "" msgid "Already signed in as @{0}" msgstr "" -#: src/components/images/AutoSizedImage.tsx:203 -#: src/components/images/Gallery/index.tsx:531 -#: src/components/images/ImageLayoutGridItem.tsx:138 +#: src/components/images/AutoSizedImage.tsx:204 +#: src/components/images/Gallery/index.tsx:539 +#: src/components/images/ImageLayoutGridItem.tsx:139 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:94 #: src/view/com/composer/GifAltText.tsx:100 #: src/view/com/composer/photos/Gallery.tsx:209 @@ -1540,7 +1540,7 @@ msgstr "" msgid "An issue occurred, please try again." msgstr "" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:120 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:121 msgid "An mockup of a iPhone showing the Bluesky app open to the profile of a verified user with a blue checkmark next to their display name." msgstr "" @@ -1729,7 +1729,7 @@ msgstr "" msgid "Are you sure you want to delete the app password \"{0}\"?" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:223 +#: src/components/dms/MessageOverlays.tsx:167 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." @@ -1968,7 +1968,7 @@ msgstr "" msgid "Block accounts" msgstr "" -#: src/components/dms/AfterReportDialog.tsx:143 +#: src/components/dms/AfterReportDialog.tsx:148 msgid "Block and delete" msgstr "Block and delete" @@ -1992,9 +1992,9 @@ msgstr "" #: src/components/dms/AfterReportConversationDialog.tsx:221 #: src/components/dms/AfterReportConversationDialog.tsx:224 -#: src/components/dms/AfterReportDialog.tsx:149 -#: src/components/dms/AfterReportDialog.tsx:184 -#: src/components/dms/AfterReportDialog.tsx:187 +#: src/components/dms/AfterReportDialog.tsx:154 +#: src/components/dms/AfterReportDialog.tsx:189 +#: src/components/dms/AfterReportDialog.tsx:192 msgid "Block user" msgstr "" @@ -2004,7 +2004,7 @@ msgctxt "button" msgid "Block user" msgstr "Block user" -#: src/components/dms/AfterReportDialog.tsx:180 +#: src/components/dms/AfterReportDialog.tsx:185 msgid "Block user and/or delete this conversation" msgstr "" @@ -2085,7 +2085,7 @@ msgstr "" msgid "Bluesky is more fun with friends" msgstr "" -#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:107 +#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:108 msgid "Bluesky is more fun with friends! Import your contacts to see who’s already here." msgstr "" @@ -2109,7 +2109,7 @@ msgstr "" msgid "Bluesky will not show your profile and posts to logged-out users. Other apps may not honor this request. This does not make your account private." msgstr "" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:134 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:136 msgid "Bluesky will proactively verify notable and authentic accounts." msgstr "" @@ -2198,7 +2198,7 @@ msgstr "" #. placeholder {0}: sanitizeHandle(handle, '@') #. placeholder {0}: sanitizeHandle(item.feed.creator.handle, '@') #: src/components/LabelingServiceCard/index.tsx:62 -#: src/components/moderation/ReportDialog/index.tsx:845 +#: src/components/moderation/ReportDialog/index.tsx:847 #: src/screens/Messages/JoinRequest.tsx:166 #: src/screens/Search/components/StarterPackCard.tsx:107 #: src/screens/Search/Explore.tsx:971 @@ -2322,7 +2322,7 @@ msgstr "" msgid "Captions & alt text" msgstr "" -#: src/components/images/Gallery/index.tsx:256 +#: src/components/images/Gallery/index.tsx:263 msgid "carousel" msgstr "carousel" @@ -2355,7 +2355,7 @@ msgstr "" msgid "Change Handle" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:443 +#: src/components/moderation/ReportDialog/index.tsx:445 msgid "Change moderation service" msgstr "" @@ -2368,11 +2368,11 @@ msgstr "" msgid "Change password dialog" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:310 +#: src/components/moderation/ReportDialog/index.tsx:312 msgid "Change report category" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:388 +#: src/components/moderation/ReportDialog/index.tsx:390 msgid "Change report reason" msgstr "" @@ -2643,7 +2643,7 @@ msgstr "Click here to view the invite link for this group chat" msgid "Click to open tag menu for {0}" msgstr "" -#: src/components/dms/MessageItem.tsx:559 +#: src/components/dms/MessageItem.tsx:528 msgid "Click to retry failed message" msgstr "" @@ -2657,15 +2657,15 @@ msgstr "" #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:233 #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:239 #: src/components/dialogs/LanguageSelectDialog.tsx:359 -#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:159 -#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:168 -#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:164 -#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:172 -#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:139 -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:176 -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:185 -#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:191 -#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:199 +#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:160 +#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:169 +#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:165 +#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:173 +#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:140 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:178 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:187 +#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:192 +#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:200 #: src/components/dialogs/SearchablePeopleList.tsx:340 #: src/components/dialogs/StarterPackDialog.tsx:187 #: src/components/dms/AddMembersFlow.tsx:367 @@ -2673,10 +2673,10 @@ msgstr "" #: src/components/dms/AfterReportConversationDialog.tsx:96 #: src/components/dms/AfterReportConversationDialog.tsx:247 #: src/components/dms/AfterReportConversationDialog.tsx:252 -#: src/components/dms/AfterReportDialog.tsx:89 #: src/components/dms/AfterReportDialog.tsx:94 -#: src/components/dms/AfterReportDialog.tsx:207 +#: src/components/dms/AfterReportDialog.tsx:99 #: src/components/dms/AfterReportDialog.tsx:212 +#: src/components/dms/AfterReportDialog.tsx:217 #: src/components/dms/EmojiPopup.android.tsx:59 #: src/components/dms/InitiateChatFlow.tsx:534 #: src/components/intents/GroupChatJoinDialog.tsx:204 @@ -2688,7 +2688,7 @@ msgstr "" #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:122 #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:128 #: src/components/verification/VerificationsDialog.tsx:146 -#: src/components/verification/VerifierDialog.tsx:146 +#: src/components/verification/VerifierDialog.tsx:147 #: src/components/WhoCanReply.tsx:236 #: src/components/WhoCanReply.tsx:243 #: src/features/gifPicker/components/GifPickerErrorBoundary.tsx:45 @@ -2724,7 +2724,7 @@ msgstr "" #: src/components/dialogs/LanguageSelectDialog.tsx:354 #: src/components/dialogs/NotificationSettingsDialog.tsx:94 #: src/components/verification/VerificationsDialog.tsx:138 -#: src/components/verification/VerifierDialog.tsx:139 +#: src/components/verification/VerifierDialog.tsx:140 #: src/features/gifPicker/components/GifPickerErrorBoundary.tsx:36 msgid "Close dialog" msgstr "" @@ -2999,8 +2999,8 @@ msgstr "" msgid "Conversation deleted" msgstr "" -#: src/components/dms/AfterReportDialog.tsx:144 -#: src/components/dms/AfterReportDialog.tsx:147 +#: src/components/dms/AfterReportDialog.tsx:149 +#: src/components/dms/AfterReportDialog.tsx:152 msgctxt "toast" msgid "Conversation deleted" msgstr "" @@ -3020,7 +3020,7 @@ msgstr "Conversation not found." msgid "Copied build version to clipboard" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:79 +#: src/components/dms/MessageContextMenu.tsx:71 #: src/components/PostControls/DiscoverDebug.tsx:36 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:272 #: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:77 @@ -3102,8 +3102,8 @@ msgstr "" msgid "Copy link to starter pack" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:169 -#: src/components/dms/MessageContextMenu.tsx:173 +#: src/components/dms/MessageContextMenu.tsx:152 +#: src/components/dms/MessageContextMenu.tsx:156 msgid "Copy message text" msgstr "" @@ -3160,7 +3160,7 @@ msgid "Could not follow all matches. {0}" msgstr "" #: src/components/dms/AfterReportConversationDialog.tsx:158 -#: src/components/dms/AfterReportDialog.tsx:134 +#: src/components/dms/AfterReportDialog.tsx:139 #: src/components/dms/LeaveConvoPrompt.tsx:34 msgid "Could not leave chat" msgstr "" @@ -3316,8 +3316,8 @@ msgstr "Create or modify an invite link for this group chat" #. Accessibility label for button to create a moderation report for the selected option #. placeholder {0}: option.title -#: src/components/moderation/ReportDialog/index.tsx:707 -#: src/components/moderation/ReportDialog/index.tsx:752 +#: src/components/moderation/ReportDialog/index.tsx:709 +#: src/components/moderation/ReportDialog/index.tsx:754 msgid "Create report for {0}" msgstr "" @@ -3418,7 +3418,7 @@ msgstr "" msgid "Default icons" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:224 +#: src/components/dms/MessageOverlays.tsx:168 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:803 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:275 #: src/screens/Settings/AppPasswords.tsx:213 @@ -3459,15 +3459,15 @@ msgstr "" msgid "Delete contacts" msgstr "" -#: src/components/dms/AfterReportDialog.tsx:146 -#: src/components/dms/AfterReportDialog.tsx:190 -#: src/components/dms/AfterReportDialog.tsx:193 +#: src/components/dms/AfterReportDialog.tsx:151 +#: src/components/dms/AfterReportDialog.tsx:195 +#: src/components/dms/AfterReportDialog.tsx:198 #: src/screens/Messages/components/RequestButtons.tsx:142 #: src/screens/Messages/components/RequestButtons.tsx:144 msgid "Delete conversation" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:184 +#: src/components/dms/MessageContextMenu.tsx:167 msgid "Delete for me" msgstr "" @@ -3476,11 +3476,11 @@ msgstr "" msgid "Delete list" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:222 +#: src/components/dms/MessageOverlays.tsx:166 msgid "Delete message" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:181 +#: src/components/dms/MessageContextMenu.tsx:164 msgid "Delete message for me" msgstr "" @@ -3661,8 +3661,8 @@ msgstr "" msgid "Discard post?" msgstr "" -#: src/screens/Profile/components/GermButton.tsx:261 -#: src/screens/Profile/components/GermButton.tsx:268 +#: src/screens/Profile/components/GermButton.tsx:262 +#: src/screens/Profile/components/GermButton.tsx:269 msgid "Disconnect Germ DM" msgstr "" @@ -3690,7 +3690,7 @@ msgstr "" msgid "Dismiss" msgstr "" -#: src/components/contacts/FindContactsBannerNUX.tsx:77 +#: src/components/contacts/FindContactsBannerNUX.tsx:78 msgid "Dismiss banner" msgstr "" @@ -3784,7 +3784,7 @@ msgstr "" #: src/components/dialogs/ServerInput.tsx:237 #: src/components/dialogs/ServerInput.tsx:239 #: src/components/dms/AfterReportConversationDialog.tsx:164 -#: src/components/dms/AfterReportDialog.tsx:140 +#: src/components/dms/AfterReportDialog.tsx:145 #: src/components/forms/DateField/index.tsx:104 #: src/components/forms/DateField/index.tsx:110 #: src/lib/media/picker.tsx:37 @@ -3805,7 +3805,7 @@ msgctxt "action" msgid "Done" msgstr "" -#: src/components/dms/MessageItem.tsx:463 +#: src/components/dms/MessageItem.tsx:432 msgid "Double tap or long press the message to add a reaction" msgstr "" @@ -3848,7 +3848,7 @@ msgstr "Download profile data" msgid "Doxxing" msgstr "" -#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:119 +#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:120 #: src/view/com/composer/drafts/DraftsButton.tsx:70 #: src/view/com/composer/drafts/DraftsButton.tsx:79 #: src/view/com/composer/drafts/DraftsListDialog.tsx:119 @@ -3917,7 +3917,7 @@ msgctxt "action" msgid "Edit" msgstr "" -#: src/view/com/util/UserAvatar.tsx:460 +#: src/view/com/util/UserAvatar.tsx:461 #: src/view/com/util/UserBanner.tsx:122 msgid "Edit avatar" msgstr "" @@ -4299,7 +4299,7 @@ msgstr "" msgid "Exit fullscreen" msgstr "" -#: src/components/Lightbox/chrome/Footer.tsx:65 +#: src/components/Lightbox/chrome/Footer.tsx:59 #: src/components/Lightbox/Lightbox.web.tsx:241 msgid "Expand alt text" msgstr "" @@ -4420,7 +4420,7 @@ msgid "Failed to accept join request" msgstr "Failed to accept join request" #: src/components/dms/ActionsWrapper.web.tsx:77 -#: src/components/dms/MessageContextMenu.tsx:119 +#: src/components/dms/MessageContextMenu.tsx:103 msgid "Failed to add emoji reaction" msgstr "" @@ -4465,7 +4465,7 @@ msgctxt "toast" msgid "Failed to delete chat" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:101 +#: src/components/dms/MessageOverlays.tsx:112 msgid "Failed to delete message" msgstr "" @@ -4482,7 +4482,7 @@ msgid "Failed to disable invite link" msgstr "Failed to disable invite link" #. placeholder {0}: error?.message -#: src/screens/Profile/components/GermButton.tsx:195 +#: src/screens/Profile/components/GermButton.tsx:196 msgid "Failed to disconnect Germ DM. Error: {0}" msgstr "" @@ -4593,7 +4593,7 @@ msgid "Failed to pin post" msgstr "" #. placeholder {0}: e?.message -#: src/screens/Profile/components/GermButton.tsx:163 +#: src/screens/Profile/components/GermButton.tsx:164 msgid "Failed to reconnect Germ DM. Error: {0}" msgstr "" @@ -4607,8 +4607,8 @@ msgid "Failed to remove data. {0}" msgstr "" #: src/components/dms/ActionsWrapper.web.tsx:73 -#: src/components/dms/MessageContextMenu.tsx:115 -#: src/components/dms/ReactionsDialog.tsx:171 +#: src/components/dms/MessageContextMenu.tsx:99 +#: src/components/dms/ReactionsDialog.tsx:175 msgid "Failed to remove emoji reaction" msgstr "" @@ -4833,11 +4833,11 @@ msgstr "" msgid "Finalizing" msgstr "" -#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:145 +#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:146 msgid "Finally!" msgstr "" -#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:155 +#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:156 msgid "Finally! Keep track of posts that matter to you. Save them to revisit anytime." msgstr "" @@ -4885,7 +4885,7 @@ msgstr "" msgid "Find posts, users, and feeds on Bluesky" msgstr "" -#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:97 +#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:98 msgid "Find your friends" msgstr "" @@ -5191,20 +5191,20 @@ msgid "Generate new link" msgstr "Generate new link" #: src/screens/Profile/components/GermButton.tsx:86 -#: src/screens/Profile/components/GermButton.tsx:224 +#: src/screens/Profile/components/GermButton.tsx:225 msgid "Germ DM" msgstr "" -#: src/screens/Profile/components/GermButton.tsx:182 +#: src/screens/Profile/components/GermButton.tsx:183 msgid "Germ DM disconnected" msgstr "" -#: src/screens/Profile/components/GermButton.tsx:233 -#: src/screens/Profile/components/GermButton.tsx:238 +#: src/screens/Profile/components/GermButton.tsx:234 +#: src/screens/Profile/components/GermButton.tsx:239 msgid "Germ DM Link" msgstr "" -#: src/screens/Profile/components/GermButton.tsx:159 +#: src/screens/Profile/components/GermButton.tsx:160 msgid "Germ DM reconnected" msgstr "" @@ -5268,7 +5268,7 @@ msgstr "" msgid "Get notified when {name} posts" msgstr "" -#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:138 +#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:139 msgid "Get notified when someone posts" msgstr "" @@ -5279,7 +5279,7 @@ msgstr "" msgid "Get started" msgstr "" -#: src/components/MediaPreview.tsx:147 +#: src/components/MediaPreview.tsx:148 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:69 msgid "GIF" msgstr "" @@ -5413,8 +5413,8 @@ msgstr "Go to user’s profile" msgid "Going live is currently disabled for your account" msgstr "" -#: src/screens/Profile/components/GermButton.tsx:252 -#: src/screens/Profile/components/GermButton.tsx:257 +#: src/screens/Profile/components/GermButton.tsx:253 +#: src/screens/Profile/components/GermButton.tsx:258 msgid "Got it" msgstr "" @@ -5524,7 +5524,7 @@ msgid "Handle too long. Please try a shorter one." msgstr "" #: src/components/FeedCard.tsx:156 -#: src/features/liveEvents/components/LiveEventFeedCardWide.tsx:122 +#: src/features/liveEvents/components/LiveEventFeedCardWide.tsx:123 msgid "Happening now" msgstr "" @@ -5903,7 +5903,7 @@ msgid "Image" msgstr "" #. placeholder {0}: index + 1 -#: src/components/images/Gallery/index.tsx:436 +#: src/components/images/Gallery/index.tsx:443 msgid "Image {0}" msgstr "Image {0}" @@ -5914,7 +5914,7 @@ msgid "Image {0} of {1}" msgstr "" #. placeholder {0}: index + 1 -#: src/components/images/Gallery/index.tsx:421 +#: src/components/images/Gallery/index.tsx:428 msgid "Image {0} of {imageCount}" msgstr "Image {0} of {imageCount}" @@ -5929,17 +5929,17 @@ msgid "Image cache cleared, freed {0}" msgstr "" #. placeholder {0}: images.length -#: src/components/images/Gallery/index.tsx:257 +#: src/components/images/Gallery/index.tsx:264 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 +#: src/features/liveNow/components/LiveStatusDialog.tsx:300 msgid "Image is hidden due to your moderation settings." msgstr "Image is hidden due to your moderation settings." #. Image has been moderated and is not visible to the user -#: src/features/liveNow/components/LiveStatusDialog.tsx:309 +#: src/features/liveNow/components/LiveStatusDialog.tsx:310 msgid "Image is unavailable." msgstr "Image is unavailable." @@ -5974,13 +5974,13 @@ msgstr "" msgid "Import contacts" msgstr "" -#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:115 -#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:126 +#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:116 +#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:127 msgid "Import Contacts" msgstr "" #: src/components/contacts/FindContactsBannerNUX.tsx:33 -#: src/components/contacts/FindContactsBannerNUX.tsx:71 +#: src/components/contacts/FindContactsBannerNUX.tsx:72 msgid "Import contacts to find your friends" msgstr "" @@ -6093,7 +6093,7 @@ msgstr "" msgid "Invalid phone number" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:98 +#: src/components/moderation/ReportDialog/index.tsx:100 msgid "Invalid report subject" msgstr "" @@ -6311,7 +6311,7 @@ msgid "Latest" msgstr "" #: src/components/verification/VerificationsDialog.tsx:168 -#: src/components/verification/VerifierDialog.tsx:135 +#: src/components/verification/VerifierDialog.tsx:136 #: src/screens/Moderation/VerificationSettings.tsx:50 #: src/screens/Profile/Header/EditProfileDialog.tsx:346 #: src/screens/Settings/components/ChangeHandleDialog.tsx:215 @@ -6366,7 +6366,7 @@ msgid "Learn more about this warning" msgstr "" #: src/components/verification/VerificationsDialog.tsx:153 -#: src/components/verification/VerifierDialog.tsx:121 +#: src/components/verification/VerifierDialog.tsx:122 msgctxt "english-only-resource" msgid "Learn more about verification on Bluesky" msgstr "" @@ -6376,7 +6376,7 @@ msgstr "" msgid "Learn more about what is public on Bluesky." msgstr "" -#: src/screens/Profile/components/GermButton.tsx:211 +#: src/screens/Profile/components/GermButton.tsx:212 msgid "Learn more about your Germ DM link" msgstr "" @@ -6674,7 +6674,7 @@ msgstr "" msgid "Live events appear occasionally when something exciting is happening. If you'd like, you can hide this particular event, or all events for this placement in your app interface." msgstr "" -#: src/features/liveNow/components/LiveStatusDialog.tsx:244 +#: src/features/liveNow/components/LiveStatusDialog.tsx:245 msgid "Live feature is in beta" msgstr "" @@ -6904,18 +6904,18 @@ msgstr "Message {displayName}" msgid "Message deleted" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:100 +#: src/components/dms/MessageOverlays.tsx:111 msgctxt "toast" msgid "Message deleted" msgstr "" -#: src/components/dms/MessageItem.tsx:553 +#: src/components/dms/MessageItem.tsx:522 msgid "Message failed to send." msgstr "Message failed to send." #. placeholder {0}: sender?.handle ?? 'unknown' #. placeholder {1}: message.text -#: src/components/dms/MessageContextMenu.tsx:146 +#: src/components/dms/MessageContextMenu.tsx:129 msgid "Message from @{0}: {1}" msgstr "" @@ -6938,7 +6938,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:145 +#: src/components/dms/MessageContextMenu.tsx:128 msgid "Message options" msgstr "" @@ -6946,11 +6946,11 @@ msgstr "" msgid "Messages" msgstr "" -#: src/components/dms/MessageItem.tsx:652 +#: src/components/dms/MessageItem.tsx:621 msgid "Messages from this person are hidden while they are blocking you." msgstr "Messages from this person are hidden while they are blocking you." -#: src/components/dms/MessageItem.tsx:647 +#: src/components/dms/MessageItem.tsx:616 msgid "Messages from this person are hidden while you are blocking them." msgstr "Messages from this person are hidden while you are blocking them." @@ -7229,8 +7229,8 @@ msgstr "" msgid "Navigates to your profile" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:340 -#: src/components/moderation/ReportDialog/index.tsx:356 +#: src/components/moderation/ReportDialog/index.tsx:342 +#: src/components/moderation/ReportDialog/index.tsx:358 msgid "Need to report a copyright violation, legal request, or regulatory compliance issue?" msgstr "" @@ -7452,7 +7452,7 @@ msgstr "No GIFs found for \"{query}\"." msgid "No GIFs to show right now. Try again in a moment." msgstr "No GIFs to show right now. Try again in a moment." -#: src/features/liveNow/components/LinkPreview.tsx:63 +#: src/features/liveNow/components/LinkPreview.tsx:64 msgid "No image" msgstr "" @@ -7640,7 +7640,7 @@ msgstr "Not followed by anyone you’re following" msgid "Not Found" msgstr "" -#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:130 +#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:131 msgid "Not ready to hit post? Keep your best ideas in Drafts until the timing is just right." msgstr "" @@ -7724,7 +7724,7 @@ msgstr "" #: src/components/BotAccountAlert.tsx:52 #: src/components/BotAccountAlert.tsx:57 #: src/components/dms/InitiateChatFlow.tsx:677 -#: src/components/dms/MessageItem.tsx:659 +#: src/components/dms/MessageItem.tsx:628 #: src/screens/Login/PasswordUpdatedForm.tsx:37 #: src/screens/PostThread/components/ThreadItemAnchor.tsx:661 msgid "Okay" @@ -7916,8 +7916,8 @@ msgstr "" msgid "Open post options menu" msgstr "" -#: src/features/liveNow/components/LiveStatusDialog.tsx:218 -#: src/features/liveNow/components/LiveStatusDialog.tsx:228 +#: src/features/liveNow/components/LiveStatusDialog.tsx:219 +#: src/features/liveNow/components/LiveStatusDialog.tsx:229 msgid "Open profile" msgstr "" @@ -8005,7 +8005,7 @@ msgstr "" msgid "Opens flow to sign in to your existing Bluesky account" msgstr "" -#: src/components/images/Gallery/index.tsx:437 +#: src/components/images/Gallery/index.tsx:444 msgid "Opens full image" msgstr "Opens full image" @@ -8022,7 +8022,7 @@ msgstr "" msgid "Opens link {0}" msgstr "" -#: src/view/com/util/UserAvatar.tsx:599 +#: src/view/com/util/UserAvatar.tsx:600 msgid "Opens live status dialog" msgstr "" @@ -8053,7 +8053,7 @@ msgstr "" #: src/components/dms/MessageItem.tsx:229 #: src/view/com/notifications/NotificationFeedItem.tsx:1019 -#: src/view/com/util/UserAvatar.tsx:617 +#: src/view/com/util/UserAvatar.tsx:618 msgid "Opens this profile" msgstr "" @@ -8129,8 +8129,8 @@ msgstr "" #: src/components/dms/AfterReportConversationDialog.tsx:86 #: src/components/dms/AfterReportConversationDialog.tsx:213 -#: src/components/dms/AfterReportDialog.tsx:84 -#: src/components/dms/AfterReportDialog.tsx:176 +#: src/components/dms/AfterReportDialog.tsx:89 +#: src/components/dms/AfterReportDialog.tsx:181 msgid "Our moderation team has received your report." msgstr "" @@ -8661,7 +8661,7 @@ msgstr "" msgid "Press to view followers of this account that you also follow" msgstr "" -#: src/features/liveEvents/components/LiveEventFeedCardWide.tsx:120 +#: src/features/liveEvents/components/LiveEventFeedCardWide.tsx:121 msgid "Preview" msgstr "" @@ -8865,8 +8865,8 @@ msgstr "Re-enable link" msgid "React with {emoji}" msgstr "" -#: src/components/dms/ReactionsDialog.tsx:65 -#: src/components/dms/ReactionsDialog.tsx:90 +#: src/components/dms/ReactionsDialog.tsx:66 +#: src/components/dms/ReactionsDialog.tsx:94 msgid "Reactions" msgstr "Reactions" @@ -8884,8 +8884,8 @@ msgctxt "english-only-resource" msgid "read about how to use search filters" msgstr "" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:160 -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:171 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:162 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:173 msgid "Read blog post" msgstr "" @@ -9020,13 +9020,13 @@ msgstr "" msgid "Remove attachment" msgstr "" -#: src/view/com/util/UserAvatar.tsx:519 -#: src/view/com/util/UserAvatar.tsx:522 +#: src/view/com/util/UserAvatar.tsx:520 +#: src/view/com/util/UserAvatar.tsx:523 msgid "Remove Avatar" msgstr "" -#: src/view/com/util/UserBanner.tsx:189 -#: src/view/com/util/UserBanner.tsx:192 +#: src/view/com/util/UserBanner.tsx:190 +#: src/view/com/util/UserBanner.tsx:193 msgid "Remove Banner" msgstr "" @@ -9261,8 +9261,8 @@ msgstr "" msgid "Reply was successfully hidden" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:193 -#: src/features/liveNow/components/LiveStatusDialog.tsx:266 +#: src/components/dms/MessageContextMenu.tsx:176 +#: src/features/liveNow/components/LiveStatusDialog.tsx:267 #: src/screens/Messages/ConversationSettings/index.tsx:517 msgid "Report" msgstr "" @@ -9279,8 +9279,8 @@ msgstr "" msgid "Report conversation" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:96 -#: src/components/moderation/ReportDialog/index.tsx:261 +#: src/components/moderation/ReportDialog/index.tsx:98 +#: src/components/moderation/ReportDialog/index.tsx:263 msgid "Report dialog" msgstr "" @@ -9294,7 +9294,7 @@ msgstr "" msgid "Report list" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:190 +#: src/components/dms/MessageContextMenu.tsx:173 msgid "Report message" msgstr "" @@ -9314,8 +9314,8 @@ msgstr "" #: src/components/dms/AfterReportConversationDialog.tsx:83 #: src/components/dms/AfterReportConversationDialog.tsx:210 -#: src/components/dms/AfterReportDialog.tsx:81 -#: src/components/dms/AfterReportDialog.tsx:173 +#: src/components/dms/AfterReportDialog.tsx:86 +#: src/components/dms/AfterReportDialog.tsx:178 msgid "Report submitted" msgstr "" @@ -9337,7 +9337,7 @@ msgid "Report this list" msgstr "" #: src/components/moderation/ReportDialog/copy.ts:19 -#: src/features/liveNow/components/LiveStatusDialog.tsx:249 +#: src/features/liveNow/components/LiveStatusDialog.tsx:250 msgid "Report this livestream" msgstr "" @@ -9530,7 +9530,7 @@ msgstr "" #: src/components/contacts/screens/VerifyNumber.tsx:355 #: src/components/Error.tsx:65 #: src/components/Lists.tsx:115 -#: src/components/moderation/ReportDialog/index.tsx:295 +#: src/components/moderation/ReportDialog/index.tsx:297 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:56 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:59 #: src/components/StarterPack/ProfileStarterPacks.tsx:377 @@ -9553,7 +9553,7 @@ msgstr "" msgid "Retry" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:292 +#: src/components/moderation/ReportDialog/index.tsx:294 #: src/view/screens/Storybook/Admonitions.tsx:61 msgid "Retry loading report options" msgstr "" @@ -9638,7 +9638,7 @@ msgid "Save draft?" msgstr "" #: src/components/Lightbox/chrome/ImageMenu.tsx:99 -#: src/components/MediaPreview.tsx:196 +#: src/components/MediaPreview.tsx:197 #: src/components/Post/Embed/ImageContextMenu.tsx:70 #: src/components/StarterPack/ShareDialog.tsx:144 #: src/components/StarterPack/ShareDialog.tsx:150 @@ -9674,7 +9674,7 @@ msgstr "" msgid "Saved Feeds" msgstr "" -#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:144 +#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:145 #: src/Navigation.tsx:555 #: src/screens/Bookmarks/index.tsx:59 msgid "Saved Posts" @@ -9899,7 +9899,7 @@ msgstr "" msgid "Select a color" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:378 +#: src/components/moderation/ReportDialog/index.tsx:380 msgid "Select a reason" msgstr "" @@ -9990,7 +9990,7 @@ msgstr "" msgid "Select languages" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:428 +#: src/components/moderation/ReportDialog/index.tsx:430 msgid "Select moderation service" msgstr "" @@ -10092,7 +10092,7 @@ msgstr "" msgid "Send post to..." msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:816 +#: src/components/moderation/ReportDialog/index.tsx:818 msgid "Send report to {title}" msgstr "" @@ -10114,7 +10114,7 @@ msgid "Send via direct message" msgstr "" #. placeholder {0}: i18n.date(new Date(message.sentAt), { timeStyle: 'short', }) -#: src/components/dms/MessageContextMenu.tsx:154 +#: src/components/dms/MessageContextMenu.tsx:137 msgid "Sent at {0}" msgstr "Sent at {0}" @@ -10219,7 +10219,7 @@ msgstr "" msgid "Sexually Suggestive" msgstr "" -#: src/components/MediaPreview.tsx:202 +#: src/components/MediaPreview.tsx:203 #: src/components/Post/Embed/ImageContextMenu.tsx:74 #: src/components/StarterPack/QrCodeDialog.tsx:195 #: src/screens/Hashtag.tsx:130 @@ -10309,16 +10309,16 @@ msgstr "" #: src/components/moderation/ScreenHider.tsx:179 #: src/components/moderation/ScreenHider.tsx:182 -#: src/features/liveNow/components/LiveStatusDialog.tsx:317 -#: src/features/liveNow/components/LiveStatusDialog.tsx:321 +#: src/features/liveNow/components/LiveStatusDialog.tsx:318 +#: src/features/liveNow/components/LiveStatusDialog.tsx:322 #: src/screens/List/ListHiddenScreen.tsx:194 #: src/screens/VideoFeed/index.tsx:646 #: src/screens/VideoFeed/index.tsx:652 msgid "Show anyway" msgstr "" -#: src/screens/Settings/AutomationLabelSettings.tsx:181 -#: src/screens/Settings/AutomationLabelSettings.tsx:194 +#: src/screens/Settings/AutomationLabelSettings.tsx:185 +#: src/screens/Settings/AutomationLabelSettings.tsx:198 msgid "Show automation label" msgstr "Show automation label" @@ -10412,7 +10412,7 @@ msgid "Show warning and filter from feeds" msgstr "" #: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:60 -#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:170 +#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:171 msgid "Show when you’re live" msgstr "" @@ -10557,7 +10557,7 @@ msgstr "" msgid "Skip to next step" msgstr "" -#: src/components/images/Gallery/index.tsx:420 +#: src/components/images/Gallery/index.tsx:427 msgid "slide" msgstr "slide" @@ -10614,7 +10614,7 @@ msgid "Someone left the group" msgstr "Someone left the group" #. placeholder {0}: reaction.value -#: src/components/dms/MessageItem.tsx:294 +#: src/components/dms/MessageItem.tsx:270 msgid "Someone reacted {0}" msgstr "" @@ -10639,7 +10639,7 @@ msgstr "Someone was removed" msgid "Someone was removed from the group" msgstr "Someone was removed from the group" -#: src/components/moderation/ReportDialog/index.tsx:101 +#: src/components/moderation/ReportDialog/index.tsx:103 msgid "Something wasn't quite right with the data you're trying to report. Please contact support." msgstr "" @@ -10650,7 +10650,7 @@ msgid "Something went wrong" msgstr "" #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:139 -#: src/components/moderation/ReportDialog/index.tsx:287 +#: src/components/moderation/ReportDialog/index.tsx:289 #: src/screens/Deactivated.tsx:86 #: src/screens/Settings/components/DeactivateAccountDialog.tsx:59 #: src/view/screens/Storybook/Admonitions.tsx:56 @@ -10670,7 +10670,7 @@ msgstr "" msgid "Something went wrong. Please try again in a moment." msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:245 +#: src/components/moderation/ReportDialog/index.tsx:247 msgid "Something went wrong. Please try again." msgstr "" @@ -10812,7 +10812,7 @@ msgstr "" msgid "Storybook" msgstr "" -#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:181 +#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:182 msgid "Streaming on Twitch? Set your live status on Bluesky to add a badge to your avatar. Tapping it takes people straight to your stream." msgstr "" @@ -10837,9 +10837,9 @@ msgstr "" msgid "Submit Appeal" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:506 -#: src/components/moderation/ReportDialog/index.tsx:567 -#: src/components/moderation/ReportDialog/index.tsx:574 +#: src/components/moderation/ReportDialog/index.tsx:508 +#: src/components/moderation/ReportDialog/index.tsx:569 +#: src/components/moderation/ReportDialog/index.tsx:576 msgid "Submit report" msgstr "" @@ -10848,13 +10848,13 @@ msgid "Subscribe" msgstr "" #. placeholder {0}: highlightedPublisher.name -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:425 -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:434 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:426 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:435 msgid "Subscribe on {0}" msgstr "Subscribe on {0}" #. placeholder {0}: highlightedPublisher.name -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:433 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:434 msgid "Subscribe to {publicationTitle} on {0}" msgstr "Subscribe to {publicationTitle} on {0}" @@ -10985,7 +10985,7 @@ msgstr "" msgid "Tap below to allow Bluesky to access your GPS location. We will then use that data to more accurately determine the content and features available in your region." msgstr "" -#: src/components/dms/MessageItem.tsx:598 +#: src/components/dms/MessageItem.tsx:567 msgid "Tap for details" msgstr "Tap for details" @@ -11015,29 +11015,29 @@ msgstr "" msgid "Tap to dismiss" msgstr "" -#: src/components/dms/ReactionsDialog.tsx:192 +#: src/components/dms/ReactionsDialog.tsx:196 msgid "Tap to remove" msgstr "Tap to remove" #. placeholder {0}: reaction.value -#: src/components/dms/ReactionsDialog.tsx:208 +#: src/components/dms/ReactionsDialog.tsx:212 msgid "Tap to remove your {0} reaction" msgstr "Tap to remove your {0} reaction" -#: src/components/dms/MessageItem.tsx:563 +#: src/components/dms/MessageItem.tsx:532 msgid "Tap to retry" msgstr "Tap to retry" #. placeholder {0}: tab.value -#: src/components/dms/ReactionsDialog.tsx:354 +#: src/components/dms/ReactionsDialog.tsx:358 msgid "Tap to show {0} reactions" msgstr "Tap to show {0} reactions" -#: src/components/dms/ReactionsDialog.tsx:353 +#: src/components/dms/ReactionsDialog.tsx:357 msgid "Tap to show all reactions" msgstr "Tap to show all reactions" -#: src/components/dms/MessageItem.tsx:317 +#: src/components/dms/MessageItem.tsx:293 msgid "Tap to view reactions" msgstr "Tap to view reactions" @@ -11427,7 +11427,7 @@ msgstr "" msgid "This author has chosen to make their posts visible only to people who are signed in." msgstr "" -#: src/screens/Profile/components/GermButton.tsx:243 +#: src/screens/Profile/components/GermButton.tsx:244 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 "" @@ -11561,7 +11561,7 @@ msgstr "" msgid "This is not a valid link" msgstr "" -#: src/screens/Settings/AutomationLabelSettings.tsx:169 +#: src/screens/Settings/AutomationLabelSettings.tsx:173 msgid "This label lets the world know that this account is automated. If turned on, this label appears next to the account's name on their profile and posts. It can be turned on or off at any time." msgstr "This label lets the world know that this account is automated. If turned on, this label appears next to the account's name on their profile and posts. It can be turned on or off at any time." @@ -11594,13 +11594,13 @@ msgstr "" msgid "This list is empty." msgstr "" -#: src/components/dms/MessageItem.tsx:596 -#: src/components/dms/MessageItem.tsx:625 +#: src/components/dms/MessageItem.tsx:565 +#: src/components/dms/MessageItem.tsx:594 msgid "This message is hidden because this user is blocking you." msgstr "This message is hidden because this user is blocking you." -#: src/components/dms/MessageItem.tsx:595 -#: src/components/dms/MessageItem.tsx:621 +#: src/components/dms/MessageItem.tsx:564 +#: src/components/dms/MessageItem.tsx:590 msgid "This message is hidden because you are blocking this user." msgstr "This message is hidden because you are blocking this user." @@ -11835,8 +11835,8 @@ msgstr "" msgid "Topic" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:162 -#: src/components/dms/MessageContextMenu.tsx:165 +#: src/components/dms/MessageContextMenu.tsx:145 +#: src/components/dms/MessageContextMenu.tsx:148 #: src/components/Post/Translated/index.tsx:150 #: src/components/Post/Translated/index.tsx:157 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:553 @@ -11887,7 +11887,7 @@ msgstr "" msgid "Trolling" msgstr "" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:140 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:142 msgid "Trust emerges from relationships, communities, and shared context, so we’re also enabling <0>trusted verifiers: organizations that can directly issue verification." msgstr "" @@ -11980,7 +11980,7 @@ msgstr "" msgid "Unavailable feed information" msgstr "" -#: src/components/dms/MessageItem.tsx:663 +#: src/components/dms/MessageItem.tsx:632 #: src/components/dms/MessagesListBlockedFooter.tsx:97 #: src/components/dms/MessagesListBlockedFooter.tsx:104 #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:222 @@ -12026,8 +12026,8 @@ msgstr "" #: src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx:79 #: src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx:40 #: src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx:46 -#: src/screens/Profile/components/GermButton.tsx:185 #: src/screens/Profile/components/GermButton.tsx:186 +#: src/screens/Profile/components/GermButton.tsx:187 msgid "Undo" msgstr "" @@ -12061,7 +12061,7 @@ msgstr "" msgid "Unfollows the user" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:490 +#: src/components/moderation/ReportDialog/index.tsx:492 msgid "Unfortunately, none of your subscribed labelers supports this report type." msgstr "" @@ -12287,22 +12287,22 @@ msgstr "" msgid "Upload a text file to:" msgstr "" -#: src/view/com/util/UserAvatar.tsx:490 -#: src/view/com/util/UserAvatar.tsx:493 -#: src/view/com/util/UserBanner.tsx:160 -#: src/view/com/util/UserBanner.tsx:163 +#: src/view/com/util/UserAvatar.tsx:491 +#: src/view/com/util/UserAvatar.tsx:494 +#: src/view/com/util/UserBanner.tsx:161 +#: src/view/com/util/UserBanner.tsx:164 msgid "Upload from Camera" msgstr "" -#: src/view/com/util/UserAvatar.tsx:507 -#: src/view/com/util/UserBanner.tsx:177 +#: src/view/com/util/UserAvatar.tsx:508 +#: src/view/com/util/UserBanner.tsx:178 msgid "Upload from Files" msgstr "" -#: src/view/com/util/UserAvatar.tsx:501 -#: src/view/com/util/UserAvatar.tsx:505 -#: src/view/com/util/UserBanner.tsx:171 -#: src/view/com/util/UserBanner.tsx:175 +#: src/view/com/util/UserAvatar.tsx:502 +#: src/view/com/util/UserAvatar.tsx:506 +#: src/view/com/util/UserBanner.tsx:172 +#: src/view/com/util/UserBanner.tsx:176 msgid "Upload from Library" msgstr "" @@ -12363,7 +12363,7 @@ msgid "user" msgstr "user" #: src/components/dms/AfterReportConversationDialog.tsx:187 -#: src/components/dms/AfterReportDialog.tsx:150 +#: src/components/dms/AfterReportDialog.tsx:155 msgctxt "toast" msgid "User blocked" msgstr "" @@ -12631,8 +12631,8 @@ msgid "View" msgstr "" #. placeholder {0}: view.source.title -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:319 -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:588 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:320 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:589 msgid "View {0}" msgstr "View {0}" @@ -12661,7 +12661,7 @@ msgstr "View {0}’s profile" msgid "View {displayName}’s profile" msgstr "View {displayName}’s profile" -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:436 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:437 msgid "View {publicationTitle}" msgstr "View {publicationTitle}" @@ -12728,10 +12728,10 @@ msgstr "" msgid "View profile banner" msgstr "View profile banner" -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:319 -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:426 -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:437 -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:588 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:320 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:427 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:438 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:589 msgid "View publication" msgstr "View publication" @@ -12785,8 +12785,8 @@ msgstr "" msgid "View your verifications" msgstr "" -#: src/components/images/AutoSizedImage.tsx:220 -#: src/components/images/AutoSizedImage.tsx:252 +#: src/components/images/AutoSizedImage.tsx:221 +#: src/components/images/AutoSizedImage.tsx:253 msgid "Views full image" msgstr "" @@ -12829,8 +12829,8 @@ msgstr "" msgid "Warn content and filter from feeds" msgstr "" -#: src/features/liveNow/components/LiveStatusDialog.tsx:189 -#: src/features/liveNow/components/LiveStatusDialog.tsx:198 +#: src/features/liveNow/components/LiveStatusDialog.tsx:190 +#: src/features/liveNow/components/LiveStatusDialog.tsx:199 msgid "Watch now" msgstr "" @@ -12977,7 +12977,7 @@ msgstr "" msgid "We’re having network issues, try again" msgstr "We’re having network issues, try again" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:96 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:97 msgid "We’re introducing a new layer of verification on Bluesky — an easy-to-see checkmark." msgstr "" @@ -13063,7 +13063,7 @@ msgstr "" msgid "What's up?" msgstr "" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:148 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:150 msgid "When you tap on a check, you’ll see which organizations have granted verification." msgstr "" @@ -13080,7 +13080,7 @@ msgstr "Who can join this group chat and how" msgid "Who can reply" msgstr "" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:129 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:131 msgid "Who can verify?" msgstr "" @@ -13132,7 +13132,7 @@ msgstr "" msgid "Why should this user be reviewed?" msgstr "" -#: src/components/dms/AfterReportDialog.tsx:46 +#: src/components/dms/AfterReportDialog.tsx:51 msgid "Would you like to block this user and/or delete this conversation?" msgstr "" @@ -13224,7 +13224,7 @@ msgid "You are accessing Bluesky from a region that legally requires us to verif msgstr "" #. placeholder {0}: sanitizeHandle(profile.handle, '@') -#: src/components/dms/MessageItem.tsx:636 +#: src/components/dms/MessageItem.tsx:605 msgid "You are blocking {0}" msgstr "You are blocking {0}" @@ -13316,7 +13316,7 @@ msgstr "" msgid "You can continue ongoing conversations regardless of which setting you choose." msgstr "" -#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:149 +#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:150 msgid "You can now choose to be notified when specific people post. If there’s someone you want timely updates from, go to their profile and find the new bell icon near the follow button." msgstr "" @@ -13552,7 +13552,7 @@ msgid "You probably want to restart the app now." msgstr "" #. placeholder {0}: reaction.value -#: src/components/dms/MessageItem.tsx:287 +#: src/components/dms/MessageItem.tsx:263 msgid "You reacted {0}" msgstr "" @@ -13841,7 +13841,7 @@ msgstr "" msgid "Your preferred language" msgstr "" -#: src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx:100 +#: src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx:102 #: src/screens/Onboarding/StepFinished/ValuePropositionPager.web.tsx:53 msgid "Your profile picture" msgstr "" @@ -13859,7 +13859,7 @@ msgid "Your reply was sent" msgstr "" #. placeholder {0}: state.selectedLabeler?.creator.displayName -#: src/components/moderation/ReportDialog/index.tsx:517 +#: src/components/moderation/ReportDialog/index.tsx:519 msgid "Your report will be sent to <0>{0}." msgstr "" From a19d652a39b4e483a30a409c37a7d74b6552f7c6 Mon Sep 17 00:00:00 2001 From: surfdude29 <149612116+surfdude29@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:50:56 +0100 Subject: [PATCH 03/19] Fix invalid `zero` plural category and add plural formatting (#10722) --- src/screens/Messages/JoinRequests.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/screens/Messages/JoinRequests.tsx b/src/screens/Messages/JoinRequests.tsx index 80d31299dc..98952aee34 100644 --- a/src/screens/Messages/JoinRequests.tsx +++ b/src/screens/Messages/JoinRequests.tsx @@ -411,7 +411,6 @@ function Header({ count?: number hasMoreRequests?: boolean }) { - const {t: l} = useLingui() return ( @@ -420,15 +419,15 @@ function Header({ {count === undefined ? ( Requests to join ) : hasMoreRequests ? ( - l({ - message: `${count}+ requests to join`, - comment: - 'Displayed when there are more requests to join a group chat than have been loaded', - }) + ) : ( From 9f200aab05e8f71f8e4fb74f59a9b400e89f8964 Mon Sep 17 00:00:00 2001 From: surfdude29 <149612116+surfdude29@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:51:51 +0100 Subject: [PATCH 04/19] Use correct `Plural` macro in InviteLinkDialog (#10723) --- src/screens/Messages/components/InviteLinkDialog.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/screens/Messages/components/InviteLinkDialog.tsx b/src/screens/Messages/components/InviteLinkDialog.tsx index ad6d4a08e5..6e898a0a9c 100644 --- a/src/screens/Messages/components/InviteLinkDialog.tsx +++ b/src/screens/Messages/components/InviteLinkDialog.tsx @@ -5,8 +5,7 @@ import { moderateProfile, type ModerationOpts, } from '@atproto/api' -import {plural} from '@lingui/core/macro' -import {Trans, useLingui} from '@lingui/react/macro' +import {Plural, Trans, useLingui} from '@lingui/react/macro' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' @@ -178,11 +177,7 @@ export function InviteLinkDialog({ Group chats can only have a maximum of{' '} - {plural(convo.details.memberLimit, { - one: '# person', - other: '# people', - })} - . + . From 49f8e5031f42762805b1ab392c68e7bf75812e9d Mon Sep 17 00:00:00 2001 From: heikadog <41552394+heikadog@users.noreply.github.com> Date: Thu, 4 Jun 2026 06:28:40 -0400 Subject: [PATCH 05/19] Fix labeller highlight when selected on bottom bar (#10716) --- src/view/shell/bottom-bar/BottomBar.tsx | 5 +++-- src/view/shell/bottom-bar/BottomBarStyles.tsx | 9 +++++++++ src/view/shell/bottom-bar/BottomBarWeb.tsx | 9 +++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index e6a1eeaad5..605542749e 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -144,6 +144,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { const [demoMode] = useDemoMode() const {isActive: live} = useActorStatus(profile) + const isLabeler = profile?.associated?.labeler return ( <> @@ -276,9 +277,9 @@ export function BottomBar({navigation}: BottomTabBarProps) { { closeAllActiveElements() @@ -186,9 +187,13 @@ export function BottomBarWeb() { From c79b5bfa08d6ddeb909dab0c0b392e5123d9c5bb Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 4 Jun 2026 09:29:36 -0500 Subject: [PATCH 06/19] Disable overscroll on Android to fix settle discrepancy (#10717) --- src/components/images/Gallery/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index 9ae677c2d2..fd18cb8a0b 100644 --- a/src/components/images/Gallery/index.tsx +++ b/src/components/images/Gallery/index.tsx @@ -40,7 +40,7 @@ import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu' import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' -import {IS_WEB} from '#/env' +import {IS_ANDROID, IS_WEB} from '#/env' export * from './const' export * from './maybeApplyGalleryOffsetStyles' @@ -264,6 +264,9 @@ export function Gallery({ aria-label={l`Image gallery, ${images.length} images`} horizontal pagingEnabled={false} + // Disable Android's stretch overscroll, which can leave the carousel + // settled just off the left edge instead of aligned to x = 0 + overScrollMode={IS_ANDROID ? 'never' : 'auto'} showsHorizontalScrollIndicator={false} directionalLockEnabled nestedScrollEnabled From 08cd4e58b023e1ffe2b3681f4f8c690a24af84a0 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:11:50 -0700 Subject: [PATCH 07/19] Add group chat join links to supported embed types (#10454) --- __tests__/lib/strings/url-helpers.test.ts | 45 +++ eslint-suppressions.json | 8 - src/components/Post/Embed/ChatInviteEmbed.tsx | 41 +++ .../Post/Embed/JoinRequestEmbed.tsx | 271 ++++++++++++++++++ src/components/Post/Embed/index.tsx | 17 ++ .../intents/GroupChatJoinDialog.tsx | 10 +- src/lib/api/index.ts | 18 +- src/lib/api/resolve.ts | 25 +- src/lib/strings/url-helpers.ts | 5 +- src/state/queries/join-links.ts | 6 +- src/view/com/composer/ExternalEmbed.tsx | 3 + .../com/composer/ExternalEmbedRemoveBtn.tsx | 7 +- 12 files changed, 434 insertions(+), 22 deletions(-) create mode 100644 src/components/Post/Embed/ChatInviteEmbed.tsx create mode 100644 src/components/Post/Embed/JoinRequestEmbed.tsx diff --git a/__tests__/lib/strings/url-helpers.test.ts b/__tests__/lib/strings/url-helpers.test.ts index 0b1b750281..23ffaa2875 100644 --- a/__tests__/lib/strings/url-helpers.test.ts +++ b/__tests__/lib/strings/url-helpers.test.ts @@ -1,6 +1,7 @@ import {describe, expect, it} from '@jest/globals' import { + getChatInviteCodeFromUrl, isPossiblyAUrl, isTrustedUrl, linkRequiresWarning, @@ -178,3 +179,47 @@ describe('isTrustedUrl', () => { expect(output).toEqual(expected) }) }) + +describe('getChatInviteCodeFromUrl', () => { + type Case = [string, string | undefined] + + const cases: Case[] = [ + ['https://bsky.app/c/abcdefg', 'abcdefg'], + ['https://bsky.app/c/abcdefghij', 'abcdefghij'], + // http is not recognized as a bsky.app url + ['http://bsky.app/c/abcdefg', undefined], + ['https://bsky.app/c/abcdefg?utm=foo', 'abcdefg'], + ['https://bsky.app/c/abcdefg#section', 'abcdefg'], + ['/c/abcdefg', 'abcdefg'], + ['/c/abcdefg?utm=foo', 'abcdefg'], + ['/c/abcdefg#section', 'abcdefg'], + + // too short + ['https://bsky.app/c/abcdef', undefined], + ['/c/abcdef', undefined], + // too long + ['https://bsky.app/c/abcdefghijk', undefined], + ['/c/abcdefghijk', undefined], + // invalid characters + ['https://bsky.app/c/abc-def', undefined], + ['/c/abc def', undefined], + // trailing path + ['https://bsky.app/c/abcdefg/extra', undefined], + ['/c/abcdefg/extra', undefined], + // wrong path + ['https://bsky.app/profile/abcdefg', undefined], + ['https://bsky.app/c', undefined], + // wrong host + ['https://example.com/c/abcdefg', undefined], + // not a url, not a path + ['c/abcdefg', undefined], + ['abcdefg', undefined], + ['', undefined], + // malformed url + ['https://[invalid/c/abcdefg', undefined], + ] + + it.each(cases)('given input %p, returns %p', (input, expected) => { + expect(getChatInviteCodeFromUrl(input)).toEqual(expected) + }) +}) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index f2e677dc79..d8555ace5b 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -811,14 +811,6 @@ "count": 1 } }, - "src/lib/api/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 3 - } - }, "src/lib/async/retry.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 diff --git a/src/components/Post/Embed/ChatInviteEmbed.tsx b/src/components/Post/Embed/ChatInviteEmbed.tsx new file mode 100644 index 0000000000..744fcfb7fa --- /dev/null +++ b/src/components/Post/Embed/ChatInviteEmbed.tsx @@ -0,0 +1,41 @@ +import {type StyleProp, type ViewStyle} from 'react-native' +import {type AppBskyEmbedExternal} from '@atproto/api' + +import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links' +import {useSession} from '#/state/session' +import {atoms as a} from '#/alf' +import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed' +import {JoinRequestEmbed} from '#/components/Post/Embed/JoinRequestEmbed' + +export function ChatInviteEmbed({ + code, + link, + onOpen, + style, +}: { + code: string + link: AppBskyEmbedExternal.ViewExternal + onOpen?: () => void + style?: StyleProp +}) { + const {hasSession} = useSession() + const {data, error, isPending} = useJoinLinkPreviewsQuery({ + codes: [code], + hasSession, + }) + + const preview = data?.joinLinkPreviews[0] + + if (error) { + return + } + + return ( + + ) +} diff --git a/src/components/Post/Embed/JoinRequestEmbed.tsx b/src/components/Post/Embed/JoinRequestEmbed.tsx new file mode 100644 index 0000000000..77a0223bec --- /dev/null +++ b/src/components/Post/Embed/JoinRequestEmbed.tsx @@ -0,0 +1,271 @@ +import {type StyleProp, View, type ViewStyle} from 'react-native' +import {type ChatBskyGroupDefs} from '@atproto/api' +import {Plural, Trans, useLingui} from '@lingui/react/macro' +import {useNavigation} from '@react-navigation/native' + +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {type NavigationProp} from '#/lib/routes/types' +import {sanitizeHandle} from '#/lib/strings/handles' +import {atoms as a, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' +import { + Button, + type ButtonColor, + ButtonIcon, + ButtonText, +} from '#/components/Button' +import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' +import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight' +import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' +import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import {useIntentDialogs} from '#/components/intents/IntentDialogs' +import {Loader} from '#/components/Loader' +import {ProfileBadges} from '#/components/ProfileBadges' +import {Text} from '#/components/Typography' + +const JOIN_REQUEST_EMBED_HEIGHT = 152 + +export function JoinRequestEmbed({ + loading = false, + preview, + style, + onOpen, +}: { + loading?: boolean + preview?: ChatBskyGroupDefs.JoinLinkPreviewView + style?: StyleProp + onOpen?: () => void +}) { + const t = useTheme() + + if (loading) { + return ( + + + + ) + } + + if (!preview) { + return ( + + + + Chat invite link no longer available + + + ) + } + + return ( + + ) +} + +function JoinRequestEmbedInner({ + preview, + style, + onOpen, +}: { + preview: ChatBskyGroupDefs.JoinLinkPreviewView + style?: StyleProp + onOpen?: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const navigation = useNavigation() + + const {groupChatJoinDialogControl, setGroupChatJoinState} = useIntentDialogs() + + const ownerDisplayName = createSanitizedDisplayName(preview.owner) + const ownerHandle = sanitizeHandle(preview.owner.handle, '@') + + const avatarProfiles = preview.convo?.members ?? [preview.owner] + + const convoId = preview.convo?.id + const isFollowing = preview.owner.viewer?.following ?? false + const hasRequested = !convoId && preview.viewer?.requestedAt != null + + let canJoin = true + let ButtonIconImage = JoinIcon + let buttonText = preview.requireApproval ? l`Request to join` : l`Join` + let buttonColor: ButtonColor = 'primary' + if (preview.enabledStatus !== 'enabled') { + canJoin = false + ButtonIconImage = WarningIcon + buttonText = l`Chat invite link no longer available` + buttonColor = 'secondary' + } else if (preview.memberCount >= preview.memberLimit) { + canJoin = false + ButtonIconImage = HandIcon + buttonText = l`This chat is full` + buttonColor = 'secondary' + } else if (preview.joinRule === 'followedByOwner' && !isFollowing) { + canJoin = false + ButtonIconImage = HandIcon + buttonText = l`Only people the chat owner follows can join` + buttonColor = 'secondary' + } else if (hasRequested) { + ButtonIconImage = CheckIcon + buttonText = l`Requested` + buttonColor = 'secondary' + } + + return ( + + + + + + {preview.name} + + + + Group chat + + + + {preview.memberCount}/{preview.memberLimit}{' '} + + + + + + + + By{' '} + + {ownerDisplayName} + + + + + + {ownerHandle} + + + + + {convoId ? ( + + ) : ( + + )} + + ) +} diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index 054558ad83..ab6f5dc439 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -12,6 +12,7 @@ import {Trans} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {makeProfileLink} from '#/lib/routes/links' +import {getChatInviteCodeFromUrl} from '#/lib/strings/url-helpers' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {unstableCacheProfileView} from '#/state/queries/profile' import {useSession} from '#/state/session' @@ -33,6 +34,7 @@ import { type EmbedType, parseEmbed, } from '#/types/bsky/post' +import {ChatInviteEmbed} from './ChatInviteEmbed' import {ExternalEmbed} from './ExternalEmbed' import {ModeratedFeedEmbed} from './FeedEmbed' import {ImageEmbed} from './ImageEmbed' @@ -110,6 +112,21 @@ function MediaEmbed({ ) } + const chatInviteCode = getChatInviteCodeFromUrl(embed.view.external.uri) + if (chatInviteCode) { + return ( + + + + ) + } return ( - Open chat + + Open chat + ) : ( @@ -416,8 +420,8 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { } accessibilityHint={ joinLinkPreview.requireApproval - ? l`Request access to join this group chat` - : l`Join this group chat` + ? l`Tap to request access to join this group chat` + : l`Tap to join this group chat immediately` } size="large" color={buttonColor} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index d9a026ca83..c5f6cc6eb5 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -177,7 +177,8 @@ export async function post( writes: writes, validate: true, }) - } catch (e: any) { + } catch (err) { + const e = err as Error logger.error(`Failed to create post`, { safeMessage: e.message, }) @@ -427,6 +428,16 @@ async function resolveMedia( }, } } + if (resolvedLink.type === 'chat-invite' && resolvedLink.view) { + return { + $type: 'app.bsky.embed.external', + external: { + uri: resolvedLink.uri, + title: resolvedLink.view.name, + description: `${resolvedLink.view.memberCount}/${resolvedLink.view.memberLimit}`, + }, + } + } } return undefined } @@ -470,6 +481,7 @@ async function computeCid(record: AppBskyFeedPost.Record): Promise { } // Returns a transformed version of the object for use in DAG-CBOR. +// eslint-disable-next-line @typescript-eslint/no-explicit-any function prepareForHashing(v: any): any { // IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing, // the API client will convert this for you but we're hashing in the client, @@ -492,9 +504,10 @@ function prepareForHashing(v: any): any { // Walk through plain objects if (isPlainObject(v)) { - const obj: any = {} + const obj: Record = {} let pure = true for (const key in v) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access let value = v[key] // `value` is undefined if (value === undefined) { @@ -513,6 +526,7 @@ function prepareForHashing(v: any): any { return v } +// eslint-disable-next-line @typescript-eslint/no-explicit-any function isPlainObject(v: any): boolean { if (typeof v !== 'object' || v === null) { return false diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index f75cd74db2..d411bd8204 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -2,11 +2,12 @@ import { type AppBskyFeedDefs, type AppBskyGraphDefs, type BskyAgent, + type ChatBskyGroupDefs, type ComAtprotoRepoStrongRef, } from '@atproto/api' import {AtUri} from '@atproto/api' -import {POST_IMG_MAX} from '#/lib/constants' +import {DM_SERVICE_HEADERS, POST_IMG_MAX} from '#/lib/constants' import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' import {resolveShortLink} from '#/lib/link-meta/resolve-short-link' import {downloadAndResize} from '#/lib/media/manip' @@ -16,6 +17,7 @@ import { } from '#/lib/strings/starter-pack' import { convertBskyAppUrlIfNeeded, + getChatInviteCodeFromUrl, isBskyCustomFeedUrl, isBskyListUrl, isBskyPostUrl, @@ -71,12 +73,20 @@ type ResolvedStarterPackRecord = { view: AppBskyGraphDefs.StarterPackView } +type ResolvedChatInvite = { + type: 'chat-invite' + uri: string + code: string + view?: ChatBskyGroupDefs.JoinLinkPreviewView +} + export type ResolvedLink = | ResolvedExternalLink | ResolvedPostRecord | ResolvedFeedRecord | ResolvedListRecord | ResolvedStarterPackRecord + | ResolvedChatInvite export class EmbeddingDisabledError extends Error { constructor() { @@ -141,6 +151,19 @@ export async function resolveLink( view: res.data.list, } } + const chatInviteCode = getChatInviteCodeFromUrl(uri) + if (chatInviteCode) { + const res = await agent.chat.bsky.group.getJoinLinkPreviews( + {codes: [chatInviteCode]}, + {headers: DM_SERVICE_HEADERS}, + ) + return { + type: 'chat-invite', + uri, + code: chatInviteCode, + view: res.data.joinLinkPreviews[0], + } + } if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) { const parsed = parseStarterPackUri(uri) if (!parsed) { diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index b5c278dacf..fd1a5ef7d9 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -1,5 +1,5 @@ import {AtUri} from '@atproto/api' -import psl from 'psl' +import {parse} from 'psl' import TLDs from 'tlds' import {BSKY_SERVICE} from '#/lib/constants' @@ -178,6 +178,7 @@ export function isBskyStarterPackUrl(url: string): boolean { return false } +// Invite codes are 7 alphanumeric characters long, supporting up to 10 here to future-proof. export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/ export function getChatInviteCodeFromUrl(url: string): string | undefined { @@ -328,7 +329,7 @@ export function isPossiblyAUrl(str: string): boolean { } export function splitApexDomain(hostname: string): [string, string] { - const hostnamep = psl.parse(hostname) + const hostnamep = parse(hostname) if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) { return ['', hostname] } diff --git a/src/state/queries/join-links.ts b/src/state/queries/join-links.ts index 6aa10331ef..99a37b6b06 100644 --- a/src/state/queries/join-links.ts +++ b/src/state/queries/join-links.ts @@ -20,9 +20,11 @@ export const createJoinLinkPreviewQueryKey = (args: { export function useJoinLinkPreviewsQuery({ codes, hasSession, + staleTime = STALE.MINUTES.ONE, }: { codes?: string[] hasSession: boolean + staleTime?: number }) { const agent = useAgent() @@ -45,7 +47,7 @@ export function useJoinLinkPreviewsQuery({ } }, enabled: codes != null && codes.length > 0, - staleTime: STALE.SECONDS.FIFTEEN, + staleTime, }) } @@ -66,7 +68,7 @@ export function usePrefetchJoinLinkPreviews() { : await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes}) return res.data }, - staleTime: STALE.SECONDS.FIFTEEN, + staleTime: STALE.MINUTES.ONE, }) } } diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index ecd22d7c39..5cc87c63f5 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -11,6 +11,7 @@ import {atoms as a, useTheme} from '#/alf' import {Loader} from '#/components/Loader' import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed' import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed' +import {JoinRequestEmbed} from '#/components/Post/Embed/JoinRequestEmbed' import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed' import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed' import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils' @@ -115,6 +116,8 @@ export const ExternalEmbedLink = ({ hideAlt /> ) + } else if (data.type === 'chat-invite') { + return } else if (data.kind === 'feed') { return ( void} & ViewStyleProp) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() return ( - ) : ( - - )} + + ) } diff --git a/src/components/dms/ChatInvite/Card.tsx b/src/components/dms/ChatInvite/Card.tsx new file mode 100644 index 0000000000..518371325e --- /dev/null +++ b/src/components/dms/ChatInvite/Card.tsx @@ -0,0 +1,90 @@ +import {View} from 'react-native' +import {Plural, Trans} from '@lingui/react/macro' + +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {sanitizeHandle} from '#/lib/strings/handles' +import {atoms as a, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' +import {ProfileBadges} from '#/components/ProfileBadges' +import {Text} from '#/components/Typography' +import {useChatInvite} from './Context' + +/** + * Presentational preview of a chat invite: member avatars, group name, member + * count, and owner. Reads the preview from `ChatInvite.Root` context. Renders + * nothing if there's no preview (use a fallback alongside it for that case). + */ +export function Card({size}: {size: 'large' | 'small'}) { + const t = useTheme() + const {preview} = useChatInvite() + + if (!preview) return null + + const ownerDisplayName = createSanitizedDisplayName(preview.owner) + const ownerHandle = sanitizeHandle(preview.owner.handle, '@') + const avatarProfiles = preview.convo?.members ?? [preview.owner] + + return ( + + + + + {preview.name} + + + + Group chat + + + + {preview.memberCount}/{preview.memberLimit}{' '} + + + + + + + + By {ownerDisplayName} + + + + + {ownerHandle} + + + + + ) +} diff --git a/src/components/dms/ChatInvite/Context.tsx b/src/components/dms/ChatInvite/Context.tsx new file mode 100644 index 0000000000..53c7a6cc22 --- /dev/null +++ b/src/components/dms/ChatInvite/Context.tsx @@ -0,0 +1,47 @@ +import {createContext, useContext} from 'react' +import {type ChatBskyGroupDefs} from '@atproto/api' + +import {type ButtonColor} from '#/components/Button' +import {type Props as SVGIconProps} from '#/components/icons/common' + +/** + * The derived state of the join/open action for a chat invite, computed once in + * `Root` and consumed by `JoinButton` (or any custom action UI). + */ +export type ChatInviteAction = { + label: string + accessibilityHint: string + icon: React.ComponentType + color: ButtonColor + /** + * Whether the action can be performed. False when the link is disabled, the + * chat is full, or the viewer doesn't meet the join rule. + */ + disabled: boolean + onPress: () => void + side: 'left' | 'right' +} + +export type ChatInviteContextValue = { + code: string + loading: boolean + error: boolean + preview: ChatBskyGroupDefs.JoinLinkPreviewView | undefined + /** + * The derived action descriptor. Undefined while loading or when there's no + * preview to act on. + */ + action: ChatInviteAction | undefined +} + +const ChatInviteContext = createContext(null) + +export function useChatInvite(): ChatInviteContextValue { + const ctx = useContext(ChatInviteContext) + if (!ctx) { + throw new Error('useChatInvite must be used within a ChatInvite.Root') + } + return ctx +} + +export const ChatInviteProvider = ChatInviteContext.Provider diff --git a/src/components/dms/ChatInvite/JoinButton.tsx b/src/components/dms/ChatInvite/JoinButton.tsx new file mode 100644 index 0000000000..0036391b0e --- /dev/null +++ b/src/components/dms/ChatInvite/JoinButton.tsx @@ -0,0 +1,42 @@ +import {type StyleProp, type ViewStyle} from 'react-native' + +import {atoms as a} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useChatInvite} from './Context' + +/** + * The join/open action button for a chat invite. Reads the derived action from + * `ChatInvite.Root` context. Pass `onPress` to intercept (e.g. to close a + * surface before navigating); it runs before the default action. Renders + * nothing while loading or when there's no preview to act on. + */ +export function JoinButton({ + onPress, + style, +}: { + onPress?: () => void + style?: StyleProp +}) { + const {action} = useChatInvite() + + if (!action) return null + + return ( + + ) +} diff --git a/src/components/dms/ChatInvite/Root.tsx b/src/components/dms/ChatInvite/Root.tsx new file mode 100644 index 0000000000..a9f2c54e3a --- /dev/null +++ b/src/components/dms/ChatInvite/Root.tsx @@ -0,0 +1,144 @@ +import {setStringAsync} from 'expo-clipboard' +import {type ChatBskyGroupDefs} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' +import {useNavigation} from '@react-navigation/native' + +import {type NavigationProp} from '#/lib/routes/types' +import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links' +import {useSession} from '#/state/session' +import {type ButtonColor} from '#/components/Button' +import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' +import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight' +import {ChainLink_Stroke2_Corner0_Rounded as LinkIcon} from '#/components/icons/ChainLink' +import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import {useIntentDialogs} from '#/components/intents/IntentDialogs' +import * as Toast from '#/components/Toast' +import {type ChatInviteAction, ChatInviteProvider} from './Context' + +/** + * Headless data + state owner for a chat invite. Fetches the join link preview + * by code and derives the join/open action, exposing both via context for the + * composable parts (`Card`, `JoinButton`) or any custom UI to consume. + * + * Pass `initialPreview` when the preview is already known (e.g. a DM message + * embed already carries the resolved view) to avoid a loading flash. + */ +export function Root({ + code, + initialPreview, + currentConvoId, + children, +}: { + code: string + initialPreview?: ChatBskyGroupDefs.JoinLinkPreviewView + /** + * The convo this invite is being viewed within, if any. When the invite + * links to the same chat, the action becomes "Copy link" instead of + * open/join (you're already here). + */ + currentConvoId?: string + children: React.ReactNode +}) { + const {hasSession} = useSession() + const {t: l} = useLingui() + const navigation = useNavigation() + const {groupChatJoinDialogControl, setGroupChatJoinState} = useIntentDialogs() + + const {data, error, isPending} = useJoinLinkPreviewsQuery({ + codes: [code], + hasSession, + // Seed the cache with the already-resolved preview so we don't refetch. + initialData: initialPreview + ? {joinLinkPreviews: [initialPreview]} + : undefined, + }) + + const preview = data?.joinLinkPreviews[0] + const loading = isPending && !preview + + let action: ChatInviteAction | undefined + if (preview) { + const convoId = preview.convo?.id + const isFollowing = preview.owner.viewer?.following ?? false + const hasRequested = !convoId && preview.viewer?.requestedAt != null + + if (convoId && convoId === currentConvoId) { + // You're already in the chat this invite links to - offer to copy the + // link rather than open/join. + action = { + label: l`Copy link`, + accessibilityHint: l`Tap to copy this invite link`, + icon: LinkIcon, + side: 'left', + color: 'primary', + disabled: false, + onPress: () => { + void setStringAsync(`https://bsky.app/c/${preview.code}`) + Toast.show(l`Copied to clipboard`, {type: 'success'}) + }, + } + } else if (convoId) { + action = { + label: l`Open chat`, + accessibilityHint: l`Tap to open this group chat`, + icon: ArrowRightIcon, + side: 'right', + color: 'primary', + disabled: false, + onPress: () => { + navigation.push('MessagesConversation', {conversation: convoId}) + }, + } + } else { + let canJoin = true + let icon: React.ComponentType = JoinIcon + let label = preview.requireApproval ? l`Request to join` : l`Join` + let color: ButtonColor = 'primary' + if (preview.enabledStatus !== 'enabled') { + canJoin = false + icon = WarningIcon + label = l`Chat invite link no longer available` + color = 'secondary' + } else if (preview.memberCount >= preview.memberLimit) { + canJoin = false + icon = HandIcon + label = l`This chat is full` + color = 'secondary' + } else if (preview.joinRule === 'followedByOwner' && !isFollowing) { + canJoin = false + icon = HandIcon + label = l`Only people the chat owner follows can join` + color = 'secondary' + } else if (hasRequested) { + icon = CheckIcon + label = l`Requested` + color = 'secondary' + } + + action = { + label, + side: 'left', + accessibilityHint: preview.requireApproval + ? l`Tap to request access to join this group chat` + : l`Tap to join this group chat immediately`, + icon, + color, + disabled: !canJoin, + onPress: () => { + setGroupChatJoinState({code: preview.code}) + groupChatJoinDialogControl.open() + }, + } + } + } + + return ( + + {children} + + ) +} diff --git a/src/components/dms/ChatInvite/index.tsx b/src/components/dms/ChatInvite/index.tsx new file mode 100644 index 0000000000..bcea3f7651 --- /dev/null +++ b/src/components/dms/ChatInvite/index.tsx @@ -0,0 +1,8 @@ +export {Card} from './Card' +export { + type ChatInviteAction, + type ChatInviteContextValue, + useChatInvite, +} from './Context' +export {JoinButton} from './JoinButton' +export {Root} from './Root' diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index ac6acb5891..f4f3cdacc3 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -20,6 +20,7 @@ import { AppBskyEmbedRecord, type ChatBskyActorDefs, ChatBskyConvoDefs, + ChatBskyEmbedJoinLink, RichText as RichTextAPI, } from '@atproto/api' import {plural} from '@lingui/core/macro' @@ -49,6 +50,7 @@ import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import {DateDivider} from './DateDivider' import {MessageItemEmbed} from './MessageItemEmbed' +import {MessageItemInviteEmbed} from './MessageItemInviteEmbed' import {groupReactions} from './ReactionsDialog' import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util' @@ -185,8 +187,10 @@ let MessageItem = ({ const rt = new RichTextAPI({text: message.text, facets: message.facets}) - const hasEmbedAndText = - AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 + const hasEmbed = + AppBskyEmbedRecord.isView(message.embed) || + ChatBskyEmbedJoinLink.isView(message.embed) + const hasEmbedAndText = hasEmbed && rt.text.length > 0 const targetBottomRadius = squaredBottomCorner ? SQUARED_BORDER_RADIUS @@ -427,6 +431,15 @@ let MessageItem = ({ squaredTopCorner={squaredTopCorner} /> )} + {ChatBskyEmbedJoinLink.isView(message.embed) && ( + + )} {rt.text.length > 0 && ( + isFromSelf: boolean + isGroupChat: boolean + squaredTopCorner: boolean + squaredBottomCorner: boolean +}): React.ReactNode => { + const t = useTheme() + const screen = useWindowDimensions() + const convo = useConvoActive() + + return ( + + + + + + + + + + + ) +} +MessageItemInviteEmbed = memo(MessageItemInviteEmbed) +export {MessageItemInviteEmbed} diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index 0aa132e232..c6899fb9d0 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -20,7 +20,7 @@ import {countGraphemes} from 'unicode-segmenter/grapheme' import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import {isBskyPostUrl} from '#/lib/strings/url-helpers' +import {isBskyChatInviteUrl, isBskyPostUrl} from '#/lib/strings/url-helpers' import {useEmail} from '#/state/email-verification' import { useMessageDraft, @@ -233,7 +233,11 @@ export function MessageComposer({ }} onChange={handleChange} onFacetCommitted={facet => { - if (facet.type === 'url' && isBskyPostUrl(facet.value)) { + if ( + facet.type === 'url' && + (isBskyPostUrl(facet.value) || + isBskyChatInviteUrl(facet.value)) + ) { setEmbed(facet.value) } }} diff --git a/src/screens/Messages/components/MessageInputEmbed.tsx b/src/screens/Messages/components/MessageInputEmbed.tsx index 5bc2f38a05..a73a6564fb 100644 --- a/src/screens/Messages/components/MessageInputEmbed.tsx +++ b/src/screens/Messages/components/MessageInputEmbed.tsx @@ -18,6 +18,8 @@ import { } from '#/lib/routes/types' import { convertBskyAppUrlIfNeeded, + getChatInviteCodeFromUrl, + isBskyChatInviteUrl, isBskyPostUrl, makeRecordUri, } from '#/lib/strings/url-helpers' @@ -26,6 +28,7 @@ import {usePostQuery} from '#/state/queries/post' import {PostMeta} from '#/view/com/util/PostMeta' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' +import * as ChatInvite from '#/components/dms/ChatInvite' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {Loader} from '#/components/Loader' import * as MediaPreview from '#/components/MediaPreview' @@ -35,35 +38,56 @@ import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import * as bsky from '#/types/bsky' +/** + * The embed staged in the message composer. A message can carry at most one + * embed: either a quoted post or a group chat invite link. + */ +export type MessageEmbedState = + | {type: 'post'; uri: string} + | {type: 'invite'; code: string} + export function useMessageEmbed() { const route = useRoute>() const navigation = useNavigation() const embedFromParams = route.params.embed - const [embedUri, setEmbedUri] = useState(embedFromParams) + const [embed, setEmbedState] = useState( + embedFromParams ? {type: 'post', uri: embedFromParams} : undefined, + ) - if (embedFromParams && embedUri !== embedFromParams) { - setEmbedUri(embedFromParams) + if (embedFromParams && embed?.type !== 'post') { + setEmbedState({type: 'post', uri: embedFromParams}) } return { - embedUri, + embed, setEmbed: useCallback( (embedUrl: string | undefined) => { if (!embedUrl) { + // Only the post embed is reflected in the route param (used by the + // share-to-DM intent flow); invites are local-only. navigation.setParams({embed: ''}) - setEmbedUri(undefined) + setEmbedState(undefined) return } if (embedFromParams) return - const url = convertBskyAppUrlIfNeeded(embedUrl) - const [_0, user, _1, rkey] = url.split('/').filter(Boolean) - const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) + if (isBskyChatInviteUrl(embedUrl)) { + const code = getChatInviteCodeFromUrl(embedUrl) + if (code) { + setEmbedState({type: 'invite', code}) + } + return + } - setEmbedUri(uri) + if (isBskyPostUrl(embedUrl)) { + const url = convertBskyAppUrlIfNeeded(embedUrl) + const [_0, user, _1, rkey] = url.split('/').filter(Boolean) + const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) + setEmbedState({type: 'post', uri}) + } }, [embedFromParams, navigation], ), @@ -81,7 +105,10 @@ export function useExtractEmbedFromFacets( for (const facet of rt.facets ?? []) { for (const feature of facet.features) { - if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) { + if ( + AppBskyRichtextFacet.isLink(feature) && + (isBskyPostUrl(feature.uri) || isBskyChatInviteUrl(feature.uri)) + ) { uriFromFacet = feature.uri break } @@ -96,16 +123,40 @@ export function useExtractEmbedFromFacets( } export function MessageInputEmbed({ - embedUri, + embed, setEmbed, }: { - embedUri: string | undefined + embed: MessageEmbedState | undefined setEmbed: (embedUrl: string | undefined) => void +}) { + const onRemove = useCallback(() => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setEmbed(undefined) + }, [setEmbed]) + + if (!embed) { + return null + } + + switch (embed.type) { + case 'post': + return + case 'invite': + return + } +} + +function MessageInputPostEmbed({ + uri, + onRemove, +}: { + uri: string + onRemove: () => void }) { const t = useTheme() const {t: l} = useLingui() - const {data: post, status} = usePostQuery(embedUri) + const {data: post, status} = usePostQuery(uri) const moderationOpts = useModerationOpts() const moderation = useMemo( @@ -134,15 +185,6 @@ export function MessageInputEmbed({ return {rt: undefined, record: undefined} }, [post]) - if (!embedUri) { - return null - } - - const onRemove = () => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) - setEmbed(undefined) - } - switch (status) { case 'pending': { return ( @@ -220,6 +262,71 @@ export function MessageInputEmbed({ } } +function MessageInputInviteEmbed({ + code, + onRemove, +}: { + code: string + onRemove: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + + + + ) +} + +function MessageInputInviteEmbedBody() { + const t = useTheme() + const {loading, preview} = ChatInvite.useChatInvite() + + if (loading) { + return ( + + + + ) + } + + if (!preview) { + return ( + + + Could not load invite + + + ) + } + + return +} + function SimpleContainer({ children, onRemove, diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index fbebdc0745..6f56c6a898 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -28,6 +28,7 @@ import { type AppBskyEmbedRecord, AppBskyRichtextFacet, ChatBskyConvoDefs, + type ChatBskyEmbedJoinLink, RichText, } from '@atproto/api' import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect' @@ -37,6 +38,7 @@ import {ScrollProvider} from '#/lib/ScrollContext' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import { convertBskyAppUrlIfNeeded, + getChatInviteCodeFromUrl, isBskyPostUrl, } from '#/lib/strings/url-helpers' import {logger} from '#/logger' @@ -46,9 +48,10 @@ import { useConvoActive, } from '#/state/messages/convo' import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types' +import {useGetJoinLinkPreview} from '#/state/queries/join-links' import {useGetPost} from '#/state/queries/post' import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util' -import {useAgent} from '#/state/session' +import {useAgent, useSession} from '#/state/session' import {List, type ListMethods} from '#/view/com/util/List' import {MessageComposer} from '#/screens/Messages/components/MessageComposer' import {MessageInput} from '#/screens/Messages/components/MessageInput' @@ -131,8 +134,10 @@ export function MessagesList({ const ax = useAnalytics() const convoState = useConvoActive() const agent = useAgent() + const {hasSession} = useSession() const getPost = useGetPost() - const {embedUri, setEmbed} = useMessageEmbed() + const getJoinLinkPreview = useGetJoinLinkPreview() + const {embed: messageEmbed, setEmbed} = useMessageEmbed() const t = useTheme() const textInputId = 'chat-input-' + useId() @@ -348,12 +353,38 @@ export function MessagesList({ // we want to remove the post link from the text, re-trim, then detect facets rt.detectFacetsWithoutResolution() - let embed: $Typed | undefined - let embedView: $Typed | undefined + let embed: + | $Typed + | $Typed + | undefined + let embedView: + | $Typed + | $Typed + | undefined - if (embedUri) { + // Find the embedded link facet and, if it's at the start or end of the + // message, remove it from the text (the embed card replaces it). + const stripLinkFacet = (predicate: (uri: string) => boolean) => { + const linkFacet = rt.facets?.find(facet => + facet.features.find( + feature => + AppBskyRichtextFacet.isLink(feature) && predicate(feature.uri), + ), + ) + if (linkFacet) { + const isAtStart = linkFacet.index.byteStart === 0 + const isAtEnd = + linkFacet.index.byteEnd === rt.unicodeText.graphemeLength + if (isAtStart || isAtEnd) { + rt.delete(linkFacet.index.byteStart, linkFacet.index.byteEnd) + } + rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true}) + } + } + + if (messageEmbed?.type === 'post') { try { - const post = await getPost({uri: embedUri}) + const post = await getPost({uri: messageEmbed.uri}) if (post) { embed = { $type: 'app.bsky.embed.record', @@ -368,42 +399,34 @@ export function MessagesList({ record: createEmbedViewRecordFromPost(post), } - // look for the embed uri in the facets, so we can remove it from the text - const postLinkFacet = rt.facets?.find(facet => { - return facet.features.find(feature => { - if (AppBskyRichtextFacet.isLink(feature)) { - if (isBskyPostUrl(feature.uri)) { - const url = convertBskyAppUrlIfNeeded(feature.uri) - const [_0, _1, _2, rkey] = url.split('/').filter(Boolean) - - // this might have a handle instead of a DID - // so just compare the rkey - not particularly dangerous - return post.uri.endsWith(rkey) - } - } - return false - }) + stripLinkFacet(uri => { + if (!isBskyPostUrl(uri)) return false + const url = convertBskyAppUrlIfNeeded(uri) + const [_0, _1, _2, rkey] = url.split('/').filter(Boolean) + // this might have a handle instead of a DID + // so just compare the rkey - not particularly dangerous + return post.uri.endsWith(rkey) }) - - if (postLinkFacet) { - const isAtStart = postLinkFacet.index.byteStart === 0 - const isAtEnd = - postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength - - // remove the post link from the text - if (isAtStart || isAtEnd) { - rt.delete( - postLinkFacet.index.byteStart, - postLinkFacet.index.byteEnd, - ) - } - - rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true}) - } } } catch (error) { logger.error('Failed to get post as quote for DM', {error}) } + } else if (messageEmbed?.type === 'invite') { + const code = messageEmbed.code + embed = { + $type: 'chat.bsky.embed.joinLink', + code, + } + + const joinLinkPreview = await getJoinLinkPreview({code, hasSession}) + if (joinLinkPreview) { + embedView = { + $type: 'chat.bsky.embed.joinLink#view', + joinLinkPreview, + } + } + + stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code) } await rt.detectFacets(agent) @@ -424,7 +447,16 @@ export function MessagesList({ embedView, ) }, - [agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled], + [ + agent, + convoState, + messageEmbed, + getPost, + getJoinLinkPreview, + hasSession, + hasScrolled, + setHasScrolled, + ], ) const scrollToEndOnPress = useCallback(() => { @@ -595,11 +627,11 @@ export function MessagesList({ onSendMessage={(message: string) => void onSendMessage(message) } - hasEmbed={!!embedUri} + hasEmbed={!!messageEmbed} setEmbed={setEmbed} loading={loading}> @@ -607,11 +639,11 @@ export function MessagesList({ diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index 08b609f71a..e89965eb45 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -6,6 +6,7 @@ import { ChatBskyConvoDefs, type ChatBskyConvoGetLog, type ChatBskyConvoSendMessage, + type ChatBskyEmbedJoinLink, type ChatBskyGroupDefs, } from '@atproto/api' import {XRPCError} from '@atproto/api' @@ -109,7 +110,9 @@ export class Convo { { id: string message: ChatBskyConvoSendMessage.InputSchema['message'] - optimisticEmbedView?: $Typed + optimisticEmbedView?: + | $Typed + | $Typed } > = new Map() private deletedMessages: Set = new Set() @@ -942,7 +945,9 @@ export class Convo { sendMessage( message: ChatBskyConvoSendMessage.InputSchema['message'], - optimisticEmbedView?: $Typed, + optimisticEmbedView?: + | $Typed + | $Typed, ) { // Ignore empty messages for now since they have no other purpose atm if (!message.text.trim() && !message.embed) return diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index 51d111356a..269f32c515 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -5,6 +5,7 @@ import { type ChatBskyActorDefs, type ChatBskyConvoDefs, type ChatBskyConvoSendMessage, + type ChatBskyEmbedJoinLink, } from '@atproto/api' import {type MessagesEventBus} from '#/state/messages/events/agent' @@ -108,7 +109,10 @@ export type ConvoItem = type DeleteMessage = (messageId: string) => Promise type SendMessage = ( message: ChatBskyConvoSendMessage.InputSchema['message'], - optimisticEmbedView: $Typed | undefined, + optimisticEmbedView: + | $Typed + | $Typed + | undefined, ) => void type FetchMessageHistory = () => Promise type MarkConvoAccepted = () => void diff --git a/src/state/queries/join-links.ts b/src/state/queries/join-links.ts index 99a37b6b06..df87ec0a24 100644 --- a/src/state/queries/join-links.ts +++ b/src/state/queries/join-links.ts @@ -1,4 +1,9 @@ -import {AtpAgent} from '@atproto/api' +import {useCallback} from 'react' +import { + AtpAgent, + type ChatBskyGroupDefs, + type ChatBskyGroupGetJoinLinkPreviews, +} from '@atproto/api' import {useQuery, useQueryClient} from '@tanstack/react-query' import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants' @@ -17,14 +22,39 @@ export const createJoinLinkPreviewQueryKey = (args: { persistedVersion: 1, }) +async function fetchJoinLinkPreviews({ + agent, + codes, + hasSession, +}: { + agent: AtpAgent + codes: string[] + hasSession: boolean +}) { + const previewAgent = new AtpAgent({service: CHAT_SERVICE}) + const res = hasSession + ? await agent.chat.bsky.group.getJoinLinkPreviews( + {codes}, + {headers: DM_SERVICE_HEADERS}, + ) + : await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes}) + return res.data +} + export function useJoinLinkPreviewsQuery({ codes, hasSession, staleTime = STALE.MINUTES.ONE, + initialData, }: { codes?: string[] hasSession: boolean staleTime?: number + /** + * Seed the query with an already-known preview (e.g. a DM message embed + * already carries the resolved view), avoiding a duplicate fetch. + */ + initialData?: ChatBskyGroupGetJoinLinkPreviews.OutputSchema }) { const agent = useAgent() @@ -33,14 +63,7 @@ export function useJoinLinkPreviewsQuery({ queryFn: async () => { if (!codes) throw new Error('No invite code') try { - const previewAgent = new AtpAgent({service: CHAT_SERVICE}) - const res = hasSession - ? await agent.chat.bsky.group.getJoinLinkPreviews( - {codes}, - {headers: DM_SERVICE_HEADERS}, - ) - : await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes}) - return res.data + return await fetchJoinLinkPreviews({agent, codes, hasSession}) } catch (error) { logger.error('Failed to fetch join link preview', {safeMessage: error}) throw error @@ -48,6 +71,7 @@ export function useJoinLinkPreviewsQuery({ }, enabled: codes != null && codes.length > 0, staleTime, + initialData, }) } @@ -58,17 +82,42 @@ export function usePrefetchJoinLinkPreviews() { return ({codes, hasSession}: {codes: string[]; hasSession: boolean}) => { return queryClient.prefetchQuery({ queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}), - queryFn: async () => { - const previewAgent = new AtpAgent({service: CHAT_SERVICE}) - const res = hasSession - ? await agent.chat.bsky.group.getJoinLinkPreviews( - {codes}, - {headers: DM_SERVICE_HEADERS}, - ) - : await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes}) - return res.data - }, + queryFn: () => fetchJoinLinkPreviews({agent, codes, hasSession}), staleTime: STALE.MINUTES.ONE, }) } } + +/** + * Imperatively fetch (or read from cache) a single join link preview by code. + * Used when sending a DM invite embed so we can build an optimistic view. + * Returns undefined if the preview can't be resolved. + */ +export function useGetJoinLinkPreview() { + const agent = useAgent() + const queryClient = useQueryClient() + + return useCallback( + async ({ + code, + hasSession, + }: { + code: string + hasSession: boolean + }): Promise => { + try { + const data = await queryClient.fetchQuery({ + queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}), + queryFn: () => + fetchJoinLinkPreviews({agent, codes: [code], hasSession}), + staleTime: STALE.MINUTES.ONE, + }) + return data.joinLinkPreviews[0] + } catch (error) { + logger.error('Failed to fetch join link preview', {safeMessage: error}) + return undefined + } + }, + [agent, queryClient], + ) +} diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 5cc87c63f5..0b14f16ed3 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -117,7 +117,7 @@ export const ExternalEmbedLink = ({ /> ) } else if (data.type === 'chat-invite') { - return + return } else if (data.kind === 'feed') { return ( Date: Thu, 4 Jun 2026 15:56:37 -0500 Subject: [PATCH 16/19] V smol log cleanups (#10731) --- eslint-suppressions.json | 3 -- src/analytics/PassiveAnalytics.tsx | 28 +++++++++---------- src/analytics/metrics/client.ts | 4 --- .../Post/Embed/StandardSiteEmbed/index.tsx | 4 ++- .../additional-moderation-authorities.ts | 8 ------ src/view/shell/desktop/LeftNav.tsx | 1 + 6 files changed, 17 insertions(+), 31 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 483ed83e7d..b8e65eb265 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -43,9 +43,6 @@ } }, "src/analytics/PassiveAnalytics.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, "react-hooks/purity": { "count": 1 } diff --git a/src/analytics/PassiveAnalytics.tsx b/src/analytics/PassiveAnalytics.tsx index 34b5f2d275..c772ed09b8 100644 --- a/src/analytics/PassiveAnalytics.tsx +++ b/src/analytics/PassiveAnalytics.tsx @@ -2,8 +2,6 @@ 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. @@ -27,19 +25,19 @@ 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(), - }) - } + // if (IS_DEV || IS_TESTFLIGHT) { + // const feats = Object.values(Features).reduce( + // (acc, feat) => { + // acc[feat] = features.evalFeature(feat) + // return acc + // }, + // {} as Record, + // ) + // ax.logger.info('FEATURES', { + // features: feats, + // definitions: features.getFeatures(), + // }) + // } }) return () => sub.remove() }, [ax]) diff --git a/src/analytics/metrics/client.ts b/src/analytics/metrics/client.ts index 2aab90f265..1ea0573dcd 100644 --- a/src/analytics/metrics/client.ts +++ b/src/analytics/metrics/client.ts @@ -67,10 +67,6 @@ export class MetricsClient> { } private async sendBatch(events: Event[], isRetry: boolean = false) { - logger.debug(`sendBatch: ${events.length}`, { - isRetry, - }) - try { const body = JSON.stringify({events}) if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) { diff --git a/src/components/Post/Embed/StandardSiteEmbed/index.tsx b/src/components/Post/Embed/StandardSiteEmbed/index.tsx index 5f7fc1f5e7..c7b8abf88c 100644 --- a/src/components/Post/Embed/StandardSiteEmbed/index.tsx +++ b/src/components/Post/Embed/StandardSiteEmbed/index.tsx @@ -356,6 +356,7 @@ export function PublicationCard({ /> {view.description && ( - + {view.description} @@ -617,6 +618,7 @@ export function PublicationFooter({ /> {sanitizeDisplayName( From 45bd5b45e876e8ebd61b846ad886c7e93e9300dd Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:11:50 -0700 Subject: [PATCH 17/19] Reduce stale time for join link preview queries (#10733) --- src/state/queries/join-links.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/state/queries/join-links.ts b/src/state/queries/join-links.ts index df87ec0a24..628f5e9102 100644 --- a/src/state/queries/join-links.ts +++ b/src/state/queries/join-links.ts @@ -83,7 +83,7 @@ export function usePrefetchJoinLinkPreviews() { return queryClient.prefetchQuery({ queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}), queryFn: () => fetchJoinLinkPreviews({agent, codes, hasSession}), - staleTime: STALE.MINUTES.ONE, + staleTime: STALE.SECONDS.FIFTEEN, }) } } @@ -110,7 +110,7 @@ export function useGetJoinLinkPreview() { queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}), queryFn: () => fetchJoinLinkPreviews({agent, codes: [code], hasSession}), - staleTime: STALE.MINUTES.ONE, + staleTime: STALE.SECONDS.FIFTEEN, }) return data.joinLinkPreviews[0] } catch (error) { From de926d1838185ce08a6fbad5b6e9edb71dc02314 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:22:29 -0700 Subject: [PATCH 18/19] Show a banner when there are incoming chat requests (#10498) Co-authored-by: Samuel Newman --- src/screens/Messages/Conversation.tsx | 29 +++++- .../Messages/components/ChatListItem.tsx | 32 +++++-- .../Messages/components/RequestStatus.tsx | 92 +++++++++++++++++++ .../queries/messages/list-join-requests.ts | 4 +- .../messages/mark-join-request-read.ts | 85 +++++++++++++++++ 5 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 src/screens/Messages/components/RequestStatus.tsx create mode 100644 src/state/queries/messages/mark-join-request-read.ts diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 6233c467ff..b269046534 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -1,7 +1,7 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {type LayoutChangeEvent, View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {moderateProfile} from '@atproto/api' +import {ChatBskyConvoDefs, moderateProfile} from '@atproto/api' import { ScrollEdgeEffect, ScrollEdgeEffectProvider, @@ -29,6 +29,7 @@ import {ConvoStatus} from '#/state/messages/convo/types' import {useCurrentConvoId} from '#/state/messages/current-convo-id' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useConvoQuery} from '#/state/queries/messages/conversation' +import {useMarkJoinRequestsRead} from '#/state/queries/messages/mark-join-request-read' import {useSession} from '#/state/session' import {MessagesList} from '#/screens/Messages/components/MessagesList' import {atoms as a, web} from '#/alf' @@ -51,6 +52,7 @@ import {IS_INTERNAL, IS_LIQUID_GLASS} from '#/env' import {ChatDisabled} from './components/ChatDisabled' import {ChatEnded} from './components/ChatEnded' import {ChatLocked} from './components/ChatLocked' +import {RequestStatus} from './components/RequestStatus' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -180,6 +182,12 @@ function InnerReady({ const {needsEmailVerification} = useEmail() const emailDialogControl = useEmailDialogControl() + const unreadRequestCount = + convo?.kind === 'group' && ChatBskyConvoDefs.isGroupConvo(convo.view.kind) + ? (convo.view.kind.unreadJoinRequestCount ?? 0) + : 0 + const {mutate: markJoinRequestsRead} = useMarkJoinRequestsRead(convo?.view.id) + /** * Must be non-reactive, otherwise the update to open the global dialog will * cause a re-render loop. @@ -264,8 +272,25 @@ function InnerReady({ {header} ) : ( - header + {header} )} + + {isActive && convo?.kind === 'group' && unreadRequestCount > 0 ? ( + { + markJoinRequestsRead() + }} + onPress={() => { + markJoinRequestsRead() + navigation.navigate('MessagesJoinRequests', { + conversation: convo.view.id, + }) + }} + /> + ) : null} + {isActive && ( 20 + requestInfo={ + convo.details.unreadJoinRequestCount + ? convo.details.unreadJoinRequestCount > JOIN_REQUESTS_THRESHOLD ? l({ - message: '20+ new join requests', + message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`, context: 'Displayed when there are more than 20 requests to join a group chat', }) - : plural(convo.details.joinRequestCount, { + : plural(convo.details.unreadJoinRequestCount, { one: '# new join request', other: '# new join requests', }) @@ -241,6 +242,7 @@ function BaseChatItem({ avatar, title, subtitle, + requestInfo, accessibilityHint, isDeletedAccount, isBlockedAccount, @@ -256,6 +258,7 @@ function BaseChatItem({ avatar: React.ReactNode title: string subtitle?: string + requestInfo?: string accessibilityHint: string isDeletedAccount: boolean isBlockedAccount: boolean @@ -280,8 +283,10 @@ function BaseChatItem({ const playHaptic = useHaptics() const queryClient = useQueryClient() const hasUnread = - convo.view.unreadCount > 0 && !isDeletedAccount && + (convo.view.unreadCount > 0 || + (convo.kind === 'group' && + (convo.details.unreadJoinRequestCount ?? 0) > 0)) && (convo.kind !== 'group' || convo.details.lockStatus === 'unlocked') const blockInfo = useMemo(() => { @@ -607,6 +612,19 @@ function BaseChatItem({ {postAlerts} + {requestInfo && ( + + {requestInfo} + + )} + {LastMessageIcon && ( diff --git a/src/screens/Messages/components/RequestStatus.tsx b/src/screens/Messages/components/RequestStatus.tsx new file mode 100644 index 0000000000..9b35b69445 --- /dev/null +++ b/src/screens/Messages/components/RequestStatus.tsx @@ -0,0 +1,92 @@ +import {Pressable} from 'react-native' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {plural} from '@lingui/core/macro' +import {useLingui} from '@lingui/react/macro' + +import {HITSLOP_10} from '#/lib/constants' +import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests' +import {atoms as a, tokens, useTheme} from '#/alf' +import {GlassView} from '#/components/GlassView' +import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope' +import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times' +import {Text} from '#/components/Typography' +import {IS_LIQUID_GLASS} from '#/env' + +export function RequestStatus({ + top, + count, + onDismiss, + onPress, +}: { + top: number + count: number + onDismiss: () => void + onPress: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + + + {count > JOIN_REQUESTS_THRESHOLD + ? l({ + message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`, + comment: + 'Displayed when the number of requests is greater than 20', + }) + : plural(count, { + one: '# new join request', + other: '# new join requests', + })} + + + + + + + + ) +} diff --git a/src/state/queries/messages/list-join-requests.ts b/src/state/queries/messages/list-join-requests.ts index 9f0860c20d..64a999c118 100644 --- a/src/state/queries/messages/list-join-requests.ts +++ b/src/state/queries/messages/list-join-requests.ts @@ -8,6 +8,8 @@ import {createQueryKey} from '#/state/queries/util' import {useAgent} from '#/state/session' import {STALE} from '..' +export const JOIN_REQUESTS_THRESHOLD = 20 + const listJoinRequestsQueryKeyRoot = 'list-join-requests' export const createListJoinRequestsQueryKey = (args: {convoId: string}) => @@ -53,7 +55,7 @@ export function useListJoinRequestsQuery({ queryKey: createListJoinRequestsQueryKey({convoId: convoId ?? ''}), queryFn: async ({pageParam}) => { const {data} = await agent.chat.bsky.group.listJoinRequests( - {convoId: convoId!, cursor: pageParam, limit: 20}, + {convoId: convoId!, cursor: pageParam, limit: JOIN_REQUESTS_THRESHOLD}, {headers: DM_SERVICE_HEADERS}, ) return data diff --git a/src/state/queries/messages/mark-join-request-read.ts b/src/state/queries/messages/mark-join-request-read.ts new file mode 100644 index 0000000000..ae6d7e88a7 --- /dev/null +++ b/src/state/queries/messages/mark-join-request-read.ts @@ -0,0 +1,85 @@ +import {ChatBskyConvoDefs} 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 {RQKEY as CONVO_KEY} from './conversation' +import { + type ConvoListQueryData, + RQKEY_ROOT as CONVO_LIST_ROOT_KEY, +} from './list-conversations' + +export function useMarkJoinRequestsRead(convoId: string | undefined) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + if (!convoId) throw new Error('No convoId provided') + await agent.chat.bsky.group.updateJoinRequestsRead( + {convoId}, + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, + ) + }, + onMutate: () => { + if (!convoId) return + + const prevConvo = queryClient.getQueryData( + CONVO_KEY(convoId), + ) + queryClient.setQueryData( + CONVO_KEY(convoId), + old => { + if (!old || !ChatBskyConvoDefs.isGroupConvo(old.kind)) return old + return { + ...old, + kind: {...old.kind, unreadJoinRequestCount: 0}, + } + }, + ) + + const prevListEntries = queryClient.getQueriesData({ + queryKey: [CONVO_LIST_ROOT_KEY], + }) + queryClient.setQueriesData( + {queryKey: [CONVO_LIST_ROOT_KEY]}, + old => { + if (!old) return old + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + convos: page.convos.map(convo => { + if ( + convo.id !== convoId || + !ChatBskyConvoDefs.isGroupConvo(convo.kind) + ) { + return convo + } + return { + ...convo, + kind: {...convo.kind, unreadJoinRequestCount: 0}, + } + }), + })), + } + }, + ) + + return {prevConvo, prevListEntries} + }, + onError: (error, _, context) => { + logger.error('Failed to mark join requests as read', {safeMessage: error}) + if (!convoId) return + if (context?.prevConvo) { + queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo) + } + for (const [key, data] of context?.prevListEntries ?? []) { + queryClient.setQueryData(key, data) + } + void queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)}) + void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]}) + }, + }) +} From 144ba30ea647f4d80a2e76b14a380f3eb0a02149 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Thu, 4 Jun 2026 17:22:40 -0400 Subject: [PATCH 19/19] Add gallery embed type support (display + compose) (#10707) --- package.json | 2 +- pnpm-lock.yaml | 10 +- src/components/MediaPreview.tsx | 34 +++- src/components/Post/Embed/ImageEmbed.tsx | 23 ++- src/components/Post/Embed/index.tsx | 4 +- .../Gallery/maybeApplyGalleryOffsetStyles.ts | 33 +++- src/components/images/ImageLayoutGrid.tsx | 22 ++- src/components/images/ImageLayoutGridItem.tsx | 3 + .../ReportDialog/utils/parseReportSubject.ts | 5 +- src/lib/api/index.ts | 31 ++++ src/types/bsky/post.ts | 10 ++ src/view/com/composer/Composer.tsx | 124 ++++++++++---- src/view/com/composer/ComposerReplyTo.tsx | 30 +++- src/view/com/composer/SelectMediaButton.tsx | 19 ++- src/view/com/composer/drafts/state/api.ts | 159 ++++++++++++------ src/view/com/composer/drafts/state/queries.ts | 23 ++- src/view/com/composer/photos/Gallery.tsx | 7 +- src/view/com/composer/state/composer.ts | 92 +++++++--- src/view/com/feeds/ComposerPrompt.tsx | 6 +- 19 files changed, 494 insertions(+), 143 deletions(-) diff --git a/package.json b/package.json index c515ecdcee..a879182d9f 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "prettier": "prettier --check ." }, "dependencies": { - "@atproto/api": "0.20.8", + "@atproto/api": "0.20.9", "@atproto/syntax": "0.6.1", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0136363f7..0ac8974cf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,8 +242,8 @@ importers: .: dependencies: '@atproto/api': - specifier: 0.20.8 - version: 0.20.8 + specifier: 0.20.9 + version: 0.20.9 '@atproto/syntax': specifier: 0.6.1 version: 0.6.1 @@ -877,8 +877,8 @@ packages: graphql: optional: true - '@atproto/api@0.20.8': - resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==} + '@atproto/api@0.20.9': + resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==} engines: {node: '>=22'} '@atproto/common-web@0.5.0': @@ -9493,7 +9493,7 @@ snapshots: '@0no-co/graphql.web@1.2.0': {} - '@atproto/api@0.20.8': + '@atproto/api@0.20.9': dependencies: '@atproto/common-web': 0.5.0 '@atproto/lexicon': 0.7.1 diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index b36705d840..034094556c 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -1,6 +1,10 @@ import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' import {Image} from 'expo-image' -import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api' +import { + AppBskyEmbedGallery, + type AppBskyEmbedImages, + type AppBskyFeedDefs, +} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {shareImageModal} from '#/lib/media/manip' @@ -47,6 +51,34 @@ export function Embed({ )} ) + } else if (e.type === 'gallery') { + // Notification/DM preview is a narrow inline strip; cap at 4 tiles so + // a 10-image gallery doesn't blow out the row width. Single pass instead + // of filter().slice().map() so we stop at 4 viewable items rather than + // walking every item in a 10-image gallery. + const tiles: React.ReactNode[] = [] + for (const item of e.view.items) { + if (tiles.length >= 4) break + if (!AppBskyEmbedGallery.isViewImage(item)) continue + if (peekable) { + const image: AppBskyEmbedImages.ViewImage = { + thumb: item.thumbnail, + fullsize: item.fullsize, + alt: item.alt, + aspectRatio: item.aspectRatio, + } + tiles.push() + } else { + tiles.push( + , + ) + } + } + return {tiles} } else if (e.type === 'link') { if (!e.view.external.thumb) return null if (!isGifEmbed(e.view.external.uri)) return null diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index 2c6bb6c5de..0a620efde4 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -2,6 +2,7 @@ import {useRef} from 'react' import {InteractionManager, View} from 'react-native' import {type AnimatedRef} from 'react-native-reanimated' import {Image} from 'expo-image' +import {AppBskyEmbedGallery, type AppBskyEmbedImages} from '@atproto/api' import {atoms as a, tokens} from '#/alf' import {AutoSizedImage} from '#/components/images/AutoSizedImage' @@ -15,16 +16,29 @@ import {useAnalytics} from '#/analytics' import {type EmbedType} from '#/types/bsky/post' import {type CommonProps} from './types' +const MAX_GRID_IMAGES = 4 + export function ImageEmbed({ embed, ...rest }: CommonProps & { - embed: EmbedType<'images'> + embed: EmbedType<'images'> | EmbedType<'gallery'> }) { const ax = useAnalytics() const {openLightbox} = useLightboxControls() - const {images} = embed.view - const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable) + const images: AppBskyEmbedImages.ViewImage[] = + embed.type === 'gallery' + ? embed.view.items.filter(AppBskyEmbedGallery.isViewImage).map(item => ({ + thumb: item.thumbnail, + fullsize: item.fullsize, + alt: item.alt, + aspectRatio: item.aspectRatio, + })) + : embed.view.images + const useExpandedLayout = + embed.type === 'gallery' + ? images.length > MAX_GRID_IMAGES + : ax.features.enabled(ax.features.PostGalleryEmbedEnable) // Captured from AutoSizedImage so the peek-commit handler can reuse the same // ref + dims that a tap would — keeps the lightbox's return animation intact. @@ -109,7 +123,7 @@ export function ImageEmbed({ ) } - if (galleryEnabled) { + if (useExpandedLayout) { return ( ) diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index ab6f5dc439..28eed2aaa9 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -54,6 +54,7 @@ export function Embed({embed: rawEmbed, ...rest}: EmbedProps) { switch (embed.type) { case 'images': + case 'gallery': case 'link': case 'video': { return @@ -89,7 +90,8 @@ function MediaEmbed({ embed: TEmbed }) { switch (embed.type) { - case 'images': { + case 'images': + case 'gallery': { return ( ( post.record, @@ -39,6 +37,13 @@ export function maybeApplyGalleryOffsetStyles( return } + // The gate only controls whether legacy image embeds opt into the new + // expanded gallery layout. Gallery embeds always render expanded by item + // count, so their offset must apply regardless of the gate. + const isPostGalleryEmbedEnabled = features.isOn( + Features.PostGalleryEmbedEnable, + ) + /* * First check if we even have images */ @@ -49,6 +54,12 @@ export function maybeApplyGalleryOffsetStyles( embed, AppBskyEmbedImages.isMain, ) + const isGalleryEmbed = + embed && + bsky.dangerousIsType( + embed, + AppBskyEmbedGallery.isMain, + ) const isRecordWithMedia = embed && bsky.dangerousIsType( @@ -57,10 +68,16 @@ export function maybeApplyGalleryOffsetStyles( ) let hasImages = false if (isImageEmbed) { + if (!isPostGalleryEmbedEnabled) return // one image, not a gallery if (embed.images.length === 1) return hasImages = true } + if (isGalleryEmbed) { + // single (or empty) gallery - no offset needed + if (embed.items.length <= 1) return + hasImages = true + } if (isRecordWithMedia) { if ( bsky.dangerousIsType( @@ -68,9 +85,19 @@ export function maybeApplyGalleryOffsetStyles( AppBskyEmbedImages.isMain, ) ) { + if (!isPostGalleryEmbedEnabled) return // one image, not a gallery if (embed.media.images.length === 1) return } + if ( + bsky.dangerousIsType( + embed.media, + AppBskyEmbedGallery.isMain, + ) + ) { + // single (or empty) gallery - no offset needed + if (embed.media.items.length <= 1) return + } hasImages = true } if (!hasImages) return diff --git a/src/components/images/ImageLayoutGrid.tsx b/src/components/images/ImageLayoutGrid.tsx index 0017ddf9cf..5b4ba608b2 100644 --- a/src/components/images/ImageLayoutGrid.tsx +++ b/src/components/images/ImageLayoutGrid.tsx @@ -19,21 +19,28 @@ interface ImageLayoutGridProps { onPressIn?: (index: number) => void style?: StyleProp viewContext?: PostEmbedViewContext + isWithinQuote?: boolean } -export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) { +export function ImageLayoutGrid({ + style, + isWithinQuote: isWithinQuoteProp, + ...props +}: ImageLayoutGridProps) { const {gtMobile} = useBreakpoints() - const gap = + const isWithinQuote = + isWithinQuoteProp ?? props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia - ? gtMobile - ? a.gap_xs - : a.gap_2xs - : a.gap_xs + const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs return ( - + ) @@ -49,6 +56,7 @@ interface ImageLayoutGridInnerProps { onLongPress?: (index: number) => void onPressIn?: (index: number) => void viewContext?: PostEmbedViewContext + isWithinQuote?: boolean gap: {gap: number} } diff --git a/src/components/images/ImageLayoutGridItem.tsx b/src/components/images/ImageLayoutGridItem.tsx index d6cbc0aded..640ecd8ba2 100644 --- a/src/components/images/ImageLayoutGridItem.tsx +++ b/src/components/images/ImageLayoutGridItem.tsx @@ -29,6 +29,7 @@ interface Props { onPressIn?: EventFunction imageStyle?: StyleProp viewContext?: PostEmbedViewContext + isWithinQuote?: boolean insetBorderStyle?: StyleProp containerRefs: AnimatedRef[] thumbDimsRef: React.RefObject<(Dimensions | null)[]> @@ -42,6 +43,7 @@ export function GalleryItem({ onPressIn, onLongPress, viewContext, + isWithinQuote, insetBorderStyle, containerRefs, thumbDimsRef, @@ -52,6 +54,7 @@ export function GalleryItem({ const image = images[index] const hasAlt = !!image.alt const hideBadges = + isWithinQuote ?? viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia const aspect = diff --git a/src/components/moderation/ReportDialog/utils/parseReportSubject.ts b/src/components/moderation/ReportDialog/utils/parseReportSubject.ts index 405640c453..a7d4b94c32 100644 --- a/src/components/moderation/ReportDialog/utils/parseReportSubject.ts +++ b/src/components/moderation/ReportDialog/utils/parseReportSubject.ts @@ -87,7 +87,10 @@ export function parseReportSubject( reply: !!record.reply, image: embed.type === 'images' || - (embed.type === 'post_with_media' && embed.media.type === 'images'), + embed.type === 'gallery' || + (embed.type === 'post_with_media' && + (embed.media.type === 'images' || + embed.media.type === 'gallery')), video: embed.type === 'video' || (embed.type === 'post_with_media' && embed.media.type === 'video'), diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 7de4e13e56..6f92e2a1dd 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,6 +1,7 @@ import { type $Typed, type AppBskyEmbedExternal, + type AppBskyEmbedGallery, type AppBskyEmbedImages, type AppBskyEmbedRecord, type AppBskyEmbedRecordWithMedia, @@ -254,6 +255,7 @@ async function resolveEmbed( onStateChange: ((state: string) => void) | undefined, ): Promise< | $Typed + | $Typed | $Typed | $Typed | $Typed @@ -313,6 +315,7 @@ async function resolveMedia( ): Promise< | $Typed | $Typed + | $Typed | $Typed | undefined > { @@ -343,6 +346,34 @@ async function resolveMedia( images, } } + if (embedDraft.media?.type === 'gallery') { + const imagesDraft = embedDraft.media.images + logger.debug(`Uploading images`, { + count: imagesDraft.length, + }) + onStateChange?.(t`Uploading images...`) + const items: $Typed[] = await Promise.all( + imagesDraft.map(async (image, i) => { + logger.debug(`Compressing image #${i}`) + const {path, width, height, mime} = await compressImage( + image, + IMAGE_SIZE_CONFIG_POSTS, + ) + logger.debug(`Uploading image #${i}`) + const res = await uploadBlob(agent, path, mime) + return { + $type: 'app.bsky.embed.gallery#image' as const, + image: res.data.blob, + alt: image.alt, + aspectRatio: {width, height}, + } + }), + ) + return { + $type: 'app.bsky.embed.gallery', + items, + } + } if ( embedDraft.media?.type === 'video' && embedDraft.media.video.status === 'done' diff --git a/src/types/bsky/post.ts b/src/types/bsky/post.ts index fada39da81..43621ef63b 100644 --- a/src/types/bsky/post.ts +++ b/src/types/bsky/post.ts @@ -1,6 +1,7 @@ import { type $Typed, AppBskyEmbedExternal, + AppBskyEmbedGallery, AppBskyEmbedImages, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, @@ -47,6 +48,10 @@ export type Embed = type: 'images' view: $Typed } + | { + type: 'gallery' + view: $Typed + } | { type: 'link' view: $Typed @@ -122,6 +127,11 @@ export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed { type: 'images', view: embed, } + } else if (AppBskyEmbedGallery.isView(embed)) { + return { + type: 'gallery', + view: embed, + } } else if (AppBskyEmbedExternal.isView(embed)) { return { type: 'link', diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 5c9f1a05fe..a2493557b7 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -159,7 +159,7 @@ import { composerReducer, createComposerState, type EmbedDraft, - MAX_IMAGES, + MAX_GALLERY_IMAGES, type PostAction, type PostDraft, type ThreadDraft, @@ -178,6 +178,65 @@ type CancelRef = { onPressCancel: () => void } +function applyGalleryCap( + currentCount: number, + incoming: ComposerImage[], +): + | {status: 'full'} + | {status: 'partial'; accepted: ComposerImage[]; dropped: number} + | {status: 'ok'; accepted: ComposerImage[]} { + const remaining = MAX_GALLERY_IMAGES - currentCount + if (remaining <= 0) { + return {status: 'full'} + } + if (incoming.length > remaining) { + return { + status: 'partial', + accepted: incoming.slice(0, remaining), + dropped: incoming.length - remaining, + } + } + return {status: 'ok', accepted: incoming} +} + +function useAddImagesWithCap( + currentCount: number, + dispatchPostAction: (action: PostAction) => void, +) { + const {t: l} = useLingui() + return useCallback( + (next: ComposerImage[]) => { + const result = applyGalleryCap(currentCount, next) + if (result.status === 'full') { + Toast.show( + l({ + message: `You can only add up to ${MAX_GALLERY_IMAGES} images per post`, + comment: + 'Toast shown when the user tries to add more images but the post gallery is already at the cap', + }), + {type: 'warning'}, + ) + return + } + if (result.status === 'partial') { + Toast.show( + l({ + message: `Only ${result.accepted.length} of ${next.length} ${plural(next.length, {one: 'image', other: 'images'})} added; limit is ${MAX_GALLERY_IMAGES}`, + comment: + 'Toast shown when adding images would exceed the post gallery cap; only the first N are kept', + }), + {type: 'warning'}, + ) + } + dispatchPostAction({ + type: 'embed_add_images', + images: result.accepted, + }) + }, + [currentCount, dispatchPostAction, l], + ) +} + type Props = ComposerOpts export const ComposePost = ({ replyTo, @@ -611,7 +670,11 @@ export const ComposePost = ({ ax.metric('draft:save', { isNewDraft, hasText: posts.some(p => p.richtext.text.trim().length > 0), - hasImages: posts.some(p => p.embed.media?.type === 'images'), + hasImages: posts.some( + p => + p.embed.media?.type === 'images' || + p.embed.media?.type === 'gallery', + ), hasVideo: posts.some(p => p.embed.media?.type === 'video'), hasGif: posts.some(p => p.embed.media?.type === 'gif'), hasQuote: posts.some(p => !!p.embed.quote), @@ -780,7 +843,10 @@ export const ComposePost = ({ for (let i = 0; i < thread.posts.length; i++) { const media = thread.posts[i].embed.media if (media) { - if (media.type === 'images' && media.images.some(img => !img.alt)) { + if ( + (media.type === 'images' || media.type === 'gallery') && + media.images.some(img => !img.alt) + ) { return l`One or more images is missing alt text.` } if (media.type === 'gif' && !media.alt) { @@ -931,7 +997,9 @@ export const ComposePost = ({ logger.error(e, { message: `Composer: create post failed`, hasImages: filteredThread.posts.some( - p => p.embed.media?.type === 'images', + p => + p.embed.media?.type === 'images' || + p.embed.media?.type === 'gallery', ), }) @@ -953,7 +1021,8 @@ export const ComposePost = ({ for (let post of filteredThread.posts) { ax.metric('post:create', { imageCount: - post.embed.media?.type === 'images' + post.embed.media?.type === 'images' || + post.embed.media?.type === 'gallery' ? post.embed.media.images.length : 0, isReply: index > 0 || !!replyTo, @@ -1395,15 +1464,11 @@ let ComposerPost = memo(function ComposerPost({ [dispatch, post.id], ) - const onImageAdd = useCallback( - (next: ComposerImage[]) => { - dispatchPost({ - type: 'embed_add_images', - images: next, - }) - }, - [dispatchPost], - ) + const postImagesCount = + post.embed.media?.type === 'images' || post.embed.media?.type === 'gallery' + ? post.embed.media.images.length + : 0 + const onImageAdd = useAddImagesWithCap(postImagesCount, dispatchPost) const onNewLink = useCallback( (uri: string) => { @@ -1708,7 +1773,7 @@ function ComposerEmbeds({ const video = embed.media?.type === 'video' ? embed.media.video : null return ( <> - {embed.media?.type === 'images' && ( + {(embed.media?.type === 'images' || embed.media?.type === 'gallery') && ( )} @@ -1819,7 +1884,11 @@ function ComposerPills({ }) { const t = useTheme() const media = post.embed.media - const hasMedia = media?.type === 'images' || media?.type === 'video' + const hasMedia = + media?.type === 'images' || + media?.type === 'gallery' || + media?.type === 'gif' || + media?.type === 'video' const hasLink = !!post.embed.link // Don't render anything if no pills are going to be displayed @@ -1908,15 +1977,16 @@ function ComposerFooter({ >(undefined) const media = post.embed.media - const images = media?.type === 'images' ? media.images : [] + const images = + media?.type === 'images' || media?.type === 'gallery' ? media.images : [] const video = media?.type === 'video' ? media.video : null - const isMaxImages = images.length >= MAX_IMAGES + const isMaxImages = images.length >= MAX_GALLERY_IMAGES const isMaxVideos = !!video let selectedAssetsCount = 0 let isMediaSelectionDisabled = false - if (media?.type === 'images') { + if (media?.type === 'images' || media?.type === 'gallery') { isMediaSelectionDisabled = isMaxImages selectedAssetsCount = images.length } else if (media?.type === 'video') { @@ -1926,15 +1996,7 @@ function ComposerFooter({ isMediaSelectionDisabled = !!media } - const onImageAdd = useCallback( - (next: ComposerImage[]) => { - dispatch({ - type: 'embed_add_images', - images: next, - }) - }, - [dispatch], - ) + const onImageAdd = useAddImagesWithCap(images.length, dispatch) const onSelectGif = useCallback( (gif: Gif) => { @@ -2017,7 +2079,11 @@ function ComposerFooter({ autoOpen={openGallery} /> diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index bac3e74d31..235139ef86 100644 --- a/src/view/com/composer/ComposerReplyTo.tsx +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -2,6 +2,7 @@ import {useCallback, useMemo, useState} from 'react' import {LayoutAnimation, Pressable, View} from 'react-native' import {Image} from 'expo-image' import { + AppBskyEmbedGallery, AppBskyEmbedImages, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, @@ -61,11 +62,14 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { const images = useMemo(() => { if (AppBskyEmbedImages.isView(embed)) { return embed.images - } else if ( - AppBskyEmbedRecordWithMedia.isView(embed) && - AppBskyEmbedImages.isView(embed.media) - ) { - return embed.media.images + } else if (AppBskyEmbedGallery.isView(embed)) { + return galleryItemsToImages(embed.items) + } else if (AppBskyEmbedRecordWithMedia.isView(embed)) { + if (AppBskyEmbedImages.isView(embed.media)) { + return embed.media.images + } else if (AppBskyEmbedGallery.isView(embed.media)) { + return galleryItemsToImages(embed.media.items) + } } }, [embed]) @@ -129,6 +133,22 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { ) } +function galleryItemsToImages( + items: AppBskyEmbedGallery.View['items'], +): AppBskyEmbedImages.ViewImage[] { + // The reply-to thumbnail only renders up to 4 tiles; slicing here keeps + // the existing layout switch valid for galleries up to 10 items. + return items + .filter(AppBskyEmbedGallery.isViewImage) + .slice(0, 4) + .map(item => ({ + thumb: item.thumbnail, + fullsize: item.fullsize, + alt: item.alt, + aspectRatio: item.aspectRatio, + })) +} + function ComposerReplyToImages({ images, }: { diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx index 2d70195488..1de39f5f54 100644 --- a/src/view/com/composer/SelectMediaButton.tsx +++ b/src/view/com/composer/SelectMediaButton.tsx @@ -16,7 +16,7 @@ import { } from '#/lib/hooks/usePermissions' import {openUnifiedPicker} from '#/lib/media/picker' import {extractDataUriMime} from '#/lib/media/util' -import {MAX_IMAGES} from '#/view/com/composer/state/composer' +import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' @@ -393,7 +393,9 @@ export function SelectMediaButton({ const t = useTheme() const hasAutoOpened = useRef(false) - const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount + // Picker uses the gallery cap; the reducer decides which embed variant + // to land in based on the final image count. + const selectionCountRemaining = MAX_GALLERY_IMAGES - selectedAssetsCount const processSelectedAssets = useCallback( async (rawAssets: ImagePickerAsset[]) => { @@ -419,10 +421,10 @@ export function SelectMediaButton({ ), [SelectedAssetError.MaxImages]: _( msg({ - message: `You can select up to ${plural(MAX_IMAGES, { + message: `You can select up to ${plural(MAX_GALLERY_IMAGES, { other: '# images', })} in total.`, - comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`, + comment: `Error message for maximum number of images that can be selected to add to a post.`, }), ), [SelectedAssetError.MaxVideos]: _( @@ -507,10 +509,11 @@ export function SelectMediaButton({ )} accessibilityHint={_( msg({ - message: `Opens device gallery to select up to ${plural(MAX_IMAGES, { - other: '# images', - })}, or a single video or GIF.`, - comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`, + message: `Opens device gallery to select up to ${plural( + MAX_GALLERY_IMAGES, + {other: '# images'}, + )}, or a single video or GIF.`, + comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post.`, }), )} style={a.p_sm} diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index 8f178d5282..817820effb 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -1,7 +1,7 @@ /** * Type converters for Draft API - convert between ComposerState and server Draft types. */ -import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api' +import {AppBskyDraftDefs, AtUri, RichText} from '@atproto/api' import {nanoid} from 'nanoid/non-secure' import {resolveLink} from '#/lib/api/resolve' @@ -15,6 +15,7 @@ import {createPublicAgent} from '#/state/session/agent' import { type ComposerState, type EmbedDraft, + LEGACY_IMAGES_EMBED_MAX, type PostDraft, } from '#/view/com/composer/state/composer' import {type VideoState} from '#/view/com/composer/state/video' @@ -115,6 +116,16 @@ async function postDraftToServerPost( post.embed.media.images, localRefPaths, ) + } else if (post.embed.media.type === 'gallery') { + draftPost.embedGallery = { + $type: 'app.bsky.draft.defs#draftEmbedGallery', + items: serializeImages(post.embed.media.images, localRefPaths).map( + img => ({ + $type: 'app.bsky.draft.defs#draftEmbedImage' as const, + ...img, + }), + ), + } } else if (post.embed.media.type === 'video') { const video = await serializeVideo(post.embed.media.video, localRefPaths) if (video) { @@ -269,6 +280,59 @@ function serializeGif(gifMedia: { } } +/** + * Restore an array of draft image refs back to ComposerImages. Shared by + * both the `embedImages` and `embedGallery` paths in draftToComposerPosts. + */ +async function restoreDraftImages( + draftImages: AppBskyDraftDefs.DraftEmbedImage[], + loadedMedia: Map, +): Promise { + const imagePromises = draftImages.map(async img => { + const path = loadedMedia.get(img.localRef.path) + if (!path) { + return null + } + + let width = 0 + let height = 0 + try { + const dims = await getImageDim(path) + width = dims.width + height = dims.height + } catch (e) { + logger.warn('Failed to get image dimensions', { + path, + error: e, + }) + } + + logger.debug('restoring image with localRefPath', { + localRefPath: img.localRef.path, + loadedPath: path, + width, + height, + }) + + return { + alt: img.alt || '', + // Preserve the original localRefPath for reuse when saving + localRefPath: img.localRef.path, + source: { + id: nanoid(), + path, + width, + height, + mime: 'image/jpeg', + }, + } satisfies ComposerImage + }) + + return (await Promise.all(imagePromises)).filter( + (img): img is NonNullable => img !== null, + ) +} + /** * Convert server DraftView to DraftSummary for list display. * Also checks which media files exist locally. @@ -314,6 +378,24 @@ export function draftViewToSummary({ } } + // Process gallery + if (post.embedGallery) { + for (const item of post.embedGallery.items) { + if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue + meta.mediaCount++ + meta.hasMedia = true + const exists = storage.mediaExists(item.localRef.path) + if (!exists) { + meta.hasMissingMedia = true + } + images.push({ + localPath: item.localRef.path, + altText: item.alt || '', + exists, + }) + } + } + // Process videos if (post.embedVideos) { for (const vid of post.embedVideos) { @@ -431,54 +513,31 @@ export async function draftToComposerPosts( media: undefined, } - // Restore images + // Restore images / gallery. Pick the variant from the restored count so + // we match the composer reducer's `imagesToMediaVariant` rule (<=4 stays + // legacy `images`, >4 promotes to `gallery`). This keeps restore robust + // to drafts whose server slot disagrees with their count - e.g. a draft + // saved in `embedImages` with 5 items would otherwise restore as a + // broken `images` variant the rest of the composer can't grow. + const restoredImages: ComposerImage[] = [] if (post.embedImages && post.embedImages.length > 0) { - const imagePromises = post.embedImages.map(async img => { - const path = loadedMedia.get(img.localRef.path) - if (!path) { - return null - } - - let width = 0 - let height = 0 - try { - const dims = await getImageDim(path) - width = dims.width - height = dims.height - } catch (e) { - logger.warn('Failed to get image dimensions', { - path, - error: e, - }) - } - - logger.debug('restoring image with localRefPath', { - localRefPath: img.localRef.path, - loadedPath: path, - width, - height, - }) - - return { - alt: img.alt || '', - // Preserve the original localRefPath for reuse when saving - localRefPath: img.localRef.path, - source: { - id: nanoid(), - path, - width, - height, - mime: 'image/jpeg', - }, - } satisfies ComposerImage - }) - - const images = (await Promise.all(imagePromises)).filter( - (img): img is NonNullable => img !== null, + restoredImages.push( + ...(await restoreDraftImages(post.embedImages, loadedMedia)), ) - if (images.length > 0) { - embed.media = {type: 'images', images} - } + } + if (post.embedGallery && post.embedGallery.items.length > 0) { + const galleryImages = post.embedGallery.items.filter( + AppBskyDraftDefs.isDraftEmbedImage, + ) + restoredImages.push( + ...(await restoreDraftImages(galleryImages, loadedMedia)), + ) + } + if (restoredImages.length > 0) { + embed.media = + restoredImages.length <= LEGACY_IMAGES_EMBED_MAX + ? {type: 'images', images: restoredImages} + : {type: 'gallery', images: restoredImages} } // Restore GIF from external embed @@ -630,6 +689,12 @@ export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set { refs.add(img.localRef.path) } } + if (post.embedGallery) { + for (const item of post.embedGallery.items) { + if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue + refs.add(item.localRef.path) + } + } if (post.embedVideos) { for (const vid of post.embedVideos) { refs.add(vid.localRef.path) diff --git a/src/view/com/composer/drafts/state/queries.ts b/src/view/com/composer/drafts/state/queries.ts index e66c9d023b..07dd67dd88 100644 --- a/src/view/com/composer/drafts/state/queries.ts +++ b/src/view/com/composer/drafts/state/queries.ts @@ -1,4 +1,4 @@ -import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api' +import {AppBskyDraftCreateDraft, AppBskyDraftDefs} from '@atproto/api' import { useInfiniteQuery, useMutation, @@ -74,6 +74,21 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{ } } } + // Load gallery + if (post.embedGallery) { + for (const item of post.embedGallery.items) { + if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue + try { + const url = await storage.loadMediaFromLocal(item.localRef.path) + loadedMedia.set(item.localRef.path, url) + } catch (e) { + logger.error('Failed to load draft gallery image', { + path: item.localRef.path, + safeMessage: e instanceof Error ? e.message : String(e), + }) + } + } + } // Load videos if (post.embedVideos) { for (const vid of post.embedVideos) { @@ -226,6 +241,12 @@ export function useDeleteDraftMutation() { await storage.deleteMediaFromLocal(img.localRef.path) } } + if (post.embedGallery) { + for (const item of post.embedGallery.items) { + if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue + await storage.deleteMediaFromLocal(item.localRef.path) + } + } if (post.embedVideos) { for (const vid of post.embedVideos) { await storage.deleteMediaFromLocal(vid.localRef.path) diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 2cc000019f..98bfac779d 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -70,11 +70,13 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => { const {isMobile} = useWebMediaQueries() const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => { + // Cap columns at 4 so tiles stay tappable when MAX_GALLERY_IMAGES is high; + // n > 4 wraps to multiple rows via flexWrap on the gallery container. + const columns = Math.min(images.length, 4) const side = images.length === 1 ? 250 - : (containerInfo.width - IMAGE_GAP * (images.length - 1)) / - images.length + : (containerInfo.width - IMAGE_GAP * (columns - 1)) / columns const isOverflow = isMobile && images.length > 2 @@ -273,6 +275,7 @@ const styles = StyleSheet.create({ gallery: { flex: 1, flexDirection: 'row', + flexWrap: 'wrap', gap: IMAGE_GAP, marginTop: 16, }, diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index d2b9af7b8e..35e1706007 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -16,6 +16,7 @@ import { postUriToRelativePath, toBskyAppUrl, } from '#/lib/strings/url-helpers' +import {logger} from '#/logger' import {type ComposerImage, createInitialImages} from '#/state/gallery' import {createPostgateRecord} from '#/state/queries/postgate/util' import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate' @@ -38,6 +39,11 @@ type ImagesMedia = { images: ComposerImage[] } +type GalleryMedia = { + type: 'gallery' + images: ComposerImage[] +} + type VideoMedia = { type: 'video' video: VideoState @@ -59,7 +65,7 @@ type Link = { export type EmbedDraft = { // We'll always submit quote and actual media (images, video, gifs) chosen by the user. quote: Link | undefined - media: ImagesMedia | VideoMedia | GifMedia | undefined + media: ImagesMedia | GalleryMedia | VideoMedia | GifMedia | undefined // This field may end up ignored if we have more important things to display than a link card: link: Link | undefined } @@ -154,7 +160,31 @@ export type ComposerAction = draftId: string } -export const MAX_IMAGES = 4 +/** + * Threshold for picking between embed variants. <= this count uses the + * legacy `app.bsky.embed.images` shape; > this count promotes to + * `app.bsky.embed.gallery`. Named to flag that if/when we deprecate the + * legacy images embed entirely, this constant (and the variant split it + * gates) should go away. + */ +export const LEGACY_IMAGES_EMBED_MAX = 4 +export const MAX_GALLERY_IMAGES = 10 + +/** + * Picks the embed variant for a set of images. <=4 lands in the legacy + * `app.bsky.embed.images` shape; >4 promotes to `app.bsky.embed.gallery`. + * Anything beyond the gallery cap is dropped by the hard slice; callers + * should already have enforced the cap upstream (picker, paste, etc), + * and the reducer logs a warning when the cap is exceeded so the UI + * layer can surface a toast. + */ +function imagesToMediaVariant( + images: ComposerImage[], +): ImagesMedia | GalleryMedia { + return images.length <= LEGACY_IMAGES_EMBED_MAX + ? {type: 'images', images: images.slice(0, LEGACY_IMAGES_EMBED_MAX)} + : {type: 'gallery', images: images.slice(0, MAX_GALLERY_IMAGES)} +} export function composerReducer( state: ComposerState, @@ -337,16 +367,28 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft { } const prevMedia = state.embed.media let nextMedia = prevMedia + const prevCount = + prevMedia?.type === 'images' || prevMedia?.type === 'gallery' + ? prevMedia.images.length + : 0 + const incomingCount = prevCount + action.images.length + if (incomingCount > MAX_GALLERY_IMAGES) { + // Defense in depth: callers (applyGalleryCap in Composer) should have + // already trimmed and surfaced a toast. The hard slice in + // imagesToMediaVariant still drops the excess so the cap holds. + logger.warn('composer: image add exceeds MAX_GALLERY_IMAGES', { + prevCount, + incomingCount, + dropped: incomingCount - MAX_GALLERY_IMAGES, + }) + } if (!prevMedia) { - nextMedia = { - type: 'images', - images: action.images.slice(0, MAX_IMAGES), - } - } else if (prevMedia.type === 'images') { - nextMedia = { - ...prevMedia, - images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES), - } + nextMedia = imagesToMediaVariant(action.images) + } else if (prevMedia.type === 'images' || prevMedia.type === 'gallery') { + nextMedia = imagesToMediaVariant([ + ...prevMedia.images, + ...action.images, + ]) } return { ...state, @@ -358,7 +400,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft { } case 'embed_update_image': { const prevMedia = state.embed.media - if (prevMedia?.type === 'images') { + if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') { const updatedImage = action.image const nextMedia = { ...prevMedia, @@ -382,19 +424,22 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft { case 'embed_remove_image': { const prevMedia = state.embed.media let nextLabels = state.labels - if (prevMedia?.type === 'images') { + if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') { const removedImage = action.image - let nextMedia: ImagesMedia | undefined = { - ...prevMedia, - images: prevMedia.images.filter(img => { - return img.source.id !== removedImage.source.id - }), - } - if (nextMedia.images.length === 0) { + const remainingImages = prevMedia.images.filter(img => { + return img.source.id !== removedImage.source.id + }) + let nextMedia: ImagesMedia | GalleryMedia | undefined + if (remainingImages.length === 0) { nextMedia = undefined if (!state.embed.link) { nextLabels = [] } + } else { + // Re-pick the variant so a gallery that shrinks to <=4 demotes + // back to the legacy `app.bsky.embed.images` shape - keeps old + // clients rendering it when possible. + nextMedia = imagesToMediaVariant(remainingImages) } return { ...state, @@ -581,12 +626,9 @@ export function createComposerState({ | AppBskyActorDefs.PostInteractionSettingsPref | undefined }): ComposerState { - let media: ImagesMedia | undefined + let media: ImagesMedia | GalleryMedia | undefined if (initImageUris?.length) { - media = { - type: 'images', - images: createInitialImages(initImageUris), - } + media = imagesToMediaVariant(createInitialImages(initImageUris)) } let quote: Link | undefined if (initQuoteUri) { diff --git a/src/view/com/feeds/ComposerPrompt.tsx b/src/view/com/feeds/ComposerPrompt.tsx index b35c1c1adf..592266871e 100644 --- a/src/view/com/feeds/ComposerPrompt.tsx +++ b/src/view/com/feeds/ComposerPrompt.tsx @@ -12,7 +12,7 @@ import { } from '#/lib/hooks/usePermissions' import {openCamera, openUnifiedPicker} from '#/lib/media/picker' import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' -import {MAX_IMAGES} from '#/view/com/composer/state/composer' +import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme, web} from '#/alf' import {Button} from '#/components/Button' @@ -64,7 +64,7 @@ export function ComposerPrompt() { Keyboard.dismiss() } - const selectionCountRemaining = MAX_IMAGES + const selectionCountRemaining = MAX_GALLERY_IMAGES const {assets, canceled} = await sheetWrapper( openUnifiedPicker({selectionCountRemaining}), ) @@ -76,7 +76,7 @@ export function ComposerPrompt() { if (assets.length > 0) { const imageUris = assets .filter(asset => asset.mimeType?.startsWith('image/')) - .slice(0, MAX_IMAGES) + .slice(0, MAX_GALLERY_IMAGES) .map(asset => ({ uri: asset.uri, width: asset.width,