Paragraph text with default styling.
-` link posted to the feed) as a join request card,
+ * falling back to a plain external embed if the invite can't be resolved.
+ */
export function ChatInviteEmbed({
code,
link,
@@ -18,24 +22,27 @@ export function ChatInviteEmbed({
onOpen?: () => void
style?: StyleProp
}) {
- const {hasSession} = useSession()
- const {data, error, isPending} = useJoinLinkPreviewsQuery({
- codes: [code],
- hasSession,
- })
+ return (
+
+
+
+ )
+}
- const preview = data?.joinLinkPreviews[0]
+function ChatInviteEmbedBody({
+ link,
+ onOpen,
+ style,
+}: {
+ link: AppBskyEmbedExternal.ViewExternal
+ onOpen?: () => void
+ style?: StyleProp
+}) {
+ const {error} = ChatInvite.useChatInvite()
if (error) {
return
}
- return (
-
- )
+ return
}
diff --git a/src/components/Post/Embed/ExternalEmbed/index.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx
index fe0be2bd2..472c40306 100644
--- a/src/components/Post/Embed/ExternalEmbed/index.tsx
+++ b/src/components/Post/Embed/ExternalEmbed/index.tsx
@@ -59,7 +59,7 @@ export const ExternalEmbed = ({
const onShareExternal = useCallback(() => {
if (link.uri && IS_NATIVE) {
playHaptic('Heavy')
- shareUrl(link.uri)
+ void shareUrl(link.uri)
}
}, [link.uri, playHaptic])
diff --git a/src/components/Post/Embed/JoinRequestEmbed.tsx b/src/components/Post/Embed/JoinRequestEmbed.tsx
index 77a0223be..db7ec589b 100644
--- a/src/components/Post/Embed/JoinRequestEmbed.tsx
+++ b/src/components/Post/Embed/JoinRequestEmbed.tsx
@@ -1,43 +1,56 @@
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 {Trans} from '@lingui/react/macro'
-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 * as ChatInvite from '#/components/dms/ChatInvite'
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
+const JOIN_REQUEST_EMBED_HEIGHT = 140
+/**
+ * The "join request" presentation of a chat invite, used as a post embed (in
+ * feeds and the post composer). Composes the headless `ChatInvite` primitive:
+ * pass either a `code` to fetch by, or an already-resolved `preview` as the
+ * initial data to avoid a loading flash.
+ */
export function JoinRequestEmbed({
- loading = false,
+ code,
preview,
style,
onOpen,
}: {
- loading?: boolean
+ code?: string
preview?: ChatBskyGroupDefs.JoinLinkPreviewView
style?: StyleProp
onOpen?: () => void
+}) {
+ const resolvedCode = code ?? preview?.code
+ if (!resolvedCode) return null
+
+ return (
+
+
+
+ )
+}
+
+/**
+ * The context-consuming presentation (loading / no-longer-available / card +
+ * join button). Exported so surfaces that own their own `ChatInvite.Root` (e.g.
+ * to add an error fallback) can render it without nesting another Root.
+ */
+export function JoinRequestEmbedBody({
+ style,
+ onOpen,
+}: {
+ style?: StyleProp
+ onOpen?: () => void
}) {
const t = useTheme()
+ const {loading, preview} = ChatInvite.useChatInvite()
if (loading) {
return (
@@ -81,60 +94,6 @@ export function JoinRequestEmbed({
)
}
- 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/dms/ChatInvite/Card.tsx b/src/components/dms/ChatInvite/Card.tsx
new file mode 100644
index 000000000..518371325
--- /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 000000000..53c7a6cc2
--- /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 000000000..0036391b0
--- /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 000000000..a9f2c54e3
--- /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 000000000..bcea3f765
--- /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 ac6acb589..f4f3cdacc 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 0aa132e23..c6899fb9d 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 5bc2f38a0..a73a6564f 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 fbebdc074..6f56c6a89 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
+
+ // 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 (embedUri) {
+ 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 08b609f71..e89965eb4 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 51d111356..269f32c51 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 99a37b6b0..df87ec0a2 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 5cc87c63f..0b14f16ed 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 30/61] 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 483ed83e7..b8e65eb26 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 34b5f2d27..c772ed09b 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 2aab90f26..1ea0573dc 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 5f7fc1f5e..c7b8abf88 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(
--
2.51.2
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 31/61] 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 df87ec0a2..628f5e910 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) {
--
2.51.2
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 32/61] 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 6233c467f..b26904653 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 000000000..9b35b6944
--- /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 9f0860c20..64a999c11 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 000000000..ae6d7e88a
--- /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]})
+ },
+ })
+}
--
2.51.2
From 144ba30ea647f4d80a2e76b14a380f3eb0a02149 Mon Sep 17 00:00:00 2001
From: Spence Pope
Date: Thu, 4 Jun 2026 17:22:40 -0400
Subject: [PATCH 33/61] 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 c515ecdce..a879182d9 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 d0136363f..0ac8974cf 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 b36705d84..034094556 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 2c6bb6c5d..0a620efde 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 ab6f5dc43..28eed2aaa 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 0017ddf9c..5b4ba608b 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 d6cbc0ade..640ecd8ba 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 405640c45..a7d4b94c3 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 7de4e13e5..6f92e2a1d 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 fada39da8..43621ef63 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 5c9f1a05f..a2493557b 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 bac3e74d3..235139ef8 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 2d7019548..1de39f5f5 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 8f178d528..817820eff 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 e66c9d023..07dd67dd8 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 2cc000019..98bfac779 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 d2b9af7b8..35e170600 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 b35c1c1ad..592266871 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,
--
2.51.2
From f84c85aac6811e76f58b12e8880e0897f4331d66 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Fri, 5 Jun 2026 00:37:42 +0300
Subject: [PATCH 34/61] Use Reanimated layout animations for Android composer
(#8546)
---
src/view/shell/Composer.ios.tsx | 23 ++++++++-------
src/view/shell/Composer.tsx | 52 ++++++++++++++-------------------
src/view/shell/Composer.web.tsx | 7 ++---
src/view/shell/index.tsx | 4 +--
src/view/shell/index.web.tsx | 2 +-
5 files changed, 38 insertions(+), 50 deletions(-)
diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx
index 437e610b2..7a1f59937 100644
--- a/src/view/shell/Composer.ios.tsx
+++ b/src/view/shell/Composer.ios.tsx
@@ -1,29 +1,30 @@
-import {useEffect, useRef} from 'react'
+import {useEffect} from 'react'
import {Modal, View} from 'react-native'
+import {SystemBars} from 'react-native-edge-to-edge'
-import {useDialogStateControlContext} from '#/state/dialogs'
import {useComposerState} from '#/state/shell/composer'
import {ComposePost, useComposerCancelRef} from '#/view/com/composer/Composer'
import {atoms as a, useTheme} from '#/alf'
import {SheetCompatProvider as TooltipSheetCompatProvider} from '#/components/Tooltip'
+import {IS_LIQUID_GLASS} from '#/env'
-export function Composer({}: {winHeight: number}) {
- const {setFullyExpandedCount} = useDialogStateControlContext()
+export function Composer() {
const t = useTheme()
const state = useComposerState()
const ref = useComposerCancelRef()
const open = !!state
- const prevOpen = useRef(open)
useEffect(() => {
- if (open && !prevOpen.current) {
- setFullyExpandedCount(c => c + 1)
- } else if (!open && prevOpen.current) {
- setFullyExpandedCount(c => c - 1)
+ if (open && !IS_LIQUID_GLASS) {
+ const entry = SystemBars.pushStackEntry({
+ style: {
+ statusBar: 'light',
+ },
+ })
+ return () => SystemBars.popStackEntry(entry)
}
- prevOpen.current = open
- }, [open, setFullyExpandedCount])
+ }, [open])
return (
{
- if (state) {
- Animated.timing(initInterp, {
- toValue: 1,
- duration: 300,
- easing: Easing.out(Easing.exp),
- useNativeDriver: true,
- }).start()
- } else {
- initInterp.setValue(0)
+ if (open) {
+ const entry = SystemBars.pushStackEntry({
+ style: {
+ statusBar: t.name !== 'light' ? 'light' : 'dark',
+ },
+ })
+ return () => SystemBars.popStackEntry(entry)
}
- }, [initInterp, state])
- const wrapperAnimStyle = {
- transform: [
- {
- translateY: initInterp.interpolate({
- inputRange: [0, 1],
- outputRange: [winHeight, 0],
- }),
- },
- ],
- }
-
- // rendering
- // =
+ }, [open, t.name])
- if (!state) {
+ if (!open) {
return null
}
return (
-
-
+
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index bfce0729d..62dec9bcf 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -64,7 +64,7 @@ function ShellInner() {
-
+
--
2.51.2
From 2f61c0a7b4ab735bf7ace70aa85ffc09206388e1 Mon Sep 17 00:00:00 2001
From: Eric Bailey
Date: Thu, 4 Jun 2026 16:51:34 -0500
Subject: [PATCH 35/61] Gate StandardSite subscribe button custom theme on AAA
contrast (#10735)
Co-authored-by: Claude Opus 4.8
---
src/alf/index.tsx | 8 +-
src/alf/util/colorGeneration.test.ts | 38 ++++++++-
src/alf/util/colorGeneration.ts | 42 ++++++++++
.../Post/Embed/StandardSiteEmbed/index.tsx | 84 ++++++++++++-------
4 files changed, 141 insertions(+), 31 deletions(-)
diff --git a/src/alf/index.tsx b/src/alf/index.tsx
index 0cb5ceb3f..a1a2d8ada 100644
--- a/src/alf/index.tsx
+++ b/src/alf/index.tsx
@@ -9,7 +9,12 @@ import {
setFontScale as persistFontScale,
} from '#/alf/fonts'
import {themes} from '#/alf/themes'
-import {darken, lighten, rgbToHex} from '#/alf/util/colorGeneration'
+import {
+ contrastRatio,
+ darken,
+ lighten,
+ rgbToHex,
+} from '#/alf/util/colorGeneration'
import {type Device} from '#/storage'
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
@@ -26,6 +31,7 @@ export const utils = {
rgbToHex,
lighten,
darken,
+ contrastRatio,
}
export type Alf = {
diff --git a/src/alf/util/colorGeneration.test.ts b/src/alf/util/colorGeneration.test.ts
index c4a2b0bbb..8d330201e 100644
--- a/src/alf/util/colorGeneration.test.ts
+++ b/src/alf/util/colorGeneration.test.ts
@@ -1,4 +1,10 @@
-import {darken, hexToRgb, lighten, rgbToHex} from './colorGeneration'
+import {
+ contrastRatio,
+ darken,
+ hexToRgb,
+ lighten,
+ rgbToHex,
+} from './colorGeneration'
describe('hexToRgb', () => {
it('parses 6-digit hex', () => {
@@ -92,3 +98,33 @@ describe('lighten / darken', () => {
expect(darken('#zzz', 10)).toBe('#zzz')
})
})
+
+describe('contrastRatio', () => {
+ it('returns 21 for black on white', () => {
+ expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 5)
+ })
+
+ it('returns 1 for identical colors', () => {
+ expect(contrastRatio('#abcdef', '#abcdef')).toBeCloseTo(1, 5)
+ })
+
+ it('is symmetric regardless of argument order', () => {
+ expect(contrastRatio('#123456', '#fedcba')).toBeCloseTo(
+ contrastRatio('#fedcba', '#123456')!,
+ 5,
+ )
+ })
+
+ it('clears AAA large text (4.5:1) for a high-contrast pairing', () => {
+ expect(contrastRatio('#1d3a5f', '#ffffff')!).toBeGreaterThanOrEqual(4.5)
+ })
+
+ it('fails AAA large text (4.5:1) for a low-contrast pairing', () => {
+ expect(contrastRatio('#777777', '#888888')!).toBeLessThan(4.5)
+ })
+
+ it('returns null for invalid hex input', () => {
+ expect(contrastRatio('not-a-color', '#ffffff')).toBeNull()
+ expect(contrastRatio('#ffffff', '#zzz')).toBeNull()
+ })
+})
diff --git a/src/alf/util/colorGeneration.ts b/src/alf/util/colorGeneration.ts
index 85659af25..f3be07d50 100644
--- a/src/alf/util/colorGeneration.ts
+++ b/src/alf/util/colorGeneration.ts
@@ -72,6 +72,48 @@ export function rgbToHex(r: number, g: number, b: number): string {
.slice(1)}`
}
+/**
+ * Computes the WCAG contrast ratio between two colors, ranging from 1 (no
+ * contrast) to 21 (maximum contrast, i.e. black on white). Returns null if
+ * either argument is not a valid hex color.
+ *
+ * @see https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio
+ */
+export function contrastRatio(hexA: string, hexB: string): number | null {
+ const rgbA = hexToRgb(hexA)
+ const rgbB = hexToRgb(hexB)
+ if (!rgbA || !rgbB) return null
+ const luminanceA = relativeLuminance(rgbA)
+ const luminanceB = relativeLuminance(rgbB)
+ const lighter = Math.max(luminanceA, luminanceB)
+ const darker = Math.min(luminanceA, luminanceB)
+ return (lighter + 0.05) / (darker + 0.05)
+}
+
+/**
+ * Computes the WCAG relative luminance of an RGB color, ranging from 0 (black)
+ * to 1 (white).
+ *
+ * @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
+ */
+function relativeLuminance({
+ r,
+ g,
+ b,
+}: {
+ r: number
+ g: number
+ b: number
+}): number {
+ const toLinear = (channel: number) => {
+ const normalized = channel / 255
+ return normalized <= 0.03928
+ ? normalized / 12.92
+ : ((normalized + 0.055) / 1.055) ** 2.4
+ }
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)
+}
+
function rgbToHsl(
r: number,
g: number,
diff --git a/src/components/Post/Embed/StandardSiteEmbed/index.tsx b/src/components/Post/Embed/StandardSiteEmbed/index.tsx
index c7b8abf88..2c38f3806 100644
--- a/src/components/Post/Embed/StandardSiteEmbed/index.tsx
+++ b/src/components/Post/Embed/StandardSiteEmbed/index.tsx
@@ -427,6 +427,26 @@ export function SubscribeButton({
? l`Subscribe on ${highlightedPublisher.name}`
: l`View publication`
+ /*
+ * The custom site theme paints the button background with `accent` and the
+ * text with `accentForeground`. Only honor it when that pairing clears WCAG
+ * AAA (4.5:1) for large text, which the button's bold label qualifies as.
+ * Otherwise we fall through to the default `secondary_inverted` styling,
+ * which is guaranteed to be legible.
+ */
+ const {accentRGB, accentForegroundRGB} = view.source?.theme || {}
+ let useCustomTheme = false
+ if (accentRGB && accentForegroundRGB) {
+ const accent = utils.rgbToHex(accentRGB.r, accentRGB.g, accentRGB.b)
+ const accentForeground = utils.rgbToHex(
+ accentForegroundRGB.r,
+ accentForegroundRGB.g,
+ accentForegroundRGB.b,
+ )
+ const ratio = utils.contrastRatio(accent, accentForeground)
+ useCustomTheme = ratio !== null && ratio >= 4.5
+ }
+
if (!view.source) return null
const publicationTitle = view.source.title
@@ -468,36 +488,42 @@ export function SubscribeButton({
}
}
+ const button = (
+
+ {highlightedPublisher ? (
+ <>
+
+
+
+ {cta}
+ >
+ ) : (
+ <>
+ {cta}
+
+ >
+ )}
+
+ )
+
+ if (!useCustomTheme) {
+ return button
+ }
+
return (
-
-
- {highlightedPublisher ? (
- <>
-
-
-
- {cta}
- >
- ) : (
- <>
- {cta}
-
- >
- )}
-
-
+ {button}
)
}
--
2.51.2
From 68da7971806182cb99856c21954836b6facff0a9 Mon Sep 17 00:00:00 2001
From: Spence Pope
Date: Thu, 4 Jun 2026 22:19:35 -0400
Subject: [PATCH 36/61] Add OTA fallback for gallery embed (#10734)
Co-authored-by: Eric Bailey
---
.../Post/Embed/GalleryFallbackEmbed.tsx | 99 +++++++++++++++++++
.../screens/Storybook/GalleryFallback.tsx | 25 +++++
src/view/screens/Storybook/Storybook.tsx | 2 +
3 files changed, 126 insertions(+)
create mode 100644 src/components/Post/Embed/GalleryFallbackEmbed.tsx
create mode 100644 src/view/screens/Storybook/GalleryFallback.tsx
diff --git a/src/components/Post/Embed/GalleryFallbackEmbed.tsx b/src/components/Post/Embed/GalleryFallbackEmbed.tsx
new file mode 100644
index 000000000..38d7e4d01
--- /dev/null
+++ b/src/components/Post/Embed/GalleryFallbackEmbed.tsx
@@ -0,0 +1,99 @@
+import {Linking, View} from 'react-native'
+import {plural} from '@lingui/core/macro'
+import {Trans, useLingui} from '@lingui/react/macro'
+
+import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Sparkle_Stroke2_Corner0_Rounded as Sparkle} from '#/components/icons/Sparkle'
+import {Text} from '#/components/Typography'
+import {IS_NATIVE} from '#/env'
+
+/**
+ * OTA-able fallback that ships to native builds which don't yet know how to
+ * render the new gallery embed (>4 images, Photos v2). Final copy and visual
+ * treatment pending design from Darrin/Danielle/Alex.
+ *
+ * Native-only per APP-2308 - web builds receive the new gallery support in
+ * the same release that adds it.
+ */
+export function GalleryFallbackEmbed({count}: {count?: number}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const bodyStyle = [
+ a.text_sm,
+ a.text_center,
+ a.leading_snug,
+ t.atoms.text_contrast_high,
+ ]
+
+ return (
+
+
+
+ Something new is here
+
+ {count ? (
+
+
+ {plural(count, {
+ one: 'This post has # photo.',
+ other: 'This post has # photos.',
+ })}
+
+ {IS_NATIVE ? (
+
+ {plural(count, {
+ one: 'Update your app to see it.',
+ other: 'Update your app to see them all.',
+ })}
+
+ ) : (
+
+ {plural(count, {
+ one: 'Refresh the page to see it.',
+ other: 'Refresh the page to see them all.',
+ })}
+
+ )}
+
+ ) : IS_NATIVE ? (
+
+ Update your app to see it.
+
+ ) : (
+
+ Refresh the page to see it.
+
+ )}
+ {IS_NATIVE && (
+
+ )}
+
+ )
+}
diff --git a/src/view/screens/Storybook/GalleryFallback.tsx b/src/view/screens/Storybook/GalleryFallback.tsx
new file mode 100644
index 000000000..7cca087db
--- /dev/null
+++ b/src/view/screens/Storybook/GalleryFallback.tsx
@@ -0,0 +1,25 @@
+import {View} from 'react-native'
+
+import {atoms as a} from '#/alf'
+import {GalleryFallbackEmbed} from '#/components/Post/Embed/GalleryFallbackEmbed'
+import {H1, H3} from '#/components/Typography'
+
+export function GalleryFallback() {
+ return (
+
+ Gallery fallback (APP-2308)
+
+ No count
+
+
+ 1 photo
+
+
+ 5 photos
+
+
+ 10 photos
+
+
+ )
+}
diff --git a/src/view/screens/Storybook/Storybook.tsx b/src/view/screens/Storybook/Storybook.tsx
index 8fb85e8e0..33c31a9ee 100644
--- a/src/view/screens/Storybook/Storybook.tsx
+++ b/src/view/screens/Storybook/Storybook.tsx
@@ -16,6 +16,7 @@ import {Breakpoints} from './Breakpoints'
import {Buttons} from './Buttons'
import {Dialogs} from './Dialogs'
import {Forms} from './Forms'
+import {GalleryFallback} from './GalleryFallback'
import {Icons} from './Icons'
import {Links} from './Links'
import {Menus} from './Menus'
@@ -120,6 +121,7 @@ export default function Storybook() {
+
} label={l`Group chat invite link dialog`} style={web({maxWidth: 400})}> {content} ) } function joinLinkToKey(joinLink: ChatBskyGroupDefs.JoinLinkView): string { return `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}` } function keyToJoinLink( key: string, ): Pick { const [joinRule, requireApproval] = key.split(':') return { joinRule, requireApproval: requireApproval === 'requireApproval', } }
-#: src/screens/Messages/components/InviteLinkDialog.tsx:179
-msgid "Group chats can only have a maximum of {0}."
-msgstr "Group chats can only have a maximum of {0}."
+#. placeholder {0}: convo.details.memberLimit
+#: src/screens/Messages/components/InviteLinkDialog.tsx:178
+msgid "Group chats can only have a maximum of {0, plural, other {# people}}."
+msgstr "Group chats can only have a maximum of {0, plural, other {# people}}."
#: src/components/dialogs/SearchablePeopleList.tsx:569
msgid "Group is locked"
@@ -5586,7 +5632,7 @@ msgstr ""
msgid "Help"
msgstr ""
-#: src/screens/Onboarding/StepProfile/index.tsx:261
+#: src/screens/Onboarding/StepProfile/index.tsx:262
msgid "Help people know you're not a bot by uploading a picture or creating an avatar."
msgstr ""
@@ -5759,14 +5805,14 @@ msgstr ""
msgid "Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!"
msgstr ""
-#: src/screens/Messages/Conversation.tsx:337
+#: src/screens/Messages/Conversation.tsx:362
msgid "Hold your horses! This feature isn't available to you yet. Please check back later."
msgstr "Hold your horses! This feature isn't available to you yet. Please check back later."
#: src/Navigation.tsx:740
#: src/Navigation.tsx:761
-#: src/view/shell/bottom-bar/BottomBar.tsx:184
-#: src/view/shell/desktop/LeftNav.tsx:666
+#: src/view/shell/bottom-bar/BottomBar.tsx:185
+#: src/view/shell/desktop/LeftNav.tsx:667
#: src/view/shell/Drawer.tsx:443
msgid "Home"
msgstr ""
@@ -5824,7 +5870,7 @@ msgstr ""
msgid "I'm on Bluesky as {0} - come find me! https://bsky.app/download"
msgstr ""
-#: src/components/Lightbox/Lightbox.web.tsx:242
+#: src/components/Lightbox/Lightbox.web.tsx:246
msgid "If alt text is long, toggles alt text expanded state"
msgstr ""
@@ -5889,32 +5935,32 @@ msgid "If you're trying to change your handle or email, do so before you deactiv
msgstr ""
#. Ignore a request to join a chat
-#: src/screens/Messages/JoinRequests.tsx:485
+#: src/screens/Messages/JoinRequests.tsx:484
msgctxt "button"
msgid "Ignore"
msgstr "Ignore"
-#: src/screens/Messages/JoinRequests.tsx:479
+#: src/screens/Messages/JoinRequests.tsx:478
msgid "Ignore join request"
msgstr "Ignore join request"
-#: src/components/images/ImageLayoutGridItem.tsx:93
+#: src/components/images/ImageLayoutGridItem.tsx:96
msgid "Image"
msgstr ""
#. placeholder {0}: index + 1
-#: src/components/images/Gallery/index.tsx:443
+#: src/components/images/Gallery/index.tsx:451
msgid "Image {0}"
msgstr "Image {0}"
#. placeholder {0}: index + 1
#. placeholder {1}: imgs.length
-#: src/components/Lightbox/Lightbox.web.tsx:257
+#: src/components/Lightbox/Lightbox.web.tsx:261
msgid "Image {0} of {1}"
msgstr ""
#. placeholder {0}: index + 1
-#: src/components/images/Gallery/index.tsx:428
+#: src/components/images/Gallery/index.tsx:436
msgid "Image {0} of {imageCount}"
msgstr "Image {0} of {imageCount}"
@@ -5944,12 +5990,12 @@ msgid "Image is unavailable."
msgstr "Image is unavailable."
#: src/components/Lightbox/chrome/ImageMenu.tsx:73
-#: src/components/Lightbox/Lightbox.web.tsx:261
-#: src/components/Lightbox/Lightbox.web.tsx:269
+#: src/components/Lightbox/Lightbox.web.tsx:265
+#: src/components/Lightbox/Lightbox.web.tsx:273
msgid "Image options"
msgstr "Image options"
-#: src/components/Lightbox/Lightbox.web.tsx:312
+#: src/components/Lightbox/Lightbox.web.tsx:316
#: src/lib/media/save-image.ios.ts:25
#: src/lib/media/save-image.ts:29
msgid "Image saved"
@@ -6075,7 +6121,7 @@ msgstr ""
msgid "Invalid 2FA confirmation code."
msgstr ""
-#: src/components/intents/GroupChatJoinDialog.tsx:126
+#: src/components/intents/GroupChatJoinDialog.tsx:127
msgid "Invalid group chat code."
msgstr "Invalid group chat code."
@@ -6097,7 +6143,7 @@ msgstr ""
msgid "Invalid report subject"
msgstr ""
-#: src/components/intents/GroupChatJoinDialog.tsx:157
+#: src/components/intents/GroupChatJoinDialog.tsx:158
msgid "Invalid rescind request."
msgstr "Invalid rescind request."
@@ -6130,10 +6176,10 @@ msgstr ""
msgid "Invite friends <0/>"
msgstr ""
-#: src/screens/Messages/components/InviteLinkDialog.tsx:167
-#: src/screens/Messages/components/InviteLinkDialog.tsx:309
-#: src/screens/Messages/components/InviteLinkDialog.tsx:315
-#: src/screens/Messages/components/InviteLinkDialog.tsx:482
+#: src/screens/Messages/components/InviteLinkDialog.tsx:166
+#: src/screens/Messages/components/InviteLinkDialog.tsx:304
+#: src/screens/Messages/components/InviteLinkDialog.tsx:310
+#: src/screens/Messages/components/InviteLinkDialog.tsx:477
#: src/screens/Messages/components/MessagesListGroupInfoPanel.tsx:148
#: src/screens/Messages/ConversationSettings/index.tsx:490
msgid "Invite link"
@@ -6144,7 +6190,7 @@ msgid "Invite link created"
msgstr "Invite link created"
#: src/components/dms/getSystemMessageInfo.ts:138
-#: src/screens/Messages/components/InviteLinkDialog.tsx:309
+#: src/screens/Messages/components/InviteLinkDialog.tsx:304
msgid "Invite link disabled"
msgstr "Invite link disabled"
@@ -6194,7 +6240,7 @@ msgid "It's just you right now! Add more people to your starter pack by searchin
msgstr ""
#. placeholder {0}: videoState.jobId
-#: src/view/com/composer/Composer.tsx:2386
+#: src/view/com/composer/Composer.tsx:2452
msgid "Job ID: {0}"
msgstr ""
@@ -6203,7 +6249,8 @@ msgstr ""
msgid "Jobs"
msgstr ""
-#: src/components/intents/GroupChatJoinDialog.tsx:253
+#: src/components/dms/ChatInvite/Root.tsx:98
+#: src/components/intents/GroupChatJoinDialog.tsx:254
msgid "Join"
msgstr "Join"
@@ -6215,11 +6262,11 @@ msgid "Join Bluesky"
msgstr ""
#: src/components/intents/GroupChatJoinDialog.tsx:63
-#: src/components/intents/GroupChatJoinDialog.tsx:415
+#: src/components/intents/GroupChatJoinDialog.tsx:419
msgid "Join group chat"
msgstr "Join group chat"
-#: src/components/intents/GroupChatJoinDialog.tsx:146
+#: src/components/intents/GroupChatJoinDialog.tsx:147
msgid "Join request rescinded."
msgstr "Join request rescinded."
@@ -6228,16 +6275,12 @@ msgstr "Join request rescinded."
msgid "Join the conversation"
msgstr ""
-#: src/components/intents/GroupChatJoinDialog.tsx:420
-msgid "Join this group chat"
-msgstr "Join this group chat"
-
#: src/lib/interests.ts:63
msgid "Journalism"
msgstr ""
-#: src/view/com/composer/Composer.tsx:1324
-#: src/view/com/composer/Composer.tsx:1334
+#: src/view/com/composer/Composer.tsx:1393
+#: src/view/com/composer/Composer.tsx:1403
#: src/view/com/composer/drafts/DraftsButton.tsx:135
msgid "Keep editing"
msgstr ""
@@ -6538,7 +6581,7 @@ msgstr ""
msgid "Linear"
msgstr ""
-#: src/components/Lightbox/Lightbox.web.tsx:296
+#: src/components/Lightbox/Lightbox.web.tsx:300
msgid "Link copied to clipboard"
msgstr "Link copied to clipboard"
@@ -6632,7 +6675,7 @@ msgstr ""
#: src/view/screens/Lists.tsx:60
#: src/view/screens/Profile.tsx:233
#: src/view/screens/Profile.tsx:241
-#: src/view/shell/desktop/LeftNav.tsx:721
+#: src/view/shell/desktop/LeftNav.tsx:722
#: src/view/shell/Drawer.tsx:548
msgid "Lists"
msgstr ""
@@ -6900,7 +6943,7 @@ msgstr ""
msgid "Message {displayName}"
msgstr "Message {displayName}"
-#: src/screens/Messages/components/ChatListItem.tsx:325
+#: src/screens/Messages/components/ChatListItem.tsx:330
msgid "Message deleted"
msgstr ""
@@ -6909,7 +6952,7 @@ msgctxt "toast"
msgid "Message deleted"
msgstr ""
-#: src/components/dms/MessageItem.tsx:522
+#: src/components/dms/MessageItem.tsx:535
msgid "Message failed to send."
msgstr "Message failed to send."
@@ -6946,11 +6989,11 @@ msgstr ""
msgid "Messages"
msgstr ""
-#: src/components/dms/MessageItem.tsx:621
+#: src/components/dms/MessageItem.tsx:634
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:616
+#: src/components/dms/MessageItem.tsx:629
msgid "Messages from this person are hidden while you are blocking them."
msgstr "Messages from this person are hidden while you are blocking them."
@@ -7344,7 +7387,7 @@ msgid "New post"
msgstr ""
#: src/view/com/feeds/FeedPage.tsx:179
-#: src/view/shell/desktop/LeftNav.tsx:597
+#: src/view/shell/desktop/LeftNav.tsx:598
msgctxt "action"
msgid "New post"
msgstr ""
@@ -7479,7 +7522,7 @@ msgstr ""
msgid "No media yet"
msgstr ""
-#: src/screens/Messages/components/ChatListItem.tsx:311
+#: src/screens/Messages/components/ChatListItem.tsx:316
msgid "No messages yet"
msgstr ""
@@ -7680,8 +7723,8 @@ msgstr ""
#: src/screens/Settings/Settings.tsx:197
#: src/screens/Settings/Settings.tsx:200
#: src/view/screens/Notifications.tsx:128
-#: src/view/shell/bottom-bar/BottomBar.tsx:260
-#: src/view/shell/desktop/LeftNav.tsx:686
+#: src/view/shell/bottom-bar/BottomBar.tsx:261
+#: src/view/shell/desktop/LeftNav.tsx:687
#: src/view/shell/Drawer.tsx:496
msgid "Notifications"
msgstr ""
@@ -7724,7 +7767,7 @@ msgstr ""
#: src/components/BotAccountAlert.tsx:52
#: src/components/BotAccountAlert.tsx:57
#: src/components/dms/InitiateChatFlow.tsx:677
-#: src/components/dms/MessageItem.tsx:628
+#: src/components/dms/MessageItem.tsx:641
#: src/screens/Login/PasswordUpdatedForm.tsx:37
#: src/screens/PostThread/components/ThreadItemAnchor.tsx:661
msgid "Okay"
@@ -7757,27 +7800,27 @@ msgstr "One of the selected recipients does not allow group chats."
msgid "One of the selected recipients has blocked you and cannot be messaged."
msgstr "One of the selected recipients has blocked you and cannot be messaged."
-#: src/view/com/composer/Composer.tsx:787
+#: src/view/com/composer/Composer.tsx:853
msgid "One or more GIFs is missing alt text."
msgstr ""
-#: src/view/com/composer/Composer.tsx:784
+#: src/view/com/composer/Composer.tsx:850
msgid "One or more images is missing alt text."
msgstr ""
-#: src/view/com/composer/SelectMediaButton.tsx:415
+#: src/view/com/composer/SelectMediaButton.tsx:417
msgid "One or more of your selected files are not supported."
msgstr ""
-#: src/view/com/composer/SelectMediaButton.tsx:438
+#: src/view/com/composer/SelectMediaButton.tsx:440
msgid "One or more of your selected files are too large. Maximum size is {VIDEO_MAX_SIZE_MB} MB."
msgstr "One or more of your selected files are too large. Maximum size is {VIDEO_MAX_SIZE_MB} MB."
-#: src/view/com/composer/Composer.tsx:589
+#: src/view/com/composer/Composer.tsx:648
msgid "One or more posts are too long to save as a draft. {MAX_DRAFT_GRAPHEME_LENGTH, plural, one {The maximum number of characters is # character.} other {The maximum number of characters is # characters.}}"
msgstr ""
-#: src/view/com/composer/Composer.tsx:794
+#: src/view/com/composer/Composer.tsx:860
msgid "One or more videos is missing alt text."
msgstr ""
@@ -7786,6 +7829,14 @@ msgstr ""
msgid "Only {0} can reply."
msgstr ""
+#. Toast shown when adding images would exceed the post gallery cap; only the first N are kept
+#. placeholder {0}: result.accepted.length
+#. placeholder {1}: next.length
+#. placeholder {2}: next.length
+#: src/view/com/composer/Composer.tsx:223
+msgid "Only {0} of {1} {2, plural, one {image} other {images}} added; limit is {MAX_GALLERY_IMAGES}"
+msgstr "Only {0} of {1} {2, plural, one {image} other {images}} added; limit is {MAX_GALLERY_IMAGES}"
+
#: src/screens/Messages/JoinRequests.tsx:196
msgid "Only admins can accept join requests."
msgstr "Only admins can accept join requests."
@@ -7794,7 +7845,7 @@ msgstr "Only admins can accept join requests."
msgid "Only admins can ignore join requests."
msgstr "Only admins can ignore join requests."
-#: src/components/intents/GroupChatJoinDialog.tsx:124
+#: src/components/intents/GroupChatJoinDialog.tsx:125
msgid "Only followers can join this group chat."
msgstr "Only followers can join this group chat."
@@ -7813,7 +7864,8 @@ msgstr ""
msgid "Only people {0} follows can join."
msgstr "Only people {0} follows can join."
-#: src/components/intents/GroupChatJoinDialog.tsx:268
+#: src/components/dms/ChatInvite/Root.tsx:113
+#: src/components/intents/GroupChatJoinDialog.tsx:269
msgid "Only people the chat owner follows can join"
msgstr "Only people the chat owner follows can join"
@@ -7834,7 +7886,7 @@ msgstr ""
msgid "Oops!"
msgstr ""
-#: src/screens/Onboarding/StepProfile/index.tsx:310
+#: src/screens/Onboarding/StepProfile/index.tsx:311
msgid "Open avatar creator"
msgstr ""
@@ -7842,12 +7894,17 @@ msgstr ""
msgid "Open camera"
msgstr ""
+#: src/components/dms/ChatInvite/Root.tsx:85
+#: src/components/intents/GroupChatJoinDialog.tsx:408
+msgid "Open chat"
+msgstr "Open chat"
+
#: src/screens/Messages/ConversationSettings/MemberMenu.tsx:139
msgid "Open chat member options for {displayName}"
msgstr "Open chat member options for {displayName}"
-#: src/screens/Messages/components/ChatListItem.tsx:486
-#: src/screens/Messages/components/ChatListItem.tsx:490
+#: src/screens/Messages/components/ChatListItem.tsx:491
+#: src/screens/Messages/components/ChatListItem.tsx:495
msgid "Open conversation options"
msgstr ""
@@ -7861,7 +7918,7 @@ msgstr ""
#: src/screens/Messages/components/MessageComposer.tsx:176
#: src/screens/Messages/components/MessageInput.web.tsx:148
-#: src/view/com/composer/Composer.tsx:2026
+#: src/view/com/composer/Composer.tsx:2092
msgid "Open emoji picker"
msgstr ""
@@ -7882,7 +7939,7 @@ msgstr ""
msgid "Open Germ DM"
msgstr ""
-#: src/components/intents/GroupChatJoinDialog.tsx:399
+#: src/components/intents/GroupChatJoinDialog.tsx:401
msgid "Open group chat"
msgstr "Open group chat"
@@ -7943,7 +8000,7 @@ msgstr ""
msgid "Open system log"
msgstr ""
-#: src/components/intents/GroupChatJoinDialog.tsx:400
+#: src/components/intents/GroupChatJoinDialog.tsx:402
msgid "Open this group chat"
msgstr "Open this group chat"
@@ -7968,7 +8025,7 @@ msgstr ""
msgid "Opens alt text dialog"
msgstr ""
-#: src/view/com/composer/photos/OpenCameraBtn.tsx:71
+#: src/view/com/composer/photos/OpenCameraBtn.tsx:70
msgid "Opens camera on device"
msgstr ""
@@ -7988,10 +8045,10 @@ msgstr ""
msgid "Opens device camera"
msgstr ""
-#. 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.
-#: src/view/com/composer/SelectMediaButton.tsx:509
-msgid "Opens device gallery to select up to {MAX_IMAGES, plural, other {# images}}, or a single video or GIF."
-msgstr ""
+#. Accessibility hint for button in composer to add images, a video, or a GIF to a post.
+#: src/view/com/composer/SelectMediaButton.tsx:511
+msgid "Opens device gallery to select up to {MAX_GALLERY_IMAGES, plural, other {# images}}, or a single video or GIF."
+msgstr "Opens device gallery to select up to {MAX_GALLERY_IMAGES, plural, other {# images}}, or a single video or GIF."
#: src/screens/Messages/JoinRequest.tsx:295
#: src/view/com/auth/SplashScreen.tsx:102
@@ -8005,7 +8062,7 @@ msgstr ""
msgid "Opens flow to sign in to your existing Bluesky account"
msgstr ""
-#: src/components/images/Gallery/index.tsx:444
+#: src/components/images/Gallery/index.tsx:452
msgid "Opens full image"
msgstr "Opens full image"
@@ -8022,7 +8079,7 @@ msgstr ""
msgid "Opens link {0}"
msgstr ""
-#: src/view/com/util/UserAvatar.tsx:600
+#: src/view/com/util/UserAvatar.tsx:603
msgid "Opens live status dialog"
msgstr ""
@@ -8051,9 +8108,9 @@ msgstr ""
msgid "Opens this draft in the composer"
msgstr ""
-#: src/components/dms/MessageItem.tsx:229
+#: src/components/dms/MessageItem.tsx:233
#: src/view/com/notifications/NotificationFeedItem.tsx:1019
-#: src/view/com/util/UserAvatar.tsx:618
+#: src/view/com/util/UserAvatar.tsx:621
msgid "Opens this profile"
msgstr ""
@@ -8197,11 +8254,11 @@ msgstr ""
msgid "People"
msgstr ""
-#: src/screens/Messages/components/InviteLinkDialog.tsx:154
+#: src/screens/Messages/components/InviteLinkDialog.tsx:153
msgid "People {ownerName} follows can join instantly"
msgstr "People {ownerName} follows can join instantly"
-#: src/screens/Messages/components/InviteLinkDialog.tsx:159
+#: src/screens/Messages/components/InviteLinkDialog.tsx:158
msgid "People {ownerName} follows can request to join"
msgstr "People {ownerName} follows can request to join"
@@ -8220,11 +8277,11 @@ msgstr ""
msgid "People I follow"
msgstr ""
-#: src/screens/Messages/components/InviteLinkDialog.tsx:153
+#: src/screens/Messages/components/InviteLinkDialog.tsx:152
msgid "People I follow can join instantly"
msgstr "People I follow can join instantly"
-#: src/screens/Messages/components/InviteLinkDialog.tsx:158
+#: src/screens/Messages/components/InviteLinkDialog.tsx:157
msgid "People I follow can request to join"
msgstr "People I follow can request to join"
@@ -8512,7 +8569,7 @@ msgstr ""
msgid "Porn"
msgstr ""
-#: src/view/com/composer/Composer.tsx:1675
+#: src/view/com/composer/Composer.tsx:1740
msgctxt "action"
msgid "Post"
msgstr ""
@@ -8532,12 +8589,12 @@ msgstr ""
msgid "Post a video"
msgstr ""
-#: src/view/com/composer/Composer.tsx:1673
+#: src/view/com/composer/Composer.tsx:1738
msgctxt "action"
msgid "Post All"
msgstr ""
-#: src/view/com/composer/Composer.tsx:1333
+#: src/view/com/composer/Composer.tsx:1402
msgid "Post anyway"
msgstr "Post anyway"
@@ -8558,7 +8615,7 @@ msgctxt "toast"
msgid "Post deleted"
msgstr ""
-#: src/lib/api/index.ts:186
+#: src/lib/api/index.ts:189
msgid "Post failed to upload. Please check your Internet connection and try again."
msgstr ""
@@ -8593,8 +8650,8 @@ msgstr ""
msgid "Post language selection"
msgstr ""
-#: src/screens/Messages/components/InviteLinkDialog.tsx:371
-#: src/screens/Messages/components/InviteLinkDialog.tsx:383
+#: src/screens/Messages/components/InviteLinkDialog.tsx:366
+#: src/screens/Messages/components/InviteLinkDialog.tsx:378
msgid "Post link"
msgstr "Post link"
@@ -8710,15 +8767,15 @@ msgstr ""
msgid "Privacy violation of a minor"
msgstr ""
-#: src/view/com/composer/Composer.tsx:2460
+#: src/view/com/composer/Composer.tsx:2526
msgid "Processing GIF..."
msgstr ""
-#: src/view/com/composer/Composer.tsx:2462
+#: src/view/com/composer/Composer.tsx:2528
msgid "Processing video..."
msgstr ""
-#: src/lib/api/index.ts:60
+#: src/lib/api/index.ts:62
msgid "Processing..."
msgstr ""
@@ -8726,8 +8783,8 @@ msgstr ""
msgid "profile"
msgstr ""
-#: src/view/shell/bottom-bar/BottomBar.tsx:303
-#: src/view/shell/desktop/LeftNav.tsx:744
+#: src/view/shell/bottom-bar/BottomBar.tsx:304
+#: src/view/shell/desktop/LeftNav.tsx:745
#: src/view/shell/Drawer.tsx:80
#: src/view/shell/Drawer.tsx:599
msgid "Profile"
@@ -8759,22 +8816,22 @@ msgid "Public, sharable lists of users to mute or block in bulk."
msgstr ""
#. Accessibility label for button to publish a single post
-#: src/view/com/composer/Composer.tsx:1659
+#: src/view/com/composer/Composer.tsx:1724
msgid "Publish post"
msgstr ""
#. Accessibility label for button to publish multiple posts in a thread
-#: src/view/com/composer/Composer.tsx:1654
+#: src/view/com/composer/Composer.tsx:1719
msgid "Publish posts"
msgstr ""
#. Accessibility label for button to publish multiple replies in a thread
-#: src/view/com/composer/Composer.tsx:1643
+#: src/view/com/composer/Composer.tsx:1708
msgid "Publish replies"
msgstr ""
#. Accessibility label for button to publish a single reply
-#: src/view/com/composer/Composer.tsx:1648
+#: src/view/com/composer/Composer.tsx:1713
msgid "Publish reply"
msgstr ""
@@ -8853,11 +8910,11 @@ msgstr ""
msgid "Re-attach quote"
msgstr ""
-#: src/screens/Messages/components/InviteLinkDialog.tsx:401
+#: src/screens/Messages/components/InviteLinkDialog.tsx:396
msgid "Re-enable invite link"
msgstr "Re-enable invite link"
-#: src/screens/Messages/components/InviteLinkDialog.tsx:408
+#: src/screens/Messages/components/InviteLinkDialog.tsx:403
msgid "Re-enable link"
msgstr "Re-enable link"
@@ -8961,6 +9018,10 @@ msgstr ""
msgid "Reconnect"
msgstr ""
+#: src/components/Post/Embed/GalleryFallbackEmbed.tsx:80
+msgid "Refresh the page to see it."
+msgstr "Refresh the page to see it."
+
#. Reject a chat request, this opens a menu with options
#: src/screens/Messages/components/RequestButtons.tsx:131
msgid "Reject"
@@ -9016,17 +9077,17 @@ msgstr ""
msgid "Remove all contacts"
msgstr ""
-#: src/view/com/composer/ExternalEmbedRemoveBtn.tsx:19
+#: src/view/com/composer/ExternalEmbedRemoveBtn.tsx:18
msgid "Remove attachment"
msgstr ""
-#: src/view/com/util/UserAvatar.tsx:520
#: src/view/com/util/UserAvatar.tsx:523
+#: src/view/com/util/UserAvatar.tsx:526
msgid "Remove Avatar"
msgstr ""
-#: src/view/com/util/UserBanner.tsx:190
#: src/view/com/util/UserBanner.tsx:193
+#: src/view/com/util/UserBanner.tsx:196
msgid "Remove Banner"
msgstr ""
@@ -9034,8 +9095,9 @@ msgstr ""
msgid "Remove caption file"
msgstr "Remove caption file"
-#: src/screens/Messages/components/MessageInputEmbed.tsx:192
-#: src/screens/Messages/components/MessageInputEmbed.tsx:248
+#: src/screens/Messages/components/MessageInputEmbed.tsx:234
+#: src/screens/Messages/components/MessageInputEmbed.tsx:289
+#: src/screens/Messages/components/MessageInputEmbed.tsx:355
msgid "Remove embed"
msgstr ""
@@ -9079,7 +9141,7 @@ msgstr ""
msgid "Remove from your feeds?"
msgstr ""
-#: src/view/com/composer/photos/Gallery.tsx:225
+#: src/view/com/composer/photos/Gallery.tsx:227
msgid "Remove image"
msgstr ""
@@ -9129,11 +9191,11 @@ msgstr ""
msgid "Remove your verification for this account?"
msgstr ""
-#: src/components/Post/Embed/index.tsx:225
+#: src/components/Post/Embed/index.tsx:244
msgid "Removed by author"
msgstr ""
-#: src/components/Post/Embed/index.tsx:223
+#: src/components/Post/Embed/index.tsx:242
msgid "Removed by you"
msgstr ""
@@ -9223,7 +9285,7 @@ msgstr ""
msgid "Replies to this post are disabled."
msgstr ""
-#: src/view/com/composer/Composer.tsx:1671
+#: src/view/com/composer/Composer.tsx:1736
msgctxt "action"
msgid "Reply"
msgstr ""
@@ -9408,14 +9470,10 @@ msgstr ""
msgid "Reposts of your reposts"
msgstr ""
-#: src/components/intents/GroupChatJoinDialog.tsx:414
+#: src/components/intents/GroupChatJoinDialog.tsx:418
msgid "Request access to group chat"
msgstr "Request access to group chat"
-#: src/components/intents/GroupChatJoinDialog.tsx:419
-msgid "Request access to join this group chat"
-msgstr "Request access to join this group chat"
-
#: src/screens/Messages/JoinRequests.tsx:178
msgid "Request approved."
msgstr "Request approved."
@@ -9429,10 +9487,15 @@ msgstr ""
msgid "Request ignored."
msgstr "Request ignored."
-#: src/components/intents/GroupChatJoinDialog.tsx:252
+#: src/components/dms/ChatInvite/Root.tsx:98
+#: src/components/intents/GroupChatJoinDialog.tsx:253
msgid "Request to join"
msgstr "Request to join"
+#: src/components/dms/ChatInvite/Root.tsx:117
+msgid "Requested"
+msgstr "Requested"
+
#. Incoming message requests
#: src/screens/Messages/components/InboxRequests.tsx:24
msgid "Requests"
@@ -9440,7 +9503,7 @@ msgstr "Requests"
#: src/Navigation.tsx:495
#: src/screens/Messages/JoinRequests.tsx:58
-#: src/screens/Messages/JoinRequests.tsx:421
+#: src/screens/Messages/JoinRequests.tsx:420
msgid "Requests to join"
msgstr "Requests to join"
@@ -9461,7 +9524,7 @@ msgstr ""
msgid "Required in your region"
msgstr ""
-#: src/components/intents/GroupChatJoinDialog.tsx:272
+#: src/components/intents/GroupChatJoinDialog.tsx:273
msgid "Rescind request"
msgstr "Rescind request"
@@ -9617,28 +9680,28 @@ msgstr ""
#: src/screens/SavedFeeds.tsx:124
#: src/screens/SavedFeeds.tsx:311
#: src/screens/SavedFeeds.tsx:315
-#: src/view/com/composer/Composer.tsx:1314
+#: src/view/com/composer/Composer.tsx:1383
#: src/view/com/composer/drafts/DraftsButton.tsx:125
msgid "Save changes"
msgstr ""
-#: src/view/com/composer/Composer.tsx:1286
+#: src/view/com/composer/Composer.tsx:1355
#: src/view/com/composer/drafts/DraftsButton.tsx:93
msgid "Save changes?"
msgstr ""
-#: src/view/com/composer/Composer.tsx:1314
+#: src/view/com/composer/Composer.tsx:1383
#: src/view/com/composer/drafts/DraftsButton.tsx:125
msgid "Save draft"
msgstr ""
-#: src/view/com/composer/Composer.tsx:1288
+#: src/view/com/composer/Composer.tsx:1357
#: src/view/com/composer/drafts/DraftsButton.tsx:95
msgid "Save draft?"
msgstr ""
#: src/components/Lightbox/chrome/ImageMenu.tsx:99
-#: src/components/MediaPreview.tsx:197
+#: src/components/MediaPreview.tsx:229
#: src/components/Post/Embed/ImageContextMenu.tsx:70
#: src/components/StarterPack/ShareDialog.tsx:144
#: src/components/StarterPack/ShareDialog.tsx:150
@@ -9663,7 +9726,7 @@ msgstr ""
msgid "Save to my feeds"
msgstr ""
-#: src/view/shell/desktop/LeftNav.tsx:731
+#: src/view/shell/desktop/LeftNav.tsx:732
#: src/view/shell/Drawer.tsx:574
msgctxt "link to bookmarks screen"
msgid "Saved"
@@ -9721,7 +9784,7 @@ msgstr ""
#: src/components/forms/SearchInput.tsx:53
#: src/screens/Search/Shell.tsx:362
#: src/screens/Search/Shell.tsx:525
-#: src/view/shell/bottom-bar/BottomBar.tsx:204
+#: src/view/shell/bottom-bar/BottomBar.tsx:205
msgid "Search"
msgstr ""
@@ -10048,7 +10111,7 @@ msgstr ""
msgid "Select your preferred notification channels"
msgstr ""
-#: src/view/com/composer/SelectMediaButton.tsx:418
+#: src/view/com/composer/SelectMediaButton.tsx:420
msgid "Selecting multiple media types is not supported."
msgstr ""
@@ -10079,7 +10142,7 @@ msgstr "Send error report"
msgid "Send feedback"
msgstr ""
-#: src/screens/Messages/components/MessageComposer.tsx:284
+#: src/screens/Messages/components/MessageComposer.tsx:288
#: src/screens/Messages/components/MessageInput.web.tsx:225
msgid "Send message"
msgstr ""
@@ -10157,7 +10220,7 @@ msgstr ""
#: src/Navigation.tsx:212
#: src/screens/Settings/Settings.tsx:98
-#: src/view/shell/desktop/LeftNav.tsx:754
+#: src/view/shell/desktop/LeftNav.tsx:755
#: src/view/shell/Drawer.tsx:612
msgid "Settings"
msgstr ""
@@ -10219,12 +10282,12 @@ msgstr ""
msgid "Sexually Suggestive"
msgstr ""
-#: src/components/MediaPreview.tsx:203
+#: src/components/MediaPreview.tsx:235
#: src/components/Post/Embed/ImageContextMenu.tsx:74
#: src/components/StarterPack/QrCodeDialog.tsx:195
#: src/screens/Hashtag.tsx:130
-#: src/screens/Messages/components/InviteLinkDialog.tsx:387
-#: src/screens/Messages/components/InviteLinkDialog.tsx:394
+#: src/screens/Messages/components/InviteLinkDialog.tsx:382
+#: src/screens/Messages/components/InviteLinkDialog.tsx:389
#: src/screens/StarterPack/StarterPackScreen.tsx:447
#: src/screens/Topic.tsx:90
msgid "Share"
@@ -10240,8 +10303,8 @@ msgid "Share author DID"
msgstr ""
#: src/components/Lightbox/chrome/ImageMenu.tsx:94
-#: src/components/Lightbox/Lightbox.web.tsx:277
-#: src/components/Lightbox/Lightbox.web.tsx:303
+#: src/components/Lightbox/Lightbox.web.tsx:281
+#: src/components/Lightbox/Lightbox.web.tsx:307
msgid "Share image"
msgstr "Share image"
@@ -10446,10 +10509,10 @@ msgstr ""
#: src/view/com/auth/SplashScreen.tsx:124
#: src/view/com/auth/SplashScreen.web.tsx:122
#: src/view/com/auth/SplashScreen.web.tsx:130
-#: src/view/shell/bottom-bar/BottomBar.tsx:342
-#: src/view/shell/bottom-bar/BottomBar.tsx:347
-#: src/view/shell/bottom-bar/BottomBarWeb.tsx:245
+#: src/view/shell/bottom-bar/BottomBar.tsx:343
+#: src/view/shell/bottom-bar/BottomBar.tsx:348
#: src/view/shell/bottom-bar/BottomBarWeb.tsx:250
+#: src/view/shell/bottom-bar/BottomBarWeb.tsx:255
#: src/view/shell/NavSignupCard.tsx:58
#: src/view/shell/NavSignupCard.tsx:63
msgid "Sign in"
@@ -10500,9 +10563,9 @@ msgstr ""
#: src/screens/SignupQueued.tsx:94
#: src/screens/SignupQueued.tsx:97
#: src/screens/Takendown.tsx:88
-#: src/view/shell/desktop/LeftNav.tsx:226
-#: src/view/shell/desktop/LeftNav.tsx:281
-#: src/view/shell/desktop/LeftNav.tsx:284
+#: src/view/shell/desktop/LeftNav.tsx:227
+#: src/view/shell/desktop/LeftNav.tsx:282
+#: src/view/shell/desktop/LeftNav.tsx:285
msgid "Sign out"
msgstr ""
@@ -10511,7 +10574,7 @@ msgid "Sign Out"
msgstr ""
#: src/screens/Settings/Settings.tsx:296
-#: src/view/shell/desktop/LeftNav.tsx:223
+#: src/view/shell/desktop/LeftNav.tsx:224
msgid "Sign out?"
msgstr ""
@@ -10543,7 +10606,7 @@ msgstr ""
msgid "Skip contact sharing and continue to the app"
msgstr ""
-#: src/view/com/composer/Composer.tsx:1331
+#: src/view/com/composer/Composer.tsx:1400
msgid "Skip empty posts?"
msgstr "Skip empty posts?"
@@ -10557,7 +10620,7 @@ msgstr ""
msgid "Skip to next step"
msgstr ""
-#: src/components/images/Gallery/index.tsx:427
+#: src/components/images/Gallery/index.tsx:435
msgid "slide"
msgstr "slide"
@@ -10614,7 +10677,7 @@ msgid "Someone left the group"
msgstr "Someone left the group"
#. placeholder {0}: reaction.value
-#: src/components/dms/MessageItem.tsx:270
+#: src/components/dms/MessageItem.tsx:274
msgid "Someone reacted {0}"
msgstr ""
@@ -10639,11 +10702,15 @@ msgstr "Someone was removed"
msgid "Someone was removed from the group"
msgstr "Someone was removed from the group"
+#: src/components/Post/Embed/GalleryFallbackEmbed.tsx:48
+msgid "Something new is here"
+msgstr "Something new is here"
+
#: 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 ""
-#: src/screens/Messages/Conversation.tsx:135
+#: src/screens/Messages/Conversation.tsx:137
#: src/screens/Messages/ConversationSettings/index.tsx:112
#: src/screens/Messages/JoinRequests.tsx:87
msgid "Something went wrong"
@@ -10848,13 +10915,13 @@ msgid "Subscribe"
msgstr ""
#. placeholder {0}: highlightedPublisher.name
-#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:426
-#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:435
+#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:427
+#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:456
msgid "Subscribe on {0}"
msgstr "Subscribe on {0}"
#. placeholder {0}: highlightedPublisher.name
-#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:434
+#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:455
msgid "Subscribe to {publicationTitle} on {0}"
msgstr "Subscribe to {publicationTitle} on {0}"
@@ -10889,7 +10956,7 @@ msgstr ""
msgid "Success!"
msgstr ""
-#: src/components/intents/GroupChatJoinDialog.tsx:100
+#: src/components/intents/GroupChatJoinDialog.tsx:101
msgid "Successfully joined the group chat!"
msgstr "Successfully joined the group chat!"
@@ -10950,7 +11017,7 @@ msgstr "Suspended accounts cannot participate in chat."
#: src/screens/Settings/Settings.tsx:122
#: src/screens/Settings/Settings.tsx:134
#: src/screens/Settings/Settings.tsx:615
-#: src/view/shell/desktop/LeftNav.tsx:261
+#: src/view/shell/desktop/LeftNav.tsx:262
msgid "Switch account"
msgstr ""
@@ -10959,7 +11026,7 @@ msgid "Switch accounts"
msgstr ""
#. placeholder {0}: sanitizeHandle( profile?.handle ?? account.handle, '@', )
-#: src/view/shell/desktop/LeftNav.tsx:363
+#: src/view/shell/desktop/LeftNav.tsx:364
msgid "Switch to {0}"
msgstr ""
@@ -10985,7 +11052,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:567
+#: src/components/dms/MessageItem.tsx:580
msgid "Tap for details"
msgstr "Tap for details"
@@ -11011,10 +11078,23 @@ msgstr ""
msgid "Tap to close context menu"
msgstr ""
+#: src/components/dms/ChatInvite/Root.tsx:73
+msgid "Tap to copy this invite link"
+msgstr "Tap to copy this invite link"
+
#: src/components/ProgressGuide/Toast.tsx:163
msgid "Tap to dismiss"
msgstr ""
+#: src/components/dms/ChatInvite/Root.tsx:126
+#: src/components/intents/GroupChatJoinDialog.tsx:424
+msgid "Tap to join this group chat immediately"
+msgstr "Tap to join this group chat immediately"
+
+#: src/components/dms/ChatInvite/Root.tsx:86
+msgid "Tap to open this group chat"
+msgstr "Tap to open this group chat"
+
#: src/components/dms/ReactionsDialog.tsx:196
msgid "Tap to remove"
msgstr "Tap to remove"
@@ -11024,7 +11104,12 @@ msgstr "Tap to remove"
msgid "Tap to remove your {0} reaction"
msgstr "Tap to remove your {0} reaction"
-#: src/components/dms/MessageItem.tsx:532
+#: src/components/dms/ChatInvite/Root.tsx:125
+#: src/components/intents/GroupChatJoinDialog.tsx:423
+msgid "Tap to request access to join this group chat"
+msgstr "Tap to request access to join this group chat"
+
+#: src/components/dms/MessageItem.tsx:545
msgid "Tap to retry"
msgstr "Tap to retry"
@@ -11037,7 +11122,7 @@ msgstr "Tap to show {0} reactions"
msgid "Tap to show all reactions"
msgstr "Tap to show all reactions"
-#: src/components/dms/MessageItem.tsx:293
+#: src/components/dms/MessageItem.tsx:297
msgid "Tap to view reactions"
msgstr "Tap to view reactions"
@@ -11206,7 +11291,7 @@ msgstr ""
msgid "The laws in your region require you to verify you're an adult to access certain features. Tap to learn more."
msgstr ""
-#: src/components/intents/GroupChatJoinDialog.tsx:134
+#: src/components/intents/GroupChatJoinDialog.tsx:135
#: src/screens/Messages/JoinRequests.tsx:201
msgid "The member limit has been reached."
msgstr "The member limit has been reached."
@@ -11263,7 +11348,7 @@ msgstr ""
msgid "There is a limit to how often you can change your birthdate. You may need to wait a day or two before updating it again."
msgstr ""
-#: src/screens/Messages/components/InviteLinkDialog.tsx:487
+#: src/screens/Messages/components/InviteLinkDialog.tsx:482
msgid "There is no invite link for this group chat."
msgstr "There is no invite link for this group chat."
@@ -11277,8 +11362,8 @@ msgid "There was a problem loading GIFs. Check your connection and try again."
msgstr "There was a problem loading GIFs. Check your connection and try again."
#: src/components/contacts/screens/GetContacts.tsx:147
-#: src/components/intents/GroupChatJoinDialog.tsx:118
-#: src/components/intents/GroupChatJoinDialog.tsx:152
+#: src/components/intents/GroupChatJoinDialog.tsx:119
+#: src/components/intents/GroupChatJoinDialog.tsx:153
msgid "There was a problem with your internet connection, please try again"
msgstr ""
@@ -11435,11 +11520,12 @@ msgstr ""
msgid "This chat has ended"
msgstr "This chat has ended"
-#: src/components/intents/GroupChatJoinDialog.tsx:263
+#: src/components/dms/ChatInvite/Root.tsx:108
+#: src/components/intents/GroupChatJoinDialog.tsx:264
msgid "This chat is full"
msgstr "This chat is full"
-#: src/screens/Messages/components/ChatListItem.tsx:375
+#: src/screens/Messages/components/ChatListItem.tsx:380
#: src/screens/Messages/components/ChatLocked.tsx:61
msgid "This chat is locked"
msgstr "This chat is locked"
@@ -11478,11 +11564,11 @@ msgstr ""
msgid "This content is not viewable without a Bluesky account."
msgstr ""
-#: src/components/intents/GroupChatJoinDialog.tsx:120
+#: src/components/intents/GroupChatJoinDialog.tsx:121
msgid "This conversation is locked."
msgstr "This conversation is locked."
-#: src/screens/Messages/components/ChatListItem.tsx:155
+#: src/screens/Messages/components/ChatListItem.tsx:156
msgid "This conversation is with a deleted or a deactivated account. Press for options"
msgstr ""
@@ -11532,7 +11618,7 @@ msgstr ""
msgid "This handle is reserved. Please try a different one."
msgstr ""
-#: src/screens/Onboarding/StepProfile/index.tsx:141
+#: src/screens/Onboarding/StepProfile/index.tsx:142
msgid "This image could not be used. Try a different format like .jpg or .png."
msgstr "This image could not be used. Try a different format like .jpg or .png."
@@ -11540,7 +11626,7 @@ msgstr "This image could not be used. Try a different format like .jpg or .png."
msgid "This information is private and not shared with other users."
msgstr ""
-#: src/components/intents/GroupChatJoinDialog.tsx:130
+#: src/components/intents/GroupChatJoinDialog.tsx:131
msgid "This invite link has been disabled."
msgstr "This invite link has been disabled."
@@ -11548,7 +11634,7 @@ msgstr "This invite link has been disabled."
msgid "This invite link has expired"
msgstr "This invite link has expired"
-#: src/components/intents/GroupChatJoinDialog.tsx:194
+#: src/components/intents/GroupChatJoinDialog.tsx:195
msgid "This invite link is invalid"
msgstr "This invite link is invalid"
@@ -11594,13 +11680,13 @@ msgstr ""
msgid "This list is empty."
msgstr ""
-#: src/components/dms/MessageItem.tsx:565
-#: src/components/dms/MessageItem.tsx:594
+#: src/components/dms/MessageItem.tsx:578
+#: src/components/dms/MessageItem.tsx:607
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:564
-#: src/components/dms/MessageItem.tsx:590
+#: src/components/dms/MessageItem.tsx:577
+#: src/components/dms/MessageItem.tsx:603
msgid "This message is hidden because you are blocking this user."
msgstr "This message is hidden because you are blocking this user."
@@ -11638,7 +11724,7 @@ msgstr ""
msgid "This post will be hidden from feeds and threads. This cannot be undone."
msgstr ""
-#: src/view/com/composer/Composer.tsx:945
+#: src/view/com/composer/Composer.tsx:1013
msgid "This post's author has disabled quote posts."
msgstr ""
@@ -11980,7 +12066,7 @@ msgstr ""
msgid "Unavailable feed information"
msgstr ""
-#: src/components/dms/MessageItem.tsx:632
+#: src/components/dms/MessageItem.tsx:645
#: src/components/dms/MessagesListBlockedFooter.tsx:97
#: src/components/dms/MessagesListBlockedFooter.tsx:104
#: src/screens/Messages/ConversationSettings/MemberMenu.tsx:222
@@ -12219,11 +12305,11 @@ msgstr ""
msgid "Unsubscribed from list"
msgstr ""
-#: src/view/com/composer/text-input/TextInput.tsx:131
+#: src/view/com/composer/text-input/TextInput.tsx:128
msgid "Unsupported clipboard content"
msgstr "Unsupported clipboard content"
-#: src/view/com/composer/Composer.tsx:1424
+#: src/view/com/composer/Composer.tsx:1489
msgid "Unsupported video type: {mimeType}"
msgstr ""
@@ -12236,6 +12322,10 @@ msgstr ""
msgid "Update <0>{displayName}0> in Lists"
msgstr ""
+#: src/components/Post/Embed/GalleryFallbackEmbed.tsx:93
+msgid "Update app"
+msgstr "Update app"
+
#: src/components/dialogs/EmailDialog/screens/Update.tsx:296
#: src/components/dialogs/EmailDialog/screens/Update.tsx:308
#: src/screens/Settings/AccountSettings.tsx:112
@@ -12243,9 +12333,9 @@ msgstr ""
msgid "Update email"
msgstr ""
-#: src/screens/Messages/components/InviteLinkDialog.tsx:217
-#: src/screens/Messages/components/InviteLinkDialog.tsx:255
-#: src/screens/Messages/components/InviteLinkDialog.tsx:283
+#: src/screens/Messages/components/InviteLinkDialog.tsx:212
+#: src/screens/Messages/components/InviteLinkDialog.tsx:250
+#: src/screens/Messages/components/InviteLinkDialog.tsx:278
msgid "Update invite link"
msgstr "Update invite link"
@@ -12254,7 +12344,15 @@ msgstr "Update invite link"
msgid "Update to {domain}"
msgstr ""
-#: src/screens/Messages/Conversation.tsx:335
+#: src/components/Post/Embed/GalleryFallbackEmbed.tsx:85
+msgid "Update your app"
+msgstr "Update your app"
+
+#: src/components/Post/Embed/GalleryFallbackEmbed.tsx:76
+msgid "Update your app to see it."
+msgstr "Update your app to see it."
+
+#: src/screens/Messages/Conversation.tsx:360
msgid "Update your app to the latest version to join in!"
msgstr "Update your app to the latest version to join in!"
@@ -12279,7 +12377,7 @@ msgctxt "toast"
msgid "Updating reply visibility failed"
msgstr ""
-#: src/screens/Onboarding/StepProfile/index.tsx:314
+#: src/screens/Onboarding/StepProfile/index.tsx:315
msgid "Upload a photo instead"
msgstr ""
@@ -12287,39 +12385,40 @@ msgstr ""
msgid "Upload a text file to:"
msgstr ""
-#: 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/UserAvatar.tsx:497
#: src/view/com/util/UserBanner.tsx:164
+#: src/view/com/util/UserBanner.tsx:167
msgid "Upload from Camera"
msgstr ""
-#: src/view/com/util/UserAvatar.tsx:508
-#: src/view/com/util/UserBanner.tsx:178
+#: src/view/com/util/UserAvatar.tsx:511
+#: src/view/com/util/UserBanner.tsx:181
msgid "Upload from Files"
msgstr ""
-#: 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
+#: src/view/com/util/UserAvatar.tsx:505
+#: src/view/com/util/UserAvatar.tsx:509
+#: src/view/com/util/UserBanner.tsx:175
+#: src/view/com/util/UserBanner.tsx:179
msgid "Upload from Library"
msgstr ""
-#: src/view/com/composer/Composer.tsx:2453
+#: src/view/com/composer/Composer.tsx:2519
msgid "Uploading GIF..."
msgstr ""
-#: src/lib/api/index.ts:322
+#: src/lib/api/index.ts:327
+#: src/lib/api/index.ts:354
msgid "Uploading images..."
msgstr ""
-#: src/lib/api/index.ts:390
-#: src/lib/api/index.ts:414
+#: src/lib/api/index.ts:426
+#: src/lib/api/index.ts:450
msgid "Uploading link thumbnail..."
msgstr ""
-#: src/view/com/composer/Composer.tsx:2455
+#: src/view/com/composer/Composer.tsx:2521
msgid "Uploading video..."
msgstr ""
@@ -12608,7 +12707,7 @@ msgstr ""
msgid "Video settings"
msgstr ""
-#: src/view/com/composer/Composer.tsx:2473
+#: src/view/com/composer/Composer.tsx:2539
msgid "Video uploaded"
msgstr ""
@@ -12621,18 +12720,18 @@ msgstr ""
msgid "Videos"
msgstr ""
-#: src/view/com/composer/SelectMediaButton.tsx:432
+#: src/view/com/composer/SelectMediaButton.tsx:434
msgid "Videos must be less than 3 minutes long."
msgstr ""
-#: src/view/com/composer/Composer.tsx:1037
+#: src/view/com/composer/Composer.tsx:1106
msgctxt "Action to view the post the user just created"
msgid "View"
msgstr ""
#. placeholder {0}: view.source.title
#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:320
-#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:589
+#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:616
msgid "View {0}"
msgstr "View {0}"
@@ -12661,7 +12760,7 @@ msgstr "View {0}’s profile"
msgid "View {displayName}’s profile"
msgstr "View {displayName}’s profile"
-#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:437
+#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:458
msgid "View {publicationTitle}"
msgstr "View {publicationTitle}"
@@ -12696,6 +12795,14 @@ msgstr ""
msgid "View incoming group chat requests"
msgstr "View incoming group chat requests"
+#: src/screens/Messages/components/RequestStatus.tsx:54
+msgid "View incoming requests"
+msgstr "View incoming requests"
+
+#: src/screens/Messages/components/RequestStatus.tsx:55
+msgid "View incoming requests to join this group chat"
+msgstr "View incoming requests to join this group chat"
+
#: src/components/moderation/LabelsOnMe.tsx:56
msgid "View information about these labels"
msgstr ""
@@ -12711,7 +12818,7 @@ msgstr ""
msgid "View more trending videos"
msgstr ""
-#: src/view/com/composer/Composer.tsx:1032
+#: src/view/com/composer/Composer.tsx:1101
msgid "View post"
msgstr ""
@@ -12729,9 +12836,9 @@ msgid "View profile banner"
msgstr "View profile banner"
#: 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
+#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:428
+#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:459
+#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:616
msgid "View publication"
msgstr "View publication"
@@ -12854,7 +12961,7 @@ msgstr ""
msgid "We couldn't find any results for that topic."
msgstr ""
-#: src/screens/Messages/Conversation.tsx:136
+#: src/screens/Messages/Conversation.tsx:138
msgid "We couldn't load this conversation"
msgstr ""
@@ -13007,7 +13114,7 @@ msgstr "We’re sorry, but your search could not be completed. Please try again
msgid "We're sorry, you cannot access this screen at this time."
msgstr ""
-#: src/view/com/composer/Composer.tsx:943
+#: src/view/com/composer/Composer.tsx:1011
msgid "We're sorry! The post you are replying to has been deleted."
msgstr ""
@@ -13058,7 +13165,7 @@ msgid "What do you want to call your starter pack?"
msgstr ""
#: src/view/com/auth/SplashScreen.web.tsx:98
-#: src/view/com/composer/Composer.tsx:1384
+#: src/view/com/composer/Composer.tsx:1453
#: src/view/com/feeds/ComposerPrompt.tsx:193
msgid "What's up?"
msgstr ""
@@ -13071,7 +13178,7 @@ msgstr ""
msgid "Who can interact with this post?"
msgstr ""
-#: src/screens/Messages/components/InviteLinkDialog.tsx:227
+#: src/screens/Messages/components/InviteLinkDialog.tsx:222
msgid "Who can join this group chat and how"
msgstr "Who can join this group chat and how"
@@ -13144,7 +13251,7 @@ msgstr "Would you like to block this user and/or leave this conversation?"
msgid "Would you like to save this as a draft before viewing your drafts?"
msgstr ""
-#: src/view/com/composer/Composer.tsx:1302
+#: src/view/com/composer/Composer.tsx:1371
msgid "Would you like to save this as a draft to edit later?"
msgstr ""
@@ -13153,12 +13260,12 @@ msgstr ""
msgid "Write a post"
msgstr ""
-#: src/view/com/composer/Composer.tsx:1484
+#: src/view/com/composer/Composer.tsx:1549
msgid "Write post"
msgstr ""
#: src/screens/PostThread/components/ThreadComposePrompt.tsx:91
-#: src/view/com/composer/Composer.tsx:1382
+#: src/view/com/composer/Composer.tsx:1451
msgid "Write your reply"
msgstr ""
@@ -13224,7 +13331,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:605
+#: src/components/dms/MessageItem.tsx:618
msgid "You are blocking {0}"
msgstr "You are blocking {0}"
@@ -13325,7 +13432,12 @@ msgstr ""
msgid "You can now sign in with your new password."
msgstr ""
-#: src/view/com/composer/Composer.tsx:1307
+#. Toast shown when the user tries to add more images but the post gallery is already at the cap
+#: src/view/com/composer/Composer.tsx:212
+msgid "You can only add up to {MAX_GALLERY_IMAGES} images per post"
+msgstr "You can only add up to {MAX_GALLERY_IMAGES} images per post"
+
+#: src/view/com/composer/Composer.tsx:1376
msgid "You can only save drafts up to 1000 characters."
msgstr ""
@@ -13333,11 +13445,11 @@ msgstr ""
msgid "You can only save drafts up to 1000 characters. Would you like to discard this post before viewing your drafts?"
msgstr ""
-#: src/view/com/composer/SelectMediaButton.tsx:435
+#: src/view/com/composer/SelectMediaButton.tsx:437
msgid "You can only select one GIF at a time."
msgstr ""
-#: src/view/com/composer/SelectMediaButton.tsx:429
+#: src/view/com/composer/SelectMediaButton.tsx:431
msgid "You can only select one video at a time."
msgstr ""
@@ -13349,10 +13461,10 @@ msgstr ""
msgid "You can read chat history but can’t send new messages."
msgstr "You can read chat history but can’t send new messages."
-#. Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.
-#: src/view/com/composer/SelectMediaButton.tsx:421
-msgid "You can select up to {MAX_IMAGES, plural, other {# images}} in total."
-msgstr ""
+#. Error message for maximum number of images that can be selected to add to a post.
+#: src/view/com/composer/SelectMediaButton.tsx:423
+msgid "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total."
+msgstr "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total."
#: src/components/interstitials/Trending.tsx:132
#: src/components/interstitials/TrendingVideos.tsx:138
@@ -13391,9 +13503,9 @@ msgstr ""
msgid "You got here first"
msgstr ""
-#: src/components/intents/GroupChatJoinDialog.tsx:136
-msgid "You have been removed from this group."
-msgstr "You have been removed from this group."
+#: src/components/intents/GroupChatJoinDialog.tsx:137
+msgid "You have been previously removed from this group and can’t join it using this link."
+msgstr "You have been previously removed from this group and can’t join it using this link."
#: src/components/moderation/ModerationDetailsDialog.tsx:77
#: src/lib/moderation/useModerationCauseDescription.ts:58
@@ -13464,7 +13576,7 @@ msgstr ""
msgid "You have temporarily reached the limit for video uploads. Please try again later."
msgstr ""
-#: src/view/com/composer/Composer.tsx:1297
+#: src/view/com/composer/Composer.tsx:1366
msgid "You have unsaved changes to this draft, would you like to save them?"
msgstr ""
@@ -13534,7 +13646,7 @@ msgstr ""
msgid "You must grant access to your photo library to save a QR code"
msgstr ""
-#: src/view/com/composer/SelectMediaButton.tsx:464
+#: src/view/com/composer/SelectMediaButton.tsx:466
msgid "You need to allow access to your media library."
msgstr ""
@@ -13552,7 +13664,7 @@ msgid "You probably want to restart the app now."
msgstr ""
#. placeholder {0}: reaction.value
-#: src/components/dms/MessageItem.tsx:263
+#: src/components/dms/MessageItem.tsx:267
msgid "You reacted {0}"
msgstr ""
@@ -13567,7 +13679,7 @@ msgid "You recently changed your birthdate"
msgstr ""
#: src/screens/Settings/Settings.tsx:297
-#: src/view/shell/desktop/LeftNav.tsx:224
+#: src/view/shell/desktop/LeftNav.tsx:225
msgid "You will be signed out of all your accounts."
msgstr ""
@@ -13661,7 +13773,7 @@ msgstr ""
msgid "You've reached the end of your feed! Find some more accounts to follow."
msgstr ""
-#: src/view/com/composer/Composer.tsx:576
+#: src/view/com/composer/Composer.tsx:635
msgid "You've reached the maximum number of drafts"
msgstr ""
@@ -13817,7 +13929,7 @@ msgstr ""
msgid "Your muted words"
msgstr ""
-#: src/screens/Messages/components/InviteLinkDialog.tsx:189
+#: src/screens/Messages/components/InviteLinkDialog.tsx:184
msgid "Your name, avatar, the name of the group chat, and the number of members will be visible to everyone."
msgstr "Your name, avatar, the name of the group chat, and the number of members will be visible to everyone."
@@ -13829,11 +13941,11 @@ msgstr ""
msgid "Your password must be at least 8 characters long."
msgstr ""
-#: src/view/com/composer/Composer.tsx:1028
+#: src/view/com/composer/Composer.tsx:1097
msgid "Your post was sent"
msgstr ""
-#: src/view/com/composer/Composer.tsx:1025
+#: src/view/com/composer/Composer.tsx:1094
msgid "Your posts were sent"
msgstr ""
@@ -13854,7 +13966,7 @@ msgstr ""
msgid "Your profile, posts, feeds, and lists will no longer be visible to other Bluesky users. You can reactivate your account at any time by logging in."
msgstr ""
-#: src/view/com/composer/Composer.tsx:1027
+#: src/view/com/composer/Composer.tsx:1096
msgid "Your reply was sent"
msgstr ""
@@ -13867,7 +13979,7 @@ msgstr ""
msgid "Your selected interests help us serve you content you care about."
msgstr ""
-#: src/view/com/composer/Composer.tsx:1332
+#: src/view/com/composer/Composer.tsx:1401
msgid "Your thread has empty posts that will be skipped. The remaining posts will be published as a thread."
msgstr "Your thread has empty posts that will be skipped. The remaining posts will be published as a thread."
--
2.51.2
From d3f073b3d29df83cc288ea6bb4ec0e24e22c87b3 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Fri, 5 Jun 2026 16:35:06 +0300
Subject: [PATCH 38/61] [Chat] Request screen tweaks (#10747)
---
src/screens/Messages/Inbox.tsx | 45 ++-----------------
.../Messages/components/ChatListItem.tsx | 18 ++++----
2 files changed, 12 insertions(+), 51 deletions(-)
diff --git a/src/screens/Messages/Inbox.tsx b/src/screens/Messages/Inbox.tsx
index 55d3b0f4d..59ca05bdb 100644
--- a/src/screens/Messages/Inbox.tsx
+++ b/src/screens/Messages/Inbox.tsx
@@ -26,10 +26,9 @@ import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
import {EmptyState} from '#/view/com/util/EmptyState'
-import {FAB} from '#/view/com/util/fab/FAB'
import {List} from '#/view/com/util/List'
import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
-import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {atoms as a, useTheme, web} from '#/alf'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -62,8 +61,6 @@ export function MessagesInboxScreen(props: Props) {
}
export function MessagesInboxScreenInner({}: Props) {
- const {gtTablet} = useBreakpoints()
-
const listConvosQuery = useListConvosQuery({status: 'request'})
const {data} = listConvosQuery
@@ -94,21 +91,16 @@ export function MessagesInboxScreenInner({}: Props) {
-
+
Chat requests
- {hasUnreadConvos && gtTablet ? (
-
- ) : (
-
- )}
+ {hasUnreadConvos ? : }
)
@@ -117,14 +109,12 @@ export function MessagesInboxScreenInner({}: Props) {
function RequestList({
listConvosQuery,
conversations,
- hasUnreadConvos,
}: {
listConvosQuery: UseInfiniteQueryResult<
InfiniteData,
Error
>
conversations: ChatBskyConvoDefs.ConvoView[]
- hasUnreadConvos: boolean
}) {
const {t: l} = useLingui()
const t = useTheme()
@@ -285,7 +275,6 @@ function RequestList({
desktopFixedHeight
sideBorders={false}
/>
- {hasUnreadConvos && }
>
)
}
@@ -298,34 +287,6 @@ function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) {
return
}
-function MarkAllReadFAB() {
- const {t: l} = useLingui()
- const t = useTheme()
- const {mutate: markAllRead} = useUpdateAllRead('request', {
- onMutate: () => {
- Toast.show(l`Marked all as read`, {
- type: 'success',
- })
- },
- onError: () => {
- Toast.show(l`Failed to mark all requests as read`, {
- type: 'error',
- })
- },
- })
-
- return (
- markAllRead()}
- icon={ }
- accessibilityRole="button"
- accessibilityLabel={l`Mark all as read`}
- accessibilityHint=""
- />
- )
-}
-
function MarkAsReadHeaderButton() {
const {t: l} = useLingui()
const {mutate: markAllRead} = useUpdateAllRead('request', {
diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx
index e8c0b9cc6..142ca4a97 100644
--- a/src/screens/Messages/components/ChatListItem.tsx
+++ b/src/screens/Messages/components/ChatListItem.tsx
@@ -122,7 +122,7 @@ function DirectChatItem({
}) {
const {t: l} = useLingui()
const profile = useProfileShadow(convo.primaryMember)
- const {isWithinSplitView} = useIsWithinSplitView()
+ const {isWithinLeftPanel} = useIsWithinSplitView()
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
@@ -140,7 +140,7 @@ function DirectChatItem({
avatar={
}
@@ -161,7 +161,7 @@ function DirectChatItem({
isBlockedAccount={moderation.blocked}
showProfileBadges
postAlerts={
- isWithinSplitView ? null : (
+ isWithinLeftPanel ? null : (
@@ -205,7 +205,7 @@ function GroupChatItem({
avatar={
}
@@ -278,7 +278,7 @@ function BaseChatItem({
const leaveConvoControl = useDialogControl()
const {mutate: markAsRead} = useMarkAsReadMutation()
const {gtMobile} = useBreakpoints()
- const {isWithinSplitView} = useIsWithinSplitView()
+ const {isWithinLeftPanel} = useIsWithinSplitView()
const playHaptic = useHaptics()
const queryClient = useQueryClient()
@@ -458,7 +458,7 @@ function BaseChatItem({
leftFirst: deleteAction,
}
- const avatarSize = isWithinSplitView ? 48 : 52
+ const avatarSize = isWithinLeftPanel ? 48 : 52
return (
@@ -469,7 +469,7 @@ function BaseChatItem({
// @ts-expect-error web only
onFocus={onFocus}
onBlur={onMouseLeave}
- style={[a.relative, t.atoms.bg, isWithinSplitView && a.mx_sm]}>
+ style={[a.relative, t.atoms.bg, isWithinLeftPanel && a.mx_sm]}>
Date: Fri, 5 Jun 2026 16:38:23 +0300
Subject: [PATCH 39/61] [Chat] Fix stale reactions dialog after removing a
reaction (#10743)
Co-authored-by: Claude Opus 4.8 (1M context)
---
src/components/dms/MessageOverlays.tsx | 20 ++++++++++++++++++--
1 file changed, 18 insertions(+), 2 deletions(-)
diff --git a/src/components/dms/MessageOverlays.tsx b/src/components/dms/MessageOverlays.tsx
index 41228f289..2384aad88 100644
--- a/src/components/dms/MessageOverlays.tsx
+++ b/src/components/dms/MessageOverlays.tsx
@@ -125,6 +125,22 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
[openDeleteMessage, openReportMessage, openReactions],
)
+ // `reactionsTarget` is a snapshot from when the dialog was opened. Read the
+ // live message out of the convo items so optimistic reaction changes (e.g.
+ // "Tap to remove") are reflected in the dialog without closing it first.
+ const reactionsMessage = useMemo(() => {
+ if (!reactionsTarget) return null
+ for (const item of convo.items) {
+ if (
+ (item.type === 'message' || item.type === 'pending-message') &&
+ item.message.id === reactionsTarget.id
+ ) {
+ return item.message
+ }
+ }
+ return reactionsTarget
+ }, [convo.items, reactionsTarget])
+
const reportSubject = reportTarget
? ({
view: 'message',
@@ -153,11 +169,11 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
onClose={() => setAfterReportTarget(null)}
/>
)}
- {reactionsTarget && (
+ {reactionsMessage && (
setReactionsTarget(null)}
/>
)}
--
2.51.2
From f81bb6f4942f6321837e5987d5c1dac51666eb35 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Fri, 5 Jun 2026 16:38:48 +0300
Subject: [PATCH 40/61] [Chat] Fix navigation stacking on messages links in
split view (#10741)
Co-authored-by: Claude Opus 4.8 (1M context)
---
src/screens/Messages/ChatList.tsx | 8 ++++++++
src/screens/Messages/components/ChatListItem.tsx | 3 +++
src/screens/Messages/components/InboxRequests.tsx | 4 ++++
3 files changed, 15 insertions(+)
diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx
index 83c4b0c00..387fcd2bd 100644
--- a/src/screens/Messages/ChatList.tsx
+++ b/src/screens/Messages/ChatList.tsx
@@ -451,6 +451,12 @@ export function Header({
const {gtMobile} = useBreakpoints()
const requireEmailVerification = useRequireEmailVerification()
const leftConvos = useLeftConvos()
+ const {isWithinSplitView} = useIsWithinSplitView()
+
+ // In split view, the left column (and this header) stays mounted while the
+ // right column shows the selected route. Pushing would stack duplicate routes
+ // on repeated clicks, so navigate instead to dedupe by route + params.
+ const action = isWithinSplitView ? 'navigate' : 'push'
const {data: unreadInboxData, hasNextPage: hasMoreRequests} =
useListConvosQuery({
@@ -494,9 +500,11 @@ export function Header({
count={inboxAllConvos.length}
more={hasMoreRequests}
variant="solid"
+ action={action}
/>
--
2.51.2
From 7d4d7642fdcfe684076dbb4d13f48e36ac470071 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Fri, 5 Jun 2026 16:39:23 +0300
Subject: [PATCH 41/61] [Chat] Sync single-convo cache on chat lock firehose
events (#10746)
Co-authored-by: Claude Opus 4.8 (1M context)
---
.../queries/messages/list-conversations.tsx | 63 +++++--------------
1 file changed, 15 insertions(+), 48 deletions(-)
diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx
index f026e57d3..dadf30bcd 100644
--- a/src/state/queries/messages/list-conversations.tsx
+++ b/src/state/queries/messages/list-conversations.tsx
@@ -418,67 +418,34 @@ export function ListConvosProviderInner({
})),
)
} else if (ChatBskyConvoDefs.isLogLockConvo(log)) {
- queryClient.setQueriesData(
- {queryKey: [RQKEY_ROOT]},
- (old?: ConvoListQueryData) =>
- optimisticUpdate(log.convoId, old, convo => {
- if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
- return {
- ...convo,
- kind: {
- ...convo.kind,
- lockStatus: 'locked',
- },
- rev: log.rev,
- }
- }
- return {
+ mutateConvoView(log.convoId, convo =>
+ ChatBskyConvoDefs.isGroupConvo(convo.kind)
+ ? {
...convo,
+ kind: {...convo.kind, lockStatus: 'locked'},
rev: log.rev,
}
- }),
+ : {...convo, rev: log.rev},
)
} else if (ChatBskyConvoDefs.isLogUnlockConvo(log)) {
- queryClient.setQueriesData(
- {queryKey: [RQKEY_ROOT]},
- (old?: ConvoListQueryData) =>
- optimisticUpdate(log.convoId, old, convo => {
- if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
- return {
- ...convo,
- kind: {
- ...convo.kind,
- lockStatus: 'unlocked',
- },
- rev: log.rev,
- }
- }
- return {
+ mutateConvoView(log.convoId, convo =>
+ ChatBskyConvoDefs.isGroupConvo(convo.kind)
+ ? {
...convo,
+ kind: {...convo.kind, lockStatus: 'unlocked'},
rev: log.rev,
}
- }),
+ : {...convo, rev: log.rev},
)
} else if (ChatBskyConvoDefs.isLogLockConvoPermanently(log)) {
- queryClient.setQueriesData(
- {queryKey: [RQKEY_ROOT]},
- (old?: ConvoListQueryData) =>
- optimisticUpdate(log.convoId, old, convo => {
- if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
- return {
- ...convo,
- kind: {
- ...convo.kind,
- lockStatus: 'locked-permanently',
- },
- rev: log.rev,
- }
- }
- return {
+ mutateConvoView(log.convoId, convo =>
+ ChatBskyConvoDefs.isGroupConvo(convo.kind)
+ ? {
...convo,
+ kind: {...convo.kind, lockStatus: 'locked-permanently'},
rev: log.rev,
}
- }),
+ : {...convo, rev: log.rev},
)
} else if (
ChatBskyConvoDefs.isLogCreateJoinLink(log) ||
--
2.51.2
From 67ed59fbcd85809ac135ae32ec3fa970d6a829ee Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Fri, 5 Jun 2026 18:10:14 +0300
Subject: [PATCH 42/61] Align group invite settings with eligibility logic
(#10748)
Co-authored-by: Claude Opus 4.8 (1M context)
---
src/components/dms/util.ts | 16 +++++++++++
src/screens/Messages/Settings.tsx | 6 ++---
.../Messages/components/ChatListItem.tsx | 2 +-
.../queries/messages/actor-declaration.ts | 27 +++++++++++++++----
4 files changed, 41 insertions(+), 10 deletions(-)
diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts
index 89b9c5b01..7fe5f91dc 100644
--- a/src/components/dms/util.ts
+++ b/src/components/dms/util.ts
@@ -47,6 +47,22 @@ export function canBeAddedToGroup(profile: bsky.profile.AnyProfileView) {
}
}
+/**
+ * Resolves the effective `allowGroupInvites` value for a chat declaration.
+ * When unset, group invites follow the general DM preference
+ * (`allowIncoming`), which itself defaults to `following`. This mirrors the
+ * `undefined` fallthrough in canBeAddedToGroup, and is the single source of
+ * truth for both displaying and persisting the setting.
+ */
+export function resolveAllowGroupInvites(
+ chat: {allowIncoming?: string; allowGroupInvites?: string} | undefined,
+): 'all' | 'none' | 'following' {
+ return (chat?.allowGroupInvites ?? chat?.allowIncoming ?? 'following') as
+ | 'all'
+ | 'none'
+ | 'following'
+}
+
export function localDateString(date: Date) {
// can't use toISOString because it should be in local time
const mm = date.getMonth()
diff --git a/src/screens/Messages/Settings.tsx b/src/screens/Messages/Settings.tsx
index f49311b0d..91d9f879d 100644
--- a/src/screens/Messages/Settings.tsx
+++ b/src/screens/Messages/Settings.tsx
@@ -13,6 +13,7 @@ import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
+import {resolveAllowGroupInvites} from '#/components/dms/util'
import * as Toggle from '#/components/forms/Toggle'
import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell'
import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car'
@@ -199,10 +200,7 @@ export function MessagesSettingsScreenInner({}: Props) {
{allowGroupInvitesFromOptions.map(option => (
diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx
index 1f691290b..b6d237f17 100644
--- a/src/screens/Messages/components/ChatListItem.tsx
+++ b/src/screens/Messages/components/ChatListItem.tsx
@@ -483,7 +483,7 @@ function BaseChatItem({
to={`/messages/${convo.view.id}`}
// In split view, this list stays mounted alongside the open convo,
// so push would stack duplicate routes on repeated clicks.
- action={isWithinSplitView ? 'navigate' : 'push'}
+ action={isWithinLeftPanel ? 'navigate' : 'push'}
label={title}
accessibilityHint={accessibilityHint}
accessibilityActions={
diff --git a/src/state/queries/messages/actor-declaration.ts b/src/state/queries/messages/actor-declaration.ts
index 53b493ab6..f6cd2d51d 100644
--- a/src/state/queries/messages/actor-declaration.ts
+++ b/src/state/queries/messages/actor-declaration.ts
@@ -7,6 +7,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
+import {resolveAllowGroupInvites} from '#/components/dms/util'
import {RQKEY as PROFILE_RKEY} from '../profile'
export function useUpdateActorDeclaration({
@@ -34,8 +35,12 @@ export function useUpdateActorDeclaration({
update.allowIncoming ??
current?.associated?.chat?.allowIncoming ??
'following'
- const allowGroupInvites =
- update.allowGroupInvites ?? current?.associated?.chat?.allowGroupInvites
+ const allowGroupInvites = resolveAllowGroupInvites({
+ allowIncoming,
+ allowGroupInvites:
+ update.allowGroupInvites ??
+ current?.associated?.chat?.allowGroupInvites,
+ })
const result = await agent.com.atproto.repo.putRecord({
repo: currentAccount.did,
collection: 'chat.bsky.actor.declaration',
@@ -43,7 +48,7 @@ export function useUpdateActorDeclaration({
record: {
$type: 'chat.bsky.actor.declaration',
allowIncoming,
- ...(allowGroupInvites && {allowGroupInvites}),
+ allowGroupInvites,
},
})
return result
@@ -54,14 +59,26 @@ export function useUpdateActorDeclaration({
PROFILE_RKEY(currentAccount?.did),
(old?: AppBskyActorDefs.ProfileViewDetailed) => {
if (!old) return old
+ const allowIncoming =
+ update.allowIncoming ??
+ old.associated?.chat?.allowIncoming ??
+ 'following'
+ // resolve the same concrete value the server will receive, so
+ // optimistic cache and persisted record stay aligned
+ const allowGroupInvites = resolveAllowGroupInvites({
+ allowIncoming,
+ allowGroupInvites:
+ update.allowGroupInvites ??
+ old.associated?.chat?.allowGroupInvites,
+ })
return {
...old,
associated: {
...old.associated,
chat: {
- allowIncoming: 'following',
...old.associated?.chat,
- ...update,
+ allowIncoming,
+ allowGroupInvites,
},
},
} satisfies AppBskyActorDefs.ProfileViewDetailed
--
2.51.2
From f8aae4a192eb81cbe551e0bf10e3f045e114eaee Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Fri, 5 Jun 2026 18:18:27 +0300
Subject: [PATCH 43/61] [Chat] Disable font scaling on fixed-height chat invite
cards (#10744)
Co-authored-by: Claude Opus 4.8 (1M context)
---
src/components/Post/Embed/ChatInviteEmbed.tsx | 2 +-
.../Post/Embed/JoinRequestEmbed.tsx | 5 ++-
src/components/ProfileBadges.tsx | 12 ++++---
src/components/Typography.tsx | 4 ++-
src/components/dms/ChatInvite/Card.tsx | 32 ++++++++++++-------
src/components/dms/ChatInvite/Context.tsx | 2 ++
src/components/dms/ChatInvite/JoinButton.tsx | 4 +--
src/components/dms/ChatInvite/Root.tsx | 4 ++-
src/components/dms/MessageItemInviteEmbed.tsx | 3 +-
.../Messages/components/MessageInputEmbed.tsx | 2 +-
10 files changed, 46 insertions(+), 24 deletions(-)
diff --git a/src/components/Post/Embed/ChatInviteEmbed.tsx b/src/components/Post/Embed/ChatInviteEmbed.tsx
index f50085426..387c3cfd9 100644
--- a/src/components/Post/Embed/ChatInviteEmbed.tsx
+++ b/src/components/Post/Embed/ChatInviteEmbed.tsx
@@ -23,7 +23,7 @@ export function ChatInviteEmbed({
style?: StyleProp
}) {
return (
-
+
)
diff --git a/src/components/Post/Embed/JoinRequestEmbed.tsx b/src/components/Post/Embed/JoinRequestEmbed.tsx
index db7ec589b..e020bb933 100644
--- a/src/components/Post/Embed/JoinRequestEmbed.tsx
+++ b/src/components/Post/Embed/JoinRequestEmbed.tsx
@@ -31,7 +31,10 @@ export function JoinRequestEmbed({
if (!resolvedCode) return null
return (
-
+
)
diff --git a/src/components/ProfileBadges.tsx b/src/components/ProfileBadges.tsx
index 22c682bbb..cb257e78b 100644
--- a/src/components/ProfileBadges.tsx
+++ b/src/components/ProfileBadges.tsx
@@ -31,10 +31,12 @@ export function ProfileBadges({
interactive = false,
size,
style,
+ allowFontScaling = true,
}: ViewStyleProp & {
profile: bsky.profile.AnyProfileView
interactive?: boolean
size: Size
+ allowFontScaling?: boolean
}) {
const shadowed = useProfileShadow(profile)
const verification = useSimpleVerificationState({profile})
@@ -48,10 +50,12 @@ export function ProfileBadges({
const isOnTheSmallSide = size === 'xs' || size === 'sm'
- const verificationIconWidth =
- verificationIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
- const botIconWidth =
- botIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
+ const scaleMultiplier = allowFontScaling
+ ? nativeScaleMultiplier * alfScaleMultiplier
+ : 1
+
+ const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier
+ const botIconWidth = botIconSizes[size] * scaleMultiplier
return (
+ numberOfLines={1}
+ allowFontScaling={!hasFixedHeight}>
{preview.name}
+ numberOfLines={1}
+ allowFontScaling={!hasFixedHeight}>
Group chat
+ numberOfLines={1}
+ allowFontScaling={!hasFixedHeight}>
{preview.memberCount}/{preview.memberLimit}{' '}
+ numberOfLines={1}
+ allowFontScaling={!hasFixedHeight}>
- By {ownerDisplayName}
+ By{' '}
+
+ {ownerDisplayName}
+
-
+
+ numberOfLines={1}
+ allowFontScaling={!hasFixedHeight}>
{ownerHandle}
diff --git a/src/components/dms/ChatInvite/Context.tsx b/src/components/dms/ChatInvite/Context.tsx
index 53c7a6cc2..cedcfd8d8 100644
--- a/src/components/dms/ChatInvite/Context.tsx
+++ b/src/components/dms/ChatInvite/Context.tsx
@@ -32,6 +32,8 @@ export type ChatInviteContextValue = {
* preview to act on.
*/
action: ChatInviteAction | undefined
+ /** Whether the invite is rendered inside a fixed-height container; when true, text inside disables font scaling so the card doesn't overflow. */
+ hasFixedHeight: boolean
}
const ChatInviteContext = createContext(null)
diff --git a/src/components/dms/ChatInvite/JoinButton.tsx b/src/components/dms/ChatInvite/JoinButton.tsx
index 0036391b0..6f3b8c654 100644
--- a/src/components/dms/ChatInvite/JoinButton.tsx
+++ b/src/components/dms/ChatInvite/JoinButton.tsx
@@ -17,7 +17,7 @@ export function JoinButton({
onPress?: () => void
style?: StyleProp
}) {
- const {action} = useChatInvite()
+ const {action, hasFixedHeight} = useChatInvite()
if (!action) return null
@@ -35,7 +35,7 @@ export function JoinButton({
disabled={action.disabled}
style={[a.w_full, style]}>
{action.side === 'left' && }
- {action.label}
+ {action.label}
{action.side === 'right' && }
)
diff --git a/src/components/dms/ChatInvite/Root.tsx b/src/components/dms/ChatInvite/Root.tsx
index a9f2c54e3..a2cfc558e 100644
--- a/src/components/dms/ChatInvite/Root.tsx
+++ b/src/components/dms/ChatInvite/Root.tsx
@@ -30,6 +30,7 @@ export function Root({
code,
initialPreview,
currentConvoId,
+ hasFixedHeight,
children,
}: {
code: string
@@ -40,6 +41,7 @@ export function Root({
* open/join (you're already here).
*/
currentConvoId?: string
+ hasFixedHeight: boolean
children: React.ReactNode
}) {
const {hasSession} = useSession()
@@ -137,7 +139,7 @@ export function Root({
return (
+ value={{code, loading, error: !!error, preview, action, hasFixedHeight}}>
{children}
)
diff --git a/src/components/dms/MessageItemInviteEmbed.tsx b/src/components/dms/MessageItemInviteEmbed.tsx
index 43fd94482..d85e7fd08 100644
--- a/src/components/dms/MessageItemInviteEmbed.tsx
+++ b/src/components/dms/MessageItemInviteEmbed.tsx
@@ -74,7 +74,8 @@ let MessageItemInviteEmbed = ({
+ currentConvoId={convo.convo.view.id}
+ hasFixedHeight={false}>
diff --git a/src/screens/Messages/components/MessageInputEmbed.tsx b/src/screens/Messages/components/MessageInputEmbed.tsx
index a73a6564f..21503ce8f 100644
--- a/src/screens/Messages/components/MessageInputEmbed.tsx
+++ b/src/screens/Messages/components/MessageInputEmbed.tsx
@@ -273,7 +273,7 @@ function MessageInputInviteEmbed({
const {t: l} = useLingui()
return (
-
+
Date: Fri, 5 Jun 2026 12:50:16 -0500
Subject: [PATCH 44/61] Fire embed:standardSite:view from feed viewability, not
embed mount (#10736)
Co-authored-by: Claude Opus 4.8
---
.../Post/Embed/StandardSiteEmbed/index.tsx | 7 -------
src/view/com/posts/PostFeed.tsx | 13 +++++++++++++
2 files changed, 13 insertions(+), 7 deletions(-)
diff --git a/src/components/Post/Embed/StandardSiteEmbed/index.tsx b/src/components/Post/Embed/StandardSiteEmbed/index.tsx
index 2c38f3806..2bee7da91 100644
--- a/src/components/Post/Embed/StandardSiteEmbed/index.tsx
+++ b/src/components/Post/Embed/StandardSiteEmbed/index.tsx
@@ -5,7 +5,6 @@ import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {useHaptics} from '#/lib/haptics'
-import {useCallOnce} from '#/lib/once'
import {shareUrl} from '#/lib/sharing'
import {niceDate} from '#/lib/strings/time'
import {toNiceDomain} from '#/lib/strings/url-helpers'
@@ -104,12 +103,6 @@ export const StandardSiteEmbed = ({
}
}
- useCallOnce(() => {
- if (!preview) {
- ax.metric('embed:standardSite:view', {url: view.uri})
- }
- })()
-
if (isStandardPublication) {
return (
>(new Set())
const seenPostUrisRef = useRef>(new Set())
+ const seenStandardSiteUrisRef = useRef>(new Set())
// Helper to calculate position in feed (count only root posts, not interstitials or thread replies)
const getPostPosition = useNonReactiveCallback(
@@ -974,6 +977,16 @@ let PostFeed = ({
})
}
}
+
+ // Standard site embed view tracking
+ if (
+ AppBskyEmbedExternal.isView(post.embed) &&
+ isStandardSiteEmbed(post.embed.external) &&
+ !seenStandardSiteUrisRef.current.has(post.embed.external.uri)
+ ) {
+ seenStandardSiteUrisRef.current.add(post.embed.external.uri)
+ ax.metric('embed:standardSite:view', {url: post.embed.external.uri})
+ }
} else if (item.type === 'videoGridRow') {
// Track each video in the grid row
for (let i = 0; i < item.items.length; i++) {
--
2.51.2
From a5aa985b33c92c5b73505fda677a76e04f9186bf Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Fri, 5 Jun 2026 22:48:34 +0300
Subject: [PATCH 45/61] Fix feed scroll reset on device rotation (#10737)
---
src/view/com/util/List.tsx | 3 ---
1 file changed, 3 deletions(-)
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index cde8d50c4..f8a537a65 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -147,12 +147,10 @@ let List = forwardRef(
)
}
- let contentOffset
if (headerOffset != null) {
style = addStyle(style, {
paddingTop: headerOffset,
})
- contentOffset = {x: 0, y: headerOffset * -1}
}
return (
@@ -170,7 +168,6 @@ let List = forwardRef(
...props.scrollIndicatorInsets,
}}
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
- contentOffset={contentOffset}
refreshControl={refreshControl}
onScroll={scrollHandler}
scrollsToTop={scrollsToTop}
--
2.51.2
From 6d468c8ca47d9bbace760526c5ed82bac41d511c Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Fri, 5 Jun 2026 22:49:02 +0300
Subject: [PATCH 46/61] [CI] Remove `Wandalen/wretry.action`, call pnpm install
directly (#10745)
---
.github/workflows/golang-test-lint.yml | 2 ++
.github/workflows/lint.yml | 12 ++----------
.../workflows/nightly-update-source-languages.yaml | 6 +-----
.github/workflows/verify-pnpm-lock.yml | 8 ++------
4 files changed, 7 insertions(+), 21 deletions(-)
diff --git a/.github/workflows/golang-test-lint.yml b/.github/workflows/golang-test-lint.yml
index 6531694d5..2e7388bbd 100644
--- a/.github/workflows/golang-test-lint.yml
+++ b/.github/workflows/golang-test-lint.yml
@@ -20,6 +20,7 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: bskyweb/go.mod
+ cache-dependency-path: bskyweb/go.sum
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Check
@@ -37,6 +38,7 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: bskyweb/go.mod
+ cache-dependency-path: bskyweb/go.sum
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Lint
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index aa6c92765..2fb464a92 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -57,11 +57,7 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
- uses: Wandalen/wretry.action@master
- with:
- command: pnpm install --frozen-lockfile
- attempt_limit: 3
- attempt_delay: 2000
+ run: pnpm install --frozen-lockfile
- name: Check & compile i18n
run: pnpm intl:build
- name: Lint checks
@@ -99,11 +95,7 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
- uses: Wandalen/wretry.action@master
- with:
- command: pnpm install --frozen-lockfile
- attempt_limit: 3
- attempt_delay: 2000
+ run: pnpm install --frozen-lockfile
- name: Check & compile i18n
run: pnpm intl:build
- name: Run tests
diff --git a/.github/workflows/nightly-update-source-languages.yaml b/.github/workflows/nightly-update-source-languages.yaml
index 4b14c65c6..2a6ec26ac 100644
--- a/.github/workflows/nightly-update-source-languages.yaml
+++ b/.github/workflows/nightly-update-source-languages.yaml
@@ -26,11 +26,7 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
- uses: Wandalen/wretry.action@master
- with:
- command: pnpm install --frozen-lockfile
- attempt_limit: 3
- attempt_delay: 2000
+ run: pnpm install --frozen-lockfile
- name: Extract language strings
run: pnpm intl:extract
- name: Create commit
diff --git a/.github/workflows/verify-pnpm-lock.yml b/.github/workflows/verify-pnpm-lock.yml
index 2d10b11a2..f1b61e95e 100644
--- a/.github/workflows/verify-pnpm-lock.yml
+++ b/.github/workflows/verify-pnpm-lock.yml
@@ -34,12 +34,8 @@ jobs:
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
- name: pnpm install
- uses: Wandalen/wretry.action@master
- with:
- # Fine to skip scripts since we don't run any code
- command: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
- attempt_limit: 3
- attempt_delay: 2000
+ # Fine to skip scripts since we don't run any code
+ run: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
- name: Verify pnpm-lock.yaml
run: |
--
2.51.2
From 32c0c0ec838367b5ddd00df97b0102fc36ea6a29 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Fri, 5 Jun 2026 23:08:39 +0300
Subject: [PATCH 47/61] "Follows You" string dedupe (#10751)
---
src/components/Pills.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/Pills.tsx b/src/components/Pills.tsx
index 36bcb3370..37e940d20 100644
--- a/src/components/Pills.tsx
+++ b/src/components/Pills.tsx
@@ -172,7 +172,7 @@ export function FollowsYou({size = 'sm'}: CommonProps) {
return (
- Follows You
+ Follows you
)
--
2.51.2
From 2e9bfd363c1f6cf6980361a93c1e4aec8dfa7234 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Fri, 5 Jun 2026 23:09:55 +0300
Subject: [PATCH 48/61] [Chat] Put `pointer_events_none` on chat bubbles in
ChatListItem (#10752)
---
src/screens/Messages/components/ChatListItem.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx
index b6d237f17..70390606f 100644
--- a/src/screens/Messages/components/ChatListItem.tsx
+++ b/src/screens/Messages/components/ChatListItem.tsx
@@ -475,6 +475,7 @@ function BaseChatItem({
a.z_10,
a.absolute,
{top: tokens.space.md, left: tokens.space.lg},
+ isGroupConvo && a.pointer_events_none,
]}>
{avatar}
--
2.51.2
From b236f274c3524a0e091a12741b6b3855ae9283ef Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Fri, 5 Jun 2026 23:17:29 +0300
Subject: [PATCH 49/61] [Chat] Enforce limits for group size (#10753)
---
.../dialogs/SearchablePeopleList.tsx | 1 -
src/components/dms/AddMembersFlow.tsx | 11 ++++
src/components/dms/InitiateChatFlow.tsx | 34 ++++++++++-
.../dms/components/GroupChatProfileCard.tsx | 58 +++++++++++--------
src/lib/constants.ts | 2 +
.../Messages/ConversationSettings/prompts.tsx | 35 +++++++++--
6 files changed, 109 insertions(+), 32 deletions(-)
diff --git a/src/components/dialogs/SearchablePeopleList.tsx b/src/components/dialogs/SearchablePeopleList.tsx
index 614234c17..cc07900be 100644
--- a/src/components/dialogs/SearchablePeopleList.tsx
+++ b/src/components/dialogs/SearchablePeopleList.tsx
@@ -667,7 +667,6 @@ function SearchInput({
/>
-
+
+ {groupNameTooLong ? (
+
+
+ Group name is too long. The maximum number of characters
+ is {MAX_GROUP_NAME_GRAPHEME_LENGTH}.
+
+
+ ) : null}
) : (
-
-
-
-
-
- {enabled ? (
-
- ) : (
-
- {handle} can’t be added
-
- )}
+ {({disabled, selected}) => (
+ <>
+
+
+
+
+
+ {enabled ? (
+
+ ) : (
+
+ {handle} can’t be added
+
+ )}
+
+
-
-
- {enabled ? : null}
+ {enabled ? : null}
+ >
+ )}
)
}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 13ae162a8..91d1319c6 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -67,6 +67,8 @@ export const MAX_DRAFT_GRAPHEME_LENGTH = 1000
export const MAX_DM_GRAPHEME_LENGTH = 1000
+export const MAX_GROUP_NAME_GRAPHEME_LENGTH = 50
+
// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
// but increasing limit per user feedback
export const MAX_ALT_TEXT = 2000
diff --git a/src/screens/Messages/ConversationSettings/prompts.tsx b/src/screens/Messages/ConversationSettings/prompts.tsx
index f5b980b99..e8b594c0b 100644
--- a/src/screens/Messages/ConversationSettings/prompts.tsx
+++ b/src/screens/Messages/ConversationSettings/prompts.tsx
@@ -1,10 +1,13 @@
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
-import {atoms as a} from '#/alf'
+import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants'
+import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
+import {atoms as a, useTheme} from '#/alf'
import type * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import * as Prompt from '#/components/Prompt'
+import {Text} from '#/components/Typography'
export function EditNamePrompt({
control,
@@ -17,8 +20,14 @@ export function EditNamePrompt({
onChangeText: (value: string) => void
onConfirm: () => void
}) {
+ const t = useTheme()
const {t: l} = useLingui()
+ const nameTooLong = isOverMaxGraphemeCount({
+ text: value,
+ maxCount: MAX_GROUP_NAME_GRAPHEME_LENGTH,
+ })
+
return (
<>
@@ -27,7 +36,7 @@ export function EditNamePrompt({
Edit group name
-
+
+ {nameTooLong ? (
+
+
+ Group name is too long. The maximum number of characters is{' '}
+ {MAX_GROUP_NAME_GRAPHEME_LENGTH}.
+
+
+ ) : null}
-
+
>
--
2.51.2
From fb8316fd85d2d596266e0e1e7c47fa07823e4f98 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Sat, 6 Jun 2026 00:04:21 +0300
Subject: [PATCH 50/61] Fix external card long press on web (#10739)
---
eslint-suppressions.json | 5 --
.../Post/Embed/ExternalEmbed/index.tsx | 20 +++---
.../Post/Embed/StandardSiteEmbed/index.tsx | 66 ++++++++++---------
3 files changed, 47 insertions(+), 44 deletions(-)
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index b8e65eb26..1c4c89f38 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -261,11 +261,6 @@
"count": 1
}
},
- "src/components/Post/Embed/StandardSiteEmbed/index.tsx": {
- "@typescript-eslint/no-floating-promises": {
- "count": 3
- }
- },
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 2
diff --git a/src/components/Post/Embed/ExternalEmbed/index.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx
index 472c40306..9a4f7e56b 100644
--- a/src/components/Post/Embed/ExternalEmbed/index.tsx
+++ b/src/components/Post/Embed/ExternalEmbed/index.tsx
@@ -1,4 +1,4 @@
-import {useCallback, useMemo} from 'react'
+import {useMemo} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {type AppBskyEmbedExternal} from '@atproto/api'
@@ -51,17 +51,19 @@ export const ExternalEmbed = ({
}, [link.uri, externalEmbedPrefs])
const hasMedia = Boolean(imageUri || embedPlayerParams)
- const onPress = useCallback(() => {
+ const onPress = () => {
playHaptic('Light')
onOpen?.()
- }, [playHaptic, onOpen])
+ }
- const onShareExternal = useCallback(() => {
- if (link.uri && IS_NATIVE) {
- playHaptic('Heavy')
- void shareUrl(link.uri)
- }
- }, [link.uri, playHaptic])
+ const onShareExternal = IS_NATIVE
+ ? () => {
+ if (link.uri) {
+ playHaptic('Heavy')
+ void shareUrl(link.uri)
+ }
+ }
+ : undefined
if (
embedPlayerParams?.source === 'tenor' ||
diff --git a/src/components/Post/Embed/StandardSiteEmbed/index.tsx b/src/components/Post/Embed/StandardSiteEmbed/index.tsx
index 2bee7da91..b328bc38f 100644
--- a/src/components/Post/Embed/StandardSiteEmbed/index.tsx
+++ b/src/components/Post/Embed/StandardSiteEmbed/index.tsx
@@ -79,13 +79,15 @@ export const StandardSiteEmbed = ({
onEmbedInteractionCallback?.()
ax.metric('embed:standardSite:article:press', {url: view.uri})
}
- const onLongPress = () => {
- if (view.uri && IS_NATIVE) {
- playHaptic('Heavy')
- shareUrl(view.uri)
- ax.metric('embed:standardSite:article:longPress', {url: view.uri})
- }
- }
+ const onLongPress = IS_NATIVE
+ ? () => {
+ if (view.uri) {
+ playHaptic('Heavy')
+ void shareUrl(view.uri)
+ ax.metric('embed:standardSite:article:longPress', {url: view.uri})
+ }
+ }
+ : undefined
const onPressPublication = () => {
playHaptic('Light')
onEmbedInteractionCallback?.()
@@ -93,15 +95,17 @@ export const StandardSiteEmbed = ({
url: view.source?.uri || '',
})
}
- const onLongPressPublication = () => {
- if (view.source?.uri && IS_NATIVE) {
- playHaptic('Heavy')
- shareUrl(view.source.uri)
- ax.metric('embed:standardSite:publication:longPress', {
- url: view.source.uri,
- })
- }
- }
+ const onLongPressPublication = IS_NATIVE
+ ? () => {
+ if (view.source?.uri) {
+ playHaptic('Heavy')
+ void shareUrl(view.source.uri)
+ ax.metric('embed:standardSite:publication:longPress', {
+ url: view.source.uri,
+ })
+ }
+ }
+ : undefined
if (isStandardPublication) {
return (
@@ -465,21 +469,23 @@ export function SubscribeButton({
}
}
- const onLongPress = () => {
- if (view.source?.uri && IS_NATIVE) {
- playHaptic('Heavy')
- shareUrl(view.source.uri)
- if (highlightedPublisher) {
- ax.metric('embed:standardSite:subscribe:longPress', {
- url: view.source?.uri || '',
- })
- } else {
- ax.metric('embed:standardSite:publicationCta:longPress', {
- url: view.source?.uri || '',
- })
+ const onLongPress = IS_NATIVE
+ ? () => {
+ if (view.source?.uri) {
+ playHaptic('Heavy')
+ void shareUrl(view.source.uri)
+ if (highlightedPublisher) {
+ ax.metric('embed:standardSite:subscribe:longPress', {
+ url: view.source?.uri || '',
+ })
+ } else {
+ ax.metric('embed:standardSite:publicationCta:longPress', {
+ url: view.source?.uri || '',
+ })
+ }
+ }
}
- }
- }
+ : undefined
const button = (
Date: Sat, 6 Jun 2026 00:13:26 +0300
Subject: [PATCH 51/61] Let specified facets take priority in feed descriptions
(#10740)
---
eslint-suppressions.json | 3 -
.../Profile/components/ProfileFeedHeader.tsx | 74 ++++++++-----------
src/state/queries/feed.ts | 28 +++++--
3 files changed, 50 insertions(+), 55 deletions(-)
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index 1c4c89f38..5bd3357d1 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -1346,9 +1346,6 @@
}
},
"src/screens/Profile/components/ProfileFeedHeader.tsx": {
- "@typescript-eslint/no-floating-promises": {
- "count": 1
- },
"@typescript-eslint/no-misused-promises": {
"count": 5
}
diff --git a/src/screens/Profile/components/ProfileFeedHeader.tsx b/src/screens/Profile/components/ProfileFeedHeader.tsx
index 6966fa6fe..c640c84f3 100644
--- a/src/screens/Profile/components/ProfileFeedHeader.tsx
+++ b/src/screens/Profile/components/ProfileFeedHeader.tsx
@@ -1,9 +1,7 @@
import {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {AtUri} from '@atproto/api'
-import {msg} from '@lingui/core/macro'
-import {useLingui} from '@lingui/react'
-import {Plural, Trans} from '@lingui/react/macro'
+import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useHaptics} from '#/lib/haptics'
import {makeCustomFeedLink, makeProfileLink} from '#/lib/routes/links'
@@ -26,7 +24,6 @@ import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
-import {useRichText} from '#/components/hooks/useRichText'
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
@@ -86,7 +83,7 @@ export function ProfileFeedHeaderSkeleton() {
export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
const t = useTheme()
- const {_, i18n} = useLingui()
+ const {t: l, i18n} = useLingui()
const ax = useAnalytics()
const {hasSession} = useSession()
const {gtMobile} = useBreakpoints()
@@ -121,7 +118,7 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
if (savedFeedConfig) {
await removeFeed(savedFeedConfig)
- Toast.show(_(msg`Removed from your feeds`))
+ Toast.show(l`Removed from your feeds`)
ax.metric('feed:unsave', {feedUrl: info.uri})
} else {
await addSavedFeeds([
@@ -131,14 +128,12 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
pinned: false,
},
])
- Toast.show(_(msg`Saved to your feeds`))
+ Toast.show(l`Saved to your feeds`)
ax.metric('feed:save', {feedUrl: info.uri})
}
} catch (err) {
Toast.show(
- _(
- msg`There was an issue updating your feeds, please check your internet connection and try again.`,
- ),
+ l`There was an issue updating your feeds, please check your internet connection and try again.`,
{
type: 'error',
},
@@ -161,10 +156,10 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
])
if (pinned) {
- Toast.show(_(msg`Pinned ${info.displayName} to Home`))
+ Toast.show(l`Pinned ${info.displayName} to Home`)
ax.metric('feed:pin', {feedUrl: info.uri})
} else {
- Toast.show(_(msg`Unpinned ${info.displayName} from Home`))
+ Toast.show(l`Unpinned ${info.displayName} from Home`)
ax.metric('feed:unpin', {feedUrl: info.uri})
}
} else {
@@ -175,11 +170,11 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
pinned: true,
},
])
- Toast.show(_(msg`Pinned ${info.displayName} to Home`))
+ Toast.show(l`Pinned ${info.displayName} to Home`)
ax.metric('feed:pin', {feedUrl: info.uri})
}
} catch (e) {
- Toast.show(_(msg`There was an issue contacting the server`), {
+ Toast.show(l`There was an issue contacting the server`, {
type: 'error',
})
logger.error('Failed to toggle pinned feed', {message: e})
@@ -194,7 +189,7 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
{isPinned ? (
-
+
{({props}) => {
return (
- {_(msg`Unpin from home`)}
+ {l`Unpin from home`}
{isSaved
- ? _(msg`Remove from my feeds`)
- : _(msg`Save to my feeds`)}
+ ? l`Remove from my feeds`
+ : l`Save to my feeds`}
) : (
-
{
playHaptic()
const url = toShareUrl(info.route.href)
- shareUrl(url)
+ void shareUrl(url)
ax.metric('feed:share', {feedUrl: info.uri})
- }, [info, playHaptic])
+ }, [ax, info, playHaptic])
const onPressReport = useCallback(() => {
reportDialogControl.open()
@@ -462,7 +451,7 @@ function DialogInner({
By{' '}