From c79b5bfa08d6ddeb909dab0c0b392e5123d9c5bb Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 4 Jun 2026 09:29:36 -0500 Subject: [PATCH 01/61] Disable overscroll on Android to fix settle discrepancy (#10717) --- src/components/images/Gallery/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index 9ae677c2d2..fd18cb8a0b 100644 --- a/src/components/images/Gallery/index.tsx +++ b/src/components/images/Gallery/index.tsx @@ -40,7 +40,7 @@ import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu' import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' -import {IS_WEB} from '#/env' +import {IS_ANDROID, IS_WEB} from '#/env' export * from './const' export * from './maybeApplyGalleryOffsetStyles' @@ -264,6 +264,9 @@ export function Gallery({ aria-label={l`Image gallery, ${images.length} images`} horizontal pagingEnabled={false} + // Disable Android's stretch overscroll, which can leave the carousel + // settled just off the left edge instead of aligned to x = 0 + overScrollMode={IS_ANDROID ? 'never' : 'auto'} showsHorizontalScrollIndicator={false} directionalLockEnabled nestedScrollEnabled From 08cd4e58b023e1ffe2b3681f4f8c690a24af84a0 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:11:50 -0700 Subject: [PATCH 02/61] Add group chat join links to supported embed types (#10454) --- __tests__/lib/strings/url-helpers.test.ts | 45 +++ eslint-suppressions.json | 8 - src/components/Post/Embed/ChatInviteEmbed.tsx | 41 +++ .../Post/Embed/JoinRequestEmbed.tsx | 271 ++++++++++++++++++ src/components/Post/Embed/index.tsx | 17 ++ .../intents/GroupChatJoinDialog.tsx | 10 +- src/lib/api/index.ts | 18 +- src/lib/api/resolve.ts | 25 +- src/lib/strings/url-helpers.ts | 5 +- src/state/queries/join-links.ts | 6 +- src/view/com/composer/ExternalEmbed.tsx | 3 + .../com/composer/ExternalEmbedRemoveBtn.tsx | 7 +- 12 files changed, 434 insertions(+), 22 deletions(-) create mode 100644 src/components/Post/Embed/ChatInviteEmbed.tsx create mode 100644 src/components/Post/Embed/JoinRequestEmbed.tsx diff --git a/__tests__/lib/strings/url-helpers.test.ts b/__tests__/lib/strings/url-helpers.test.ts index 0b1b750281..23ffaa2875 100644 --- a/__tests__/lib/strings/url-helpers.test.ts +++ b/__tests__/lib/strings/url-helpers.test.ts @@ -1,6 +1,7 @@ import {describe, expect, it} from '@jest/globals' import { + getChatInviteCodeFromUrl, isPossiblyAUrl, isTrustedUrl, linkRequiresWarning, @@ -178,3 +179,47 @@ describe('isTrustedUrl', () => { expect(output).toEqual(expected) }) }) + +describe('getChatInviteCodeFromUrl', () => { + type Case = [string, string | undefined] + + const cases: Case[] = [ + ['https://bsky.app/c/abcdefg', 'abcdefg'], + ['https://bsky.app/c/abcdefghij', 'abcdefghij'], + // http is not recognized as a bsky.app url + ['http://bsky.app/c/abcdefg', undefined], + ['https://bsky.app/c/abcdefg?utm=foo', 'abcdefg'], + ['https://bsky.app/c/abcdefg#section', 'abcdefg'], + ['/c/abcdefg', 'abcdefg'], + ['/c/abcdefg?utm=foo', 'abcdefg'], + ['/c/abcdefg#section', 'abcdefg'], + + // too short + ['https://bsky.app/c/abcdef', undefined], + ['/c/abcdef', undefined], + // too long + ['https://bsky.app/c/abcdefghijk', undefined], + ['/c/abcdefghijk', undefined], + // invalid characters + ['https://bsky.app/c/abc-def', undefined], + ['/c/abc def', undefined], + // trailing path + ['https://bsky.app/c/abcdefg/extra', undefined], + ['/c/abcdefg/extra', undefined], + // wrong path + ['https://bsky.app/profile/abcdefg', undefined], + ['https://bsky.app/c', undefined], + // wrong host + ['https://example.com/c/abcdefg', undefined], + // not a url, not a path + ['c/abcdefg', undefined], + ['abcdefg', undefined], + ['', undefined], + // malformed url + ['https://[invalid/c/abcdefg', undefined], + ] + + it.each(cases)('given input %p, returns %p', (input, expected) => { + expect(getChatInviteCodeFromUrl(input)).toEqual(expected) + }) +}) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index f2e677dc79..d8555ace5b 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -811,14 +811,6 @@ "count": 1 } }, - "src/lib/api/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 3 - } - }, "src/lib/async/retry.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 diff --git a/src/components/Post/Embed/ChatInviteEmbed.tsx b/src/components/Post/Embed/ChatInviteEmbed.tsx new file mode 100644 index 0000000000..744fcfb7fa --- /dev/null +++ b/src/components/Post/Embed/ChatInviteEmbed.tsx @@ -0,0 +1,41 @@ +import {type StyleProp, type ViewStyle} from 'react-native' +import {type AppBskyEmbedExternal} from '@atproto/api' + +import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links' +import {useSession} from '#/state/session' +import {atoms as a} from '#/alf' +import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed' +import {JoinRequestEmbed} from '#/components/Post/Embed/JoinRequestEmbed' + +export function ChatInviteEmbed({ + code, + link, + onOpen, + style, +}: { + code: string + link: AppBskyEmbedExternal.ViewExternal + onOpen?: () => void + style?: StyleProp +}) { + const {hasSession} = useSession() + const {data, error, isPending} = useJoinLinkPreviewsQuery({ + codes: [code], + hasSession, + }) + + const preview = data?.joinLinkPreviews[0] + + if (error) { + return + } + + return ( + + ) +} diff --git a/src/components/Post/Embed/JoinRequestEmbed.tsx b/src/components/Post/Embed/JoinRequestEmbed.tsx new file mode 100644 index 0000000000..77a0223bec --- /dev/null +++ b/src/components/Post/Embed/JoinRequestEmbed.tsx @@ -0,0 +1,271 @@ +import {type StyleProp, View, type ViewStyle} from 'react-native' +import {type ChatBskyGroupDefs} from '@atproto/api' +import {Plural, Trans, useLingui} from '@lingui/react/macro' +import {useNavigation} from '@react-navigation/native' + +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {type NavigationProp} from '#/lib/routes/types' +import {sanitizeHandle} from '#/lib/strings/handles' +import {atoms as a, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' +import { + Button, + type ButtonColor, + ButtonIcon, + ButtonText, +} from '#/components/Button' +import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' +import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight' +import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' +import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import {useIntentDialogs} from '#/components/intents/IntentDialogs' +import {Loader} from '#/components/Loader' +import {ProfileBadges} from '#/components/ProfileBadges' +import {Text} from '#/components/Typography' + +const JOIN_REQUEST_EMBED_HEIGHT = 152 + +export function JoinRequestEmbed({ + loading = false, + preview, + style, + onOpen, +}: { + loading?: boolean + preview?: ChatBskyGroupDefs.JoinLinkPreviewView + style?: StyleProp + onOpen?: () => void +}) { + const t = useTheme() + + if (loading) { + return ( + + + + ) + } + + if (!preview) { + return ( + + + + Chat invite link no longer available + + + ) + } + + return ( + + ) +} + +function JoinRequestEmbedInner({ + preview, + style, + onOpen, +}: { + preview: ChatBskyGroupDefs.JoinLinkPreviewView + style?: StyleProp + onOpen?: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const navigation = useNavigation() + + const {groupChatJoinDialogControl, setGroupChatJoinState} = useIntentDialogs() + + const ownerDisplayName = createSanitizedDisplayName(preview.owner) + const ownerHandle = sanitizeHandle(preview.owner.handle, '@') + + const avatarProfiles = preview.convo?.members ?? [preview.owner] + + const convoId = preview.convo?.id + const isFollowing = preview.owner.viewer?.following ?? false + const hasRequested = !convoId && preview.viewer?.requestedAt != null + + let canJoin = true + let ButtonIconImage = JoinIcon + let buttonText = preview.requireApproval ? l`Request to join` : l`Join` + let buttonColor: ButtonColor = 'primary' + if (preview.enabledStatus !== 'enabled') { + canJoin = false + ButtonIconImage = WarningIcon + buttonText = l`Chat invite link no longer available` + buttonColor = 'secondary' + } else if (preview.memberCount >= preview.memberLimit) { + canJoin = false + ButtonIconImage = HandIcon + buttonText = l`This chat is full` + buttonColor = 'secondary' + } else if (preview.joinRule === 'followedByOwner' && !isFollowing) { + canJoin = false + ButtonIconImage = HandIcon + buttonText = l`Only people the chat owner follows can join` + buttonColor = 'secondary' + } else if (hasRequested) { + ButtonIconImage = CheckIcon + buttonText = l`Requested` + buttonColor = 'secondary' + } + + return ( + + + + + + {preview.name} + + + + Group chat + + + + {preview.memberCount}/{preview.memberLimit}{' '} + + + + + + + + By{' '} + + {ownerDisplayName} + + + + + + {ownerHandle} + + + + + {convoId ? ( + + ) : ( + + )} + + ) +} diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index 054558ad83..ab6f5dc439 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -12,6 +12,7 @@ import {Trans} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {makeProfileLink} from '#/lib/routes/links' +import {getChatInviteCodeFromUrl} from '#/lib/strings/url-helpers' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {unstableCacheProfileView} from '#/state/queries/profile' import {useSession} from '#/state/session' @@ -33,6 +34,7 @@ import { type EmbedType, parseEmbed, } from '#/types/bsky/post' +import {ChatInviteEmbed} from './ChatInviteEmbed' import {ExternalEmbed} from './ExternalEmbed' import {ModeratedFeedEmbed} from './FeedEmbed' import {ImageEmbed} from './ImageEmbed' @@ -110,6 +112,21 @@ function MediaEmbed({ ) } + const chatInviteCode = getChatInviteCodeFromUrl(embed.view.external.uri) + if (chatInviteCode) { + return ( + + + + ) + } return ( - Open chat + + Open chat + ) : ( @@ -416,8 +420,8 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { } accessibilityHint={ joinLinkPreview.requireApproval - ? l`Request access to join this group chat` - : l`Join this group chat` + ? l`Tap to request access to join this group chat` + : l`Tap to join this group chat immediately` } size="large" color={buttonColor} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index d9a026ca83..c5f6cc6eb5 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -177,7 +177,8 @@ export async function post( writes: writes, validate: true, }) - } catch (e: any) { + } catch (err) { + const e = err as Error logger.error(`Failed to create post`, { safeMessage: e.message, }) @@ -427,6 +428,16 @@ async function resolveMedia( }, } } + if (resolvedLink.type === 'chat-invite' && resolvedLink.view) { + return { + $type: 'app.bsky.embed.external', + external: { + uri: resolvedLink.uri, + title: resolvedLink.view.name, + description: `${resolvedLink.view.memberCount}/${resolvedLink.view.memberLimit}`, + }, + } + } } return undefined } @@ -470,6 +481,7 @@ async function computeCid(record: AppBskyFeedPost.Record): Promise { } // Returns a transformed version of the object for use in DAG-CBOR. +// eslint-disable-next-line @typescript-eslint/no-explicit-any function prepareForHashing(v: any): any { // IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing, // the API client will convert this for you but we're hashing in the client, @@ -492,9 +504,10 @@ function prepareForHashing(v: any): any { // Walk through plain objects if (isPlainObject(v)) { - const obj: any = {} + const obj: Record = {} let pure = true for (const key in v) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access let value = v[key] // `value` is undefined if (value === undefined) { @@ -513,6 +526,7 @@ function prepareForHashing(v: any): any { return v } +// eslint-disable-next-line @typescript-eslint/no-explicit-any function isPlainObject(v: any): boolean { if (typeof v !== 'object' || v === null) { return false diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index f75cd74db2..d411bd8204 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -2,11 +2,12 @@ import { type AppBskyFeedDefs, type AppBskyGraphDefs, type BskyAgent, + type ChatBskyGroupDefs, type ComAtprotoRepoStrongRef, } from '@atproto/api' import {AtUri} from '@atproto/api' -import {POST_IMG_MAX} from '#/lib/constants' +import {DM_SERVICE_HEADERS, POST_IMG_MAX} from '#/lib/constants' import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' import {resolveShortLink} from '#/lib/link-meta/resolve-short-link' import {downloadAndResize} from '#/lib/media/manip' @@ -16,6 +17,7 @@ import { } from '#/lib/strings/starter-pack' import { convertBskyAppUrlIfNeeded, + getChatInviteCodeFromUrl, isBskyCustomFeedUrl, isBskyListUrl, isBskyPostUrl, @@ -71,12 +73,20 @@ type ResolvedStarterPackRecord = { view: AppBskyGraphDefs.StarterPackView } +type ResolvedChatInvite = { + type: 'chat-invite' + uri: string + code: string + view?: ChatBskyGroupDefs.JoinLinkPreviewView +} + export type ResolvedLink = | ResolvedExternalLink | ResolvedPostRecord | ResolvedFeedRecord | ResolvedListRecord | ResolvedStarterPackRecord + | ResolvedChatInvite export class EmbeddingDisabledError extends Error { constructor() { @@ -141,6 +151,19 @@ export async function resolveLink( view: res.data.list, } } + const chatInviteCode = getChatInviteCodeFromUrl(uri) + if (chatInviteCode) { + const res = await agent.chat.bsky.group.getJoinLinkPreviews( + {codes: [chatInviteCode]}, + {headers: DM_SERVICE_HEADERS}, + ) + return { + type: 'chat-invite', + uri, + code: chatInviteCode, + view: res.data.joinLinkPreviews[0], + } + } if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) { const parsed = parseStarterPackUri(uri) if (!parsed) { diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index b5c278dacf..fd1a5ef7d9 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -1,5 +1,5 @@ import {AtUri} from '@atproto/api' -import psl from 'psl' +import {parse} from 'psl' import TLDs from 'tlds' import {BSKY_SERVICE} from '#/lib/constants' @@ -178,6 +178,7 @@ export function isBskyStarterPackUrl(url: string): boolean { return false } +// Invite codes are 7 alphanumeric characters long, supporting up to 10 here to future-proof. export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/ export function getChatInviteCodeFromUrl(url: string): string | undefined { @@ -328,7 +329,7 @@ export function isPossiblyAUrl(str: string): boolean { } export function splitApexDomain(hostname: string): [string, string] { - const hostnamep = psl.parse(hostname) + const hostnamep = parse(hostname) if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) { return ['', hostname] } diff --git a/src/state/queries/join-links.ts b/src/state/queries/join-links.ts index 6aa10331ef..99a37b6b06 100644 --- a/src/state/queries/join-links.ts +++ b/src/state/queries/join-links.ts @@ -20,9 +20,11 @@ export const createJoinLinkPreviewQueryKey = (args: { export function useJoinLinkPreviewsQuery({ codes, hasSession, + staleTime = STALE.MINUTES.ONE, }: { codes?: string[] hasSession: boolean + staleTime?: number }) { const agent = useAgent() @@ -45,7 +47,7 @@ export function useJoinLinkPreviewsQuery({ } }, enabled: codes != null && codes.length > 0, - staleTime: STALE.SECONDS.FIFTEEN, + staleTime, }) } @@ -66,7 +68,7 @@ export function usePrefetchJoinLinkPreviews() { : await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes}) return res.data }, - staleTime: STALE.SECONDS.FIFTEEN, + staleTime: STALE.MINUTES.ONE, }) } } diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index ecd22d7c39..5cc87c63f5 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -11,6 +11,7 @@ import {atoms as a, useTheme} from '#/alf' import {Loader} from '#/components/Loader' import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed' import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed' +import {JoinRequestEmbed} from '#/components/Post/Embed/JoinRequestEmbed' import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed' import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed' import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils' @@ -115,6 +116,8 @@ export const ExternalEmbedLink = ({ hideAlt /> ) + } else if (data.type === 'chat-invite') { + return } else if (data.kind === 'feed') { return ( void} & ViewStyleProp) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() return ( - ) : ( - - )} + + ) } diff --git a/src/components/dms/ChatInvite/Card.tsx b/src/components/dms/ChatInvite/Card.tsx new file mode 100644 index 0000000000..518371325e --- /dev/null +++ b/src/components/dms/ChatInvite/Card.tsx @@ -0,0 +1,90 @@ +import {View} from 'react-native' +import {Plural, Trans} from '@lingui/react/macro' + +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {sanitizeHandle} from '#/lib/strings/handles' +import {atoms as a, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' +import {ProfileBadges} from '#/components/ProfileBadges' +import {Text} from '#/components/Typography' +import {useChatInvite} from './Context' + +/** + * Presentational preview of a chat invite: member avatars, group name, member + * count, and owner. Reads the preview from `ChatInvite.Root` context. Renders + * nothing if there's no preview (use a fallback alongside it for that case). + */ +export function Card({size}: {size: 'large' | 'small'}) { + const t = useTheme() + const {preview} = useChatInvite() + + if (!preview) return null + + const ownerDisplayName = createSanitizedDisplayName(preview.owner) + const ownerHandle = sanitizeHandle(preview.owner.handle, '@') + const avatarProfiles = preview.convo?.members ?? [preview.owner] + + return ( + + + + + {preview.name} + + + + Group chat + + + + {preview.memberCount}/{preview.memberLimit}{' '} + + + + + + + + By {ownerDisplayName} + + + + + {ownerHandle} + + + + + ) +} diff --git a/src/components/dms/ChatInvite/Context.tsx b/src/components/dms/ChatInvite/Context.tsx new file mode 100644 index 0000000000..53c7a6cc22 --- /dev/null +++ b/src/components/dms/ChatInvite/Context.tsx @@ -0,0 +1,47 @@ +import {createContext, useContext} from 'react' +import {type ChatBskyGroupDefs} from '@atproto/api' + +import {type ButtonColor} from '#/components/Button' +import {type Props as SVGIconProps} from '#/components/icons/common' + +/** + * The derived state of the join/open action for a chat invite, computed once in + * `Root` and consumed by `JoinButton` (or any custom action UI). + */ +export type ChatInviteAction = { + label: string + accessibilityHint: string + icon: React.ComponentType + color: ButtonColor + /** + * Whether the action can be performed. False when the link is disabled, the + * chat is full, or the viewer doesn't meet the join rule. + */ + disabled: boolean + onPress: () => void + side: 'left' | 'right' +} + +export type ChatInviteContextValue = { + code: string + loading: boolean + error: boolean + preview: ChatBskyGroupDefs.JoinLinkPreviewView | undefined + /** + * The derived action descriptor. Undefined while loading or when there's no + * preview to act on. + */ + action: ChatInviteAction | undefined +} + +const ChatInviteContext = createContext(null) + +export function useChatInvite(): ChatInviteContextValue { + const ctx = useContext(ChatInviteContext) + if (!ctx) { + throw new Error('useChatInvite must be used within a ChatInvite.Root') + } + return ctx +} + +export const ChatInviteProvider = ChatInviteContext.Provider diff --git a/src/components/dms/ChatInvite/JoinButton.tsx b/src/components/dms/ChatInvite/JoinButton.tsx new file mode 100644 index 0000000000..0036391b0e --- /dev/null +++ b/src/components/dms/ChatInvite/JoinButton.tsx @@ -0,0 +1,42 @@ +import {type StyleProp, type ViewStyle} from 'react-native' + +import {atoms as a} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useChatInvite} from './Context' + +/** + * The join/open action button for a chat invite. Reads the derived action from + * `ChatInvite.Root` context. Pass `onPress` to intercept (e.g. to close a + * surface before navigating); it runs before the default action. Renders + * nothing while loading or when there's no preview to act on. + */ +export function JoinButton({ + onPress, + style, +}: { + onPress?: () => void + style?: StyleProp +}) { + const {action} = useChatInvite() + + if (!action) return null + + return ( + + ) +} diff --git a/src/components/dms/ChatInvite/Root.tsx b/src/components/dms/ChatInvite/Root.tsx new file mode 100644 index 0000000000..a9f2c54e3a --- /dev/null +++ b/src/components/dms/ChatInvite/Root.tsx @@ -0,0 +1,144 @@ +import {setStringAsync} from 'expo-clipboard' +import {type ChatBskyGroupDefs} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' +import {useNavigation} from '@react-navigation/native' + +import {type NavigationProp} from '#/lib/routes/types' +import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links' +import {useSession} from '#/state/session' +import {type ButtonColor} from '#/components/Button' +import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' +import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight' +import {ChainLink_Stroke2_Corner0_Rounded as LinkIcon} from '#/components/icons/ChainLink' +import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import {useIntentDialogs} from '#/components/intents/IntentDialogs' +import * as Toast from '#/components/Toast' +import {type ChatInviteAction, ChatInviteProvider} from './Context' + +/** + * Headless data + state owner for a chat invite. Fetches the join link preview + * by code and derives the join/open action, exposing both via context for the + * composable parts (`Card`, `JoinButton`) or any custom UI to consume. + * + * Pass `initialPreview` when the preview is already known (e.g. a DM message + * embed already carries the resolved view) to avoid a loading flash. + */ +export function Root({ + code, + initialPreview, + currentConvoId, + children, +}: { + code: string + initialPreview?: ChatBskyGroupDefs.JoinLinkPreviewView + /** + * The convo this invite is being viewed within, if any. When the invite + * links to the same chat, the action becomes "Copy link" instead of + * open/join (you're already here). + */ + currentConvoId?: string + children: React.ReactNode +}) { + const {hasSession} = useSession() + const {t: l} = useLingui() + const navigation = useNavigation() + const {groupChatJoinDialogControl, setGroupChatJoinState} = useIntentDialogs() + + const {data, error, isPending} = useJoinLinkPreviewsQuery({ + codes: [code], + hasSession, + // Seed the cache with the already-resolved preview so we don't refetch. + initialData: initialPreview + ? {joinLinkPreviews: [initialPreview]} + : undefined, + }) + + const preview = data?.joinLinkPreviews[0] + const loading = isPending && !preview + + let action: ChatInviteAction | undefined + if (preview) { + const convoId = preview.convo?.id + const isFollowing = preview.owner.viewer?.following ?? false + const hasRequested = !convoId && preview.viewer?.requestedAt != null + + if (convoId && convoId === currentConvoId) { + // You're already in the chat this invite links to - offer to copy the + // link rather than open/join. + action = { + label: l`Copy link`, + accessibilityHint: l`Tap to copy this invite link`, + icon: LinkIcon, + side: 'left', + color: 'primary', + disabled: false, + onPress: () => { + void setStringAsync(`https://bsky.app/c/${preview.code}`) + Toast.show(l`Copied to clipboard`, {type: 'success'}) + }, + } + } else if (convoId) { + action = { + label: l`Open chat`, + accessibilityHint: l`Tap to open this group chat`, + icon: ArrowRightIcon, + side: 'right', + color: 'primary', + disabled: false, + onPress: () => { + navigation.push('MessagesConversation', {conversation: convoId}) + }, + } + } else { + let canJoin = true + let icon: React.ComponentType = JoinIcon + let label = preview.requireApproval ? l`Request to join` : l`Join` + let color: ButtonColor = 'primary' + if (preview.enabledStatus !== 'enabled') { + canJoin = false + icon = WarningIcon + label = l`Chat invite link no longer available` + color = 'secondary' + } else if (preview.memberCount >= preview.memberLimit) { + canJoin = false + icon = HandIcon + label = l`This chat is full` + color = 'secondary' + } else if (preview.joinRule === 'followedByOwner' && !isFollowing) { + canJoin = false + icon = HandIcon + label = l`Only people the chat owner follows can join` + color = 'secondary' + } else if (hasRequested) { + icon = CheckIcon + label = l`Requested` + color = 'secondary' + } + + action = { + label, + side: 'left', + accessibilityHint: preview.requireApproval + ? l`Tap to request access to join this group chat` + : l`Tap to join this group chat immediately`, + icon, + color, + disabled: !canJoin, + onPress: () => { + setGroupChatJoinState({code: preview.code}) + groupChatJoinDialogControl.open() + }, + } + } + } + + return ( + + {children} + + ) +} diff --git a/src/components/dms/ChatInvite/index.tsx b/src/components/dms/ChatInvite/index.tsx new file mode 100644 index 0000000000..bcea3f7651 --- /dev/null +++ b/src/components/dms/ChatInvite/index.tsx @@ -0,0 +1,8 @@ +export {Card} from './Card' +export { + type ChatInviteAction, + type ChatInviteContextValue, + useChatInvite, +} from './Context' +export {JoinButton} from './JoinButton' +export {Root} from './Root' diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index ac6acb5891..f4f3cdacc3 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -20,6 +20,7 @@ import { AppBskyEmbedRecord, type ChatBskyActorDefs, ChatBskyConvoDefs, + ChatBskyEmbedJoinLink, RichText as RichTextAPI, } from '@atproto/api' import {plural} from '@lingui/core/macro' @@ -49,6 +50,7 @@ import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import {DateDivider} from './DateDivider' import {MessageItemEmbed} from './MessageItemEmbed' +import {MessageItemInviteEmbed} from './MessageItemInviteEmbed' import {groupReactions} from './ReactionsDialog' import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util' @@ -185,8 +187,10 @@ let MessageItem = ({ const rt = new RichTextAPI({text: message.text, facets: message.facets}) - const hasEmbedAndText = - AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 + const hasEmbed = + AppBskyEmbedRecord.isView(message.embed) || + ChatBskyEmbedJoinLink.isView(message.embed) + const hasEmbedAndText = hasEmbed && rt.text.length > 0 const targetBottomRadius = squaredBottomCorner ? SQUARED_BORDER_RADIUS @@ -427,6 +431,15 @@ let MessageItem = ({ squaredTopCorner={squaredTopCorner} /> )} + {ChatBskyEmbedJoinLink.isView(message.embed) && ( + + )} {rt.text.length > 0 && ( + isFromSelf: boolean + isGroupChat: boolean + squaredTopCorner: boolean + squaredBottomCorner: boolean +}): React.ReactNode => { + const t = useTheme() + const screen = useWindowDimensions() + const convo = useConvoActive() + + return ( + + + + + + + + + + + ) +} +MessageItemInviteEmbed = memo(MessageItemInviteEmbed) +export {MessageItemInviteEmbed} diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index 0aa132e232..c6899fb9d0 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -20,7 +20,7 @@ import {countGraphemes} from 'unicode-segmenter/grapheme' import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import {isBskyPostUrl} from '#/lib/strings/url-helpers' +import {isBskyChatInviteUrl, isBskyPostUrl} from '#/lib/strings/url-helpers' import {useEmail} from '#/state/email-verification' import { useMessageDraft, @@ -233,7 +233,11 @@ export function MessageComposer({ }} onChange={handleChange} onFacetCommitted={facet => { - if (facet.type === 'url' && isBskyPostUrl(facet.value)) { + if ( + facet.type === 'url' && + (isBskyPostUrl(facet.value) || + isBskyChatInviteUrl(facet.value)) + ) { setEmbed(facet.value) } }} diff --git a/src/screens/Messages/components/MessageInputEmbed.tsx b/src/screens/Messages/components/MessageInputEmbed.tsx index 5bc2f38a05..a73a6564fb 100644 --- a/src/screens/Messages/components/MessageInputEmbed.tsx +++ b/src/screens/Messages/components/MessageInputEmbed.tsx @@ -18,6 +18,8 @@ import { } from '#/lib/routes/types' import { convertBskyAppUrlIfNeeded, + getChatInviteCodeFromUrl, + isBskyChatInviteUrl, isBskyPostUrl, makeRecordUri, } from '#/lib/strings/url-helpers' @@ -26,6 +28,7 @@ import {usePostQuery} from '#/state/queries/post' import {PostMeta} from '#/view/com/util/PostMeta' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' +import * as ChatInvite from '#/components/dms/ChatInvite' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {Loader} from '#/components/Loader' import * as MediaPreview from '#/components/MediaPreview' @@ -35,35 +38,56 @@ import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import * as bsky from '#/types/bsky' +/** + * The embed staged in the message composer. A message can carry at most one + * embed: either a quoted post or a group chat invite link. + */ +export type MessageEmbedState = + | {type: 'post'; uri: string} + | {type: 'invite'; code: string} + export function useMessageEmbed() { const route = useRoute>() const navigation = useNavigation() const embedFromParams = route.params.embed - const [embedUri, setEmbedUri] = useState(embedFromParams) + const [embed, setEmbedState] = useState( + embedFromParams ? {type: 'post', uri: embedFromParams} : undefined, + ) - if (embedFromParams && embedUri !== embedFromParams) { - setEmbedUri(embedFromParams) + if (embedFromParams && embed?.type !== 'post') { + setEmbedState({type: 'post', uri: embedFromParams}) } return { - embedUri, + embed, setEmbed: useCallback( (embedUrl: string | undefined) => { if (!embedUrl) { + // Only the post embed is reflected in the route param (used by the + // share-to-DM intent flow); invites are local-only. navigation.setParams({embed: ''}) - setEmbedUri(undefined) + setEmbedState(undefined) return } if (embedFromParams) return - const url = convertBskyAppUrlIfNeeded(embedUrl) - const [_0, user, _1, rkey] = url.split('/').filter(Boolean) - const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) + if (isBskyChatInviteUrl(embedUrl)) { + const code = getChatInviteCodeFromUrl(embedUrl) + if (code) { + setEmbedState({type: 'invite', code}) + } + return + } - setEmbedUri(uri) + if (isBskyPostUrl(embedUrl)) { + const url = convertBskyAppUrlIfNeeded(embedUrl) + const [_0, user, _1, rkey] = url.split('/').filter(Boolean) + const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) + setEmbedState({type: 'post', uri}) + } }, [embedFromParams, navigation], ), @@ -81,7 +105,10 @@ export function useExtractEmbedFromFacets( for (const facet of rt.facets ?? []) { for (const feature of facet.features) { - if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) { + if ( + AppBskyRichtextFacet.isLink(feature) && + (isBskyPostUrl(feature.uri) || isBskyChatInviteUrl(feature.uri)) + ) { uriFromFacet = feature.uri break } @@ -96,16 +123,40 @@ export function useExtractEmbedFromFacets( } export function MessageInputEmbed({ - embedUri, + embed, setEmbed, }: { - embedUri: string | undefined + embed: MessageEmbedState | undefined setEmbed: (embedUrl: string | undefined) => void +}) { + const onRemove = useCallback(() => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setEmbed(undefined) + }, [setEmbed]) + + if (!embed) { + return null + } + + switch (embed.type) { + case 'post': + return + case 'invite': + return + } +} + +function MessageInputPostEmbed({ + uri, + onRemove, +}: { + uri: string + onRemove: () => void }) { const t = useTheme() const {t: l} = useLingui() - const {data: post, status} = usePostQuery(embedUri) + const {data: post, status} = usePostQuery(uri) const moderationOpts = useModerationOpts() const moderation = useMemo( @@ -134,15 +185,6 @@ export function MessageInputEmbed({ return {rt: undefined, record: undefined} }, [post]) - if (!embedUri) { - return null - } - - const onRemove = () => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) - setEmbed(undefined) - } - switch (status) { case 'pending': { return ( @@ -220,6 +262,71 @@ export function MessageInputEmbed({ } } +function MessageInputInviteEmbed({ + code, + onRemove, +}: { + code: string + onRemove: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + + + + ) +} + +function MessageInputInviteEmbedBody() { + const t = useTheme() + const {loading, preview} = ChatInvite.useChatInvite() + + if (loading) { + return ( + + + + ) + } + + if (!preview) { + return ( + + + Could not load invite + + + ) + } + + return +} + function SimpleContainer({ children, onRemove, diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index fbebdc0745..6f56c6a898 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -28,6 +28,7 @@ import { type AppBskyEmbedRecord, AppBskyRichtextFacet, ChatBskyConvoDefs, + type ChatBskyEmbedJoinLink, RichText, } from '@atproto/api' import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect' @@ -37,6 +38,7 @@ import {ScrollProvider} from '#/lib/ScrollContext' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import { convertBskyAppUrlIfNeeded, + getChatInviteCodeFromUrl, isBskyPostUrl, } from '#/lib/strings/url-helpers' import {logger} from '#/logger' @@ -46,9 +48,10 @@ import { useConvoActive, } from '#/state/messages/convo' import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types' +import {useGetJoinLinkPreview} from '#/state/queries/join-links' import {useGetPost} from '#/state/queries/post' import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util' -import {useAgent} from '#/state/session' +import {useAgent, useSession} from '#/state/session' import {List, type ListMethods} from '#/view/com/util/List' import {MessageComposer} from '#/screens/Messages/components/MessageComposer' import {MessageInput} from '#/screens/Messages/components/MessageInput' @@ -131,8 +134,10 @@ export function MessagesList({ const ax = useAnalytics() const convoState = useConvoActive() const agent = useAgent() + const {hasSession} = useSession() const getPost = useGetPost() - const {embedUri, setEmbed} = useMessageEmbed() + const getJoinLinkPreview = useGetJoinLinkPreview() + const {embed: messageEmbed, setEmbed} = useMessageEmbed() const t = useTheme() const textInputId = 'chat-input-' + useId() @@ -348,12 +353,38 @@ export function MessagesList({ // we want to remove the post link from the text, re-trim, then detect facets rt.detectFacetsWithoutResolution() - let embed: $Typed | undefined - let embedView: $Typed | undefined + let embed: + | $Typed + | $Typed + | undefined + let embedView: + | $Typed + | $Typed + | undefined - if (embedUri) { + // Find the embedded link facet and, if it's at the start or end of the + // message, remove it from the text (the embed card replaces it). + const stripLinkFacet = (predicate: (uri: string) => boolean) => { + const linkFacet = rt.facets?.find(facet => + facet.features.find( + feature => + AppBskyRichtextFacet.isLink(feature) && predicate(feature.uri), + ), + ) + if (linkFacet) { + const isAtStart = linkFacet.index.byteStart === 0 + const isAtEnd = + linkFacet.index.byteEnd === rt.unicodeText.graphemeLength + if (isAtStart || isAtEnd) { + rt.delete(linkFacet.index.byteStart, linkFacet.index.byteEnd) + } + rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true}) + } + } + + if (messageEmbed?.type === 'post') { try { - const post = await getPost({uri: embedUri}) + const post = await getPost({uri: messageEmbed.uri}) if (post) { embed = { $type: 'app.bsky.embed.record', @@ -368,42 +399,34 @@ export function MessagesList({ record: createEmbedViewRecordFromPost(post), } - // look for the embed uri in the facets, so we can remove it from the text - const postLinkFacet = rt.facets?.find(facet => { - return facet.features.find(feature => { - if (AppBskyRichtextFacet.isLink(feature)) { - if (isBskyPostUrl(feature.uri)) { - const url = convertBskyAppUrlIfNeeded(feature.uri) - const [_0, _1, _2, rkey] = url.split('/').filter(Boolean) - - // this might have a handle instead of a DID - // so just compare the rkey - not particularly dangerous - return post.uri.endsWith(rkey) - } - } - return false - }) + stripLinkFacet(uri => { + if (!isBskyPostUrl(uri)) return false + const url = convertBskyAppUrlIfNeeded(uri) + const [_0, _1, _2, rkey] = url.split('/').filter(Boolean) + // this might have a handle instead of a DID + // so just compare the rkey - not particularly dangerous + return post.uri.endsWith(rkey) }) - - if (postLinkFacet) { - const isAtStart = postLinkFacet.index.byteStart === 0 - const isAtEnd = - postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength - - // remove the post link from the text - if (isAtStart || isAtEnd) { - rt.delete( - postLinkFacet.index.byteStart, - postLinkFacet.index.byteEnd, - ) - } - - rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true}) - } } } catch (error) { logger.error('Failed to get post as quote for DM', {error}) } + } else if (messageEmbed?.type === 'invite') { + const code = messageEmbed.code + embed = { + $type: 'chat.bsky.embed.joinLink', + code, + } + + const joinLinkPreview = await getJoinLinkPreview({code, hasSession}) + if (joinLinkPreview) { + embedView = { + $type: 'chat.bsky.embed.joinLink#view', + joinLinkPreview, + } + } + + stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code) } await rt.detectFacets(agent) @@ -424,7 +447,16 @@ export function MessagesList({ embedView, ) }, - [agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled], + [ + agent, + convoState, + messageEmbed, + getPost, + getJoinLinkPreview, + hasSession, + hasScrolled, + setHasScrolled, + ], ) const scrollToEndOnPress = useCallback(() => { @@ -595,11 +627,11 @@ export function MessagesList({ onSendMessage={(message: string) => void onSendMessage(message) } - hasEmbed={!!embedUri} + hasEmbed={!!messageEmbed} setEmbed={setEmbed} loading={loading}> @@ -607,11 +639,11 @@ export function MessagesList({ diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index 08b609f71a..e89965eb45 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -6,6 +6,7 @@ import { ChatBskyConvoDefs, type ChatBskyConvoGetLog, type ChatBskyConvoSendMessage, + type ChatBskyEmbedJoinLink, type ChatBskyGroupDefs, } from '@atproto/api' import {XRPCError} from '@atproto/api' @@ -109,7 +110,9 @@ export class Convo { { id: string message: ChatBskyConvoSendMessage.InputSchema['message'] - optimisticEmbedView?: $Typed + optimisticEmbedView?: + | $Typed + | $Typed } > = new Map() private deletedMessages: Set = new Set() @@ -942,7 +945,9 @@ export class Convo { sendMessage( message: ChatBskyConvoSendMessage.InputSchema['message'], - optimisticEmbedView?: $Typed, + optimisticEmbedView?: + | $Typed + | $Typed, ) { // Ignore empty messages for now since they have no other purpose atm if (!message.text.trim() && !message.embed) return diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index 51d111356a..269f32c515 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -5,6 +5,7 @@ import { type ChatBskyActorDefs, type ChatBskyConvoDefs, type ChatBskyConvoSendMessage, + type ChatBskyEmbedJoinLink, } from '@atproto/api' import {type MessagesEventBus} from '#/state/messages/events/agent' @@ -108,7 +109,10 @@ export type ConvoItem = type DeleteMessage = (messageId: string) => Promise type SendMessage = ( message: ChatBskyConvoSendMessage.InputSchema['message'], - optimisticEmbedView: $Typed | undefined, + optimisticEmbedView: + | $Typed + | $Typed + | undefined, ) => void type FetchMessageHistory = () => Promise type MarkConvoAccepted = () => void diff --git a/src/state/queries/join-links.ts b/src/state/queries/join-links.ts index 99a37b6b06..df87ec0a24 100644 --- a/src/state/queries/join-links.ts +++ b/src/state/queries/join-links.ts @@ -1,4 +1,9 @@ -import {AtpAgent} from '@atproto/api' +import {useCallback} from 'react' +import { + AtpAgent, + type ChatBskyGroupDefs, + type ChatBskyGroupGetJoinLinkPreviews, +} from '@atproto/api' import {useQuery, useQueryClient} from '@tanstack/react-query' import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants' @@ -17,14 +22,39 @@ export const createJoinLinkPreviewQueryKey = (args: { persistedVersion: 1, }) +async function fetchJoinLinkPreviews({ + agent, + codes, + hasSession, +}: { + agent: AtpAgent + codes: string[] + hasSession: boolean +}) { + const previewAgent = new AtpAgent({service: CHAT_SERVICE}) + const res = hasSession + ? await agent.chat.bsky.group.getJoinLinkPreviews( + {codes}, + {headers: DM_SERVICE_HEADERS}, + ) + : await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes}) + return res.data +} + export function useJoinLinkPreviewsQuery({ codes, hasSession, staleTime = STALE.MINUTES.ONE, + initialData, }: { codes?: string[] hasSession: boolean staleTime?: number + /** + * Seed the query with an already-known preview (e.g. a DM message embed + * already carries the resolved view), avoiding a duplicate fetch. + */ + initialData?: ChatBskyGroupGetJoinLinkPreviews.OutputSchema }) { const agent = useAgent() @@ -33,14 +63,7 @@ export function useJoinLinkPreviewsQuery({ queryFn: async () => { if (!codes) throw new Error('No invite code') try { - const previewAgent = new AtpAgent({service: CHAT_SERVICE}) - const res = hasSession - ? await agent.chat.bsky.group.getJoinLinkPreviews( - {codes}, - {headers: DM_SERVICE_HEADERS}, - ) - : await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes}) - return res.data + return await fetchJoinLinkPreviews({agent, codes, hasSession}) } catch (error) { logger.error('Failed to fetch join link preview', {safeMessage: error}) throw error @@ -48,6 +71,7 @@ export function useJoinLinkPreviewsQuery({ }, enabled: codes != null && codes.length > 0, staleTime, + initialData, }) } @@ -58,17 +82,42 @@ export function usePrefetchJoinLinkPreviews() { return ({codes, hasSession}: {codes: string[]; hasSession: boolean}) => { return queryClient.prefetchQuery({ queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}), - queryFn: async () => { - const previewAgent = new AtpAgent({service: CHAT_SERVICE}) - const res = hasSession - ? await agent.chat.bsky.group.getJoinLinkPreviews( - {codes}, - {headers: DM_SERVICE_HEADERS}, - ) - : await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes}) - return res.data - }, + queryFn: () => fetchJoinLinkPreviews({agent, codes, hasSession}), staleTime: STALE.MINUTES.ONE, }) } } + +/** + * Imperatively fetch (or read from cache) a single join link preview by code. + * Used when sending a DM invite embed so we can build an optimistic view. + * Returns undefined if the preview can't be resolved. + */ +export function useGetJoinLinkPreview() { + const agent = useAgent() + const queryClient = useQueryClient() + + return useCallback( + async ({ + code, + hasSession, + }: { + code: string + hasSession: boolean + }): Promise => { + try { + const data = await queryClient.fetchQuery({ + queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}), + queryFn: () => + fetchJoinLinkPreviews({agent, codes: [code], hasSession}), + staleTime: STALE.MINUTES.ONE, + }) + return data.joinLinkPreviews[0] + } catch (error) { + logger.error('Failed to fetch join link preview', {safeMessage: error}) + return undefined + } + }, + [agent, queryClient], + ) +} diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 5cc87c63f5..0b14f16ed3 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -117,7 +117,7 @@ export const ExternalEmbedLink = ({ /> ) } else if (data.type === 'chat-invite') { - return + return } else if (data.kind === 'feed') { return ( Date: Thu, 4 Jun 2026 15:56:37 -0500 Subject: [PATCH 11/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 483ed83e7d..b8e65eb265 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -43,9 +43,6 @@ } }, "src/analytics/PassiveAnalytics.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, "react-hooks/purity": { "count": 1 } diff --git a/src/analytics/PassiveAnalytics.tsx b/src/analytics/PassiveAnalytics.tsx index 34b5f2d275..c772ed09b8 100644 --- a/src/analytics/PassiveAnalytics.tsx +++ b/src/analytics/PassiveAnalytics.tsx @@ -2,8 +2,6 @@ import {useEffect, useRef} from 'react' import {getCurrentState, onAppStateChange} from '#/lib/appState' import {useAnalytics} from '#/analytics' -import {Features, features} from '#/analytics/features' -import {IS_DEV, IS_TESTFLIGHT} from '#/env' /** * Tracks passive analytics like app foreground/background time. @@ -27,19 +25,19 @@ export function PassiveAnalytics() { }) } - if (IS_DEV || IS_TESTFLIGHT) { - const feats = Object.values(Features).reduce( - (acc, feat) => { - acc[feat] = features.evalFeature(feat) - return acc - }, - {} as Record, - ) - ax.logger.info('FEATURES', { - features: feats, - definitions: features.getFeatures(), - }) - } + // if (IS_DEV || IS_TESTFLIGHT) { + // const feats = Object.values(Features).reduce( + // (acc, feat) => { + // acc[feat] = features.evalFeature(feat) + // return acc + // }, + // {} as Record, + // ) + // ax.logger.info('FEATURES', { + // features: feats, + // definitions: features.getFeatures(), + // }) + // } }) return () => sub.remove() }, [ax]) diff --git a/src/analytics/metrics/client.ts b/src/analytics/metrics/client.ts index 2aab90f265..1ea0573dcd 100644 --- a/src/analytics/metrics/client.ts +++ b/src/analytics/metrics/client.ts @@ -67,10 +67,6 @@ export class MetricsClient> { } private async sendBatch(events: Event[], isRetry: boolean = false) { - logger.debug(`sendBatch: ${events.length}`, { - isRetry, - }) - try { const body = JSON.stringify({events}) if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) { diff --git a/src/components/Post/Embed/StandardSiteEmbed/index.tsx b/src/components/Post/Embed/StandardSiteEmbed/index.tsx index 5f7fc1f5e7..c7b8abf88c 100644 --- a/src/components/Post/Embed/StandardSiteEmbed/index.tsx +++ b/src/components/Post/Embed/StandardSiteEmbed/index.tsx @@ -356,6 +356,7 @@ export function PublicationCard({ /> {view.description && ( - + {view.description} @@ -617,6 +618,7 @@ export function PublicationFooter({ /> {sanitizeDisplayName( From 45bd5b45e876e8ebd61b846ad886c7e93e9300dd Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:11:50 -0700 Subject: [PATCH 12/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 df87ec0a24..628f5e9102 100644 --- a/src/state/queries/join-links.ts +++ b/src/state/queries/join-links.ts @@ -83,7 +83,7 @@ export function usePrefetchJoinLinkPreviews() { return queryClient.prefetchQuery({ queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}), queryFn: () => fetchJoinLinkPreviews({agent, codes, hasSession}), - staleTime: STALE.MINUTES.ONE, + staleTime: STALE.SECONDS.FIFTEEN, }) } } @@ -110,7 +110,7 @@ export function useGetJoinLinkPreview() { queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}), queryFn: () => fetchJoinLinkPreviews({agent, codes: [code], hasSession}), - staleTime: STALE.MINUTES.ONE, + staleTime: STALE.SECONDS.FIFTEEN, }) return data.joinLinkPreviews[0] } catch (error) { From de926d1838185ce08a6fbad5b6e9edb71dc02314 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:22:29 -0700 Subject: [PATCH 13/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 6233c467ff..b269046534 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -1,7 +1,7 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {type LayoutChangeEvent, View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {moderateProfile} from '@atproto/api' +import {ChatBskyConvoDefs, moderateProfile} from '@atproto/api' import { ScrollEdgeEffect, ScrollEdgeEffectProvider, @@ -29,6 +29,7 @@ import {ConvoStatus} from '#/state/messages/convo/types' import {useCurrentConvoId} from '#/state/messages/current-convo-id' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useConvoQuery} from '#/state/queries/messages/conversation' +import {useMarkJoinRequestsRead} from '#/state/queries/messages/mark-join-request-read' import {useSession} from '#/state/session' import {MessagesList} from '#/screens/Messages/components/MessagesList' import {atoms as a, web} from '#/alf' @@ -51,6 +52,7 @@ import {IS_INTERNAL, IS_LIQUID_GLASS} from '#/env' import {ChatDisabled} from './components/ChatDisabled' import {ChatEnded} from './components/ChatEnded' import {ChatLocked} from './components/ChatLocked' +import {RequestStatus} from './components/RequestStatus' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -180,6 +182,12 @@ function InnerReady({ const {needsEmailVerification} = useEmail() const emailDialogControl = useEmailDialogControl() + const unreadRequestCount = + convo?.kind === 'group' && ChatBskyConvoDefs.isGroupConvo(convo.view.kind) + ? (convo.view.kind.unreadJoinRequestCount ?? 0) + : 0 + const {mutate: markJoinRequestsRead} = useMarkJoinRequestsRead(convo?.view.id) + /** * Must be non-reactive, otherwise the update to open the global dialog will * cause a re-render loop. @@ -264,8 +272,25 @@ function InnerReady({ {header} ) : ( - header + {header} )} + + {isActive && convo?.kind === 'group' && unreadRequestCount > 0 ? ( + { + markJoinRequestsRead() + }} + onPress={() => { + markJoinRequestsRead() + navigation.navigate('MessagesJoinRequests', { + conversation: convo.view.id, + }) + }} + /> + ) : null} + {isActive && ( 20 + requestInfo={ + convo.details.unreadJoinRequestCount + ? convo.details.unreadJoinRequestCount > JOIN_REQUESTS_THRESHOLD ? l({ - message: '20+ new join requests', + message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`, context: 'Displayed when there are more than 20 requests to join a group chat', }) - : plural(convo.details.joinRequestCount, { + : plural(convo.details.unreadJoinRequestCount, { one: '# new join request', other: '# new join requests', }) @@ -241,6 +242,7 @@ function BaseChatItem({ avatar, title, subtitle, + requestInfo, accessibilityHint, isDeletedAccount, isBlockedAccount, @@ -256,6 +258,7 @@ function BaseChatItem({ avatar: React.ReactNode title: string subtitle?: string + requestInfo?: string accessibilityHint: string isDeletedAccount: boolean isBlockedAccount: boolean @@ -280,8 +283,10 @@ function BaseChatItem({ const playHaptic = useHaptics() const queryClient = useQueryClient() const hasUnread = - convo.view.unreadCount > 0 && !isDeletedAccount && + (convo.view.unreadCount > 0 || + (convo.kind === 'group' && + (convo.details.unreadJoinRequestCount ?? 0) > 0)) && (convo.kind !== 'group' || convo.details.lockStatus === 'unlocked') const blockInfo = useMemo(() => { @@ -607,6 +612,19 @@ function BaseChatItem({ {postAlerts} + {requestInfo && ( + + {requestInfo} + + )} + {LastMessageIcon && ( diff --git a/src/screens/Messages/components/RequestStatus.tsx b/src/screens/Messages/components/RequestStatus.tsx new file mode 100644 index 0000000000..9b35b69445 --- /dev/null +++ b/src/screens/Messages/components/RequestStatus.tsx @@ -0,0 +1,92 @@ +import {Pressable} from 'react-native' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {plural} from '@lingui/core/macro' +import {useLingui} from '@lingui/react/macro' + +import {HITSLOP_10} from '#/lib/constants' +import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests' +import {atoms as a, tokens, useTheme} from '#/alf' +import {GlassView} from '#/components/GlassView' +import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope' +import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times' +import {Text} from '#/components/Typography' +import {IS_LIQUID_GLASS} from '#/env' + +export function RequestStatus({ + top, + count, + onDismiss, + onPress, +}: { + top: number + count: number + onDismiss: () => void + onPress: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + + + {count > JOIN_REQUESTS_THRESHOLD + ? l({ + message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`, + comment: + 'Displayed when the number of requests is greater than 20', + }) + : plural(count, { + one: '# new join request', + other: '# new join requests', + })} + + + + + + + + ) +} diff --git a/src/state/queries/messages/list-join-requests.ts b/src/state/queries/messages/list-join-requests.ts index 9f0860c20d..64a999c118 100644 --- a/src/state/queries/messages/list-join-requests.ts +++ b/src/state/queries/messages/list-join-requests.ts @@ -8,6 +8,8 @@ import {createQueryKey} from '#/state/queries/util' import {useAgent} from '#/state/session' import {STALE} from '..' +export const JOIN_REQUESTS_THRESHOLD = 20 + const listJoinRequestsQueryKeyRoot = 'list-join-requests' export const createListJoinRequestsQueryKey = (args: {convoId: string}) => @@ -53,7 +55,7 @@ export function useListJoinRequestsQuery({ queryKey: createListJoinRequestsQueryKey({convoId: convoId ?? ''}), queryFn: async ({pageParam}) => { const {data} = await agent.chat.bsky.group.listJoinRequests( - {convoId: convoId!, cursor: pageParam, limit: 20}, + {convoId: convoId!, cursor: pageParam, limit: JOIN_REQUESTS_THRESHOLD}, {headers: DM_SERVICE_HEADERS}, ) return data diff --git a/src/state/queries/messages/mark-join-request-read.ts b/src/state/queries/messages/mark-join-request-read.ts new file mode 100644 index 0000000000..ae6d7e88a7 --- /dev/null +++ b/src/state/queries/messages/mark-join-request-read.ts @@ -0,0 +1,85 @@ +import {ChatBskyConvoDefs} from '@atproto/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {DM_SERVICE_HEADERS} from '#/lib/constants' +import {logger} from '#/logger' +import {useAgent} from '#/state/session' +import {RQKEY as CONVO_KEY} from './conversation' +import { + type ConvoListQueryData, + RQKEY_ROOT as CONVO_LIST_ROOT_KEY, +} from './list-conversations' + +export function useMarkJoinRequestsRead(convoId: string | undefined) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + if (!convoId) throw new Error('No convoId provided') + await agent.chat.bsky.group.updateJoinRequestsRead( + {convoId}, + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, + ) + }, + onMutate: () => { + if (!convoId) return + + const prevConvo = queryClient.getQueryData( + CONVO_KEY(convoId), + ) + queryClient.setQueryData( + CONVO_KEY(convoId), + old => { + if (!old || !ChatBskyConvoDefs.isGroupConvo(old.kind)) return old + return { + ...old, + kind: {...old.kind, unreadJoinRequestCount: 0}, + } + }, + ) + + const prevListEntries = queryClient.getQueriesData({ + queryKey: [CONVO_LIST_ROOT_KEY], + }) + queryClient.setQueriesData( + {queryKey: [CONVO_LIST_ROOT_KEY]}, + old => { + if (!old) return old + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + convos: page.convos.map(convo => { + if ( + convo.id !== convoId || + !ChatBskyConvoDefs.isGroupConvo(convo.kind) + ) { + return convo + } + return { + ...convo, + kind: {...convo.kind, unreadJoinRequestCount: 0}, + } + }), + })), + } + }, + ) + + return {prevConvo, prevListEntries} + }, + onError: (error, _, context) => { + logger.error('Failed to mark join requests as read', {safeMessage: error}) + if (!convoId) return + if (context?.prevConvo) { + queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo) + } + for (const [key, data] of context?.prevListEntries ?? []) { + queryClient.setQueryData(key, data) + } + void queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)}) + void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]}) + }, + }) +} From 144ba30ea647f4d80a2e76b14a380f3eb0a02149 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Thu, 4 Jun 2026 17:22:40 -0400 Subject: [PATCH 14/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 c515ecdcee..a879182d9f 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "prettier": "prettier --check ." }, "dependencies": { - "@atproto/api": "0.20.8", + "@atproto/api": "0.20.9", "@atproto/syntax": "0.6.1", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0136363f7..0ac8974cf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,8 +242,8 @@ importers: .: dependencies: '@atproto/api': - specifier: 0.20.8 - version: 0.20.8 + specifier: 0.20.9 + version: 0.20.9 '@atproto/syntax': specifier: 0.6.1 version: 0.6.1 @@ -877,8 +877,8 @@ packages: graphql: optional: true - '@atproto/api@0.20.8': - resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==} + '@atproto/api@0.20.9': + resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==} engines: {node: '>=22'} '@atproto/common-web@0.5.0': @@ -9493,7 +9493,7 @@ snapshots: '@0no-co/graphql.web@1.2.0': {} - '@atproto/api@0.20.8': + '@atproto/api@0.20.9': dependencies: '@atproto/common-web': 0.5.0 '@atproto/lexicon': 0.7.1 diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index b36705d840..034094556c 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -1,6 +1,10 @@ import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' import {Image} from 'expo-image' -import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api' +import { + AppBskyEmbedGallery, + type AppBskyEmbedImages, + type AppBskyFeedDefs, +} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {shareImageModal} from '#/lib/media/manip' @@ -47,6 +51,34 @@ export function Embed({ )} ) + } else if (e.type === 'gallery') { + // Notification/DM preview is a narrow inline strip; cap at 4 tiles so + // a 10-image gallery doesn't blow out the row width. Single pass instead + // of filter().slice().map() so we stop at 4 viewable items rather than + // walking every item in a 10-image gallery. + const tiles: React.ReactNode[] = [] + for (const item of e.view.items) { + if (tiles.length >= 4) break + if (!AppBskyEmbedGallery.isViewImage(item)) continue + if (peekable) { + const image: AppBskyEmbedImages.ViewImage = { + thumb: item.thumbnail, + fullsize: item.fullsize, + alt: item.alt, + aspectRatio: item.aspectRatio, + } + tiles.push() + } else { + tiles.push( + , + ) + } + } + return {tiles} } else if (e.type === 'link') { if (!e.view.external.thumb) return null if (!isGifEmbed(e.view.external.uri)) return null diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index 2c6bb6c5de..0a620efde4 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -2,6 +2,7 @@ import {useRef} from 'react' import {InteractionManager, View} from 'react-native' import {type AnimatedRef} from 'react-native-reanimated' import {Image} from 'expo-image' +import {AppBskyEmbedGallery, type AppBskyEmbedImages} from '@atproto/api' import {atoms as a, tokens} from '#/alf' import {AutoSizedImage} from '#/components/images/AutoSizedImage' @@ -15,16 +16,29 @@ import {useAnalytics} from '#/analytics' import {type EmbedType} from '#/types/bsky/post' import {type CommonProps} from './types' +const MAX_GRID_IMAGES = 4 + export function ImageEmbed({ embed, ...rest }: CommonProps & { - embed: EmbedType<'images'> + embed: EmbedType<'images'> | EmbedType<'gallery'> }) { const ax = useAnalytics() const {openLightbox} = useLightboxControls() - const {images} = embed.view - const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable) + const images: AppBskyEmbedImages.ViewImage[] = + embed.type === 'gallery' + ? embed.view.items.filter(AppBskyEmbedGallery.isViewImage).map(item => ({ + thumb: item.thumbnail, + fullsize: item.fullsize, + alt: item.alt, + aspectRatio: item.aspectRatio, + })) + : embed.view.images + const useExpandedLayout = + embed.type === 'gallery' + ? images.length > MAX_GRID_IMAGES + : ax.features.enabled(ax.features.PostGalleryEmbedEnable) // Captured from AutoSizedImage so the peek-commit handler can reuse the same // ref + dims that a tap would — keeps the lightbox's return animation intact. @@ -109,7 +123,7 @@ export function ImageEmbed({ ) } - if (galleryEnabled) { + if (useExpandedLayout) { return ( ) diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index ab6f5dc439..28eed2aaa9 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -54,6 +54,7 @@ export function Embed({embed: rawEmbed, ...rest}: EmbedProps) { switch (embed.type) { case 'images': + case 'gallery': case 'link': case 'video': { return @@ -89,7 +90,8 @@ function MediaEmbed({ embed: TEmbed }) { switch (embed.type) { - case 'images': { + case 'images': + case 'gallery': { return ( ( post.record, @@ -39,6 +37,13 @@ export function maybeApplyGalleryOffsetStyles( return } + // The gate only controls whether legacy image embeds opt into the new + // expanded gallery layout. Gallery embeds always render expanded by item + // count, so their offset must apply regardless of the gate. + const isPostGalleryEmbedEnabled = features.isOn( + Features.PostGalleryEmbedEnable, + ) + /* * First check if we even have images */ @@ -49,6 +54,12 @@ export function maybeApplyGalleryOffsetStyles( embed, AppBskyEmbedImages.isMain, ) + const isGalleryEmbed = + embed && + bsky.dangerousIsType( + embed, + AppBskyEmbedGallery.isMain, + ) const isRecordWithMedia = embed && bsky.dangerousIsType( @@ -57,10 +68,16 @@ export function maybeApplyGalleryOffsetStyles( ) let hasImages = false if (isImageEmbed) { + if (!isPostGalleryEmbedEnabled) return // one image, not a gallery if (embed.images.length === 1) return hasImages = true } + if (isGalleryEmbed) { + // single (or empty) gallery - no offset needed + if (embed.items.length <= 1) return + hasImages = true + } if (isRecordWithMedia) { if ( bsky.dangerousIsType( @@ -68,9 +85,19 @@ export function maybeApplyGalleryOffsetStyles( AppBskyEmbedImages.isMain, ) ) { + if (!isPostGalleryEmbedEnabled) return // one image, not a gallery if (embed.media.images.length === 1) return } + if ( + bsky.dangerousIsType( + embed.media, + AppBskyEmbedGallery.isMain, + ) + ) { + // single (or empty) gallery - no offset needed + if (embed.media.items.length <= 1) return + } hasImages = true } if (!hasImages) return diff --git a/src/components/images/ImageLayoutGrid.tsx b/src/components/images/ImageLayoutGrid.tsx index 0017ddf9cf..5b4ba608b2 100644 --- a/src/components/images/ImageLayoutGrid.tsx +++ b/src/components/images/ImageLayoutGrid.tsx @@ -19,21 +19,28 @@ interface ImageLayoutGridProps { onPressIn?: (index: number) => void style?: StyleProp viewContext?: PostEmbedViewContext + isWithinQuote?: boolean } -export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) { +export function ImageLayoutGrid({ + style, + isWithinQuote: isWithinQuoteProp, + ...props +}: ImageLayoutGridProps) { const {gtMobile} = useBreakpoints() - const gap = + const isWithinQuote = + isWithinQuoteProp ?? props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia - ? gtMobile - ? a.gap_xs - : a.gap_2xs - : a.gap_xs + const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs return ( - + ) @@ -49,6 +56,7 @@ interface ImageLayoutGridInnerProps { onLongPress?: (index: number) => void onPressIn?: (index: number) => void viewContext?: PostEmbedViewContext + isWithinQuote?: boolean gap: {gap: number} } diff --git a/src/components/images/ImageLayoutGridItem.tsx b/src/components/images/ImageLayoutGridItem.tsx index d6cbc0aded..640ecd8ba2 100644 --- a/src/components/images/ImageLayoutGridItem.tsx +++ b/src/components/images/ImageLayoutGridItem.tsx @@ -29,6 +29,7 @@ interface Props { onPressIn?: EventFunction imageStyle?: StyleProp viewContext?: PostEmbedViewContext + isWithinQuote?: boolean insetBorderStyle?: StyleProp containerRefs: AnimatedRef[] thumbDimsRef: React.RefObject<(Dimensions | null)[]> @@ -42,6 +43,7 @@ export function GalleryItem({ onPressIn, onLongPress, viewContext, + isWithinQuote, insetBorderStyle, containerRefs, thumbDimsRef, @@ -52,6 +54,7 @@ export function GalleryItem({ const image = images[index] const hasAlt = !!image.alt const hideBadges = + isWithinQuote ?? viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia const aspect = diff --git a/src/components/moderation/ReportDialog/utils/parseReportSubject.ts b/src/components/moderation/ReportDialog/utils/parseReportSubject.ts index 405640c453..a7d4b94c32 100644 --- a/src/components/moderation/ReportDialog/utils/parseReportSubject.ts +++ b/src/components/moderation/ReportDialog/utils/parseReportSubject.ts @@ -87,7 +87,10 @@ export function parseReportSubject( reply: !!record.reply, image: embed.type === 'images' || - (embed.type === 'post_with_media' && embed.media.type === 'images'), + embed.type === 'gallery' || + (embed.type === 'post_with_media' && + (embed.media.type === 'images' || + embed.media.type === 'gallery')), video: embed.type === 'video' || (embed.type === 'post_with_media' && embed.media.type === 'video'), diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 7de4e13e56..6f92e2a1dd 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,6 +1,7 @@ import { type $Typed, type AppBskyEmbedExternal, + type AppBskyEmbedGallery, type AppBskyEmbedImages, type AppBskyEmbedRecord, type AppBskyEmbedRecordWithMedia, @@ -254,6 +255,7 @@ async function resolveEmbed( onStateChange: ((state: string) => void) | undefined, ): Promise< | $Typed + | $Typed | $Typed | $Typed | $Typed @@ -313,6 +315,7 @@ async function resolveMedia( ): Promise< | $Typed | $Typed + | $Typed | $Typed | undefined > { @@ -343,6 +346,34 @@ async function resolveMedia( images, } } + if (embedDraft.media?.type === 'gallery') { + const imagesDraft = embedDraft.media.images + logger.debug(`Uploading images`, { + count: imagesDraft.length, + }) + onStateChange?.(t`Uploading images...`) + const items: $Typed[] = await Promise.all( + imagesDraft.map(async (image, i) => { + logger.debug(`Compressing image #${i}`) + const {path, width, height, mime} = await compressImage( + image, + IMAGE_SIZE_CONFIG_POSTS, + ) + logger.debug(`Uploading image #${i}`) + const res = await uploadBlob(agent, path, mime) + return { + $type: 'app.bsky.embed.gallery#image' as const, + image: res.data.blob, + alt: image.alt, + aspectRatio: {width, height}, + } + }), + ) + return { + $type: 'app.bsky.embed.gallery', + items, + } + } if ( embedDraft.media?.type === 'video' && embedDraft.media.video.status === 'done' diff --git a/src/types/bsky/post.ts b/src/types/bsky/post.ts index fada39da81..43621ef63b 100644 --- a/src/types/bsky/post.ts +++ b/src/types/bsky/post.ts @@ -1,6 +1,7 @@ import { type $Typed, AppBskyEmbedExternal, + AppBskyEmbedGallery, AppBskyEmbedImages, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, @@ -47,6 +48,10 @@ export type Embed = type: 'images' view: $Typed } + | { + type: 'gallery' + view: $Typed + } | { type: 'link' view: $Typed @@ -122,6 +127,11 @@ export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed { type: 'images', view: embed, } + } else if (AppBskyEmbedGallery.isView(embed)) { + return { + type: 'gallery', + view: embed, + } } else if (AppBskyEmbedExternal.isView(embed)) { return { type: 'link', diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 5c9f1a05fe..a2493557b7 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -159,7 +159,7 @@ import { composerReducer, createComposerState, type EmbedDraft, - MAX_IMAGES, + MAX_GALLERY_IMAGES, type PostAction, type PostDraft, type ThreadDraft, @@ -178,6 +178,65 @@ type CancelRef = { onPressCancel: () => void } +function applyGalleryCap( + currentCount: number, + incoming: ComposerImage[], +): + | {status: 'full'} + | {status: 'partial'; accepted: ComposerImage[]; dropped: number} + | {status: 'ok'; accepted: ComposerImage[]} { + const remaining = MAX_GALLERY_IMAGES - currentCount + if (remaining <= 0) { + return {status: 'full'} + } + if (incoming.length > remaining) { + return { + status: 'partial', + accepted: incoming.slice(0, remaining), + dropped: incoming.length - remaining, + } + } + return {status: 'ok', accepted: incoming} +} + +function useAddImagesWithCap( + currentCount: number, + dispatchPostAction: (action: PostAction) => void, +) { + const {t: l} = useLingui() + return useCallback( + (next: ComposerImage[]) => { + const result = applyGalleryCap(currentCount, next) + if (result.status === 'full') { + Toast.show( + l({ + message: `You can only add up to ${MAX_GALLERY_IMAGES} images per post`, + comment: + 'Toast shown when the user tries to add more images but the post gallery is already at the cap', + }), + {type: 'warning'}, + ) + return + } + if (result.status === 'partial') { + Toast.show( + l({ + message: `Only ${result.accepted.length} of ${next.length} ${plural(next.length, {one: 'image', other: 'images'})} added; limit is ${MAX_GALLERY_IMAGES}`, + comment: + 'Toast shown when adding images would exceed the post gallery cap; only the first N are kept', + }), + {type: 'warning'}, + ) + } + dispatchPostAction({ + type: 'embed_add_images', + images: result.accepted, + }) + }, + [currentCount, dispatchPostAction, l], + ) +} + type Props = ComposerOpts export const ComposePost = ({ replyTo, @@ -611,7 +670,11 @@ export const ComposePost = ({ ax.metric('draft:save', { isNewDraft, hasText: posts.some(p => p.richtext.text.trim().length > 0), - hasImages: posts.some(p => p.embed.media?.type === 'images'), + hasImages: posts.some( + p => + p.embed.media?.type === 'images' || + p.embed.media?.type === 'gallery', + ), hasVideo: posts.some(p => p.embed.media?.type === 'video'), hasGif: posts.some(p => p.embed.media?.type === 'gif'), hasQuote: posts.some(p => !!p.embed.quote), @@ -780,7 +843,10 @@ export const ComposePost = ({ for (let i = 0; i < thread.posts.length; i++) { const media = thread.posts[i].embed.media if (media) { - if (media.type === 'images' && media.images.some(img => !img.alt)) { + if ( + (media.type === 'images' || media.type === 'gallery') && + media.images.some(img => !img.alt) + ) { return l`One or more images is missing alt text.` } if (media.type === 'gif' && !media.alt) { @@ -931,7 +997,9 @@ export const ComposePost = ({ logger.error(e, { message: `Composer: create post failed`, hasImages: filteredThread.posts.some( - p => p.embed.media?.type === 'images', + p => + p.embed.media?.type === 'images' || + p.embed.media?.type === 'gallery', ), }) @@ -953,7 +1021,8 @@ export const ComposePost = ({ for (let post of filteredThread.posts) { ax.metric('post:create', { imageCount: - post.embed.media?.type === 'images' + post.embed.media?.type === 'images' || + post.embed.media?.type === 'gallery' ? post.embed.media.images.length : 0, isReply: index > 0 || !!replyTo, @@ -1395,15 +1464,11 @@ let ComposerPost = memo(function ComposerPost({ [dispatch, post.id], ) - const onImageAdd = useCallback( - (next: ComposerImage[]) => { - dispatchPost({ - type: 'embed_add_images', - images: next, - }) - }, - [dispatchPost], - ) + const postImagesCount = + post.embed.media?.type === 'images' || post.embed.media?.type === 'gallery' + ? post.embed.media.images.length + : 0 + const onImageAdd = useAddImagesWithCap(postImagesCount, dispatchPost) const onNewLink = useCallback( (uri: string) => { @@ -1708,7 +1773,7 @@ function ComposerEmbeds({ const video = embed.media?.type === 'video' ? embed.media.video : null return ( <> - {embed.media?.type === 'images' && ( + {(embed.media?.type === 'images' || embed.media?.type === 'gallery') && ( )} @@ -1819,7 +1884,11 @@ function ComposerPills({ }) { const t = useTheme() const media = post.embed.media - const hasMedia = media?.type === 'images' || media?.type === 'video' + const hasMedia = + media?.type === 'images' || + media?.type === 'gallery' || + media?.type === 'gif' || + media?.type === 'video' const hasLink = !!post.embed.link // Don't render anything if no pills are going to be displayed @@ -1908,15 +1977,16 @@ function ComposerFooter({ >(undefined) const media = post.embed.media - const images = media?.type === 'images' ? media.images : [] + const images = + media?.type === 'images' || media?.type === 'gallery' ? media.images : [] const video = media?.type === 'video' ? media.video : null - const isMaxImages = images.length >= MAX_IMAGES + const isMaxImages = images.length >= MAX_GALLERY_IMAGES const isMaxVideos = !!video let selectedAssetsCount = 0 let isMediaSelectionDisabled = false - if (media?.type === 'images') { + if (media?.type === 'images' || media?.type === 'gallery') { isMediaSelectionDisabled = isMaxImages selectedAssetsCount = images.length } else if (media?.type === 'video') { @@ -1926,15 +1996,7 @@ function ComposerFooter({ isMediaSelectionDisabled = !!media } - const onImageAdd = useCallback( - (next: ComposerImage[]) => { - dispatch({ - type: 'embed_add_images', - images: next, - }) - }, - [dispatch], - ) + const onImageAdd = useAddImagesWithCap(images.length, dispatch) const onSelectGif = useCallback( (gif: Gif) => { @@ -2017,7 +2079,11 @@ function ComposerFooter({ autoOpen={openGallery} /> diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index bac3e74d31..235139ef86 100644 --- a/src/view/com/composer/ComposerReplyTo.tsx +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -2,6 +2,7 @@ import {useCallback, useMemo, useState} from 'react' import {LayoutAnimation, Pressable, View} from 'react-native' import {Image} from 'expo-image' import { + AppBskyEmbedGallery, AppBskyEmbedImages, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, @@ -61,11 +62,14 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { const images = useMemo(() => { if (AppBskyEmbedImages.isView(embed)) { return embed.images - } else if ( - AppBskyEmbedRecordWithMedia.isView(embed) && - AppBskyEmbedImages.isView(embed.media) - ) { - return embed.media.images + } else if (AppBskyEmbedGallery.isView(embed)) { + return galleryItemsToImages(embed.items) + } else if (AppBskyEmbedRecordWithMedia.isView(embed)) { + if (AppBskyEmbedImages.isView(embed.media)) { + return embed.media.images + } else if (AppBskyEmbedGallery.isView(embed.media)) { + return galleryItemsToImages(embed.media.items) + } } }, [embed]) @@ -129,6 +133,22 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { ) } +function galleryItemsToImages( + items: AppBskyEmbedGallery.View['items'], +): AppBskyEmbedImages.ViewImage[] { + // The reply-to thumbnail only renders up to 4 tiles; slicing here keeps + // the existing layout switch valid for galleries up to 10 items. + return items + .filter(AppBskyEmbedGallery.isViewImage) + .slice(0, 4) + .map(item => ({ + thumb: item.thumbnail, + fullsize: item.fullsize, + alt: item.alt, + aspectRatio: item.aspectRatio, + })) +} + function ComposerReplyToImages({ images, }: { diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx index 2d70195488..1de39f5f54 100644 --- a/src/view/com/composer/SelectMediaButton.tsx +++ b/src/view/com/composer/SelectMediaButton.tsx @@ -16,7 +16,7 @@ import { } from '#/lib/hooks/usePermissions' import {openUnifiedPicker} from '#/lib/media/picker' import {extractDataUriMime} from '#/lib/media/util' -import {MAX_IMAGES} from '#/view/com/composer/state/composer' +import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' @@ -393,7 +393,9 @@ export function SelectMediaButton({ const t = useTheme() const hasAutoOpened = useRef(false) - const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount + // Picker uses the gallery cap; the reducer decides which embed variant + // to land in based on the final image count. + const selectionCountRemaining = MAX_GALLERY_IMAGES - selectedAssetsCount const processSelectedAssets = useCallback( async (rawAssets: ImagePickerAsset[]) => { @@ -419,10 +421,10 @@ export function SelectMediaButton({ ), [SelectedAssetError.MaxImages]: _( msg({ - message: `You can select up to ${plural(MAX_IMAGES, { + message: `You can select up to ${plural(MAX_GALLERY_IMAGES, { other: '# images', })} in total.`, - comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`, + comment: `Error message for maximum number of images that can be selected to add to a post.`, }), ), [SelectedAssetError.MaxVideos]: _( @@ -507,10 +509,11 @@ export function SelectMediaButton({ )} accessibilityHint={_( msg({ - message: `Opens device gallery to select up to ${plural(MAX_IMAGES, { - other: '# images', - })}, or a single video or GIF.`, - comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`, + message: `Opens device gallery to select up to ${plural( + MAX_GALLERY_IMAGES, + {other: '# images'}, + )}, or a single video or GIF.`, + comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post.`, }), )} style={a.p_sm} diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index 8f178d5282..817820effb 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -1,7 +1,7 @@ /** * Type converters for Draft API - convert between ComposerState and server Draft types. */ -import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api' +import {AppBskyDraftDefs, AtUri, RichText} from '@atproto/api' import {nanoid} from 'nanoid/non-secure' import {resolveLink} from '#/lib/api/resolve' @@ -15,6 +15,7 @@ import {createPublicAgent} from '#/state/session/agent' import { type ComposerState, type EmbedDraft, + LEGACY_IMAGES_EMBED_MAX, type PostDraft, } from '#/view/com/composer/state/composer' import {type VideoState} from '#/view/com/composer/state/video' @@ -115,6 +116,16 @@ async function postDraftToServerPost( post.embed.media.images, localRefPaths, ) + } else if (post.embed.media.type === 'gallery') { + draftPost.embedGallery = { + $type: 'app.bsky.draft.defs#draftEmbedGallery', + items: serializeImages(post.embed.media.images, localRefPaths).map( + img => ({ + $type: 'app.bsky.draft.defs#draftEmbedImage' as const, + ...img, + }), + ), + } } else if (post.embed.media.type === 'video') { const video = await serializeVideo(post.embed.media.video, localRefPaths) if (video) { @@ -269,6 +280,59 @@ function serializeGif(gifMedia: { } } +/** + * Restore an array of draft image refs back to ComposerImages. Shared by + * both the `embedImages` and `embedGallery` paths in draftToComposerPosts. + */ +async function restoreDraftImages( + draftImages: AppBskyDraftDefs.DraftEmbedImage[], + loadedMedia: Map, +): Promise { + const imagePromises = draftImages.map(async img => { + const path = loadedMedia.get(img.localRef.path) + if (!path) { + return null + } + + let width = 0 + let height = 0 + try { + const dims = await getImageDim(path) + width = dims.width + height = dims.height + } catch (e) { + logger.warn('Failed to get image dimensions', { + path, + error: e, + }) + } + + logger.debug('restoring image with localRefPath', { + localRefPath: img.localRef.path, + loadedPath: path, + width, + height, + }) + + return { + alt: img.alt || '', + // Preserve the original localRefPath for reuse when saving + localRefPath: img.localRef.path, + source: { + id: nanoid(), + path, + width, + height, + mime: 'image/jpeg', + }, + } satisfies ComposerImage + }) + + return (await Promise.all(imagePromises)).filter( + (img): img is NonNullable => img !== null, + ) +} + /** * Convert server DraftView to DraftSummary for list display. * Also checks which media files exist locally. @@ -314,6 +378,24 @@ export function draftViewToSummary({ } } + // Process gallery + if (post.embedGallery) { + for (const item of post.embedGallery.items) { + if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue + meta.mediaCount++ + meta.hasMedia = true + const exists = storage.mediaExists(item.localRef.path) + if (!exists) { + meta.hasMissingMedia = true + } + images.push({ + localPath: item.localRef.path, + altText: item.alt || '', + exists, + }) + } + } + // Process videos if (post.embedVideos) { for (const vid of post.embedVideos) { @@ -431,54 +513,31 @@ export async function draftToComposerPosts( media: undefined, } - // Restore images + // Restore images / gallery. Pick the variant from the restored count so + // we match the composer reducer's `imagesToMediaVariant` rule (<=4 stays + // legacy `images`, >4 promotes to `gallery`). This keeps restore robust + // to drafts whose server slot disagrees with their count - e.g. a draft + // saved in `embedImages` with 5 items would otherwise restore as a + // broken `images` variant the rest of the composer can't grow. + const restoredImages: ComposerImage[] = [] if (post.embedImages && post.embedImages.length > 0) { - const imagePromises = post.embedImages.map(async img => { - const path = loadedMedia.get(img.localRef.path) - if (!path) { - return null - } - - let width = 0 - let height = 0 - try { - const dims = await getImageDim(path) - width = dims.width - height = dims.height - } catch (e) { - logger.warn('Failed to get image dimensions', { - path, - error: e, - }) - } - - logger.debug('restoring image with localRefPath', { - localRefPath: img.localRef.path, - loadedPath: path, - width, - height, - }) - - return { - alt: img.alt || '', - // Preserve the original localRefPath for reuse when saving - localRefPath: img.localRef.path, - source: { - id: nanoid(), - path, - width, - height, - mime: 'image/jpeg', - }, - } satisfies ComposerImage - }) - - const images = (await Promise.all(imagePromises)).filter( - (img): img is NonNullable => img !== null, + restoredImages.push( + ...(await restoreDraftImages(post.embedImages, loadedMedia)), ) - if (images.length > 0) { - embed.media = {type: 'images', images} - } + } + if (post.embedGallery && post.embedGallery.items.length > 0) { + const galleryImages = post.embedGallery.items.filter( + AppBskyDraftDefs.isDraftEmbedImage, + ) + restoredImages.push( + ...(await restoreDraftImages(galleryImages, loadedMedia)), + ) + } + if (restoredImages.length > 0) { + embed.media = + restoredImages.length <= LEGACY_IMAGES_EMBED_MAX + ? {type: 'images', images: restoredImages} + : {type: 'gallery', images: restoredImages} } // Restore GIF from external embed @@ -630,6 +689,12 @@ export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set { refs.add(img.localRef.path) } } + if (post.embedGallery) { + for (const item of post.embedGallery.items) { + if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue + refs.add(item.localRef.path) + } + } if (post.embedVideos) { for (const vid of post.embedVideos) { refs.add(vid.localRef.path) diff --git a/src/view/com/composer/drafts/state/queries.ts b/src/view/com/composer/drafts/state/queries.ts index e66c9d023b..07dd67dd88 100644 --- a/src/view/com/composer/drafts/state/queries.ts +++ b/src/view/com/composer/drafts/state/queries.ts @@ -1,4 +1,4 @@ -import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api' +import {AppBskyDraftCreateDraft, AppBskyDraftDefs} from '@atproto/api' import { useInfiniteQuery, useMutation, @@ -74,6 +74,21 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{ } } } + // Load gallery + if (post.embedGallery) { + for (const item of post.embedGallery.items) { + if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue + try { + const url = await storage.loadMediaFromLocal(item.localRef.path) + loadedMedia.set(item.localRef.path, url) + } catch (e) { + logger.error('Failed to load draft gallery image', { + path: item.localRef.path, + safeMessage: e instanceof Error ? e.message : String(e), + }) + } + } + } // Load videos if (post.embedVideos) { for (const vid of post.embedVideos) { @@ -226,6 +241,12 @@ export function useDeleteDraftMutation() { await storage.deleteMediaFromLocal(img.localRef.path) } } + if (post.embedGallery) { + for (const item of post.embedGallery.items) { + if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue + await storage.deleteMediaFromLocal(item.localRef.path) + } + } if (post.embedVideos) { for (const vid of post.embedVideos) { await storage.deleteMediaFromLocal(vid.localRef.path) diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 2cc000019f..98bfac779d 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -70,11 +70,13 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => { const {isMobile} = useWebMediaQueries() const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => { + // Cap columns at 4 so tiles stay tappable when MAX_GALLERY_IMAGES is high; + // n > 4 wraps to multiple rows via flexWrap on the gallery container. + const columns = Math.min(images.length, 4) const side = images.length === 1 ? 250 - : (containerInfo.width - IMAGE_GAP * (images.length - 1)) / - images.length + : (containerInfo.width - IMAGE_GAP * (columns - 1)) / columns const isOverflow = isMobile && images.length > 2 @@ -273,6 +275,7 @@ const styles = StyleSheet.create({ gallery: { flex: 1, flexDirection: 'row', + flexWrap: 'wrap', gap: IMAGE_GAP, marginTop: 16, }, diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index d2b9af7b8e..35e1706007 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -16,6 +16,7 @@ import { postUriToRelativePath, toBskyAppUrl, } from '#/lib/strings/url-helpers' +import {logger} from '#/logger' import {type ComposerImage, createInitialImages} from '#/state/gallery' import {createPostgateRecord} from '#/state/queries/postgate/util' import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate' @@ -38,6 +39,11 @@ type ImagesMedia = { images: ComposerImage[] } +type GalleryMedia = { + type: 'gallery' + images: ComposerImage[] +} + type VideoMedia = { type: 'video' video: VideoState @@ -59,7 +65,7 @@ type Link = { export type EmbedDraft = { // We'll always submit quote and actual media (images, video, gifs) chosen by the user. quote: Link | undefined - media: ImagesMedia | VideoMedia | GifMedia | undefined + media: ImagesMedia | GalleryMedia | VideoMedia | GifMedia | undefined // This field may end up ignored if we have more important things to display than a link card: link: Link | undefined } @@ -154,7 +160,31 @@ export type ComposerAction = draftId: string } -export const MAX_IMAGES = 4 +/** + * Threshold for picking between embed variants. <= this count uses the + * legacy `app.bsky.embed.images` shape; > this count promotes to + * `app.bsky.embed.gallery`. Named to flag that if/when we deprecate the + * legacy images embed entirely, this constant (and the variant split it + * gates) should go away. + */ +export const LEGACY_IMAGES_EMBED_MAX = 4 +export const MAX_GALLERY_IMAGES = 10 + +/** + * Picks the embed variant for a set of images. <=4 lands in the legacy + * `app.bsky.embed.images` shape; >4 promotes to `app.bsky.embed.gallery`. + * Anything beyond the gallery cap is dropped by the hard slice; callers + * should already have enforced the cap upstream (picker, paste, etc), + * and the reducer logs a warning when the cap is exceeded so the UI + * layer can surface a toast. + */ +function imagesToMediaVariant( + images: ComposerImage[], +): ImagesMedia | GalleryMedia { + return images.length <= LEGACY_IMAGES_EMBED_MAX + ? {type: 'images', images: images.slice(0, LEGACY_IMAGES_EMBED_MAX)} + : {type: 'gallery', images: images.slice(0, MAX_GALLERY_IMAGES)} +} export function composerReducer( state: ComposerState, @@ -337,16 +367,28 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft { } const prevMedia = state.embed.media let nextMedia = prevMedia + const prevCount = + prevMedia?.type === 'images' || prevMedia?.type === 'gallery' + ? prevMedia.images.length + : 0 + const incomingCount = prevCount + action.images.length + if (incomingCount > MAX_GALLERY_IMAGES) { + // Defense in depth: callers (applyGalleryCap in Composer) should have + // already trimmed and surfaced a toast. The hard slice in + // imagesToMediaVariant still drops the excess so the cap holds. + logger.warn('composer: image add exceeds MAX_GALLERY_IMAGES', { + prevCount, + incomingCount, + dropped: incomingCount - MAX_GALLERY_IMAGES, + }) + } if (!prevMedia) { - nextMedia = { - type: 'images', - images: action.images.slice(0, MAX_IMAGES), - } - } else if (prevMedia.type === 'images') { - nextMedia = { - ...prevMedia, - images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES), - } + nextMedia = imagesToMediaVariant(action.images) + } else if (prevMedia.type === 'images' || prevMedia.type === 'gallery') { + nextMedia = imagesToMediaVariant([ + ...prevMedia.images, + ...action.images, + ]) } return { ...state, @@ -358,7 +400,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft { } case 'embed_update_image': { const prevMedia = state.embed.media - if (prevMedia?.type === 'images') { + if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') { const updatedImage = action.image const nextMedia = { ...prevMedia, @@ -382,19 +424,22 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft { case 'embed_remove_image': { const prevMedia = state.embed.media let nextLabels = state.labels - if (prevMedia?.type === 'images') { + if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') { const removedImage = action.image - let nextMedia: ImagesMedia | undefined = { - ...prevMedia, - images: prevMedia.images.filter(img => { - return img.source.id !== removedImage.source.id - }), - } - if (nextMedia.images.length === 0) { + const remainingImages = prevMedia.images.filter(img => { + return img.source.id !== removedImage.source.id + }) + let nextMedia: ImagesMedia | GalleryMedia | undefined + if (remainingImages.length === 0) { nextMedia = undefined if (!state.embed.link) { nextLabels = [] } + } else { + // Re-pick the variant so a gallery that shrinks to <=4 demotes + // back to the legacy `app.bsky.embed.images` shape - keeps old + // clients rendering it when possible. + nextMedia = imagesToMediaVariant(remainingImages) } return { ...state, @@ -581,12 +626,9 @@ export function createComposerState({ | AppBskyActorDefs.PostInteractionSettingsPref | undefined }): ComposerState { - let media: ImagesMedia | undefined + let media: ImagesMedia | GalleryMedia | undefined if (initImageUris?.length) { - media = { - type: 'images', - images: createInitialImages(initImageUris), - } + media = imagesToMediaVariant(createInitialImages(initImageUris)) } let quote: Link | undefined if (initQuoteUri) { diff --git a/src/view/com/feeds/ComposerPrompt.tsx b/src/view/com/feeds/ComposerPrompt.tsx index b35c1c1adf..592266871e 100644 --- a/src/view/com/feeds/ComposerPrompt.tsx +++ b/src/view/com/feeds/ComposerPrompt.tsx @@ -12,7 +12,7 @@ import { } from '#/lib/hooks/usePermissions' import {openCamera, openUnifiedPicker} from '#/lib/media/picker' import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' -import {MAX_IMAGES} from '#/view/com/composer/state/composer' +import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme, web} from '#/alf' import {Button} from '#/components/Button' @@ -64,7 +64,7 @@ export function ComposerPrompt() { Keyboard.dismiss() } - const selectionCountRemaining = MAX_IMAGES + const selectionCountRemaining = MAX_GALLERY_IMAGES const {assets, canceled} = await sheetWrapper( openUnifiedPicker({selectionCountRemaining}), ) @@ -76,7 +76,7 @@ export function ComposerPrompt() { if (assets.length > 0) { const imageUris = assets .filter(asset => asset.mimeType?.startsWith('image/')) - .slice(0, MAX_IMAGES) + .slice(0, MAX_GALLERY_IMAGES) .map(asset => ({ uri: asset.uri, width: asset.width, From f84c85aac6811e76f58b12e8880e0897f4331d66 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 5 Jun 2026 00:37:42 +0300 Subject: [PATCH 15/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 437e610b20..7a1f599374 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], - }), - }, - ], - } + }, [open, t.name]) - // rendering - // = - - if (!state) { + if (!open) { return null } return ( - - + diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index bfce0729d2..62dec9bcf7 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -64,7 +64,7 @@ function ShellInner() { - + From 2f61c0a7b4ab735bf7ace70aa85ffc09206388e1 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 4 Jun 2026 16:51:34 -0500 Subject: [PATCH 16/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 0cb5ceb3f7..a1a2d8adaa 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 c4a2b0bbb5..8d330201e0 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 85659af25f..f3be07d502 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 c7b8abf88c..2c38f3806f 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} ) } From 68da7971806182cb99856c21954836b6facff0a9 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Thu, 4 Jun 2026 22:19:35 -0400 Subject: [PATCH 17/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 0000000000..38d7e4d013 --- /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 0000000000..7cca087dba --- /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 8fb85e8e0a..33c31a9ee6 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() { +
) break } case Step.GENERATE: { const linkEnabled = joinLink?.enabledStatus === 'enabled' const linkHasChanged = linkEnabled && joinLinkRuleKey !== whoCanJoin header = linkEnabled ? l`Update invite link` : l`Generate invite link` content = ( <> Choose who can join this group chat and how. setWhoCanJoin(value)}> {whoCanJoinOptions.map(option => ( {({selected}) => ( )} ))} ) break } case Step.MANAGE: { const linkEnabled = joinLink?.enabledStatus === 'enabled' const linkDisabled = joinLink?.enabledStatus === 'disabled' const joinLinkURI = joinLink?.code ? `https://bsky.app/c/${joinLink.code}` : 'https://bsky.app/' const createdAt = joinLink ? new Date(joinLink.createdAt) : null const currentOption = whoCanJoinOptions.find( o => o.name === (joinLink ? joinLinkToKey(joinLink) : null), ) ?? whoCanJoinOptions[0] const ownerValue = currentOption?.owner ?? whoCanJoinOptions[0].owner const memberValue = currentOption?.member ?? whoCanJoinOptions[0].member header = linkEnabled ? l`Invite link` : l`Invite link disabled` content = ( <> {joinLinkURI} {createdAt ? ( Created{' '} {i18n.date(createdAt, { dateStyle: 'long', timeStyle: 'short', })} ) : null} {linkEnabled ? ( {isOwner ? ( setStep(Step.GENERATE)}> {ownerValue} ) : ( {memberValue} )} ) : null} {linkEnabled ? ( {isOwner ? ( setStep(Step.CONFIRM_DISABLE)}> Disable ) : null} { control.close(() => { openComposer({ text: joinLinkURI, logContext: 'Other', }) }) }}> Post link { void shareUrl(joinLinkURI) }}> Share ) : ( )} ) break } case Step.CONFIRM_DISABLE: { content = ( <> Disable this invite link? Anyone who has it will no longer be able to join or request to join. You can always create a new one. ) break } } if (!isOwner && (!joinLink || joinLink.enabledStatus === 'disabled')) { header = l`Invite link` content = ( <> There is no invite link for this group chat. ) } return ( { setStep(defaultStep) setWhoCanJoin(defaultWhoCanJoin) }}> {header}
} 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} 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." From d3f073b3d29df83cc288ea6bb4ec0e24e22c87b3 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 5 Jun 2026 16:35:06 +0300 Subject: [PATCH 19/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 55d3b0f4d7..59ca05bdba 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 e8c0b9cc68..142ca4a971 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 20/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 41228f2899..2384aad88d 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)} /> )} From f81bb6f4942f6321837e5987d5c1dac51666eb35 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 5 Jun 2026 16:38:48 +0300 Subject: [PATCH 21/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 83c4b0c004..387fcd2bd1 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} /> From 7d4d7642fdcfe684076dbb4d13f48e36ac470071 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 5 Jun 2026 16:39:23 +0300 Subject: [PATCH 22/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 f026e57d33..dadf30bcdf 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) || From 67ed59fbcd85809ac135ae32ec3fa970d6a829ee Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 5 Jun 2026 18:10:14 +0300 Subject: [PATCH 23/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 89b9c5b01f..7fe5f91dc3 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 f49311b0da..91d9f879d5 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 1f691290bf..b6d237f17d 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 53b493ab6a..f6cd2d51d5 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 From f8aae4a192eb81cbe551e0bf10e3f045e114eaee Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 5 Jun 2026 18:18:27 +0300 Subject: [PATCH 24/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 f50085426f..387c3cfd9c 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 db7ec589bb..e020bb9333 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 22c682bbba..cb257e78b6 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 53c7a6cc22..cedcfd8d88 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 0036391b0e..6f3b8c654d 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 a9f2c54e3a..a2cfc558ea 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 43fd944822..d85e7fd08a 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 a73a6564fb..21503ce8fa 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 25/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 2c38f3806f..2bee7da91e 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++) { From a5aa985b33c92c5b73505fda677a76e04f9186bf Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 5 Jun 2026 22:48:34 +0300 Subject: [PATCH 26/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 cde8d50c48..f8a537a657 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} From 6d468c8ca47d9bbace760526c5ed82bac41d511c Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 5 Jun 2026 22:49:02 +0300 Subject: [PATCH 27/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 6531694d5c..2e7388bbd5 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 aa6c92765d..2fb464a92a 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 4b14c65c69..2a6ec26ac4 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 2d10b11a23..f1b61e95e7 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: | From 32c0c0ec838367b5ddd00df97b0102fc36ea6a29 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 5 Jun 2026 23:08:39 +0300 Subject: [PATCH 28/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 36bcb3370e..37e940d206 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 ) From 2e9bfd363c1f6cf6980361a93c1e4aec8dfa7234 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 5 Jun 2026 23:09:55 +0300 Subject: [PATCH 29/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 b6d237f17d..70390606fc 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} From b236f274c3524a0e091a12741b6b3855ae9283ef Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 5 Jun 2026 23:17:29 +0300 Subject: [PATCH 30/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 614234c17f..cc07900bea 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 13ae162a85..91d1319c6b 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 f5b980b990..e8b594c0bd 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} - + From fb8316fd85d2d596266e0e1e7c47fa07823e4f98 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sat, 6 Jun 2026 00:04:21 +0300 Subject: [PATCH 31/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 b8e65eb265..1c4c89f381 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 472c403060..9a4f7e56bc 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 2bee7da91e..b328bc38ff 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 32/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 1c4c89f381..5bd3357d19 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 6966fa6fe2..c640c84f33 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}) { - - - + {typeof likeCount === 'number' && ( control.close()}> @@ -502,13 +489,12 @@ function DialogInner({ )} - {hasSession && ( <> )} - + ) } diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx index e1008300ad..d0cb34215f 100644 --- a/src/components/Lists.tsx +++ b/src/components/Lists.tsx @@ -185,7 +185,6 @@ let ListMaybePlaceholder = ({ message={errorMessage ?? _(msg`Something went wrong!`)} onRetry={onRetry} onGoBack={onGoBack} - sideBorders={sideBorders} hideBackButton={hideBackButton} /> ) @@ -226,7 +225,6 @@ let ListMaybePlaceholder = ({ onRetry={onRetry} onGoBack={onGoBack} hideBackButton={hideBackButton} - sideBorders={sideBorders} /> ) } diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index b269046534..36adb63e7a 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -137,7 +137,6 @@ function Inner({convoId}: {convoId: string}) { title={l`Something went wrong`} message={l`We couldn't load this conversation`} onRetry={() => convoState.error.retry()} - sideBorders={false} /> ) diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx index 5e4ad91424..fbc4ffcc63 100644 --- a/src/screens/Messages/ConversationSettings/index.tsx +++ b/src/screens/Messages/ConversationSettings/index.tsx @@ -112,7 +112,6 @@ function SettingsInner() { title={l`Something went wrong`} message={l`We couldn’t load this conversation’s settings`} onRetry={() => convoState.error.retry()} - sideBorders={false} /> ) } diff --git a/src/screens/Messages/JoinRequests.tsx b/src/screens/Messages/JoinRequests.tsx index 98952aee34..5198cd7f19 100644 --- a/src/screens/Messages/JoinRequests.tsx +++ b/src/screens/Messages/JoinRequests.tsx @@ -87,7 +87,6 @@ function JoinRequestsInner() { title={l`Something went wrong`} message={l`We couldn’t load this conversation’s join requests`} onRetry={() => convoState.error.retry()} - sideBorders={false} /> ) From e60e31733af4b7c82c22b3b0f4f1e9d54a343c9d Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Mon, 8 Jun 2026 04:36:34 -0700 Subject: [PATCH 46/61] Use new listConvoRequests endpoint (#10755) Co-authored-by: Samuel Newman --- eslint-suppressions.json | 10 - src/components/AvatarBubbles.tsx | 6 +- src/screens/Messages/Inbox.tsx | 63 ++-- ...stItem.tsx => IncomingRequestListItem.tsx} | 2 +- .../components/OutgoingRequestListItem.tsx | 128 +++++++ src/state/cache/profile-shadow.ts | 2 + .../queries/messages/accept-conversation.ts | 26 +- .../messages/list-conversation-requests.tsx | 165 +++++++++ .../queries/messages/list-conversations.tsx | 348 +++++++++++------- .../messages/request-join-group-chat.ts | 5 +- src/state/queries/messages/update-all-read.ts | 39 +- .../messages/withdraw-join-group-chat.ts | 14 +- 12 files changed, 628 insertions(+), 180 deletions(-) rename src/screens/Messages/components/{RequestListItem.tsx => IncomingRequestListItem.tsx} (98%) create mode 100644 src/screens/Messages/components/OutgoingRequestListItem.tsx create mode 100644 src/state/queries/messages/list-conversation-requests.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5bd3357d19..304fd4a065 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1773,16 +1773,6 @@ "count": 7 } }, - "src/state/queries/messages/accept-conversation.ts": { - "@typescript-eslint/no-floating-promises": { - "count": 2 - } - }, - "src/state/queries/messages/update-all-read.ts": { - "@typescript-eslint/no-floating-promises": { - "count": 3 - } - }, "src/state/queries/my-lists.ts": { "@typescript-eslint/no-floating-promises": { "count": 2 diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx index c49dada16f..2f3fb8b15d 100644 --- a/src/components/AvatarBubbles.tsx +++ b/src/components/AvatarBubbles.tsx @@ -36,7 +36,7 @@ export function AvatarBubbles({ moderationOpts, }: { animate?: boolean - profiles: bsky.profile.AnyProfileView[] + profiles: (bsky.profile.AnyProfileView | undefined)[] /** * By default, when there are more than 2 profiles, the current user is * filtered out (so you don't see yourself among your own group's members). @@ -50,12 +50,12 @@ export function AvatarBubbles({ const {currentAccount} = useSession() const profiles = !self && allProfiles.length > 2 - ? allProfiles.filter(p => p?.did != null && p.did !== currentAccount?.did) + ? allProfiles.filter(p => !p || p.did !== currentAccount?.did) : allProfiles const moderations = useMemo(() => { if (!moderationOpts) return [] return profiles.map(p => { - return moderateProfile(p, moderationOpts) + return p && moderateProfile(p, moderationOpts) }) }, [profiles, moderationOpts]) diff --git a/src/screens/Messages/Inbox.tsx b/src/screens/Messages/Inbox.tsx index 59ca05bdba..60891c3c11 100644 --- a/src/screens/Messages/Inbox.tsx +++ b/src/screens/Messages/Inbox.tsx @@ -1,8 +1,9 @@ import {useCallback, useMemo, useState} from 'react' import {View} from 'react-native' import { - type ChatBskyConvoDefs, - type ChatBskyConvoListConvos, + ChatBskyConvoDefs, + type ChatBskyConvoListConvoRequests, + ChatBskyGroupDefs, } from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {useFocusEffect, useNavigation} from '@react-navigation/native' @@ -23,7 +24,7 @@ import {logger} from '#/logger' import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' import {useMessagesEventBus} from '#/state/messages/events' import {useLeftConvos} from '#/state/queries/messages/leave-conversation' -import {useListConvosQuery} from '#/state/queries/messages/list-conversations' +import {useListConvoRequests} from '#/state/queries/messages/list-conversation-requests' import {useUpdateAllRead} from '#/state/queries/messages/update-all-read' import {EmptyState} from '#/view/com/util/EmptyState' import {List} from '#/view/com/util/List' @@ -43,11 +44,16 @@ import {ListFooter} from '#/components/Lists' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {IS_NATIVE} from '#/env' -import {RequestListItem} from './components/RequestListItem' +import {IncomingRequestListItem} from './components/IncomingRequestListItem' +import {OutgoingRequestListItem} from './components/OutgoingRequestListItem' import {useIsWithinSplitView} from './components/splitView/context' type Props = NativeStackScreenProps +type RequestItem = + | {type: 'incoming'; view: ChatBskyConvoDefs.ConvoView} + | {type: 'outgoing'; view: ChatBskyGroupDefs.JoinRequestConvoView} + export function MessagesInboxScreen(props: Props) { const {t: l} = useLingui() const aaCopy = useAgeAssuranceCopy() @@ -61,29 +67,36 @@ export function MessagesInboxScreen(props: Props) { } export function MessagesInboxScreenInner({}: Props) { - const listConvosQuery = useListConvosQuery({status: 'request'}) + const listConvosQuery = useListConvoRequests() const {data} = listConvosQuery const leftConvos = useLeftConvos() - const conversations = useMemo(() => { - if (data?.pages) { - const convos = data.pages - .flatMap(page => page.convos) - // filter out convos that are actively being left - .filter(convo => !leftConvos.includes(convo.id)) - - return convos + const conversations = useMemo(() => { + if (!data?.pages) return [] + const items: RequestItem[] = [] + for (const page of data.pages) { + for (const item of page.requests) { + if (ChatBskyConvoDefs.isConvoView(item)) { + // filter out convos that are actively being left + if (leftConvos.includes(item.id)) continue + items.push({type: 'incoming', view: item}) + } else if (ChatBskyGroupDefs.isJoinRequestConvoView(item)) { + items.push({type: 'outgoing', view: item}) + } + } } - return [] + return items }, [data, leftConvos]) const hasUnreadConvos = useMemo(() => { return conversations.some( - conversation => - conversation.members.every( + item => + item.type === 'incoming' && + item.view.members.every( member => member.handle !== 'missing.invalid', - ) && conversation.unreadCount > 0, + ) && + item.view.unreadCount > 0, ) }, [conversations]) @@ -111,10 +124,10 @@ function RequestList({ conversations, }: { listConvosQuery: UseInfiniteQueryResult< - InfiniteData, + InfiniteData, Error > - conversations: ChatBskyConvoDefs.ConvoView[] + conversations: RequestItem[] }) { const {t: l} = useLingui() const t = useTheme() @@ -274,17 +287,21 @@ function RequestList({ windowSize={11} desktopFixedHeight sideBorders={false} + contentContainerStyle={[web(a.py_sm)]} /> ) } -function keyExtractor(item: ChatBskyConvoDefs.ConvoView) { - return item.id +function keyExtractor(item: RequestItem) { + return item.type === 'incoming' ? item.view.id : item.view.convoId } -function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) { - return +function renderItem({item}: {item: RequestItem}) { + if (item.type === 'incoming') { + return + } + return } function MarkAsReadHeaderButton() { diff --git a/src/screens/Messages/components/RequestListItem.tsx b/src/screens/Messages/components/IncomingRequestListItem.tsx similarity index 98% rename from src/screens/Messages/components/RequestListItem.tsx rename to src/screens/Messages/components/IncomingRequestListItem.tsx index 79eab480ae..2ddc8d1997 100644 --- a/src/screens/Messages/components/RequestListItem.tsx +++ b/src/screens/Messages/components/IncomingRequestListItem.tsx @@ -11,7 +11,7 @@ import {Text} from '#/components/Typography' import {ChatListItem, ChatListItemPortal} from './ChatListItem' import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons' -export function RequestListItem({ +export function IncomingRequestListItem({ convo: convoView, }: { convo: ChatBskyConvoDefs.ConvoView diff --git a/src/screens/Messages/components/OutgoingRequestListItem.tsx b/src/screens/Messages/components/OutgoingRequestListItem.tsx new file mode 100644 index 0000000000..8bd7ba78d0 --- /dev/null +++ b/src/screens/Messages/components/OutgoingRequestListItem.tsx @@ -0,0 +1,128 @@ +import {View} from 'react-native' +import { + type ChatBskyGroupDefs, + ChatBskyGroupWithdrawJoinRequest, +} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' + +import {isNetworkError} from '#/lib/strings/errors' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useWithdrawJoinGroupChatRequest} from '#/state/queries/messages/withdraw-join-group-chat' +import {TimeElapsed} from '#/view/com/util/TimeElapsed' +import {atoms as a, useTheme, web} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' +import {createStaticClick, Link} from '#/components/Link' +import * as Prompt from '#/components/Prompt' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' + +export function OutgoingRequestListItem({ + convo: convoView, +}: { + convo: ChatBskyGroupDefs.JoinRequestConvoView +}) { + const t = useTheme() + const {t: l} = useLingui() + + const prompt = Prompt.usePromptControl() + + const moderationOpts = useModerationOpts() + + const {mutate: withdrawRequest, isPending: isWithdrawPending} = + useWithdrawJoinGroupChatRequest({ + onSuccess: () => { + Toast.show(l`Join request rescinded.`) + }, + onError: error => { + let errorMessage = l`Failed to rescind your request. Please try again.` + if (isNetworkError(error)) { + errorMessage = l`There was a problem with your internet connection, please try again` + } else if ( + error instanceof + ChatBskyGroupWithdrawJoinRequest.InvalidJoinRequestError + ) { + errorMessage = l`Invalid rescind request.` + } + Toast.show(errorMessage) + }, + }) + + return ( + <> + { + prompt.open() + })}> + {({hovered, pressed, focused}) => ( + + + + + + + {convoView.name} + + + + + {({timeElapsed}) => ( + + {timeElapsed} + + )} + + + + + + You requested to join + + + + + )} + + { + prompt.close(() => { + if (isWithdrawPending) return + withdrawRequest({convoId: convoView.convoId}) + }) + }} + /> + + ) +} diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 8b61b4416e..f387306f4e 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -11,6 +11,7 @@ import {findAllProfilesInQueryData as findAllProfilesInContactMatchesQueryData} import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers' import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members' import {findAllProfilesInQueryData as findAllProfilesInGetConvoQueryData} from '#/state/queries/messages/conversation' +import {findAllProfilesInQueryData as findAllProfilesInListConvoRequestsQueryData} from '#/state/queries/messages/list-conversation-requests' import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations' import {findAllProfilesInQueryData as findAllProfilesInMessagesQueryData} from '#/state/queries/messages/list-convo-members' import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts' @@ -259,6 +260,7 @@ function* findProfilesInCache( yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did) yield* findAllProfilesInActorSearchQueryData(queryClient, did) yield* findAllProfilesInListConvosQueryData(queryClient, did) + yield* findAllProfilesInListConvoRequestsQueryData(queryClient, did) yield* findAllProfilesInFeedsQueryData(queryClient, did) yield* findAllProfilesInPostThreadV2QueryData(queryClient, did) yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) diff --git a/src/state/queries/messages/accept-conversation.ts b/src/state/queries/messages/accept-conversation.ts index 0c06055b55..6f1b3d272b 100644 --- a/src/state/queries/messages/accept-conversation.ts +++ b/src/state/queries/messages/accept-conversation.ts @@ -7,6 +7,11 @@ import {useMutation, useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS} from '#/lib/constants' import {logger} from '#/logger' import {useAgent} from '#/state/session' +import { + type ConvoRequestListQueryData, + optimisticDelete as optimisticDeleteRequest, + RQKEY_ROOT as REQUESTS_RQKEY_ROOT, +} from './list-conversation-requests' import { RQKEY as CONVO_LIST_KEY, RQKEY_ROOT as CONVO_LIST_ROOT_KEY, @@ -96,11 +101,20 @@ export function useAcceptConversation( } }, ) + const prevRequestsQueries = + queryClient.getQueriesData({ + queryKey: [REQUESTS_RQKEY_ROOT], + }) + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + old => optimisticDeleteRequest(convoId, old), + ) onMutate?.() - return {prevAcceptedPages, prevInboxPages} + return {prevAcceptedPages, prevInboxPages, prevRequestsQueries} }, onSuccess: data => { - queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) + void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) + void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) onSuccess?.(data) }, onError: (error, _, context) => { @@ -131,7 +145,13 @@ export function useAcceptConversation( } }, ) - queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]}) + if (context?.prevRequestsQueries) { + for (const [queryKey, prevData] of context.prevRequestsQueries) { + queryClient.setQueryData(queryKey, prevData) + } + } + void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]}) + void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) onError?.(error) }, }) diff --git a/src/state/queries/messages/list-conversation-requests.tsx b/src/state/queries/messages/list-conversation-requests.tsx new file mode 100644 index 0000000000..10023db4eb --- /dev/null +++ b/src/state/queries/messages/list-conversation-requests.tsx @@ -0,0 +1,165 @@ +import { + ChatBskyConvoDefs, + type ChatBskyConvoListConvoRequests, + ChatBskyGroupDefs, +} from '@atproto/api' +import { + type InfiniteData, + type QueryClient, + useInfiniteQuery, +} from '@tanstack/react-query' + +import {DM_SERVICE_HEADERS} from '#/lib/constants' +import {useAgent} from '#/state/session' + +const DEFAULT_LIMIT = 10 + +export const RQKEY_ROOT = 'convo-request-list' +export const RQKEY = (limit: number = DEFAULT_LIMIT) => [RQKEY_ROOT, limit] + +type RQPageParam = string | undefined + +export function useListConvoRequests({ + enabled = true, + limit = DEFAULT_LIMIT, +}: { + enabled?: boolean + limit?: number +} = {}) { + const agent = useAgent() + + return useInfiniteQuery({ + enabled, + queryKey: RQKEY(limit), + queryFn: async ({pageParam}) => { + const {data} = await agent.chat.bsky.convo.listConvoRequests( + {limit, cursor: pageParam}, + {headers: DM_SERVICE_HEADERS}, + ) + return data + }, + initialPageParam: undefined as RQPageParam, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + +export type ConvoRequestListQueryData = { + pageParams: Array + pages: Array +} + +export type ConvoRequestItem = + ChatBskyConvoListConvoRequests.OutputSchema['requests'][number] + +export function optimisticUpdate( + chatId: string, + old: ConvoRequestListQueryData | undefined, + updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView, +): ConvoRequestListQueryData | undefined { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + requests: page.requests.map((item): ConvoRequestItem => { + if (ChatBskyConvoDefs.isConvoView(item) && item.id === chatId) { + return { + ...updateFn(item), + $type: 'chat.bsky.convo.defs#convoView', + } + } + return item + }), + })), + } +} + +export function optimisticDelete( + chatId: string, + old: ConvoRequestListQueryData | undefined, +) { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + requests: page.requests.filter( + item => !ChatBskyConvoDefs.isConvoView(item) || item.id !== chatId, + ), + })), + } +} + +export function markAllRead( + old: ConvoRequestListQueryData | undefined, +): ConvoRequestListQueryData | undefined { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + requests: page.requests.map((item): ConvoRequestItem => { + if (ChatBskyConvoDefs.isConvoView(item)) { + return { + ...item, + $type: 'chat.bsky.convo.defs#convoView', + unreadCount: 0, + } + } + return item + }), + })), + } +} + +export function optimisticDeleteJoinRequest( + convoId: string, + old: ConvoRequestListQueryData | undefined, +) { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + requests: page.requests.filter( + item => + !ChatBskyGroupDefs.isJoinRequestConvoView(item) || + item.convoId !== convoId, + ), + })), + } +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +) { + const queryDatas = queryClient.getQueriesData< + InfiniteData + >({ + queryKey: [RQKEY_ROOT], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) continue + + for (const page of queryData.pages) { + for (const item of page.requests) { + if (ChatBskyConvoDefs.isConvoView(item)) { + for (const member of item.members) { + if (member.did === did) { + yield member + } + } + } else if (ChatBskyGroupDefs.isJoinRequestConvoView(item)) { + if (item.owner.did === did) { + yield item.owner + } + } + } + } + } +} diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index dadf30bcdf..372ef708f3 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -23,6 +23,13 @@ import {parseConvoView} from '#/components/dms/util' import * as bsky from '#/types/bsky' import {RQKEY as CONVO_KEY} from './conversation' import {useLeftConvos} from './leave-conversation' +import { + type ConvoRequestListQueryData, + optimisticDelete as optimisticDeleteRequest, + optimisticDeleteJoinRequest, + optimisticUpdate as optimisticUpdateRequest, + RQKEY_ROOT as REQUESTS_RQKEY_ROOT, +} from './list-conversation-requests' import {listConvoMembersQueryKey} from './list-convo-members' const DEFAULT_LIMIT = 10 @@ -130,6 +137,7 @@ export function ListConvosProviderInner({ const refetchAndInvalidate = () => { void refetch() void queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]}) + void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) } return throttle(refetchAndInvalidate, 500, { leading: true, @@ -157,6 +165,22 @@ export function ListConvosProviderInner({ ) } + function updateConvoInAllLists( + convoId: string, + fn: ( + convo: ChatBskyConvoDefs.ConvoView, + ) => ChatBskyConvoDefs.ConvoView, + ) { + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + old => optimisticUpdate(convoId, old, fn), + ) + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + old => optimisticUpdateRequest(convoId, old, fn), + ) + } + function mutateConvoView( convoId: string, fn: ( @@ -167,9 +191,17 @@ export function ListConvosProviderInner({ CONVO_KEY(convoId), old => (old ? fn(old) : old), ) + updateConvoInAllLists(convoId, fn) + } + + function deleteConvoFromAllLists(convoId: string) { queryClient.setQueriesData( {queryKey: [RQKEY_ROOT]}, - old => optimisticUpdate(convoId, old, fn), + old => optimisticDelete(convoId, old), + ) + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + old => optimisticDeleteRequest(convoId, old), ) } @@ -220,35 +252,26 @@ export function ListConvosProviderInner({ if (ChatBskyConvoDefs.isLogBeginConvo(log)) { debouncedRefetch() } else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => optimisticDelete(log.convoId, old), - ) + deleteConvoFromAllLists(log.convoId) } else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - optimisticUpdate(log.convoId, old, convo => { - if ( - (ChatBskyConvoDefs.isDeletedMessageView(log.message) || - ChatBskyConvoDefs.isMessageView(log.message)) && - (ChatBskyConvoDefs.isDeletedMessageView( - convo.lastMessage, - ) || - ChatBskyConvoDefs.isMessageView(convo.lastMessage)) - ) { - return log.message.id === convo.lastMessage.id - ? { - ...convo, - rev: log.rev, - lastMessage: log.message, - } - : convo - } else { - return convo - } - }), - ) + updateConvoInAllLists(log.convoId, convo => { + if ( + (ChatBskyConvoDefs.isDeletedMessageView(log.message) || + ChatBskyConvoDefs.isMessageView(log.message)) && + (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage) || + ChatBskyConvoDefs.isMessageView(convo.lastMessage)) + ) { + return log.message.id === convo.lastMessage.id + ? { + ...convo, + rev: log.rev, + lastMessage: log.message, + } + : convo + } else { + return convo + } + }) } else if (ChatBskyConvoDefs.isLogCreateMessage(log)) { // Store in a new var to avoid TS errors due to closures. const logRef: ChatBskyConvoDefs.LogCreateMessage = log @@ -335,27 +358,24 @@ export function ListConvosProviderInner({ ) } else if (updatedConvo.status === 'request') { queryClient.setQueriesData({queryKey: RQKEY('request')}, updateFn) + // also move-to-top in the new requests cache + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + old => moveConvoToTopInRequests(updatedConvo, old), + ) } } else if (ChatBskyConvoDefs.isLogReadMessage(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - optimisticUpdate(log.convoId, old, convo => ({ - ...convo, - unreadCount: 0, - rev: log.rev, - })), - ) + updateConvoInAllLists(log.convoId, convo => ({ + ...convo, + unreadCount: 0, + rev: log.rev, + })) } else if (ChatBskyConvoDefs.isLogReadConvo(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - optimisticUpdate(log.convoId, old, convo => ({ - ...convo, - unreadCount: 0, - rev: log.rev, - })), - ) + updateConvoInAllLists(log.convoId, convo => ({ + ...convo, + unreadCount: 0, + rev: log.rev, + })) } else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) { const requests = queryClient.getQueryData( RQKEY('request'), @@ -373,6 +393,11 @@ export function ListConvosProviderInner({ RQKEY('request'), (old?: ConvoListQueryData) => optimisticDelete(log.convoId, old), ) + // also remove from the new requests cache + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + old => optimisticDeleteRequest(log.convoId, old), + ) queryClient.setQueriesData( {queryKey: RQKEY('accepted')}, (old?: ConvoListQueryData) => { @@ -398,55 +423,50 @@ export function ListConvosProviderInner({ }, ) } else if (ChatBskyConvoDefs.isLogMuteConvo(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - optimisticUpdate(log.convoId, old, convo => ({ - ...convo, - muted: true, - rev: log.rev, - })), - ) + updateConvoInAllLists(log.convoId, convo => ({ + ...convo, + muted: true, + rev: log.rev, + })) } else if (ChatBskyConvoDefs.isLogUnmuteConvo(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - optimisticUpdate(log.convoId, old, convo => ({ - ...convo, - muted: false, - rev: log.rev, - })), - ) + updateConvoInAllLists(log.convoId, convo => ({ + ...convo, + muted: false, + rev: log.rev, + })) } else if (ChatBskyConvoDefs.isLogLockConvo(log)) { - mutateConvoView(log.convoId, convo => - ChatBskyConvoDefs.isGroupConvo(convo.kind) - ? { - ...convo, - kind: {...convo.kind, lockStatus: 'locked'}, - rev: log.rev, - } - : {...convo, rev: log.rev}, - ) + mutateConvoView(log.convoId, convo => { + if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) { + return { + ...convo, + kind: {...convo.kind, lockStatus: 'locked'}, + rev: log.rev, + } + } + return {...convo, rev: log.rev} + }) } else if (ChatBskyConvoDefs.isLogUnlockConvo(log)) { - mutateConvoView(log.convoId, convo => - ChatBskyConvoDefs.isGroupConvo(convo.kind) - ? { - ...convo, - kind: {...convo.kind, lockStatus: 'unlocked'}, - rev: log.rev, - } - : {...convo, rev: log.rev}, - ) + mutateConvoView(log.convoId, convo => { + if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) { + return { + ...convo, + kind: {...convo.kind, lockStatus: 'unlocked'}, + rev: log.rev, + } + } + return {...convo, rev: log.rev} + }) } else if (ChatBskyConvoDefs.isLogLockConvoPermanently(log)) { - mutateConvoView(log.convoId, convo => - ChatBskyConvoDefs.isGroupConvo(convo.kind) - ? { - ...convo, - kind: {...convo.kind, lockStatus: 'locked-permanently'}, - rev: log.rev, - } - : {...convo, rev: log.rev}, - ) + mutateConvoView(log.convoId, convo => { + if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) { + return { + ...convo, + kind: {...convo.kind, lockStatus: 'locked-permanently'}, + rev: log.rev, + } + } + return {...convo, rev: log.rev} + }) } else if ( ChatBskyConvoDefs.isLogCreateJoinLink(log) || ChatBskyConvoDefs.isLogEditJoinLink(log) || @@ -455,37 +475,68 @@ export function ListConvosProviderInner({ ) { // Join link data not included in the log event, trigger refetch to get it debouncedRefetch() + } else if (ChatBskyConvoDefs.isLogEditGroup(log)) { + // Updated group details (name etc.) aren't included in the log + // event, so refetch to pick them up. + debouncedRefetch() } else if ( ChatBskyConvoDefs.isLogApproveJoinRequest(log) || ChatBskyConvoDefs.isLogRejectJoinRequest(log) ) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - updateGroupConvoJoinRequestCount(log, old, -1), + // Route through mutateConvoView (not updateConvoInAllLists) so the + // single-convo cache updates too, keeping the in-convo requests + // banner in sync. + mutateConvoView(log.convoId, convo => + applyJoinRequestCountDelta(convo, log.rev, -1), ) } else if (ChatBskyConvoDefs.isLogIncomingJoinRequest(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - updateGroupConvoJoinRequestCount(log, old, 1), + // Route through mutateConvoView (not updateConvoInAllLists) so the + // single-convo cache updates too, letting the in-convo requests + // banner appear live. + mutateConvoView(log.convoId, convo => + applyJoinRequestCountDelta(convo, log.rev, 1), ) + } else if (ChatBskyConvoDefs.isLogReadJoinRequests(log)) { + // The owner marked join requests as read (possibly on another + // device). Zero the unread count but keep the total, mirroring the + // useMarkJoinRequestsRead mutation. + mutateConvoView(log.convoId, convo => { + if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) { + return {...convo, rev: log.rev} + } + return { + ...convo, + kind: {...convo.kind, unreadJoinRequestCount: 0}, + rev: log.rev, + } + }) } else if (ChatBskyConvoDefs.isLogOutgoingJoinRequest(log)) { - // Viewer isn't in the chat yet, no need to do anything - } else if (ChatBskyConvoDefs.isLogAddReaction(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - optimisticUpdate(log.convoId, old, convo => ({ - ...convo, - lastReaction: { - $type: 'chat.bsky.convo.defs#messageAndReactionView', - reaction: log.reaction, - message: log.message, - }, - rev: log.rev, - })), + // Viewer isn't in the chat yet, but the inbox surfaces outgoing + // requests, so refetch to pick up the new entry. + debouncedRefetch() + } else if (ChatBskyConvoDefs.isLogWithdrawIncomingJoinRequest(log)) { + // A requester rescinded their request to a group the viewer owns. + // Mirror of isLogIncomingJoinRequest: decrement the counts. + mutateConvoView(log.convoId, convo => + applyJoinRequestCountDelta(convo, log.rev, -1), ) + } else if (ChatBskyConvoDefs.isLogWithdrawOutgoingJoinRequest(log)) { + // The viewer rescinded their own outgoing join request (possibly on + // another device). Remove it from the requests inbox cache. + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + old => optimisticDeleteJoinRequest(log.convoId, old), + ) + } else if (ChatBskyConvoDefs.isLogAddReaction(log)) { + updateConvoInAllLists(log.convoId, convo => ({ + ...convo, + lastReaction: { + $type: 'chat.bsky.convo.defs#messageAndReactionView', + reaction: log.reaction, + message: log.message, + }, + rev: log.rev, + })) } else if (ChatBskyConvoDefs.isLogAddMember(log)) { const data = log.message.data if ( @@ -739,27 +790,58 @@ function optimisticUpdate( } } -function updateGroupConvoJoinRequestCount( - log: {convoId: string; rev: string}, - old: ConvoListQueryData | undefined, +function applyJoinRequestCountDelta( + convo: ChatBskyConvoDefs.ConvoView, + rev: string, delta: 1 | -1, -) { - return optimisticUpdate(log.convoId, old, convo => { - // Join requests are only meaningful for group convos. - if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) { - return {...convo, rev: log.rev} +): ChatBskyConvoDefs.ConvoView { + // Join requests are only meaningful for group convos. + if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) { + return {...convo, rev} + } + // Bump the total and unread counts together. Both are clamped at 0 and + // collapse to undefined when empty, matching the server's shape. + const bump = (current: number | undefined) => { + const next = Math.max(0, (current ?? 0) + delta) + return next === 0 ? undefined : next + } + return { + ...convo, + kind: { + ...convo.kind, + joinRequestCount: bump(convo.kind.joinRequestCount), + unreadJoinRequestCount: bump(convo.kind.unreadJoinRequestCount), + }, + rev, + } +} + +function moveConvoToTopInRequests( + updatedConvo: ChatBskyConvoDefs.ConvoView, + old: ConvoRequestListQueryData | undefined, +): ConvoRequestListQueryData | undefined { + if (!old) return old + const typedConvo: ConvoRequestListQueryData['pages'][number]['requests'][number] = + { + $type: 'chat.bsky.convo.defs#convoView', + ...updatedConvo, } - const current = convo.kind.joinRequestCount ?? 0 - const next = Math.max(0, current + delta) - return { - ...convo, - kind: { - ...convo.kind, - joinRequestCount: next === 0 ? undefined : next, - }, - rev: log.rev, - } - }) + return { + ...old, + pages: old.pages.map((page, i) => { + const filtered = page.requests.filter( + item => + !ChatBskyConvoDefs.isConvoView(item) || item.id !== updatedConvo.id, + ) + if (i === 0) { + return { + ...page, + requests: [typedConvo, ...filtered], + } + } + return {...page, requests: filtered} + }), + } } function removeMemberFromConvoView( diff --git a/src/state/queries/messages/request-join-group-chat.ts b/src/state/queries/messages/request-join-group-chat.ts index 04e63395f7..f4d5650346 100644 --- a/src/state/queries/messages/request-join-group-chat.ts +++ b/src/state/queries/messages/request-join-group-chat.ts @@ -1,9 +1,10 @@ import {type ChatBskyGroupRequestJoin} from '@atproto/api' -import {useMutation} from '@tanstack/react-query' +import {useMutation, useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS} from '#/lib/constants' import {logger} from '#/logger' import {useAgent, useSession} from '#/state/session' +import {RQKEY_ROOT as REQUESTS_RQKEY_ROOT} from './list-conversation-requests' export function useRequestJoinGroupChat({ onSuccess, @@ -13,6 +14,7 @@ export function useRequestJoinGroupChat({ onError?: (error: Error) => void } = {}) { const agent = useAgent() + const queryClient = useQueryClient() const {hasSession} = useSession() return useMutation({ @@ -27,6 +29,7 @@ export function useRequestJoinGroupChat({ return res.data }, onSuccess: data => { + void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) onSuccess?.(data) }, onError: error => { diff --git a/src/state/queries/messages/update-all-read.ts b/src/state/queries/messages/update-all-read.ts index 3d0fd3a45e..79173362dc 100644 --- a/src/state/queries/messages/update-all-read.ts +++ b/src/state/queries/messages/update-all-read.ts @@ -4,6 +4,11 @@ import {useMutation, useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS} from '#/lib/constants' import {logger} from '#/logger' import {useAgent} from '#/state/session' +import { + type ConvoRequestListQueryData, + markAllRead as markAllRequestsRead, + RQKEY_ROOT as REQUESTS_RQKEY_ROOT, +} from './list-conversation-requests' import {RQKEY as CONVO_LIST_KEY} from './list-conversations' export function useUpdateAllRead( @@ -32,6 +37,9 @@ export function useUpdateAllRead( }, onMutate: () => { let prevPages: ChatBskyConvoListConvos.OutputSchema[] = [] + let prevRequestsQueries: Array< + [readonly unknown[], ConvoRequestListQueryData | undefined] + > = [] queryClient.setQueryData( CONVO_LIST_KEY(status), (old?: { @@ -75,11 +83,24 @@ export function useUpdateAllRead( } }, ) + if (status === 'request') { + prevRequestsQueries = + queryClient.getQueriesData({ + queryKey: [REQUESTS_RQKEY_ROOT], + }) + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + markAllRequestsRead, + ) + } onMutate?.() - return {prevPages} + return {prevPages, prevRequestsQueries} }, onSuccess: () => { - queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) + void queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) + if (status === 'request') { + void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) + } onSuccess?.() }, onError: (error, _, context) => { @@ -97,8 +118,18 @@ export function useUpdateAllRead( } }, ) - queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) - queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY('all', 'unread')}) + if (status === 'request' && context?.prevRequestsQueries) { + for (const [queryKey, prevData] of context.prevRequestsQueries) { + queryClient.setQueryData(queryKey, prevData) + } + } + void queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) + void queryClient.invalidateQueries({ + queryKey: CONVO_LIST_KEY('all', 'unread'), + }) + if (status === 'request') { + void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) + } onError?.(error) }, }) diff --git a/src/state/queries/messages/withdraw-join-group-chat.ts b/src/state/queries/messages/withdraw-join-group-chat.ts index 92025cc2be..2ede8c941d 100644 --- a/src/state/queries/messages/withdraw-join-group-chat.ts +++ b/src/state/queries/messages/withdraw-join-group-chat.ts @@ -1,9 +1,14 @@ import {type ChatBskyGroupWithdrawJoinRequest} from '@atproto/api' -import {useMutation} from '@tanstack/react-query' +import {useMutation, useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS} from '#/lib/constants' import {logger} from '#/logger' import {useAgent, useSession} from '#/state/session' +import { + type ConvoRequestListQueryData, + optimisticDeleteJoinRequest, + RQKEY_ROOT as REQUESTS_RQKEY_ROOT, +} from './list-conversation-requests' export function useWithdrawJoinGroupChatRequest({ onSuccess, @@ -13,6 +18,7 @@ export function useWithdrawJoinGroupChatRequest({ onError?: (error: Error) => void } = {}) { const agent = useAgent() + const queryClient = useQueryClient() const {hasSession} = useSession() return useMutation({ @@ -27,7 +33,11 @@ export function useWithdrawJoinGroupChatRequest({ ) return res.data }, - onSuccess: data => { + onSuccess: (data, {convoId}) => { + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + old => optimisticDeleteJoinRequest(convoId, old), + ) onSuccess?.(data) }, onError: error => { From 6997d220a02f34f34d311eef71f3eeef3c5f478d Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Mon, 8 Jun 2026 04:57:08 -0700 Subject: [PATCH 47/61] Add Leave chat button to settings for chat owners (#10764) Co-authored-by: Samuel Newman --- .../Messages/ConversationSettings/index.tsx | 92 ++++++++++++------- .../Messages/ConversationSettings/prompts.tsx | 24 +++++ .../queries/messages/lock-conversation.ts | 16 +++- 3 files changed, 96 insertions(+), 36 deletions(-) diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx index fbc4ffcc63..2ae09b5130 100644 --- a/src/screens/Messages/ConversationSettings/index.tsx +++ b/src/screens/Messages/ConversationSettings/index.tsx @@ -58,7 +58,12 @@ import {InviteLinkDialog} from '../components/InviteLinkDialog' import {AddMembersLink} from './AddMembersLink' import {Member, MemberPlaceholder} from './Member' import {MembersAndRequests} from './MembersAndRequests' -import {EditNamePrompt, LeaveChatPrompt, LockChatPrompt} from './prompts' +import { + EditNamePrompt, + LeaveAndLockChatPrompt, + LeaveChatPrompt, + LockChatPrompt, +} from './prompts' type Item = | {type: 'MEMBERS_AND_REQUESTS'; key: string} @@ -374,33 +379,49 @@ function SettingsHeader({ }, ) - const {mutate: lockConvo, isPending: isLocking} = useLockConvo( - convo.view.id, - { - onSuccess: data => { - if (!ChatBskyConvoDefs.isGroupConvo(data.convo.kind)) return - if (data.convo.kind.lockStatus === 'locked') { - Toast.show(l({message: 'Group chat locked', context: 'toast'})) - } else { - Toast.show(l({message: 'Group chat unlocked', context: 'toast'})) - } - }, - onError: (e, {lock}) => { - if (lock) { - logger.error('Failed to lock group chat', {message: e}) - Toast.show(l`Failed to lock group chat`, {type: 'error'}) - } else { - logger.error('Failed to unlock group chat', {message: e}) - Toast.show(l`Failed to unlock group chat`, {type: 'error'}) - } - }, + const { + mutate: lockConvo, + mutateAsync: lockConvoAsync, + isPending: isLocking, + } = useLockConvo(convo.view.id, { + onSuccess: (data, {silent}) => { + if (!ChatBskyConvoDefs.isGroupConvo(data.convo.kind)) return + if (silent) return + if (data.convo.kind.lockStatus === 'locked') { + Toast.show(l({message: 'Group chat locked', context: 'toast'})) + } else { + Toast.show(l({message: 'Group chat unlocked', context: 'toast'})) + } }, - ) + onError: (e, {lock}) => { + if (lock) { + logger.error('Failed to lock group chat', {message: e}) + Toast.show(l`Failed to lock group chat`, {type: 'error'}) + } else { + logger.error('Failed to unlock group chat', {message: e}) + Toast.show(l`Failed to unlock group chat`, {type: 'error'}) + } + }, + }) + + const leaveAndLockConvo = async () => { + try { + if (lockStatus === 'unlocked') { + await lockConvoAsync({lock: true, silent: true}) + } + } catch { + // Handled by onError in useLockConvo + return + } + // Owners can only leave a locked chat + leaveConvo() + } const inviteLinkDialog = Dialog.useDialogControl() const editNamePrompt = Prompt.usePromptControl() const lockChatPrompt = Prompt.usePromptControl() const leaveChatPrompt = Prompt.usePromptControl() + const leaveAndLockChatPrompt = Prompt.usePromptControl() const reportControl = Prompt.usePromptControl() const deleteControl = Prompt.usePromptControl() @@ -452,6 +473,7 @@ function SettingsHeader({ a.justify_center, a.gap_2xl, a.pt_2xl, + a.flex_wrap, ]}> ) : null} - {!isOwner ? ( - - ) : null} + + { + void leaveAndLockConvo() + }} + /> {reportSubjectDid ? ( <> void +}) { + const {t: l} = useLingui() + + return ( + + ) +} + export function BlockMemberPrompt({ control, onConfirm, diff --git a/src/state/queries/messages/lock-conversation.ts b/src/state/queries/messages/lock-conversation.ts index b10db13462..122c0633b4 100644 --- a/src/state/queries/messages/lock-conversation.ts +++ b/src/state/queries/messages/lock-conversation.ts @@ -14,15 +14,21 @@ export function useLockConvo( onSuccess, onError, }: { - onSuccess?: (data: ChatBskyConvoLockConvo.OutputSchema) => void - onError?: (error: Error, variables: {lock: boolean}) => void + onSuccess?: ( + data: ChatBskyConvoLockConvo.OutputSchema, + variables: {lock: boolean; silent?: boolean}, + ) => void + onError?: ( + error: Error, + variables: {lock: boolean; silent?: boolean}, + ) => void }, ) { const queryClient = useQueryClient() const agent = useAgent() return useMutation({ - mutationFn: async ({lock}: {lock: boolean}) => { + mutationFn: async ({lock}: {lock: boolean; silent?: boolean}) => { if (!convoId) throw new Error('No convoId provided') if (lock) { const {data} = await agent.chat.bsky.convo.lockConvo( @@ -51,8 +57,8 @@ export function useLockConvo( } }) }, - onSuccess: data => { - onSuccess?.(data) + onSuccess: (data, variables) => { + onSuccess?.(data, variables) }, onError: (e, variables, context) => { if (convoId && context) { From eb79c4d53c1558c22ed7349894ea2f91447ae505 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 8 Jun 2026 15:10:41 +0300 Subject: [PATCH 48/61] Show accept/reject footer for empty request convos (#10781) Co-authored-by: Claude Opus 4.8 (1M context) --- src/screens/Messages/components/MessagesList.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 6f56c6a898..727829795c 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -709,6 +709,10 @@ function getFooterState( convoState: ActiveConvoStates, hasAcceptOverride?: boolean, ): FooterState { + if (convoState.convo.view.status === 'request' && !hasAcceptOverride) { + return 'request' + } + if (convoState.items.length === 0) { if (convoState.isFetchingHistory) { return 'loading' @@ -717,10 +721,6 @@ function getFooterState( } } - if (convoState.convo.view.status === 'request' && !hasAcceptOverride) { - return 'request' - } - return 'standard' } From 16046cd85e0cda11f30376f16bb60418158ba54f Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 8 Jun 2026 15:27:58 +0300 Subject: [PATCH 49/61] [Chat] Invite link dialog tweaks (#10750) --- src/screens/Messages/components/InviteLinkDialog.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/screens/Messages/components/InviteLinkDialog.tsx b/src/screens/Messages/components/InviteLinkDialog.tsx index 6e898a0a9c..02d4505af0 100644 --- a/src/screens/Messages/components/InviteLinkDialog.tsx +++ b/src/screens/Messages/components/InviteLinkDialog.tsx @@ -14,7 +14,7 @@ import {useCreateJoinLink} from '#/state/queries/messages/create-join-link' import {useDisableJoinLink} from '#/state/queries/messages/disable-join-link' import {useEditJoinLink} from '#/state/queries/messages/edit-join-link' import {useEnableJoinLink} from '#/state/queries/messages/enable-join-link' -import {atoms as a, useTheme, web} from '#/alf' +import {atoms as a, native, useTheme, web} from '#/alf' import { Button, ButtonIcon, @@ -304,7 +304,7 @@ export function InviteLinkDialog({ header = linkEnabled ? l`Invite link` : l`Invite link disabled` content = ( <> - + { setStep(defaultStep) setWhoCanJoin(defaultWhoCanJoin) - }}> + }} + nativeOptions={{preventExpansion: true}}> Date: Mon, 8 Jun 2026 05:28:24 -0700 Subject: [PATCH 50/61] Block posting of invalid chat invites (#10757) Co-authored-by: Samuel Newman --- src/components/dms/ChatInvite/Root.tsx | 2 +- src/state/queries/resolve-link.ts | 31 +++++++++++--------------- src/view/com/composer/Composer.tsx | 20 ++++++++++++++++- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/components/dms/ChatInvite/Root.tsx b/src/components/dms/ChatInvite/Root.tsx index a2cfc558ea..025cae7a2e 100644 --- a/src/components/dms/ChatInvite/Root.tsx +++ b/src/components/dms/ChatInvite/Root.tsx @@ -10,7 +10,7 @@ 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 {Check_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' diff --git a/src/state/queries/resolve-link.ts b/src/state/queries/resolve-link.ts index a6b21e0bcc..2ccd0af863 100644 --- a/src/state/queries/resolve-link.ts +++ b/src/state/queries/resolve-link.ts @@ -1,5 +1,5 @@ -import {type BskyAgent} from '@atproto/api' -import {type QueryClient, useQuery} from '@tanstack/react-query' +import {type AtpAgent} from '@atproto/api' +import {type QueryClient, queryOptions, useQuery} from '@tanstack/react-query' import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve' import {STALE} from '#/state/queries/index' @@ -12,29 +12,24 @@ export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url] export const RQKEY_GIF_ROOT = 'resolve-gif' export const RQKEY_GIF = (url: string) => [RQKEY_GIF_ROOT, url] -export function useResolveLinkQuery(url: string) { - const agent = useAgent() - - return useQuery({ +export function resolveLinkQueryOptions(agent: AtpAgent, url: string) { + return queryOptions({ staleTime: STALE.HOURS.ONE, queryKey: RQKEY_LINK(url), - queryFn: async () => { - return await resolveLink(agent, url) - }, + queryFn: () => resolveLink(agent, url), }) } + +export function useResolveLinkQuery(url: string) { + const agent = useAgent() + return useQuery(resolveLinkQueryOptions(agent, url)) +} export function fetchResolveLinkQuery( queryClient: QueryClient, - agent: BskyAgent, + agent: AtpAgent, url: string, ) { - return queryClient.fetchQuery({ - staleTime: STALE.HOURS.ONE, - queryKey: RQKEY_LINK(url), - queryFn: async () => { - return await resolveLink(agent, url) - }, - }) + return queryClient.fetchQuery(resolveLinkQueryOptions(agent, url)) } export function precacheResolveLinkQuery( queryClient: QueryClient, @@ -56,7 +51,7 @@ export function useResolveGifQuery(gif: Gif) { } export function fetchResolveGifQuery( queryClient: QueryClient, - agent: BskyAgent, + agent: AtpAgent, gif: Gif, ) { return queryClient.fetchQuery({ diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index e130472c72..ab8f4d9cc5 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -57,7 +57,7 @@ import { import {plural} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' -import {useQueryClient} from '@tanstack/react-query' +import {useQueries, useQueryClient} from '@tanstack/react-query' import * as apilib from '#/lib/api/index' import {EmbeddingDisabledError} from '#/lib/api/resolve' @@ -95,6 +95,7 @@ import { } from '#/state/preferences/languages' import {usePreferencesQuery} from '#/state/queries/preferences' import {useProfileQuery} from '#/state/queries/profile' +import {resolveLinkQueryOptions} from '#/state/queries/resolve-link' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer' @@ -865,8 +866,25 @@ export const ComposePost = ({ } }, [thread, requireAltTextEnabled, l]) + // Subscribe to the resolve-link cache for any link URIs in the thread so we + // can detect chat invites that resolved to no preview (revoked/expired) and + // block publishing - otherwise the post would go out without the embed. + const linkUris = thread.posts + .filter(post => post.embed.link) + .map(post => post.embed.link!.uri) + const linkQueries = useQueries({ + queries: linkUris.map(uri => ({ + ...resolveLinkQueryOptions(agent, uri), + enabled: false, + })), + }) + const hasUnavailableChatInvite = linkQueries.some( + q => q.data?.type === 'chat-invite' && !q.data.view, + ) + const canPost = !missingAltError && + !hasUnavailableChatInvite && thread.posts.some(post => !isEmptyPost(post)) && thread.posts.every( post => From d9345cb1cfc54c54123044e0306c014e32b347ae Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 8 Jun 2026 19:20:37 +0300 Subject: [PATCH 51/61] [Chat] Fix footer logic (#10785) --- .../Messages/components/MessagesList.tsx | 17 ++++++++++++++++- .../queries/messages/list-conversations.tsx | 11 ++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 727829795c..2d471a8d35 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -709,7 +709,13 @@ function getFooterState( convoState: ActiveConvoStates, hasAcceptOverride?: boolean, ): FooterState { - if (convoState.convo.view.status === 'request' && !hasAcceptOverride) { + const isRequest = + convoState.convo.view.status === 'request' && !hasAcceptOverride + + // For group chats, the request footer is driven purely off status: the owner + // is always 'accepted' so never sees it, while members the owner added are + // 'request' until they accept. This holds even before any messages load. + if (convoState.convo.kind === 'group' && isRequest) { return 'request' } @@ -721,6 +727,15 @@ function getFooterState( } } + // For direct chats, only show the request footer once there's a message. The + // viewer's status stays 'request' until they send their first message, so an + // empty direct request is one the viewer started themselves (show the + // composer), whereas any message present must be an incoming one from the + // other user (show the accept/reject footer). + if (isRequest) { + return 'request' + } + return 'standard' } diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index 372ef708f3..62f5772bef 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -738,7 +738,16 @@ function calculateCount( moderateProfile(convo.primaryMember, moderationOpts).blocked || convo.primaryMember.handle === 'missing.invalid' || (convo.kind === 'group' && convo.details.lockStatus !== 'unlocked') - const unreadCount = !shouldIgnore && convo.view.unreadCount > 0 ? 1 : 0 + const unreadJoinRequestCount = + convo.kind === 'group' + ? (convo.details.unreadJoinRequestCount ?? 0) + : 0 + + const unreadCount = + !shouldIgnore && + (convo.view.unreadCount > 0 || unreadJoinRequestCount > 0) + ? 1 + : 0 return acc + unreadCount }, 0) ?? 0 From c4f3a2cb89b72b59ad25f2cdad303f5f3d747eb3 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 8 Jun 2026 19:26:23 +0300 Subject: [PATCH 52/61] [Chat] Rename invite route to /chat/ (#10782) --- __tests__/lib/strings/url-helpers.test.ts | 40 +++++++++---------- bskyweb/cmd/bskyweb/server.go | 6 +-- src/Navigation.tsx | 2 +- src/components/Post/Embed/ChatInviteEmbed.tsx | 2 +- src/components/dms/ChatInvite/Root.tsx | 2 +- src/lib/strings/url-helpers.ts | 2 +- .../Messages/components/InviteLinkDialog.tsx | 2 +- 7 files changed, 28 insertions(+), 28 deletions(-) diff --git a/__tests__/lib/strings/url-helpers.test.ts b/__tests__/lib/strings/url-helpers.test.ts index 23ffaa2875..dd1a096ef5 100644 --- a/__tests__/lib/strings/url-helpers.test.ts +++ b/__tests__/lib/strings/url-helpers.test.ts @@ -184,39 +184,39 @@ describe('getChatInviteCodeFromUrl', () => { type Case = [string, string | undefined] const cases: Case[] = [ - ['https://bsky.app/c/abcdefg', 'abcdefg'], - ['https://bsky.app/c/abcdefghij', 'abcdefghij'], + ['https://bsky.app/chat/abcdefg', 'abcdefg'], + ['https://bsky.app/chat/abcdefghij', 'abcdefghij'], // http is not recognized as a bsky.app url - ['http://bsky.app/c/abcdefg', undefined], - ['https://bsky.app/c/abcdefg?utm=foo', 'abcdefg'], - ['https://bsky.app/c/abcdefg#section', 'abcdefg'], - ['/c/abcdefg', 'abcdefg'], - ['/c/abcdefg?utm=foo', 'abcdefg'], - ['/c/abcdefg#section', 'abcdefg'], + ['http://bsky.app/chat/abcdefg', undefined], + ['https://bsky.app/chat/abcdefg?utm=foo', 'abcdefg'], + ['https://bsky.app/chat/abcdefg#section', 'abcdefg'], + ['/chat/abcdefg', 'abcdefg'], + ['/chat/abcdefg?utm=foo', 'abcdefg'], + ['/chat/abcdefg#section', 'abcdefg'], // too short - ['https://bsky.app/c/abcdef', undefined], - ['/c/abcdef', undefined], + ['https://bsky.app/chat/abcdef', undefined], + ['/chat/abcdef', undefined], // too long - ['https://bsky.app/c/abcdefghijk', undefined], - ['/c/abcdefghijk', undefined], + ['https://bsky.app/chat/abcdefghijk', undefined], + ['/chat/abcdefghijk', undefined], // invalid characters - ['https://bsky.app/c/abc-def', undefined], - ['/c/abc def', undefined], + ['https://bsky.app/chat/abc-def', undefined], + ['/chat/abc def', undefined], // trailing path - ['https://bsky.app/c/abcdefg/extra', undefined], - ['/c/abcdefg/extra', undefined], + ['https://bsky.app/chat/abcdefg/extra', undefined], + ['/chat/abcdefg/extra', undefined], // wrong path ['https://bsky.app/profile/abcdefg', undefined], - ['https://bsky.app/c', undefined], + ['https://bsky.app/chat', undefined], // wrong host - ['https://example.com/c/abcdefg', undefined], + ['https://example.com/chat/abcdefg', undefined], // not a url, not a path - ['c/abcdefg', undefined], + ['chat/abcdefg', undefined], ['abcdefg', undefined], ['', undefined], // malformed url - ['https://[invalid/c/abcdefg', undefined], + ['https://[invalid/chat/abcdefg', undefined], ] it.each(cases)('given input %p, returns %p', (input, expected) => { diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index cc5119e8d4..c4f31f20b4 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -88,7 +88,7 @@ func serve(cctx *cli.Context) error { Host: appviewHost, } - // optional client for the chat appview, used by /c/ for OG previews. + // optional client for the chat appview, used by /chat/ for OG previews. var chatXrpcc *xrpc.Client if chatHost != "" { chatXrpcc = &xrpc.Client{ @@ -367,7 +367,7 @@ func serve(cctx *cli.Context) error { e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack) // chat invites - e.GET("/c/:code", server.WebChatInvite) + e.GET("/chat/:code", server.WebChatInvite) // bookmarks e.GET("/saved", server.WebGeneric) @@ -695,7 +695,7 @@ func (srv *Server) WebChatInvite(c echo.Context) error { data["title"] = preview.Name if srv.cfg.ogcardHost != "" { - // bskyogcard registers this route as /chat-invite/:code, not /c/:code. + // bskyogcard registers this route as /chat-invite/:code, not /chat/:code. data["imgThumbUrl"] = fmt.Sprintf("%s/chat-invite/%s", srv.cfg.ogcardHost, code) } return c.Render(http.StatusOK, "chatinvite.html", data) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index efd1125295..ceebcf3986 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -804,7 +804,7 @@ const LINKING = { return buildStateObject('Flat', 'Home', params) } - // Chat invite URLs (`/c/:code`) are handled by `useIntentHandler`, which + // Chat invite URLs (`/chat/:code`) are handled by `useIntentHandler`, which // opens the GroupChatJoinDialog (or the logged-out join flow). Route the // path to Home so the dialog overlays Home instead of NotFound. On native, // react-navigation strips the `bluesky://` prefix and passes the path diff --git a/src/components/Post/Embed/ChatInviteEmbed.tsx b/src/components/Post/Embed/ChatInviteEmbed.tsx index 387c3cfd9c..16f05cbfea 100644 --- a/src/components/Post/Embed/ChatInviteEmbed.tsx +++ b/src/components/Post/Embed/ChatInviteEmbed.tsx @@ -8,7 +8,7 @@ import {JoinRequestEmbedBody} from '#/components/Post/Embed/JoinRequestEmbed' /** * Renders a chat invite link found in an `app.bsky.embed.external` embed (e.g. - * a `bsky.app/c/` link posted to the feed) as a join request card, + * a `bsky.app/chat/` 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({ diff --git a/src/components/dms/ChatInvite/Root.tsx b/src/components/dms/ChatInvite/Root.tsx index 025cae7a2e..ddfdec0d56 100644 --- a/src/components/dms/ChatInvite/Root.tsx +++ b/src/components/dms/ChatInvite/Root.tsx @@ -78,7 +78,7 @@ export function Root({ color: 'primary', disabled: false, onPress: () => { - void setStringAsync(`https://bsky.app/c/${preview.code}`) + void setStringAsync(`https://bsky.app/chat/${preview.code}`) Toast.show(l`Copied to clipboard`, {type: 'success'}) }, } diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index fd1a5ef7d9..38d3d70813 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -179,7 +179,7 @@ export function isBskyStarterPackUrl(url: string): boolean { } // Invite codes are 7 alphanumeric characters long, supporting up to 10 here to future-proof. -export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/ +export const CHAT_INVITE_CODE_REGEX = /^\/chat\/([a-zA-Z0-9]{7,10})$/ export function getChatInviteCodeFromUrl(url: string): string | undefined { let pathname: string diff --git a/src/screens/Messages/components/InviteLinkDialog.tsx b/src/screens/Messages/components/InviteLinkDialog.tsx index 02d4505af0..636e2be64e 100644 --- a/src/screens/Messages/components/InviteLinkDialog.tsx +++ b/src/screens/Messages/components/InviteLinkDialog.tsx @@ -292,7 +292,7 @@ export function InviteLinkDialog({ const linkEnabled = joinLink?.enabledStatus === 'enabled' const linkDisabled = joinLink?.enabledStatus === 'disabled' const joinLinkURI = joinLink?.code - ? `https://bsky.app/c/${joinLink.code}` + ? `https://bsky.app/chat/${joinLink.code}` : 'https://bsky.app/' const createdAt = joinLink ? new Date(joinLink.createdAt) : null const currentOption = From 0090285fc0e8ee8f4a722749b3e217428fdab1d7 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Mon, 8 Jun 2026 12:44:59 -0400 Subject: [PATCH 53/61] Refactor photo embed analytics to post:photoEmbed:* namespace (#10784) Co-authored-by: Eric Bailey Co-authored-by: Claude Opus 4.8 (1M context) --- src/analytics/metrics/types.ts | 31 ++++++++-- src/components/Lightbox/pager/ImagePager.tsx | 20 ++++++- src/components/Lightbox/state.tsx | 10 ++++ src/components/Post/Embed/ImageEmbed.tsx | 29 ++++++++- src/components/Post/Embed/index.tsx | 3 + src/components/Post/Embed/types.ts | 7 +++ src/components/images/Gallery/index.tsx | 20 +++++-- .../components/ThreadItemAnchor.tsx | 2 + .../PostThread/components/ThreadItemPost.tsx | 1 + .../components/ThreadItemTreePost.tsx | 1 + src/view/com/post/Post.tsx | 1 + src/view/com/posts/PostFeed.tsx | 60 +++++++++++++++---- src/view/com/posts/PostFeedItem.tsx | 5 ++ 13 files changed, 164 insertions(+), 26 deletions(-) diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 90dbc4f983..5157c66e30 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -1175,18 +1175,37 @@ export type Events = { 'profile:associated:germ:self-disconnect': {} 'profile:associated:germ:self-reconnect': {} - // Gallery carousel events - 'post:gallery:swipe': { + // Post photo embed events + 'post:photoEmbed:impression': { + layout: 'single' | 'grid' | 'carousel' + totalImages: number + postUri: string + postAuthorDid: string + feedDescriptor?: string + } + 'post:photoEmbed:open': { + layout: 'single' | 'grid' | 'carousel' + fromImage: number + totalImages: number + postUri: string + postAuthorDid: string + feedDescriptor?: string + } + 'post:photoEmbed:carouselSwipe': { fromImage: number toImage: number totalImages: number + postUri: string + postAuthorDid: string + feedDescriptor?: string } - 'post:gallery:openLightbox': { + 'post:photoEmbed:lightboxSwipe': { + layout: 'single' | 'grid' | 'carousel' fromImage: number - totalImages: number - } - 'post:gallery:impression': { + toImage: number totalImages: number postUri: string + postAuthorDid: string + feedDescriptor?: string } } diff --git a/src/components/Lightbox/pager/ImagePager.tsx b/src/components/Lightbox/pager/ImagePager.tsx index 7b8815f276..43942b0f37 100644 --- a/src/components/Lightbox/pager/ImagePager.tsx +++ b/src/components/Lightbox/pager/ImagePager.tsx @@ -39,6 +39,7 @@ import {type Dimensions} from '#/lib/media/types' import {useTheme} from '#/alf' import {setSystemUITheme} from '#/alf/util/systemUI' import {type Lightbox} from '#/components/Lightbox/state' +import {useAnalytics} from '#/analytics' import {IS_IOS} from '#/env' import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army' import {Footer} from '../chrome/Footer' @@ -228,7 +229,8 @@ function ImageView({ openProgress: SharedValue thumbRects: SharedValue> }) { - const {images, index: initialImageIndex} = lightbox + const {images, index: initialImageIndex, metricsContext} = lightbox + const ax = useAnalytics() const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox]) const [isScaled, setIsScaled] = useState(false) const [isDragging, setIsDragging] = useState(false) @@ -377,7 +379,21 @@ function ImageView({ scrollEnabled={!isScaled} initialPage={initialImageIndex} onPageSelected={e => { - setImageIndex(e.nativeEvent.position) + const next = e.nativeEvent.position + setImageIndex(prev => { + if (metricsContext && prev !== next) { + ax.metric('post:photoEmbed:lightboxSwipe', { + layout: metricsContext.layout, + fromImage: prev + 1, + toImage: next + 1, + totalImages: images.length, + postUri: metricsContext.postUri, + postAuthorDid: metricsContext.postAuthorDid, + feedDescriptor: metricsContext.feedDescriptor, + }) + } + return next + }) setIsScaled(false) }} onPageScrollStateChanged={e => { diff --git a/src/components/Lightbox/state.tsx b/src/components/Lightbox/state.tsx index 23af527734..6a8fdbe0b7 100644 --- a/src/components/Lightbox/state.tsx +++ b/src/components/Lightbox/state.tsx @@ -11,10 +11,20 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useHotkeysContext} from '#/lib/hotkeys' import {type ImageSource} from '#/components/Lightbox/types' +export type LightboxMetricsContext = { + layout: 'single' | 'grid' | 'carousel' + postUri: string + postAuthorDid: string + feedDescriptor?: string +} + export type Lightbox = { id: string images: ImageSource[] index: number + // Set for post photo embeds so the lightbox can emit post:photoEmbed:lightboxSwipe. + // Left unset for non-post contexts (e.g. profile avatar/banner lightbox). + metricsContext?: LightboxMetricsContext } const LightboxContext = createContext<{ diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index 0a620efde4..9d47302063 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -8,7 +8,10 @@ import {atoms as a, tokens} from '#/alf' import {AutoSizedImage} from '#/components/images/AutoSizedImage' import {Gallery} from '#/components/images/Gallery' import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid' -import {useLightboxControls} from '#/components/Lightbox/state' +import { + type LightboxMetricsContext, + useLightboxControls, +} from '#/components/Lightbox/state' import {type Dimensions} from '#/components/Lightbox/types' import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu' import {PostEmbedViewContext} from '#/components/Post/Embed/types' @@ -40,6 +43,20 @@ export function ImageEmbed({ ? images.length > MAX_GRID_IMAGES : ax.features.enabled(ax.features.PostGalleryEmbedEnable) + const layout: 'single' | 'grid' | 'carousel' = + images.length === 1 ? 'single' : useExpandedLayout ? 'carousel' : 'grid' + + const postContext = rest.post + ? { + postUri: rest.post.uri, + postAuthorDid: rest.post.author.did, + feedDescriptor: rest.feedDescriptor, + } + : undefined + const metricsContext: LightboxMetricsContext | undefined = postContext + ? {layout, ...postContext} + : undefined + // 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. const singleContainerRef = useRef | null>(null) @@ -57,6 +74,14 @@ export function ImageEmbed({ refs: AnimatedRef[], fetchedDims: (Dimensions | null)[], ) => { + if (postContext) { + ax.metric('post:photoEmbed:open', { + layout, + fromImage: index + 1, + totalImages: images.length, + ...postContext, + }) + } openLightbox({ images: items.map((item, i) => ({ ...item, @@ -67,6 +92,7 @@ export function ImageEmbed({ type: 'image', })), index, + metricsContext, }) } const onPressIn = (_: number) => { @@ -132,6 +158,7 @@ export function ImageEmbed({ onPressIn={onPressIn} viewContext={rest.viewContext} isWithinQuote={rest.isWithinQuote} + metricsPostContext={postContext} /> ) diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index 28eed2aaa9..69d92983ed 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -345,6 +345,9 @@ export function QuoteEmbed({ allowNestedQuotes={ parentIsWithinQuote ? false : parentAllowNestedQuotes } + // The photo embed belongs to the quoted post, so attribute its + // analytics to the quoted post rather than the parent. + post={quote} /> )} diff --git a/src/components/Post/Embed/types.ts b/src/components/Post/Embed/types.ts index 6c023a14ef..77319b0e86 100644 --- a/src/components/Post/Embed/types.ts +++ b/src/components/Post/Embed/types.ts @@ -15,6 +15,13 @@ export type CommonProps = { viewContext?: PostEmbedViewContext isWithinQuote?: boolean allowNestedQuotes?: boolean + /** + * The post that contains this embed. Used for analytics on photo embed + * events (post:photoEmbed:*). When the embed has no owning post (e.g. + * composer previews), leave this undefined and no events will be emitted. + */ + post?: AppBskyFeedDefs.PostView + feedDescriptor?: string } export type EmbedProps = CommonProps & { diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index 0ca46c0b67..1b4e1d6239 100644 --- a/src/components/images/Gallery/index.tsx +++ b/src/components/images/Gallery/index.tsx @@ -55,6 +55,13 @@ interface GalleryProps { onPressIn?: (index: number) => void viewContext?: PostEmbedViewContext isWithinQuote?: boolean + // Post context for the in-feed carousel swipe metric. Omit for non-post + // contexts (no event will be emitted). + metricsPostContext?: { + postUri: string + postAuthorDid: string + feedDescriptor?: string + } } const Context = createContext<{ @@ -99,6 +106,7 @@ export function Gallery({ onPressIn, viewContext, isWithinQuote, + metricsPostContext, }: GalleryProps) { const {t: l} = useLingui() const ax = useAnalytics() @@ -169,13 +177,17 @@ export function Gallery({ const emitSwipeMetric = useMemo( () => debounce((fromIndex: number, toIndex: number) => { - ax.metric('post:gallery:swipe', { + if (!metricsPostContext) return + ax.metric('post:photoEmbed:carouselSwipe', { fromImage: fromIndex + 1, // convert to 1-based index for easier analysis toImage: toIndex + 1, // convert to 1-based index for easier analysis totalImages: images.length, + postUri: metricsPostContext.postUri, + postAuthorDid: metricsPostContext.postAuthorDid, + feedDescriptor: metricsPostContext.feedDescriptor, }) }, 200), - [ax, images.length], + [ax, images.length, metricsPostContext], ) const setCurrentIndex = (index: number) => { @@ -277,10 +289,6 @@ export function Gallery({ renderItem={({item, index}) => { const openLightboxAtIndex = onPress ? () => { - ax.metric('post:gallery:openLightbox', { - fromImage: index + 1, // convert to 1-based index for easier analysis - totalImages: images.length, - }) const refs: AnimatedRef[] = [] const dims: (Dimensions | null)[] = [] for (let i = 0; i < images.length; i++) { diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 17c8e54be8..dc3c55f125 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -414,6 +414,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ moderation={moderation} viewContext={PostEmbedViewContext.ThreadHighlighted} onOpen={onOpenEmbed} + post={post} + feedDescriptor={feedFeedback.feedDescriptor} /> )} diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx index 841c2af745..c1e27222ff 100644 --- a/src/screens/PostThread/components/ThreadItemPost.tsx +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -349,6 +349,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({ embed={post.embed} moderation={moderation} viewContext={PostEmbedViewContext.Feed} + post={post} /> )} diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx index 6ee116d166..9868786648 100644 --- a/src/screens/PostThread/components/ThreadItemTreePost.tsx +++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx @@ -371,6 +371,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({ embed={post.embed} moderation={moderation} viewContext={PostEmbedViewContext.Feed} + post={post} /> )} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 62e7cb2aae..c952224553 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -255,6 +255,7 @@ function PostInner({ embed={post.embed} moderation={moderation} viewContext={PostEmbedViewContext.Feed} + post={post} /> ) : null} diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 48754d2753..20c6a31a3a 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -13,6 +13,8 @@ import { import { type AppBskyActorDefs, AppBskyEmbedExternal, + AppBskyEmbedGallery, + AppBskyEmbedImages, AppBskyEmbedVideo, type AppBskyFeedDefs, } from '@atproto/api' @@ -907,7 +909,9 @@ let PostFeed = ({ const seenActorWithStatusRef = useRef>(new Set()) const seenPostUrisRef = useRef>(new Set()) - const seenStandardSiteUrisRef = useRef>(new Set()) + // Tracks every post we've seen so we can fire per-post events exactly once, + // regardless of the post's position within its slice. + const seenPerPostUrisRef = useRef>(new Set()) // Helper to calculate position in feed (count only root posts, not interstitials or thread replies) const getPostPosition = useNonReactiveCallback( @@ -940,6 +944,48 @@ let PostFeed = ({ (item: FeedRow) => { feedFeedback.onItemSeen(item) + // Events that should fire exactly once for every new post, regardless of + // its position within a slice or video grid row. + const onPostSeen = (post: AppBskyFeedDefs.PostView) => { + if (seenPerPostUrisRef.current.has(post.uri)) return + seenPerPostUrisRef.current.add(post.uri) + + // Standard site embed view tracking + if ( + AppBskyEmbedExternal.isView(post.embed) && + isStandardSiteEmbed(post.embed.external) + ) { + ax.metric('embed:standardSite:view', {url: post.embed.external.uri}) + } + + // Photo embed impression tracking + if ( + AppBskyEmbedImages.isView(post.embed) || + AppBskyEmbedGallery.isView(post.embed) + ) { + const totalImages = AppBskyEmbedGallery.isView(post.embed) + ? post.embed.items.filter(AppBskyEmbedGallery.isViewImage).length + : post.embed.images.length + const useExpandedLayout = AppBskyEmbedGallery.isView(post.embed) + ? totalImages > 4 + : ax.features.enabled(ax.features.PostGalleryEmbedEnable) + const layout = + totalImages === 1 + ? 'single' + : useExpandedLayout + ? 'carousel' + : 'grid' + + ax.metric('post:photoEmbed:impression', { + layout, + totalImages, + postUri: post.uri, + postAuthorDid: post.author.did, + feedDescriptor: feedFeedback.feedDescriptor || feed, + }) + } + } + // Track post:view events if (item.type === 'sliceItem') { const slice = item.slice @@ -947,6 +993,8 @@ let PostFeed = ({ const postItem = slice.items[indexInSlice] const post = postItem.post + onPostSeen(post) + // Only track the root post of each slice (index 0) to avoid double-counting thread items if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) { seenPostUrisRef.current.add(post.uri) @@ -977,16 +1025,6 @@ 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++) { diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 748d14faca..c0ff26206d 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -429,6 +429,7 @@ let FeedItemInner = ({ onOpenEmbed={onOpenEmbed} post={post} additionalPostAlerts={additionalPostAlerts} + feedDescriptor={feedDescriptor} /> void post: AppBskyFeedDefs.PostView additionalPostAlerts?: AppModerationCause[] + feedDescriptor?: string }): React.ReactNode => { const [limitLines, setLimitLines] = useState( () => countLines(richText.text) >= MAX_POST_LINES, @@ -528,6 +531,8 @@ let PostContent = ({ moderation={moderation} onOpen={onOpenEmbed} viewContext={PostEmbedViewContext.Feed} + post={post} + feedDescriptor={feedDescriptor} /> ) : null} From 9b5fc19613ce92ca391f00ddb75513ba98907783 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 8 Jun 2026 20:07:35 +0300 Subject: [PATCH 54/61] [Chat] Centralize conversation report-subject resolution (#10754) Co-authored-by: Claude Opus 4.8 (1M context) --- src/components/dms/ConvoMenu.tsx | 122 ++++++++---------- src/components/dms/MessagesListHeader.tsx | 17 +-- src/components/dms/util.ts | 39 ++++++ .../Messages/components/ChatListItem.tsx | 14 +- .../Messages/components/ChatStatusInfo.tsx | 12 +- .../components/IncomingRequestListItem.tsx | 2 +- .../Messages/components/RequestButtons.tsx | 93 ++++++------- 7 files changed, 151 insertions(+), 148 deletions(-) diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index 1e492c80d4..2eac8cdb3a 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -1,6 +1,6 @@ import {memo, useCallback} from 'react' import {Keyboard, View} from 'react-native' -import {ChatBskyConvoDefs, type ModerationCause} from '@atproto/api' +import {type ModerationCause} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' @@ -16,13 +16,18 @@ import { unstableCacheProfileView, useProfileBlockMutationQueue, } from '#/state/queries/profile' +import {useSession} from '#/state/session' import {type ViewStyleProp} from '#/alf' import {atoms as a} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' +import {AfterReportConversationDialog} from '#/components/dms/AfterReportConversationDialog' import {AfterReportDialog} from '#/components/dms/AfterReportDialog' import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' -import {ReportConversationDialog} from '#/components/dms/ReportConversationDialog' +import { + type ConvoWithDetails, + getConvoReportSubject, +} from '#/components/dms/util' import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble' import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' @@ -39,7 +44,6 @@ import {ReportDialog} from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' import * as Toast from '#/components/Toast' import type * as bsky from '#/types/bsky' -import {AfterReportConversationDialog} from './AfterReportConversationDialog' let ConvoMenu = ({ convo, @@ -49,10 +53,9 @@ let ConvoMenu = ({ showMarkAsRead, hideTrigger, blockInfo, - latestReportableMessage, style, }: { - convo: ChatBskyConvoDefs.ConvoView + convo: ConvoWithDetails profile: Shadow control?: Menu.MenuControlProps currentScreen: 'list' | 'conversation' @@ -62,20 +65,21 @@ let ConvoMenu = ({ listBlocks: ModerationCause[] userBlock?: ModerationCause } - latestReportableMessage?: ChatBskyConvoDefs.MessageView style?: ViewStyleProp['style'] }): React.ReactNode => { const {t: l} = useLingui() const queryClient = useQueryClient() + const {currentAccount} = useSession() const leaveConvoControl = Prompt.usePromptControl() const reportControl = Prompt.usePromptControl() const blockedByListControl = Prompt.usePromptControl() - const blockOrDeleteControl = Prompt.usePromptControl() - const deleteControl = Prompt.usePromptControl() + const afterReportControl = Prompt.usePromptControl() const {listBlocks} = blockInfo + const reportSubject = getConvoReportSubject(convo, currentAccount?.did) + return ( <> @@ -108,6 +112,7 @@ let ConvoMenu = ({ showMarkAsRead={showMarkAsRead} blockInfo={blockInfo} convo={convo} + canReport={!!reportSubject} leaveConvoControl={leaveConvoControl} reportControl={reportControl} blockedByListControl={blockedByListControl} @@ -116,54 +121,37 @@ let ConvoMenu = ({ - {latestReportableMessage ? ( - <> - { - const sender = convo.members.find( - member => member.did === latestReportableMessage.sender.did, - ) - if (sender) { - unstableCacheProfileView(queryClient, sender) - } - blockOrDeleteControl.open() - }} - /> - - + {reportSubject && ( + { + unstableCacheProfileView(queryClient, profile) + afterReportControl.open() + }} + /> + )} + {convo.kind === 'group' ? ( + ) : ( - <> - - - + )} + canReport: boolean showMarkAsRead?: boolean blockInfo: { listBlocks: ModerationCause[] @@ -201,9 +191,9 @@ function MenuContent({ const {listBlocks, userBlock} = blockInfo const isBlocking = userBlock || !!listBlocks.length const isDeletedAccount = profile.handle === 'missing.invalid' - const isGroupConvo = ChatBskyConvoDefs.isGroupConvo(initialConvo.kind) + const isGroupConvo = initialConvo.kind === 'group' - const convoId = initialConvo.id + const convoId = initialConvo.view.id const {data: convo} = useConvoQuery({convoId}) const onNavigateToProfile = useCallback(() => { @@ -299,15 +289,17 @@ function MenuContent({ )} - - - - Report conversation - - + {canReport && ( + + + + Report conversation + + + )} diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index f23e7cdfae..745adc62b7 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -1,10 +1,6 @@ import {useMemo} from 'react' import {View} from 'react-native' -import { - ChatBskyConvoDefs, - moderateProfile, - type ModerationOpts, -} from '@atproto/api' +import {moderateProfile, type ModerationOpts} from '@atproto/api' import {useLingui} from '@lingui/react/macro' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' @@ -12,7 +8,6 @@ import {makeProfileLink} from '#/lib/routes/links' import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useSession} from '#/state/session' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context' import {atoms as a, useTheme, web} from '#/alf' @@ -88,7 +83,6 @@ function ProfileHeaderReady({ }) { const t = useTheme() const {t: l} = useLingui() - const {currentAccount} = useSession() const profile = useProfileShadow(convo.primaryMember) const moderation = moderateProfile(profile, moderationOpts) @@ -110,12 +104,6 @@ function ProfileHeaderReady({ : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) const handle = isDeletedAccount ? null : sanitizeHandle(profile.handle, '@') - const latestReportableMessage = - ChatBskyConvoDefs.isMessageView(convo.view.lastMessage) && - convo.view.lastMessage.sender?.did !== currentAccount?.did - ? convo.view.lastMessage - : undefined - return ( } /> diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts index 7fe5f91dc3..d10b2296fb 100644 --- a/src/components/dms/util.ts +++ b/src/components/dms/util.ts @@ -10,6 +10,7 @@ import {EMOJI_REACTION_LIMIT} from '#/lib/constants' import {logger} from '#/logger' import {type Shadow} from '#/state/cache/profile-shadow' import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types' +import {type ReportSubject} from '#/components/moderation/ReportDialog/types' import * as bsky from '#/types/bsky' export const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 @@ -240,3 +241,41 @@ export function parseConvoView( return null } } + +/** + * Resolves the report subject for a conversation-level "Report conversation" + * action (as opposed to reporting an individual message, which always reports + * that message + its sender). + * + * - group: always report the whole convo, targeting the owner. Returns null if + * the owner has left, in which case there is nothing to report against. + * - direct: report the last reportable message if there is one (i.e. the last + * message exists and wasn't sent by us), otherwise report the whole convo + * targeting the other user. + */ +export function getConvoReportSubject( + convo: ConvoWithDetails, + ownDid: string | undefined, +): ReportSubject | null { + if (convo.kind === 'group') { + if (!convo.primaryMember) return null + return {convoId: convo.view.id, did: convo.primaryMember.did} + } + + const lastMessage = convo.view.lastMessage + const reportableMessage = + ChatBskyConvoDefs.isMessageView(lastMessage) && + lastMessage.sender?.did !== ownDid + ? lastMessage + : null + + if (reportableMessage) { + return { + view: 'convo', + convoId: convo.view.id, + message: reportableMessage, + } + } + + return {convoId: convo.view.id, did: convo.primaryMember.did} +} diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index 70390606fc..1c83a6876b 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -307,20 +307,13 @@ function BaseChatItem({ isDeletedAccount || (convo.kind === 'group' && convo.details.lockStatus !== 'unlocked') - const { - lastMessage, - LastMessageIcon, - lastMessageSentAt, - latestReportableMessage, - } = useMemo(() => { + const {lastMessage, LastMessageIcon, lastMessageSentAt} = useMemo(() => { let lastMessage = l`No messages yet` let LastMessageIcon: React.ComponentType | null = null let lastMessageSentAt: string | null = null - let latestReportableMessage: ChatBskyConvoDefs.MessageView | undefined - // Deleted message if (ChatBskyConvoDefs.isDeletedMessageView(convo.view.lastMessage)) { lastMessageSentAt = convo.view.lastMessage.sentAt @@ -340,7 +333,6 @@ function BaseChatItem({ if (info) { lastMessage = info.message ?? lastMessage lastMessageSentAt = info.sentAt - latestReportableMessage = info.reportableMessage } } @@ -385,7 +377,6 @@ function BaseChatItem({ lastMessage, LastMessageIcon, lastMessageSentAt, - latestReportableMessage, } }, [l, convo, currentAccount?.did, isDeletedAccount, i18n]) @@ -663,7 +654,7 @@ function BaseChatItem({ {/* TODO: Allow showing menu for groups where the owner has left! */} {showMenu && primaryProfile && ( )} diff --git a/src/screens/Messages/components/ChatStatusInfo.tsx b/src/screens/Messages/components/ChatStatusInfo.tsx index ecb0139e66..e2e9a75faa 100644 --- a/src/screens/Messages/components/ChatStatusInfo.tsx +++ b/src/screens/Messages/components/ChatStatusInfo.tsx @@ -1,7 +1,7 @@ import {useCallback, useMemo} from 'react' import {View} from 'react-native' import {LinearGradient} from 'expo-linear-gradient' -import {ChatBskyConvoDefs, moderateProfile} from '@atproto/api' +import {moderateProfile} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' @@ -33,12 +33,6 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) { // if we ever allow someone other than the owner to invite people, this will need to change const otherUser = convoState.convo.primaryMember - const lastMessage = ChatBskyConvoDefs.isMessageView( - convoState.convo.view.lastMessage, - ) - ? convoState.convo.view.lastMessage - : null - if (!moderationOpts) { return null } @@ -64,9 +58,9 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) { {otherUser && ( ) : null} & { label?: string icon?: boolean - convo: ChatBskyConvoDefs.ConvoView + convo: ConvoWithDetails profile: ChatBskyActorDefs.ProfileViewBasic showDeleteConvo?: boolean currentScreen: 'list' | 'conversation' }) { const {t: l} = useLingui() + const {currentAccount} = useSession() const shadowedProfile = useProfileShadow(profile) const navigation = useNavigation() const queryClient = useQueryClient() - const {mutate: leaveConvo} = useLeaveConvo(convo.id, { + const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, { onMutate: () => { if (currentScreen === 'conversation') { navigation.dispatch(StackActions.pop()) @@ -110,9 +117,7 @@ export function RejectMenu({ const reportControl = useDialogControl() const blockOrDeleteControl = useDialogControl() - const lastMessage = ChatBskyConvoDefs.isMessageView(convo.lastMessage) - ? convo.lastMessage - : null + const reportSubject = getConvoReportSubject(convo, currentAccount?.did) return ( <> @@ -152,50 +157,46 @@ export function RejectMenu({ - {/* note: last message will almost certainly be defined, since you can't - delete messages for other people and it's impossible for a convo on this - screen to have a message sent by you */} - {lastMessage && ( - - - Report conversation - - - - )} + + + Report conversation + + + - {lastMessage && ( - <> - { - const sender = convo.members.find( - member => member.did === lastMessage.sender.did, - ) - if (sender) { - unstableCacheProfileView(queryClient, sender) - } - blockOrDeleteControl.open() - }} - /> - - + + {reportSubject && ( + { + unstableCacheProfileView(queryClient, profile) + blockOrDeleteControl.open() + }} + /> + )} + {convo.kind === 'group' ? ( + + ) : ( + )} ) From 4142eb4834302280fc9a60751a4a3394e8f64220 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:13:43 -0700 Subject: [PATCH 55/61] Add fade-in animation to chat footer/composer (#10786) --- .../Messages/components/MessagesList.tsx | 69 ++++++++++--------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 2d471a8d35..e96872a646 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -13,6 +13,7 @@ import { KeyboardGestureArea, } from 'react-native-keyboard-controller' import Animated, { + FadeIn, runOnJS, type ScrollEvent, type SharedValue, @@ -615,41 +616,43 @@ export function MessagesList({ opened: 0, }}> {footer ?? ( - - {({loading}) => - ax.features.enabled( - ax.features.DmsNewMessageComposerEnable, - ) ? ( - - void onSendMessage(message) - } - hasEmbed={!!messageEmbed} - setEmbed={setEmbed} - loading={loading}> - + + {({loading}) => + ax.features.enabled( + ax.features.DmsNewMessageComposerEnable, + ) ? ( + + void onSendMessage(message) + } + hasEmbed={!!messageEmbed} setEmbed={setEmbed} - /> - - ) : ( - - + + + ) : ( + - - ) - } - + loading={loading}> + + + ) + } + + )} From fe9dabbc4a9183f0e747e37f991dbebe52b40594 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:14:24 -0700 Subject: [PATCH 56/61] Open edit chat name dialog via tap (#10790) --- .../Messages/ConversationSettings/index.tsx | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx index 2ae09b5130..b4c7e1ce03 100644 --- a/src/screens/Messages/ConversationSettings/index.tsx +++ b/src/screens/Messages/ConversationSettings/index.tsx @@ -1,5 +1,5 @@ import {useState} from 'react' -import {View} from 'react-native' +import {Pressable, View} from 'react-native' import { ChatBskyActorDefs, ChatBskyConvoDefs, @@ -8,6 +8,7 @@ import { import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' +import {HITSLOP_10} from '#/lib/constants' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import { @@ -429,6 +430,13 @@ function SettingsHeader({ const canLockGroupChat = isOwner && lockStatus !== 'locked-permanently' + const groupNameComponent = ( + + {groupName} + + ) + return ( <> - - {groupName} - + {isOwner ? ( + { + setNewGroupName(groupName) + editNamePrompt.open() + }}> + {groupNameComponent} + + ) : ( + groupNameComponent + )} Date: Mon, 8 Jun 2026 12:14:51 -0700 Subject: [PATCH 57/61] Remove TODO for uninviting group chat members (#10789) --- .../ConversationSettings/MemberMenu.tsx | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/screens/Messages/ConversationSettings/MemberMenu.tsx b/src/screens/Messages/ConversationSettings/MemberMenu.tsx index c13835a85a..9101864d91 100644 --- a/src/screens/Messages/ConversationSettings/MemberMenu.tsx +++ b/src/screens/Messages/ConversationSettings/MemberMenu.tsx @@ -38,7 +38,7 @@ export function MemberMenu({ }: { convo: ConvoWithDetails profile: Shadow - type: 'owner' | 'standard' | 'invited' + type: 'owner' | 'standard' displayName: string isOwner: boolean }) { @@ -128,10 +128,7 @@ export function MemberMenu({ } const canBlockMember = type === 'owner' || type === 'standard' - const canRemoveMember = isOwner && type !== 'invited' - // TODO Need to integrate this. -dsb - const canUninviteMember = false - // const canUninviteMember = isOwner && type === 'invited' + const canRemoveMember = isOwner return ( <> @@ -147,7 +144,7 @@ export function MemberMenu({ props.onPress() }, } - return type === 'owner' || type === 'invited' ? ( + return type === 'owner' ? ( ) : null} - {canUninviteMember ? ( - {}}> - - - Uninvite - - - ) : null} From 1c5b3dd3a152e262a1bc2c751f7b2273fbc13653 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:15:13 -0700 Subject: [PATCH 58/61] Only show Message option if chat member can be messaged (#10788) --- .../ConversationSettings/MemberMenu.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/screens/Messages/ConversationSettings/MemberMenu.tsx b/src/screens/Messages/ConversationSettings/MemberMenu.tsx index 9101864d91..aee0cbf54e 100644 --- a/src/screens/Messages/ConversationSettings/MemberMenu.tsx +++ b/src/screens/Messages/ConversationSettings/MemberMenu.tsx @@ -12,7 +12,7 @@ import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-memb import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group' import {useProfileBlockMutationQueue} from '#/state/queries/profile' import {atoms as a, useTheme} from '#/alf' -import {type ConvoWithDetails} from '#/components/dms/util' +import {canBeMessaged, type ConvoWithDetails} from '#/components/dms/util' import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' @@ -127,6 +127,7 @@ export function MemberMenu({ } } + const canMessageMember = canBeMessaged(profile) const canBlockMember = type === 'owner' || type === 'standard' const canRemoveMember = isOwner @@ -188,14 +189,16 @@ export function MemberMenu({ Go to profile - - - - Message - - + {canMessageMember ? ( + + + + Message + + + ) : null} From 38ee14d517d572e9cf16261041fd9362b8b2f625 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 8 Jun 2026 23:14:29 +0300 Subject: [PATCH 59/61] Remove social proof from group invites (#10787) --- src/screens/Messages/components/IncomingRequestListItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/Messages/components/IncomingRequestListItem.tsx b/src/screens/Messages/components/IncomingRequestListItem.tsx index 8c630b4a04..aa4618188a 100644 --- a/src/screens/Messages/components/IncomingRequestListItem.tsx +++ b/src/screens/Messages/components/IncomingRequestListItem.tsx @@ -34,7 +34,7 @@ export function IncomingRequestListItem({ return ( - {convo.primaryMember && ( + {convo.kind === 'direct' && convo.primaryMember && ( Date: Mon, 8 Jun 2026 23:14:43 +0300 Subject: [PATCH 60/61] [Chat] Try and resolve loading bug once and for all (#10791) --- .../Messages/components/MessagesList.tsx | 51 ++++++++++--------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index e96872a646..599b928337 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -204,15 +204,39 @@ export function MessagesList({ // Tracks whether the initial scroll-to-bottom has been triggered. Separated from isAtBottom so that contentInset // (which causes an early onScroll with negative offset) can't prevent the first scroll. // Reset when hasScrolled goes back to false (e.g. convo re-initialization after backgrounding). + // `didInitialScroll` is the reactive mirror of the ref so the reveal effect below can depend on it; the ref + // itself stays as the synchronous re-entry guard inside onContentSizeChange. const hasInitiallyScrolled = useRef(false) + const [didInitialScroll, setDidInitialScroll] = useState(false) const prevHasScrolled = useRef(hasScrolled) useLayoutEffect(() => { if (prevHasScrolled.current && !hasScrolled) { hasInitiallyScrolled.current = false + setDidInitialScroll(false) } prevHasScrolled.current = hasScrolled }, [hasScrolled]) + // Reveal the list once history has finished loading. We can't reveal earlier because the list isn't inverted - + // we must scroll to the bottom (newest message) before fading in, or the user sees a flash of top-anchored content. + // This is purely state-driven so it doesn't depend on a layout callback firing: a firehose-delivered message can + // dedupe against the fetched history and produce no content-size change, in which case nothing would otherwise + // reveal the list and it would stay hidden forever (APP-2238). Either the initial scroll has run, or there's + // nothing to scroll (empty convo) - both are safe to reveal once !isFetchingHistory. + useEffect(() => { + if (hasScrolled || convoState.isFetchingHistory) return + if (didInitialScroll || renderItems.length === 0) { + const raf = requestAnimationFrame(() => setHasScrolled(true)) + return () => cancelAnimationFrame(raf) + } + }, [ + convoState.isFetchingHistory, + hasScrolled, + didInitialScroll, + renderItems.length, + setHasScrolled, + ]) + // -- Keep track of background state and positioning for new pill const layoutHeight = useSharedValue(0) const didBackground = useRef(false) @@ -247,20 +271,15 @@ export function MessagesList({ // Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset // can cause an early onScroll with a negative offset that sets isAtBottom to false before we get here. - // Empty convos take this path too (once history is done) so hasScrolled gets set without an animated scroll. + // Empty convos take this path too (once history is done). Revealing the list is handled by the effect above, + // which fires once history finishes - we just record that the scroll has happened. if ( !hasInitiallyScrolled.current && (renderItems.length > 0 || !convoState.isFetchingHistory) ) { hasInitiallyScrolled.current = true + setDidInitialScroll(true) flatListRef.current?.scrollToOffset({offset: height, animated: false}) - // If history is already done loading, mark ready after a frame for the scroll to settle. - // Otherwise, the footer sentinel's onLayout will handle it when history finishes. - if (!convoState.isFetchingHistory) { - requestAnimationFrame(() => { - setHasScrolled(true) - }) - } prevContentHeight.current = height prevItemCount.current = renderItems.length return @@ -300,7 +319,6 @@ export function MessagesList({ }, [ hasScrolled, - setHasScrolled, convoState.isFetchingHistory, renderItems.length, // these are stable @@ -505,20 +523,6 @@ export function MessagesList({ return null } - // Footer sentinel: when history is still loading during the initial scroll, the footer's onLayout fires each time - // new items are prepended (shifting its position). Once history finishes, this triggers setHasScrolled. - const onFooterLayout = useCallback(() => { - if ( - hasInitiallyScrolled.current && - !hasScrolled && - !convoState.isFetchingHistory - ) { - requestAnimationFrame(() => { - setHasScrolled(true) - }) - } - }, [hasScrolled, setHasScrolled, convoState.isFetchingHistory]) - const renderScrollComponent = useCallback( (props: ScrollViewProps) => ( @@ -588,7 +592,6 @@ export function MessagesList({ ListFooterComponent={ } style={[ From 690b8184a12fa939c687862e5ccbf00b8dbdce5d Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 8 Jun 2026 15:34:51 -0500 Subject: [PATCH 61/61] Refactor age assurance flags and contexts (#10794) --- src/ageAssurance/__mocks__/data.tsx | 2 +- .../components/NoAccessScreen.tsx | 10 +- src/ageAssurance/data.tsx | 46 ++-- src/ageAssurance/debug.ts | 241 ++++++++++++++++-- src/ageAssurance/index.tsx | 62 ++--- src/ageAssurance/state.ts | 80 +++--- src/ageAssurance/types.ts | 16 ++ .../useComputeAgeAssuranceRegionAccess.ts | 11 +- src/ageAssurance/util.ts | 42 ++- src/state/session/__tests__/session-test.ts | 2 +- src/state/session/agent.ts | 12 +- src/state/session/index.tsx | 12 +- 12 files changed, 390 insertions(+), 146 deletions(-) diff --git a/src/ageAssurance/__mocks__/data.tsx b/src/ageAssurance/__mocks__/data.tsx index b548a2f866..f813e14ccc 100644 --- a/src/ageAssurance/__mocks__/data.tsx +++ b/src/ageAssurance/__mocks__/data.tsx @@ -1,3 +1,3 @@ -export const prefetchAgeAssuranceData = () => {} +export const prefetchAgeAssuranceServerData = () => {} export const setBirthdateForDid = () => {} export const setCreatedAtForDid = () => {} diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx index 84584b2717..ac88e59f7a 100644 --- a/src/ageAssurance/components/NoAccessScreen.tsx +++ b/src/ageAssurance/components/NoAccessScreen.tsx @@ -32,7 +32,7 @@ import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {BottomSheetOutlet} from '#/../modules/bottom-sheet' import {useAgeAssurance} from '#/ageAssurance' -import {useAgeAssuranceDataContext} from '#/ageAssurance/data' +import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data' import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' import { isLegacyBirthdateBug, @@ -53,7 +53,7 @@ export function NoAccessScreen() { const birthdateControl = useDialogControl() const deactivateAccountControl = useDialogControl() const deleteAccountControl = useDialogControl() - const {data} = useAgeAssuranceDataContext() + const {metadata} = useAgeAssuranceServerDataContext() const region = useAgeAssuranceRegionConfig() const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed() const {logoutCurrentAccount} = useSessionApi() @@ -62,15 +62,15 @@ export function NoAccessScreen() { const aa = useAgeAssurance() const isBlocked = aa.state.status === aa.Status.Blocked const isAARegion = !!region - const hasDeclaredAge = data?.declaredAge !== undefined + const hasDeclaredAge = metadata?.declaredAge !== undefined const canUpdateBirthday = - isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '') + isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '') useEffect(() => { // just counting overall hits here ax.metric(`blockedGeoOverlay:shown`, {}) ax.metric(`ageAssurance:noAccessScreen:shown`, { - accountCreatedAt: data?.accountCreatedAt || 'unknown', + accountCreatedAt: metadata?.accountCreatedAt || 'unknown', isAARegion, hasDeclaredAge, canUpdateBirthday, diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index 36a06fbd22..114d946602 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -24,6 +24,7 @@ import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declar import {useAgent, useSession} from '#/state/session' import * as debug from '#/ageAssurance/debug' import {logger} from '#/ageAssurance/logger' +import {type AgeAssuranceMetadata} from '#/ageAssurance/types' import { getBirthdateStringFromAge, isLegacyBirthdateBug, @@ -485,9 +486,9 @@ export function useOtherRequiredDataQuery() { } /** - * Helper to prefetch all age assurance data. + * Helper to prefetch all age assurance data from the server. */ -export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) { +export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) { return Promise.allSettled([ // config fetch initiated at the top of the App.platform.tsx files, awaited here configPrefetchPromise, @@ -496,8 +497,8 @@ export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) { ]) } -export function clearAgeAssuranceDataForDid({did}: {did: string}) { - logger.debug(`clearAgeAssuranceDataForDid: ${did}`) +export function clearAgeAssuranceServerDataForDid({did}: {did: string}) { + logger.debug(`clearAgeAssuranceServerDataForDid: ${did}`) qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true}) qc.removeQueries({ queryKey: createOtherRequiredDataQueryKey({did}), @@ -505,8 +506,8 @@ export function clearAgeAssuranceDataForDid({did}: {did: string}) { }) } -export function clearAgeAssuranceData() { - logger.debug(`clearAgeAssuranceData`) +export function clearAgeAssuranceServerDataForAll() { + logger.debug(`clearAgeAssuranceServerDataForAll`) qc.clear() } @@ -514,30 +515,30 @@ export function clearAgeAssuranceData() { * Context */ -export type AgeAssuranceData = { +export type AgeAssuranceServerData = { + /** + * The raw config from the appview. + */ config: AppBskyAgeassuranceDefs.Config | undefined + /** + * The raw state from the appview. Must be further processed before being useful. + */ state: AppBskyAgeassuranceDefs.State | undefined - data: - | { - accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt'] - declaredAge: number | undefined - birthdate: string | undefined - } - | undefined + metadata: AgeAssuranceMetadata | undefined } -export const AgeAssuranceDataContext = createContext({ +const AgeAssuranceServerDataContext = createContext({ config: undefined, state: undefined, - data: { + metadata: { accountCreatedAt: undefined, declaredAge: undefined, birthdate: undefined, }, }) -export function useAgeAssuranceDataContext() { - return useContext(AgeAssuranceDataContext) +export function useAgeAssuranceServerDataContext() { + return useContext(AgeAssuranceServerDataContext) } -export function AgeAssuranceDataProvider({ +export function AgeAssuranceServerDataProvider({ children, }: { children: React.ReactNode @@ -550,7 +551,8 @@ export function AgeAssuranceDataProvider({ () => ({ config, state, - data: { + metadata: { + // yes, it's weird, but accountCreatedAt comes back on the `getState` endpoint accountCreatedAt: metadata?.accountCreatedAt, declaredAge: data?.birthdate ? getAge(new Date(data.birthdate)) @@ -561,8 +563,8 @@ export function AgeAssuranceDataProvider({ [config, state, data, metadata], ) return ( - + {children} - + ) } diff --git a/src/ageAssurance/debug.ts b/src/ageAssurance/debug.ts index 257eeff93d..3368ddf147 100644 --- a/src/ageAssurance/debug.ts +++ b/src/ageAssurance/debug.ts @@ -26,35 +26,8 @@ export const deviceGeolocation: Geolocation | undefined = } : undefined -export const config: AppBskyAgeassuranceDefs.Config = { - regions: [ - { - countryCode: 'AA', - regionCode: undefined, - minAccessAge: 13, - rules: [ - { - $type: ids.Default, - access: 'full', - }, - ], - }, - { - countryCode: 'BB', - regionCode: undefined, - minAccessAge: 16, - rules: [ - { - $type: ids.Default, - access: 'full', - }, - ], - }, - ], -} - export const otherRequiredData: OtherRequiredData = { - birthdate: new Date(2000, 1, 1).toISOString(), + birthdate: new Date(2010, 12, 1).toISOString(), } const serverStateEnabled = false || IS_E2E @@ -72,6 +45,218 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined = } : undefined +export const config: AppBskyAgeassuranceDefs.Config = { + regions: [ + { + countryCode: 'AA', + regionCode: undefined, + minAccessAge: 13, + rules: [ + { + $type: ids.Default, + access: 'full', + }, + ], + }, + { + countryCode: 'GB', + minAccessAge: 13, + rules: [ + { + age: 18, + access: 'full', + $type: ids.IfAssuredOverAge, + }, + { + age: 13, + access: 'safe', + $type: ids.IfDeclaredOverAge, + }, + { + access: 'none', + $type: ids.Default, + }, + ], + }, + { + countryCode: 'AU', + minAccessAge: 16, + rules: [ + { + date: '2025-12-10T00:00:00Z', + access: 'none', + $type: ids.IfAccountNewerThan, + }, + { + age: 18, + access: 'full', + $type: ids.IfAssuredOverAge, + }, + { + age: 16, + access: 'safe', + $type: ids.IfAssuredOverAge, + }, + { + age: 16, + access: 'safe', + $type: ids.IfDeclaredOverAge, + }, + { + access: 'none', + $type: ids.Default, + }, + ], + }, + { + countryCode: 'US', + regionCode: 'SD', + minAccessAge: 13, + rules: [ + { + age: 18, + access: 'full', + $type: ids.IfAssuredOverAge, + }, + { + age: 13, + access: 'safe', + $type: ids.IfDeclaredOverAge, + }, + { + access: 'none', + $type: ids.Default, + }, + ], + }, + { + countryCode: 'US', + regionCode: 'WY', + minAccessAge: 13, + rules: [ + { + age: 18, + access: 'full', + $type: ids.IfAssuredOverAge, + }, + { + age: 13, + access: 'safe', + $type: ids.IfDeclaredOverAge, + }, + { + access: 'none', + $type: ids.Default, + }, + ], + }, + { + countryCode: 'US', + regionCode: 'OH', + minAccessAge: 13, + rules: [ + { + age: 18, + access: 'full', + $type: ids.IfAssuredOverAge, + }, + { + age: 13, + access: 'safe', + $type: ids.IfDeclaredOverAge, + }, + { + access: 'none', + $type: ids.Default, + }, + ], + }, + { + countryCode: 'US', + regionCode: 'MS', + minAccessAge: 18, + rules: [ + { + age: 18, + access: 'full', + $type: ids.IfAssuredOverAge, + }, + { + access: 'none', + $type: ids.Default, + }, + ], + }, + { + countryCode: 'US', + regionCode: 'VA', + minAccessAge: 16, + rules: [ + { + age: 16, + access: 'full', + $type: ids.IfAssuredOverAge, + }, + { + age: 16, + access: 'full', + $type: ids.IfDeclaredOverAge, + }, + { + access: 'none', + $type: ids.Default, + }, + ], + }, + { + countryCode: 'US', + regionCode: 'TN', + minAccessAge: 18, + rules: [ + { + age: 18, + access: 'full', + $type: ids.IfAssuredOverAge, + }, + { + age: 18, + access: 'full', + $type: ids.IfDeclaredOverAge, + }, + { + access: 'none', + $type: ids.Default, + }, + ], + }, + { + countryCode: 'BR', + minAccessAge: 13, + rules: [ + { + age: 18, + access: 'full', + $type: ids.IfAssuredOverAge, + }, + { + age: 18, + access: 'full', + $type: ids.IfDeclaredOverAge, + }, + { + age: 13, + access: 'safe', + $type: ids.IfDeclaredOverAge, + }, + { + access: 'none', + $type: ids.Default, + }, + ], + }, + ], +} + export async function resolve(data: T) { await new Promise(y => setTimeout(y, 500)) // simulate network return data diff --git a/src/ageAssurance/index.tsx b/src/ageAssurance/index.tsx index a5cca327ae..2b10c93a5d 100644 --- a/src/ageAssurance/index.tsx +++ b/src/ageAssurance/index.tsx @@ -1,11 +1,11 @@ -import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' +import {createContext, useCallback, useContext, useMemo} from 'react' import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications' import {useAgent} from '#/state/session' import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay' import { - AgeAssuranceDataProvider, - useAgeAssuranceDataContext, + AgeAssuranceServerDataProvider, + useAgeAssuranceServerDataContext, } from '#/ageAssurance/data' import {logger} from '#/ageAssurance/logger' import { @@ -14,19 +14,19 @@ import { } from '#/ageAssurance/state' import { AgeAssuranceAccess, + type AgeAssuranceFlags, type AgeAssuranceState, AgeAssuranceStatus, } from '#/ageAssurance/types' import { - isUnderAge, + computeAgeAssuranceFlags, maybeRestrictChatSettings, - MIN_ACCESS_AGE, useAgeAssuranceRegionConfigWithFallback, } from '#/ageAssurance/util' export { prefetchConfig as prefetchAgeAssuranceConfig, - prefetchAgeAssuranceData, + prefetchAgeAssuranceServerData, refetchServerState as refetchAgeAssuranceServerState, usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData, usePatchServerState as usePatchAgeAssuranceServerState, @@ -38,13 +38,7 @@ const AgeAssuranceStateContext = createContext<{ Access: typeof AgeAssuranceAccess Status: typeof AgeAssuranceStatus state: AgeAssuranceState - flags: { - adultContentDisabled: boolean - chatDisabled: boolean - isDeclaredUnderAdultAge: boolean - isOverRegionMinAccessAge: boolean - isOverAppMinAccessAge: boolean - } + flags: AgeAssuranceFlags }>({ Access: AgeAssuranceAccess, Status: AgeAssuranceStatus, @@ -73,19 +67,19 @@ export function useAgeAssurance() { export function Provider({children}: {children: React.ReactNode}) { return ( - + {children} - + ) } function InnerProvider({children}: {children: React.ReactNode}) { const agent = useAgent() const state = useAgeAssuranceState() - const {data} = useAgeAssuranceDataContext() - const config = useAgeAssuranceRegionConfigWithFallback() + const {metadata} = useAgeAssuranceServerDataContext() + const regionConfig = useAgeAssuranceRegionConfigWithFallback() const getAndRegisterPushToken = useGetAndRegisterPushToken() const handleAccessUpdate = useCallback( @@ -100,38 +94,22 @@ function InnerProvider({children}: {children: React.ReactNode}) { ) useOnAgeAssuranceAccessUpdate(handleAccessUpdate) - useEffect(() => { - logger.debug(`useAgeAssuranceState`, {state}) - }, [state]) - return ( { - const chatDisabled = state.access !== AgeAssuranceAccess.Full - const isDeclaredUnderAdultAge = data?.birthdate - ? isUnderAge(data.birthdate, 18) - : true - const isOverRegionMinAccessAge = data?.birthdate - ? !isUnderAge(data.birthdate, config.minAccessAge) - : false - const isOverAppMinAccessAge = data?.birthdate - ? !isUnderAge(data.birthdate, MIN_ACCESS_AGE) - : false - const adultContentDisabled = - state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge - return { + const res = { Access: AgeAssuranceAccess, Status: AgeAssuranceStatus, state, - flags: { - adultContentDisabled, - chatDisabled, - isDeclaredUnderAdultAge, - isOverRegionMinAccessAge, - isOverAppMinAccessAge, - }, + flags: computeAgeAssuranceFlags({ + state, + regionConfig, + metadata, + }), } - }, [state, data, config])}> + logger.debug(`useAgeAssurance`, res) + return res + }, [state, metadata, regionConfig])}> {children} ) diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts index 5aac40ef44..ff80bca725 100644 --- a/src/ageAssurance/state.ts +++ b/src/ageAssurance/state.ts @@ -1,24 +1,30 @@ import {useEffect, useMemo, useState} from 'react' -import {computeAgeAssuranceRegionAccess} from '@atproto/api' +import { + type AppBskyAgeassuranceDefs, + computeAgeAssuranceRegionAccess, +} from '@atproto/api' import {getAge} from '#/lib/strings/time' import {useSession} from '#/state/session' import { - type AgeAssuranceData, getConfigFromCache, getOtherRequiredDataFromCache, getServerStateFromCache, - useAgeAssuranceDataContext, + useAgeAssuranceServerDataContext, } from '#/ageAssurance/data' import {logger} from '#/ageAssurance/logger' import { AgeAssuranceAccess, + type AgeAssuranceMetadata, type AgeAssuranceState, AgeAssuranceStatus, parseAccessFromString, parseStatusFromString, } from '#/ageAssurance/types' -import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util' +import { + computeAgeAssuranceFlags, + getAgeAssuranceRegionConfigWithFallback, +} from '#/ageAssurance/util' import {type Geolocation, useGeolocation} from '#/geolocation' import {device} from '#/storage' @@ -27,18 +33,18 @@ import {device} from '#/storage' * server state before computing access based on AA config from the server + * geolocation and other data. */ -export function computeAgeAssuranceState({ +function computeAgeAssuranceState({ hasSession, - config, geolocation, + config, state, - data, + metadata, }: { hasSession: boolean - config: AgeAssuranceData['config'] geolocation: Geolocation - state: AgeAssuranceData['state'] - data: AgeAssuranceData['data'] + config?: AppBskyAgeassuranceDefs.Config + state?: AppBskyAgeassuranceDefs.State + metadata?: AgeAssuranceMetadata }) { /** * This is where we control logged-out moderation prefs. It's all @@ -88,7 +94,10 @@ export function computeAgeAssuranceState({ * accounts with an accurate birthdate, our default fallback rules should * ensure correct access. */ - const result = computeAgeAssuranceRegionAccess(region, data) + const result = computeAgeAssuranceRegionAccess(region, { + accountCreatedAt: metadata?.accountCreatedAt, + declaredAge: metadata?.declaredAge, + }) const computed = { lastInitiatedAt: state?.lastInitiatedAt, // prefer server state @@ -100,10 +109,10 @@ export function computeAgeAssuranceState({ ? parseAccessFromString(result.access) : AgeAssuranceAccess.Full, } - logger.debug('debug useAgeAssuranceState', { + logger.debug('computeAgeAssuranceState', { region, state, - data, + metadata, computed, }) return computed @@ -113,38 +122,51 @@ export function computeAgeAssuranceState({ * This is a last-ditch helper for out-of-band reads of the AA state, such as * during account creation. Don't use it for anything else. */ -export function getAndComputeAgeAssuranceState({did}: {did: string}) { +export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) { const config = getConfigFromCache() const state = getServerStateFromCache({did}) - const data = getOtherRequiredDataFromCache({did}) + const requiredData = getOtherRequiredDataFromCache({did}) const geolocation = device.get(['mergedGeolocation']) - if (!geolocation || !config || !state || !data) { + if (!geolocation || !config || !state || !requiredData) { return { - status: AgeAssuranceStatus.Unknown, - access: AgeAssuranceAccess.Safe, + state: { + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.Safe, + }, } } - return computeAgeAssuranceState({ + const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation) + const metadata: AgeAssuranceMetadata = { + accountCreatedAt: state.metadata?.accountCreatedAt, + declaredAge: requiredData?.birthdate + ? getAge(new Date(requiredData.birthdate)) + : undefined, + birthdate: requiredData?.birthdate, + } + const computed = computeAgeAssuranceState({ hasSession: true, config, geolocation, state: state.state, - data: { - accountCreatedAt: state.metadata?.accountCreatedAt, - declaredAge: data?.birthdate - ? getAge(new Date(data.birthdate)) - : undefined, - birthdate: data?.birthdate, - }, + metadata, }) + + return { + state: computed, + flags: computeAgeAssuranceFlags({ + state: computed, + regionConfig: region, + metadata, + }), + } } export function useAgeAssuranceState(): AgeAssuranceState { const {hasSession} = useSession() const geolocation = useGeolocation() - const {config, state, data} = useAgeAssuranceDataContext() + const {config, state, metadata} = useAgeAssuranceServerDataContext() return useMemo( () => @@ -153,9 +175,9 @@ export function useAgeAssuranceState(): AgeAssuranceState { config, geolocation, state, - data, + metadata, }), - [hasSession, geolocation, config, state, data], + [hasSession, geolocation, config, state, metadata], ) } diff --git a/src/ageAssurance/types.ts b/src/ageAssurance/types.ts index f34ed10aea..12473bc2e6 100644 --- a/src/ageAssurance/types.ts +++ b/src/ageAssurance/types.ts @@ -1,3 +1,5 @@ +import {type computeAgeAssuranceRegionAccess} from '@atproto/api' + import {logger} from '#/ageAssurance/logger' export enum AgeAssuranceAccess { @@ -14,6 +16,12 @@ export enum AgeAssuranceStatus { Blocked = 'blocked', } +export type AgeAssuranceMetadata = Parameters< + typeof computeAgeAssuranceRegionAccess +>[1] & { + birthdate: string | undefined +} + export type AgeAssuranceState = { lastInitiatedAt?: string status: AgeAssuranceStatus @@ -21,6 +29,14 @@ export type AgeAssuranceState = { error?: 'config' // maybe other specific cases in the future } +export type AgeAssuranceFlags = { + adultContentDisabled: boolean + chatDisabled: boolean + isDeclaredUnderAdultAge: boolean + isOverRegionMinAccessAge: boolean + isOverAppMinAccessAge: boolean +} + export function parseStatusFromString(raw: string) { switch (raw) { case 'unknown': diff --git a/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts b/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts index e3ea48860f..5ba9e1ba6d 100644 --- a/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts +++ b/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts @@ -1,14 +1,14 @@ import {useCallback} from 'react' import {computeAgeAssuranceRegionAccess} from '@atproto/api' -import {useAgeAssuranceDataContext} from '#/ageAssurance/data' +import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data' import {logger} from '#/ageAssurance/logger' import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types' import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util' import {type Geolocation} from '#/geolocation' export function useComputeAgeAssuranceRegionAccess() { - const {config, data} = useAgeAssuranceDataContext() + const {config, metadata} = useAgeAssuranceServerDataContext() return useCallback( (geolocation: Geolocation) => { if (!config) { @@ -19,11 +19,14 @@ export function useComputeAgeAssuranceRegionAccess() { config, geolocation, ) - const result = computeAgeAssuranceRegionAccess(region, data) + const result = computeAgeAssuranceRegionAccess(region, { + accountCreatedAt: metadata?.accountCreatedAt, + declaredAge: metadata?.declaredAge, + }) return result ? parseAccessFromString(result.access) : AgeAssuranceAccess.Full }, - [config, data], + [config, metadata], ) } diff --git a/src/ageAssurance/util.ts b/src/ageAssurance/util.ts index 310725db8f..b0e601d6c7 100644 --- a/src/ageAssurance/util.ts +++ b/src/ageAssurance/util.ts @@ -13,9 +13,14 @@ import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/ import { getDidFromAgentSession, getOtherRequiredDataFromCache, - useAgeAssuranceDataContext, + useAgeAssuranceServerDataContext, } from '#/ageAssurance/data' -import {AgeAssuranceAccess} from '#/ageAssurance/types' +import { + AgeAssuranceAccess, + type AgeAssuranceFlags, + type AgeAssuranceMetadata, + type AgeAssuranceState, +} from '#/ageAssurance/types' import {type Geolocation, useGeolocation} from '#/geolocation' export const MIN_ACCESS_AGE = 13 @@ -62,7 +67,7 @@ export function getAgeAssuranceRegionConfigWithFallback( */ export function useAgeAssuranceRegionConfig() { const geolocation = useGeolocation() - const {config} = useAgeAssuranceDataContext() + const {config} = useAgeAssuranceServerDataContext() return useMemo(() => { if (!config) return // use generic helper, we want to potentially return undefined @@ -128,3 +133,34 @@ export function maybeRestrictChatSettings({agent}: {agent: AtpAgent}) { if (data?.actorDeclaration?.allowIncoming === 'none') return restrictChatSettings({agent, did}) } + +export function computeAgeAssuranceFlags({ + state, + regionConfig, + metadata, +}: { + state: AgeAssuranceState + regionConfig: AppBskyAgeassuranceDefs.ConfigRegion + metadata?: AgeAssuranceMetadata +}): AgeAssuranceFlags { + const chatDisabled = state.access !== AgeAssuranceAccess.Full + const isDeclaredUnderAdultAge = metadata?.declaredAge + ? metadata.declaredAge < 18 + : true + const isOverRegionMinAccessAge = metadata?.declaredAge + ? metadata.declaredAge >= regionConfig.minAccessAge + : false + const isOverAppMinAccessAge = metadata?.declaredAge + ? metadata.declaredAge >= MIN_ACCESS_AGE + : false + const adultContentDisabled = + state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge + + return { + adultContentDisabled, + chatDisabled, + isDeclaredUnderAdultAge, + isOverRegionMinAccessAge, + isOverAppMinAccessAge, + } +} diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index 4b014d6448..4398a90a0b 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -13,7 +13,7 @@ jest.mock('jwt-decode', () => ({ jest.mock('../../birthdate') jest.mock('../../../ageAssurance/data') jest.mock('../../../ageAssurance/state', () => ({ - getAndComputeAgeAssuranceState: () => ({}), + unsafeGetAndComputeAgeAssurance: () => ({state: {}}), })) jest.mock('#/lib/notifications/notifications', () => ({ unregisterPushToken(_agents: BskyAgent[]) { diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 9f2d70927b..0abc0ca6cf 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -24,11 +24,11 @@ import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate' import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings' import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' import { - prefetchAgeAssuranceData, + prefetchAgeAssuranceServerData, setBirthdateForDid, setCreatedAtForDid, } from '#/ageAssurance/data' -import {getAndComputeAgeAssuranceState} from '#/ageAssurance/state' +import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state' import {AgeAssuranceAccess} from '#/ageAssurance/types' import {features} from '#/analytics' import {emitNetworkConfirmed, emitNetworkLost} from '../events' @@ -74,7 +74,7 @@ export async function createAgentAndResume( } // after session is attached - const aa = prefetchAgeAssuranceData({agent}) + const aa = prefetchAgeAssuranceServerData({agent}) agent.configureProxy(BLUESKY_PROXY_HEADER.get()) @@ -113,7 +113,7 @@ export async function createAgentAndLogin( const account = agentToSessionAccountOrThrow(agent) const gates = features.refresh({strategy: 'prefer-fresh-gates'}) const moderation = configureModerationForAccount(agent, account) - const aa = prefetchAgeAssuranceData({agent}) + const aa = prefetchAgeAssuranceServerData({agent}) agent.configureProxy(BLUESKY_PROXY_HEADER.get()) @@ -175,7 +175,7 @@ export async function createAgentAndCreateAccount( setBirthdateForDid({did: account.did, birthdate}) snoozeBirthdateUpdateAllowedForDid(account.did) // do this last - const aa = prefetchAgeAssuranceData({agent}) + const aa = prefetchAgeAssuranceServerData({agent}) // Not awaited so that we can still get into onboarding. // This is OK because we won't let you toggle adult stuff until you set the date. @@ -219,7 +219,7 @@ export async function createAgentAndCreateAccount( }), // wait for AA data to load first, then check state aa.then(async () => { - const state = getAndComputeAgeAssuranceState({did: account.did}) + const {state} = unsafeGetAndComputeAgeAssurance({did: account.did}) if (state.access !== AgeAssuranceAccess.Full) { restrictChatSettings({agent, did: account.did}) } diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 09e7bd0db0..fd31261a9d 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -36,8 +36,8 @@ import { } from '#/state/session/types' import {useOnboardingDispatch} from '#/state/shell/onboarding' import { - clearAgeAssuranceData, - clearAgeAssuranceDataForDid, + clearAgeAssuranceServerDataForAll, + clearAgeAssuranceServerDataForDid, } from '#/ageAssurance/data' const StateContext = createContext({ @@ -203,7 +203,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) addSessionDebugLog({type: 'method:end', method: 'logout'}) if (prevState.currentAgentState.did) { - clearAgeAssuranceDataForDid({did: prevState.currentAgentState.did}) + clearAgeAssuranceServerDataForDid({ + did: prevState.currentAgentState.did, + }) void clearPersistedQueryStorage(prevState.currentAgentState.did) } // reset onboarding flow on logout @@ -234,7 +236,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }, ) addSessionDebugLog({type: 'method:end', method: 'logout'}) - clearAgeAssuranceData() + clearAgeAssuranceServerDataForAll() for (const account of prevState.accounts) { void clearPersistedQueryStorage(account.did) } @@ -304,7 +306,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { accountDid: account.did, }) addSessionDebugLog({type: 'method:end', method: 'removeAccount', account}) - clearAgeAssuranceDataForDid({did: account.did}) + clearAgeAssuranceServerDataForDid({did: account.did}) }, [store, cancelPendingTask], )