From f075187e57a0c1a267efc690f105452209b6ce40 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:25:34 -0700 Subject: [PATCH 01/37] Reset textarea height after clear (#10911) --- src/components/Composer/index.tsx | 10 ++++++++++ src/components/forms/AutosizedTextarea.tsx | 23 +++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index b7a67f3033..b587d19eae 100644 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -34,6 +34,7 @@ import { } from '#/components/Autocomplete' import { AutosizedTextarea, + type AutosizedTextareaHeightApi, type AutosizedTextareaProps, } from '#/components/forms/AutosizedTextarea' import {Span, Text} from '#/components/Typography' @@ -150,6 +151,13 @@ export function Composer({ */ const inputScrollSharedValue = useSharedValue(0) + /* + * Imperative handle on the underlying textarea, used to reset its height when + * the input is cleared programmatically (Android doesn't fire + * `onContentSizeChange` in that case). + */ + const heightApiRef = useRef(null) + /* * Expose imperative internal API */ @@ -160,6 +168,7 @@ export function Composer({ clear: () => { tapper.inputProps.onChangeText('') inputScrollSharedValue.value = 0 + heightApiRef.current?.resetHeight() }, insert: tapper.insert, setAutocompleteAnchor: sift.refs.setAnchor, @@ -321,6 +330,7 @@ export function Composer({ )} void +} + export type AutosizedTextareaProps = Omit & { ref?: React.Ref + heightApiRef?: React.Ref label: string minRows?: number maxRows?: number @@ -20,6 +30,7 @@ export type AutosizedTextareaProps = Omit & { export function AutosizedTextarea({ ref, + heightApiRef, label, minRows = 1, maxRows, @@ -107,6 +118,16 @@ export function AutosizedTextarea({ * directly drive the `height`. */ const [nativeHeight, setNativeHeight] = useState(minInputHeight) + useImperativeHandle( + heightApiRef, + () => ({ + resetHeight: () => { + setNativeHeight(minInputHeight) + onUpdateHeight?.(minInputHeight) + }, + }), + [minInputHeight, onUpdateHeight], + ) const onContentSizeChange = (e: TextInputContentSizeChangeEvent) => { const contentSize = Math.ceil(e.nativeEvent.contentSize.height) // ios reports the content size without padding From b8fdce64783778008234b667c7a225d439702eda Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 16 Jun 2026 20:26:48 +0300 Subject: [PATCH 02/37] Message replies in chat (#10903) Co-authored-by: Claude Opus 4.8 (1M context) --- ...rnerDownRight_stroke2_corner2_rounded.svg} | 0 ...ornerDownRight_stroke2_corner3_rounded.svg | 1 + package.json | 2 +- pnpm-lock.yaml | 10 +- src/analytics/metrics/types.ts | 8 + src/components/ContextMenu/index.tsx | 12 +- src/components/dms/MessageContextMenu.tsx | 12 +- src/components/dms/MessageItem.tsx | 285 ++++++++++++++- src/components/dms/MessageReplies.tsx | 124 +++++++ src/components/icons/ArrowCornerDownRight.tsx | 6 + src/screens/Messages/Conversation.tsx | 1 - .../Messages/components/MessageComposer.tsx | 37 +- .../Messages/components/MessageInput.tsx | 20 +- .../Messages/components/MessageInput.web.tsx | 27 +- .../Messages/components/MessageInputReply.tsx | 91 +++++ .../Messages/components/MessagesList.tsx | 337 +++++++++++------- src/state/messages/convo/agent.ts | 82 +++-- src/state/messages/convo/types.ts | 1 + src/view/com/util/List.web.tsx | 49 +++ 19 files changed, 925 insertions(+), 180 deletions(-) rename assets/icons/{arrowCornerDownRight_stroke2_rounded_2_rounded.svg => arrowCornerDownRight_stroke2_corner2_rounded.svg} (100%) create mode 100644 assets/icons/arrowCornerDownRight_stroke2_corner3_rounded.svg create mode 100644 src/components/dms/MessageReplies.tsx create mode 100644 src/screens/Messages/components/MessageInputReply.tsx diff --git a/assets/icons/arrowCornerDownRight_stroke2_rounded_2_rounded.svg b/assets/icons/arrowCornerDownRight_stroke2_corner2_rounded.svg similarity index 100% rename from assets/icons/arrowCornerDownRight_stroke2_rounded_2_rounded.svg rename to assets/icons/arrowCornerDownRight_stroke2_corner2_rounded.svg diff --git a/assets/icons/arrowCornerDownRight_stroke2_corner3_rounded.svg b/assets/icons/arrowCornerDownRight_stroke2_corner3_rounded.svg new file mode 100644 index 0000000000..ff7edc539e --- /dev/null +++ b/assets/icons/arrowCornerDownRight_stroke2_corner3_rounded.svg @@ -0,0 +1 @@ + diff --git a/package.json b/package.json index 7499cf8114..7d61fb5264 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "prettier": "prettier --check ." }, "dependencies": { - "@atproto/api": "0.20.12", + "@atproto/api": "0.20.15", "@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 1efbc5defc..80917f76c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,8 +242,8 @@ importers: .: dependencies: '@atproto/api': - specifier: 0.20.12 - version: 0.20.12 + specifier: 0.20.15 + version: 0.20.15 '@atproto/syntax': specifier: 0.6.1 version: 0.6.1 @@ -877,8 +877,8 @@ packages: graphql: optional: true - '@atproto/api@0.20.12': - resolution: {integrity: sha512-pNCrl/BSmkjlrVu0W5A9zkOVRIWdWAdajuHX/EyhDZFxu8HExsiQi6j46H5kv85GrNRXS/QtULE+ocSuMtEJfw==} + '@atproto/api@0.20.15': + resolution: {integrity: sha512-b9TuVNY9iWIaRXAeKegNCqRsK9tSpB68DE/j/ytVTxEMK+/m43B0DycJND9tnhRiNIY+i7MhwLqNHPak9YaDJg==} engines: {node: '>=22'} '@atproto/common-web@0.5.0': @@ -9494,7 +9494,7 @@ snapshots: '@0no-co/graphql.web@1.2.0': {} - '@atproto/api@0.20.12': + '@atproto/api@0.20.15': dependencies: '@atproto/common-web': 0.5.0 '@atproto/lexicon': 0.7.1 diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index eec5c3c207..c5b9ff28be 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -586,6 +586,14 @@ export type Events = { | 'SendViaChatDialog' | 'ConvoSettings' } + // Message replies + 'chat:message:reply:send': { + convoId: string + isGroup: boolean + } + 'chat:message:reply:tap': { + convoId: string + } // Group chat adoption 'groupchat:create': { diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index 51bf83309f..0d8a2761e5 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -850,10 +850,11 @@ export function Item({ !unstyled && [ a.flex_row, a.align_center, - a.px_2xl, + a.px_lg, + a.gap_sm, a.rounded_md, t.atoms.bg_contrast_25, - {gap: 6, minHeight: 44, paddingVertical: 10}, + {minHeight: 44, paddingVertical: 10}, (focused || pressed || context.hoveredMenuItem === id) && !rest.disabled && t.atoms.bg_contrast_50, @@ -882,8 +883,7 @@ export function ItemText({children, style}: ItemTextProps) { style={[ a.flex_1, a.text_md, - a.font_semi_bold, - t.atoms.text_contrast_high, + a.font_medium, style, destructive && {color: t.palette.negative_500}, disabled && t.atoms.text_contrast_low, @@ -898,13 +898,13 @@ export function ItemIcon({icon: Comp}: ItemIconProps) { const {disabled, destructive} = useContextMenuItemContext() return ( ) diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index ddfa5118c0..a9d3108cef 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -20,6 +20,8 @@ import {atoms as a} from '#/alf' import * as ContextMenu from '#/components/ContextMenu' import {type TriggerProps} from '#/components/ContextMenu/types' import {useMessageDialogs} from '#/components/dms/MessageOverlays' +import {useMessageReplies} from '#/components/dms/MessageReplies' +import {ArrowCornerDownRight_Stroke2_Corner2_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language' @@ -47,6 +49,7 @@ export let MessageContextMenu = ({ const {currentAccount} = useSession() const convo = useConvoActive() const {openDeleteMessage, openReportMessage} = useMessageDialogs() + const {setReply} = useMessageReplies() const langPrefs = useLanguagePrefs() const translate = useGoogleTranslate() @@ -151,6 +154,13 @@ export let MessageContextMenu = ({ timeStyle: 'short', })}`} style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}> + setReply(message)}> + + {l`Reply`} + {message.text.length > 0 && ( <> )} openDeleteMessage(message)}> @@ -181,7 +190,6 @@ export let MessageContextMenu = ({ {!isFromSelf && ( openReportMessage(message, senderProfile)}> diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 192e8f9e7b..05e165bed3 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -14,6 +14,8 @@ import Animated, { LinearTransition, useAnimatedStyle, useSharedValue, + withDelay, + withSequence, withTiming, ZoomIn, ZoomOut, @@ -41,11 +43,13 @@ import {useProfileBlockMutationQueue} from '#/state/queries/profile' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {useSession} from '#/state/session' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, native, platform, useTheme} from '#/alf' +import {atoms as a, native, platform, useTheme, utils} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' import {Button} from '#/components/Button' import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {useMessageDialogs} from '#/components/dms/MessageOverlays' +import {useMessageReplies} from '#/components/dms/MessageReplies' +import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight' import {InlineLinkText} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import * as Prompt from '#/components/Prompt' @@ -63,28 +67,48 @@ import { const AVATAR_SIZE = 28 const CLUSTERED_MESSAGE_GAP = 2 -const BORDER_RADIUS = 18 +const BORDER_RADIUS = 20 const SQUARED_BORDER_RADIUS = 4 const DISPLAY_NAME_INSET = 20 +function messageIsReply( + message: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null, +): boolean { + return ( + ChatBskyConvoDefs.isMessageView(message) && + (ChatBskyConvoDefs.isMessageView(message.replyTo) || + ChatBskyConvoDefs.isDeletedMessageView(message.replyTo)) + ) +} + function isWithinClusterBoundary({ isPending, + message, adjacentMessage, isFromSameSender, - currentSentAt, direction, }: { isPending: boolean + message: ChatBskyConvoDefs.MessageView adjacentMessage: | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null isFromSameSender: boolean - currentSentAt: string direction: 'prev' | 'next' }): boolean { + // A reply always starts its own cluster, breaking grouping with the message + // above it. Looking back, that's a boundary if this message is a reply; + // looking forward, it's a boundary if the next message is a reply. + if (messageIsReply(direction === 'prev' ? message : adjacentMessage)) { + return true + } if (!isFromSameSender) return true if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) { + const currentSentAt = message.sentAt const thisDate = new Date(currentSentAt) const adjDate = new Date(adjacentMessage.sentAt) const diff = @@ -128,6 +152,16 @@ let MessageItem = ({ const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did)) const {openReactions} = useMessageDialogs() + const {scrollToMessage, highlightedMessage} = useMessageReplies() + + // `replyTo` comes back hydrated as the referenced message (or a deleted- + // message tombstone). Narrow away the open-union fallback so we only render + // shapes we understand. + const replyTo = + ChatBskyConvoDefs.isMessageView(message.replyTo) || + ChatBskyConvoDefs.isDeletedMessageView(message.replyTo) + ? message.replyTo + : undefined const isPending = item.type === 'pending-message' @@ -150,17 +184,17 @@ let MessageItem = ({ const isFirstInCluster = isWithinClusterBoundary({ isPending, + message, adjacentMessage: prevMessage, isFromSameSender: isPrevFromSameSender, - currentSentAt: message.sentAt, direction: 'prev', }) const isLastInCluster = isWithinClusterBoundary({ isPending, + message, adjacentMessage: nextMessage, isFromSameSender: isNextFromSameSender, - currentSentAt: message.sentAt, direction: 'next', }) @@ -226,6 +260,26 @@ let MessageItem = ({ topRadiusSV.set(withTiming(targetTopRadius, {duration: 300})) }, [targetTopRadius, topRadiusSV]) + // Flash the message background when it's been scrolled to (e.g. by tapping a + // reply that quotes it), so it's easy to spot. Keyed on the highlight `key` + // so re-tapping the same message re-triggers the flash. + const highlightSV = useSharedValue(0) + const isHighlighted = highlightedMessage?.id === message.id + const highlightKey = isHighlighted ? highlightedMessage.key : null + useEffect(() => { + if (highlightKey === null) return + highlightSV.set( + withSequence( + withTiming(1, {duration: 150}), + withDelay(400, withTiming(0, {duration: 450})), + ), + ) + }, [highlightKey, highlightSV]) + + const highlightStyle = useAnimatedStyle(() => ({ + opacity: highlightSV.get(), + })) + const borderRadiusStyle = useAnimatedStyle(() => isFromSelf ? { @@ -372,6 +426,14 @@ let MessageItem = ({ web: a.mx_lg, }) + // Negative of `messageInset` so the flash bleeds past the row's horizontal + // margin to the screen edges. + const flashBleed = platform({ + android: -a.mx_sm.marginLeft, + ios: -a.mx_md.marginLeft, + web: -a.mx_lg.marginLeft, + }) + return ( <> {hasLargeGapFromPrev && } @@ -381,6 +443,20 @@ let MessageItem = ({ isFirstInCluster ? a.mt_md : {marginTop: CLUSTERED_MESSAGE_GAP}, hasReactions && {paddingBottom: 26}, ]}> + {showAvatar ? ( {avatar} @@ -391,7 +467,16 @@ let MessageItem = ({ a.flex_grow, !isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE}, ]}> - {displayName && showDisplayName ? ( + {replyTo ? ( + scrollToMessage(replyTo.id)} + /> + ) : displayName && showDisplayName ? ( + {replyTo && !isOnlyEmoji(message.text) ? ( + scrollToMessage(replyTo.id)} + /> + ) : null} ) } + +/** + * The "↪ X replied to Y" caption rendered above a reply message, in place of + * the display name. `X` is the person sending the reply (self -> "you"), `Y` is + * the original sender. Tapping it scrolls to the original (if loaded). + * + * Aligns with the sender's display name for others (left), or with the message + * bubble for self (right). + */ +function ReplyCaption({ + replyTo, + isFromSelf, + isGroupChat, + replierDisplayName, + relatedProfiles, + onPress, +}: { + replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView + isFromSelf: boolean + isGroupChat: boolean + replierDisplayName: string | null + relatedProfiles: Map + onPress: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const {currentAccount} = useSession() + + const originalSenderIsSelf = replyTo.sender.did === currentAccount?.did + const originalProfile = relatedProfiles.get(replyTo.sender.did) + const originalName = originalSenderIsSelf + ? null + : originalProfile + ? createSanitizedDisplayName(originalProfile) + : null + + return ( + + ) +} + +/** + * The nested quote of the original message, rendered at the top of a reply + * bubble. Tapping it scrolls to the original (if loaded). + */ +function ReplyQuote({ + replyTo, + isFromSelf, + relatedProfiles, + onPress, +}: { + replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView + isFromSelf: boolean + relatedProfiles: Map + onPress: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + const senderProfile = useMaybeProfileShadow( + relatedProfiles.get(replyTo.sender.did), + ) + // Hide the quoted content if we block, or are blocked by, the original + // sender - mirroring how the message bubble itself is hidden. + const isBlocked = senderProfile ? isBlockedOrBlocking(senderProfile) : false + const senderName = + senderProfile && !isBlocked + ? createSanitizedDisplayName(senderProfile) + : null + + const tintColor = isFromSelf ? t.palette.white : t.atoms.text.color + const subtleColor = isFromSelf + ? t.palette.white + : t.atoms.text_contrast_high.color + const borderColor = isFromSelf + ? utils.alpha(t.palette.white, 0.5) + : t.atoms.border_contrast_high.borderColor + + let text: string + let subtle = false + if (isBlocked) { + text = l`Blocked message hidden` + subtle = true + } else if (ChatBskyConvoDefs.isMessageView(replyTo)) { + text = replyTo.text + if (!text.trim()) { + subtle = true + if (ChatBskyEmbedJoinLink.isView(replyTo.embed)) { + text = l`(chat invite link)` + } else if (AppBskyEmbedRecord.isView(replyTo.embed)) { + text = l`(contains embedded content)` + } else { + text = l`No text` + } + } + } else { + text = l`Deleted message` + subtle = true + } + + return ( + + ) +} diff --git a/src/components/dms/MessageReplies.tsx b/src/components/dms/MessageReplies.tsx new file mode 100644 index 0000000000..5093d8d157 --- /dev/null +++ b/src/components/dms/MessageReplies.tsx @@ -0,0 +1,124 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import {type ChatBskyConvoDefs} from '@atproto/api' + +/** + * How long a message stays highlighted after scrolling to it, before the flash + * fades out. + */ +export const MESSAGE_HIGHLIGHT_DURATION_MS = 1500 + +type HighlightedMessage = { + id: string + /** + * Bumped on every highlight so that re-tapping the same reply re-triggers the + * flash even while the previous highlight is still active. + */ + key: number +} + +type MessageRepliesContextType = { + /** + * The message currently staged for reply in the composer, or null. + */ + replyTo: ChatBskyConvoDefs.MessageView | null + setReply: (message: ChatBskyConvoDefs.MessageView) => void + clearReply: () => void + /** + * Scroll the list to a message, if it's currently loaded, and flash it. No-op + * otherwise. + */ + scrollToMessage: (messageId: string) => void + /** + * The message to flash, or null. Consumers compare against their own id. + */ + highlightedMessage: HighlightedMessage | null +} + +const Context = createContext(null) + +export function useMessageReplies() { + const ctx = useContext(Context) + if (!ctx) { + throw new Error( + 'useMessageReplies must be used within a MessageRepliesProvider', + ) + } + return ctx +} + +export function MessageRepliesProvider({ + children, + scrollToMessage: scrollToMessageRaw, +}: { + children: React.ReactNode + /** + * Performs the actual scroll. Returns true if the message was found and + * scrolled to, false if it isn't currently loaded (so we know whether to + * flash it). + */ + scrollToMessage: (messageId: string) => boolean +}) { + const [replyTo, setReplyTo] = useState( + null, + ) + const [highlightedMessage, setHighlightedMessage] = + useState(null) + const highlightKey = useRef(0) + const clearHighlightTimeout = useRef | null>( + null, + ) + + const setReply = useCallback((message: ChatBskyConvoDefs.MessageView) => { + setReplyTo(message) + }, []) + + const clearReply = useCallback(() => { + setReplyTo(null) + }, []) + + const scrollToMessage = useCallback( + (messageId: string) => { + const didScroll = scrollToMessageRaw(messageId) + if (!didScroll) return + + highlightKey.current += 1 + setHighlightedMessage({id: messageId, key: highlightKey.current}) + if (clearHighlightTimeout.current) { + clearTimeout(clearHighlightTimeout.current) + } + clearHighlightTimeout.current = setTimeout(() => { + setHighlightedMessage(null) + }, MESSAGE_HIGHLIGHT_DURATION_MS) + }, + [scrollToMessageRaw], + ) + + useEffect(() => { + return () => { + if (clearHighlightTimeout.current) { + clearTimeout(clearHighlightTimeout.current) + } + } + }, []) + + const ctx = useMemo( + () => ({ + replyTo, + setReply, + clearReply, + scrollToMessage, + highlightedMessage, + }), + [replyTo, setReply, clearReply, scrollToMessage, highlightedMessage], + ) + + return {children} +} diff --git a/src/components/icons/ArrowCornerDownRight.tsx b/src/components/icons/ArrowCornerDownRight.tsx index 86dde70154..85346f270d 100644 --- a/src/components/icons/ArrowCornerDownRight.tsx +++ b/src/components/icons/ArrowCornerDownRight.tsx @@ -5,3 +5,9 @@ export const ArrowCornerDownRight_Stroke2_Corner2_Rounded = createSinglePathSVG( path: 'M15.793 10.293a1 1 0 0 1 1.338-.068l.076.068 3.293 3.293a2 2 0 0 1 .138 2.677l-.138.151-3.293 3.293a1 1 0 1 1-1.414-1.414L18.086 16H8a5 5 0 0 1-5-5V5a1 1 0 0 1 2 0v6a3 3 0 0 0 3 3h10.086l-2.293-2.293-.068-.076a1 1 0 0 1 .068-1.338Z', }, ) + +export const ArrowCornerDownRight_Stroke2_Corner3_Rounded = createSinglePathSVG( + { + path: 'M5 5a1 1 0 0 0-2 0v4a7 7 0 0 0 7 7h8.086l-2.293 2.293a1 1 0 0 0 1.414 1.414l2.94-2.94a2.5 2.5 0 0 0 0-3.535l-2.94-2.94a1 1 0 1 0-1.414 1.415L18.086 14H10a5 5 0 0 1-5-5V5Z', + }, +) diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 36c0a1ec2f..1e2f4037a8 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -262,7 +262,6 @@ function InnerReady({ {IS_LIQUID_GLASS ? ( {header} diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index 83bab02be3..926a3cccca 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -1,4 +1,4 @@ -import {useRef, useState} from 'react' +import {useEffect, useRef, useState} from 'react' import {Pressable, View} from 'react-native' import { useKeyboardHandler, @@ -13,6 +13,7 @@ import Animated, { import {useSafeAreaInsets} from 'react-native-safe-area-context' import {GlassContainer} from 'expo-glass-effect' import {LinearGradient} from 'expo-linear-gradient' +import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api' import {ScrollEdgeEffect} from '@bsky.app/expo-scroll-edge-effect' import {useLingui} from '@lingui/react/macro' import {countGraphemes} from 'unicode-segmenter/grapheme' @@ -28,6 +29,7 @@ import { } from '#/state/messages/message-drafts' import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf' import {Composer, useComposerInternalApiRef} from '#/components/Composer' +import {useMessageReplies} from '#/components/dms/MessageReplies' import * as EmojiPicker from '#/components/EmojiPicker' import {GlassView} from '#/components/GlassView' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' @@ -47,7 +49,10 @@ export function MessageComposer({ loading = false, }: { textInputId?: string - onSendMessage: (message: string) => void + onSendMessage: ( + message: string, + replyTo?: $Typed, + ) => void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode @@ -60,10 +65,16 @@ export function MessageComposer({ const editable = !needsEmailVerification && !loading const {getDraft, clearDraft} = useMessageDraft() const composerInternalApiRef = useComposerInternalApiRef() + const {replyTo, clearReply} = useMessageReplies() const [text, setText] = useState(getDraft) useSaveMessageDraft(text) + useEffect(() => { + if (!replyTo) return + composerInternalApiRef.current?.input?.focus() + }, [replyTo, composerInternalApiRef]) + // Android interactive dismiss sometimes doesn't blur the input const blur = useNonReactiveCallback(() => { composerInternalApiRef.current?.input?.blur() @@ -80,7 +91,10 @@ export function MessageComposer({ const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0) - const onSubmit = (message: string) => { + const onSubmit = ( + message: string, + replyTo: ChatBskyConvoDefs.MessageView | null, + ) => { if (!editable) return if (!hasEmbed && message.trim() === '') return const graphemeCount = countGraphemes(message) @@ -95,6 +109,7 @@ export function MessageComposer({ clearDraft() playHaptic() setEmbed(undefined) + clearReply() composerInternalApiRef.current?.clear() if (IS_WEB) { @@ -103,7 +118,15 @@ export function MessageComposer({ // defer send by a frame so that the textinput resizes before we send the message requestAnimationFrame(() => { - onSendMessage(message) + onSendMessage( + message, + replyTo + ? { + ...replyTo, + $type: 'chat.bsky.convo.defs#messageView', + } + : undefined, + ) }) } @@ -129,18 +152,18 @@ export function MessageComposer({ setTimeout(() => { if (isFlushingAutocorrectSuggestion.current) { isFlushingAutocorrectSuggestion.current = false - onSubmit(text) + onSubmit(text, replyTo) } }, 20) } else { - onSubmit(text) + onSubmit(text, replyTo) } } const handleChange = (nextText: string) => { if (IS_IOS && isFlushingAutocorrectSuggestion.current) { isFlushingAutocorrectSuggestion.current = false - onSubmit(nextText) + onSubmit(nextText, replyTo) } else { setText(nextText) } diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx index dbe716efb1..cbd47151d7 100644 --- a/src/screens/Messages/components/MessageInput.tsx +++ b/src/screens/Messages/components/MessageInput.tsx @@ -15,6 +15,7 @@ import Animated, { } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {GlassContainer} from 'expo-glass-effect' +import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api' import {useLingui} from '@lingui/react/macro' import {countGraphemes} from 'unicode-segmenter/grapheme' @@ -26,6 +27,7 @@ import { useSaveMessageDraft, } from '#/state/messages/message-drafts' import {atoms as a, platform, tokens, useTheme} from '#/alf' +import {useMessageReplies} from '#/components/dms/MessageReplies' import {GlassView} from '#/components/GlassView' import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' import {Loader} from '#/components/Loader' @@ -47,7 +49,10 @@ export function MessageInput({ loading = false, }: { textInputId?: string - onSendMessage: (message: string) => Promise | void + onSendMessage: ( + message: string, + replyTo?: $Typed, + ) => Promise | void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode @@ -57,6 +62,7 @@ export function MessageInput({ const t = useTheme() const playHaptic = useHaptics() const {getDraft, clearDraft} = useMessageDraft() + const {replyTo, clearReply} = useMessageReplies() // Input layout const {top: topInset} = useSafeAreaInsets() @@ -92,6 +98,9 @@ export function MessageInput({ playHaptic() setEmbed(undefined) setMessage('') + // Capture the reply before clearing - the deferred send below reads it. + const reply = replyTo + clearReply() if (IS_IOS) { setShouldEnforceClear(true) } @@ -104,7 +113,12 @@ export function MessageInput({ } requestAnimationFrame(() => { - void onSendMessage(message) + void onSendMessage( + message, + reply + ? {...reply, $type: 'chat.bsky.convo.defs#messageView'} + : undefined, + ) }) }, [ editable, @@ -116,6 +130,8 @@ export function MessageInput({ setEmbed, inputRef, l, + replyTo, + clearReply, ]) useFocusedInputHandler( diff --git a/src/screens/Messages/components/MessageInput.web.tsx b/src/screens/Messages/components/MessageInput.web.tsx index 17c996cec9..2fe3d86fa1 100644 --- a/src/screens/Messages/components/MessageInput.web.tsx +++ b/src/screens/Messages/components/MessageInput.web.tsx @@ -1,5 +1,6 @@ import {useCallback, useRef, useState} from 'react' import {Pressable, View} from 'react-native' +import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api' import {useLingui} from '@lingui/react/macro' import {flushSync} from 'react-dom' import TextareaAutosize from 'react-textarea-autosize' @@ -13,6 +14,7 @@ import { } from '#/state/messages/message-drafts' import {atoms as a, flatten, useTheme} from '#/alf' import {Button} from '#/components/Button' +import {useMessageReplies} from '#/components/dms/MessageReplies' import * as EmojiPicker from '#/components/EmojiPicker' import {useSharedInputStyles} from '#/components/forms/TextField' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' @@ -28,7 +30,10 @@ export function MessageInput({ children, loading = false, }: { - onSendMessage: (message: string) => void + onSendMessage: ( + message: string, + replyTo?: $Typed, + ) => void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode @@ -38,6 +43,7 @@ export function MessageInput({ const {t: l} = useLingui() const t = useTheme() const {getDraft, clearDraft} = useMessageDraft() + const {replyTo, clearReply} = useMessageReplies() const [message, setMessage] = useState(getDraft) const inputStyles = useSharedInputStyles() @@ -58,10 +64,25 @@ export function MessageInput({ return } clearDraft() - onSendMessage(message) + onSendMessage( + message, + replyTo + ? {...replyTo, $type: 'chat.bsky.convo.defs#messageView'} + : undefined, + ) + clearReply() setMessage('') setEmbed(undefined) - }, [message, onSendMessage, l, clearDraft, hasEmbed, setEmbed]) + }, [ + message, + onSendMessage, + l, + clearDraft, + hasEmbed, + setEmbed, + replyTo, + clearReply, + ]) const onKeyDown = useCallback( (e: React.KeyboardEvent) => { diff --git a/src/screens/Messages/components/MessageInputReply.tsx b/src/screens/Messages/components/MessageInputReply.tsx new file mode 100644 index 0000000000..5853e46d81 --- /dev/null +++ b/src/screens/Messages/components/MessageInputReply.tsx @@ -0,0 +1,91 @@ +import {LayoutAnimation, View} from 'react-native' +import {AppBskyEmbedRecord, ChatBskyEmbedJoinLink} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' + +import {HITSLOP_20} from '#/lib/constants' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {useConvoActive} from '#/state/messages/convo' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {useMessageReplies} from '#/components/dms/MessageReplies' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import {Text} from '#/components/Typography' + +/** + * The reply staged in the message composer. Renders a preview of the message + * being replied to, with a button to cancel the reply. + */ +export function MessageInputReply() { + const t = useTheme() + const {t: l} = useLingui() + const convo = useConvoActive() + const {replyTo, clearReply} = useMessageReplies() + + if (!replyTo) { + return null + } + + const onRemove = () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + clearReply() + } + + const senderProfile = convo.relatedProfiles.get(replyTo.sender.did) + const displayName = senderProfile + ? createSanitizedDisplayName(senderProfile, false) + : null + + let text = replyTo.text + let subtle = false + if (!text.trim()) { + subtle = true + if (ChatBskyEmbedJoinLink.isView(replyTo.embed)) { + text = l`(chat invite link)` + } else if (AppBskyEmbedRecord.isView(replyTo.embed)) { + text = l`(contains embedded content)` + } else { + text = l`No text` + } + } + + return ( + + + {displayName && ( + + {displayName} + + )} + + {text} + + + + + ) +} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index f0b1389b86..be8ebcb8a9 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -35,6 +35,7 @@ import { } from '@atproto/api' import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {mergeRefs} from '#/lib/merge-refs' import {ScrollProvider} from '#/lib/ScrollContext' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' @@ -62,6 +63,7 @@ import {atoms as a, platform, tokens, useTheme, web} from '#/alf' import {DateDivider} from '#/components/dms/DateDivider' import {MessageItem} from '#/components/dms/MessageItem' import {MessageOverlays} from '#/components/dms/MessageOverlays' +import {MessageRepliesProvider} from '#/components/dms/MessageReplies' import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup' import {SystemMessageItem} from '#/components/dms/SystemMessageItem' @@ -72,7 +74,12 @@ import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env' import {ChatStatusInfo} from './ChatStatusInfo' import {groupSystemMessages, type RenderItem} from './groupSystemMessages' import {InviteLinkDialogProvider} from './InviteLinkDialogProvider' -import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' +import { + type MessageEmbedState, + MessageInputEmbed, + useMessageEmbed, +} from './MessageInputEmbed' +import {MessageInputReply} from './MessageInputReply' import {MessagesListGroupInfoPanel} from './MessagesListGroupInfoPanel' import {MessagesListInfoPanel} from './MessagesListInfoPanel' import {KeyboardStickyView} from './vendor/KeyboardStickyView' @@ -371,7 +378,7 @@ export function MessagesList({ // -- Message sending const onSendMessage = useCallback( - async (text: string) => { + async (text: string, reply?: $Typed) => { let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true}) // detect facets without resolution first - this is used to see if there's @@ -387,6 +394,7 @@ export function MessagesList({ | $Typed | $Typed | undefined + let replyTo: ChatBskyConvoDefs.ReplyRef | undefined // Find the embedded link facet and, if it's at the start or end of the // message, remove it from the text (the embed card replaces it). @@ -455,6 +463,10 @@ export function MessagesList({ stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code) } + if (reply) { + replyTo = {messageId: reply.id} + } + await rt.detectFacets(agent) rt = shortenLinks(rt) @@ -469,10 +481,18 @@ export function MessagesList({ text: rt.text, facets: rt.facets, embed, + replyTo, }, embedView, + reply, ) + if (replyTo) { + ax.metric('chat:message:reply:send', { + convoId: convoState.convo.view.id, + isGroup: convoState.convo.kind === 'group', + }) + } if (convoState.convo.kind === 'group') { ax.metric('groupchat:message:send', { convoId: convoState.convo.view.id, @@ -510,6 +530,29 @@ export function MessagesList({ }) }, [flatListRef]) + // Scroll to a message by id, if it's currently loaded in the list. Per the + // feature scope, we don't fetch history to find unloaded messages - tapping a + // reply to an out-of-window message is a no-op. Returns whether the message + // was found, so the caller knows whether to flash it. + const scrollToMessage = useNonReactiveCallback((messageId: string) => { + const index = renderItems.findIndex( + item => + (item.type === 'message' || + item.type === 'pending-message' || + item.type === 'deleted-message') && + item.message.id === messageId, + ) + if (index === -1) return false + + ax.metric('chat:message:reply:tap', {convoId: convoState.convo.view.id}) + flatListRef.current?.scrollToIndex({ + index, + viewPosition: 0.3, + animated: true, + }) + return true + }) + const renderItem = ({item, index}: {item: RenderItem; index: number}) => { if (item.type === 'message' || item.type === 'pending-message') { return ( @@ -561,138 +604,180 @@ export function MessagesList({ return ( - - - {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} - - - - - {convoState.hasAllHistory ? ( - convoState.convo?.kind === 'group' ? ( - - ) : ( - - ) - ) : null} - - } - // native only (prop is not supported on web) - renderScrollComponent={renderScrollComponent} - contentContainerStyle={{ - paddingBottom: platform({ - // ios is slightly larger as the input has no top padding - ios: tokens.space.lg, - android: tokens.space.md, - web: 0, // web uses ListFooterComponent instead for scroll reasons - }), - }} - ListFooterComponent={ - - } - style={[ - web({ - scrollbarWidth: 'thin', - scrollbarColor: `${t.palette.contrast_100} transparent`, - scrollbarGutter: 'stable', - }), - ]} - pointerEvents={!hasScrolled ? 'none' : 'auto'} - contentInset={{top: transparentHeaderHeight}} - scrollIndicatorInsets={{top: transparentHeaderHeight}} - /> - - - - {footer ?? ( - - - {({loading}) => - ax.features.enabled( - ax.features.DmsNewMessageComposerEnable, - ) ? ( - - void onSendMessage(message) - } - hasEmbed={!!messageEmbed} - setEmbed={setEmbed} - loading={loading}> - - - ) : ( - + + + {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} + + + + + {convoState.hasAllHistory ? ( + convoState.convo?.kind === 'group' ? ( + + ) : ( + + ) + ) : null} + + } + // native only (prop is not supported on web) + renderScrollComponent={renderScrollComponent} + contentContainerStyle={{ + paddingBottom: platform({ + // ios is slightly larger as the input has no top padding + ios: tokens.space.lg, + android: tokens.space.md, + web: 0, // web uses ListFooterComponent instead for scroll reasons + }), + }} + ListFooterComponent={ + + } + style={[ + web({ + scrollbarWidth: 'thin', + scrollbarColor: `${t.palette.contrast_100} transparent`, + scrollbarGutter: 'stable', + }), + ]} + pointerEvents={!hasScrolled ? 'none' : 'auto'} + contentInset={{top: transparentHeaderHeight}} + scrollIndicatorInsets={{top: transparentHeaderHeight}} + /> + + + + {footer ?? ( + + + {({loading}) => ( + - - - ) - } - - - )} - - + loading={loading} + useNewComposer={ax.features.enabled( + ax.features.DmsNewMessageComposerEnable, + )} + /> + )} + + + )} + + - {newMessagesPill.show && ( - - )} - + {newMessagesPill.show && ( + + )} + + ) } +/** + * Picks the new vs legacy composer and mounts the reply preview alongside the + * existing embed preview in the composer's children slot. The staged reply + * itself is read and cleared inside the composer via `useMessageReplies`. + */ +function Composer({ + textInputId, + onSendMessage, + messageEmbed, + setEmbed, + loading, + useNewComposer, +}: { + textInputId: string + onSendMessage: ( + message: string, + replyTo?: $Typed, + ) => Promise + messageEmbed: MessageEmbedState | undefined + setEmbed: (embedUrl: string | undefined) => void + loading?: boolean + useNewComposer: boolean +}) { + const handleSendMessage = useNonReactiveCallback( + (message: string, replyTo?: $Typed) => { + void onSendMessage(message, replyTo) + }, + ) + + const previews = ( + <> + + + + ) + + return useNewComposer ? ( + + {previews} + + ) : ( + + {previews} + + ) +} + /** Note: native only */ function ChatScrollComponent({ ref, diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index 3603026d78..b1dc348e79 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -86,6 +86,22 @@ function toSystemMessageView( return ev.message } +/** + * Derive a deleted-message tombstone from a (now-deleted) message, preserving + * the fields the deleted view carries so a reply can render it as deleted. + */ +function toDeletedMessageView( + m: ChatBskyConvoDefs.MessageView, +): $Typed { + return { + $type: 'chat.bsky.convo.defs#deletedMessageView', + id: m.id, + rev: m.rev, + sender: m.sender, + sentAt: m.sentAt, + } +} + export class Convo { private id: string @@ -119,6 +135,7 @@ export class Convo { optimisticEmbedView?: | $Typed | $Typed + optimisticReplyTo?: $Typed } > = new Map() private deletedMessages: Set = new Set() @@ -802,6 +819,12 @@ export class Convo { }) const {cursor, messages, relatedProfiles} = response.data + // Trust the cursor for pagination. We can't infer "no more pages" from a + // short page: the server pages by raw rows but strips deleted messages + // from the response, so a full page containing a deleted message (e.g. + // from a deleted account) comes back short *with* a valid cursor. Using a + // count heuristic here would stop history fetching early and hide + // messages. The tradeoff is one extra empty fetch at the true top. this.oldestRev = cursor ?? null if (relatedProfiles) { @@ -811,14 +834,6 @@ export class Convo { this.applyProfileShadows() } - /* - * If the response contained fewer messages than the limit, we know - * there are no more pages, regardless of whether a cursor was returned. - */ - if (messages.length < (IS_NATIVE ? 30 : 60)) { - this.oldestRev = null - } - for (const message of messages) { if ( ChatBskyConvoDefs.isMessageView(message) || @@ -965,17 +980,17 @@ export class Convo { ChatBskyConvoDefs.isDeletedMessageView(ev.message) ) { /* - * Update if we have this in state. If we don't, don't worry about it. + * Remove the message itself, and keep its id in `deletedMessages` + * so any message that quotes it keeps rendering a deleted-message + * tombstone (see `tombstoneDeletedReplyTo`) rather than reverting + * to the original hydrated text. We add here rather than relying on + * the optimistic entry so deletes from elsewhere (e.g. another + * device) are covered too. */ - if ( - this.pastMessages.has(ev.message.id) || - this.newMessages.has(ev.message.id) - ) { - this.pastMessages.delete(ev.message.id) - this.newMessages.delete(ev.message.id) - this.deletedMessages.delete(ev.message.id) - needsCommit = true - } + this.pastMessages.delete(ev.message.id) + this.newMessages.delete(ev.message.id) + this.deletedMessages.add(ev.message.id) + needsCommit = true } else if ( (ChatBskyConvoDefs.isLogAddReaction(ev) || ChatBskyConvoDefs.isLogRemoveReaction(ev)) && @@ -1020,6 +1035,7 @@ export class Convo { optimisticEmbedView?: | $Typed | $Typed, + optimisticReplyTo?: $Typed, ) { // Ignore empty messages for now since they have no other purpose atm if (!message.text.trim() && !message.embed) return @@ -1033,6 +1049,7 @@ export class Convo { id: tempId, message, optimisticEmbedView, + optimisticReplyTo, }) if (this.convo?.view.status === 'request') { this.updateConvo({ @@ -1316,6 +1333,26 @@ export class Convo { } } + /** + * When a message is deleted locally, it's removed from the list, but other + * messages that reply to it still carry a hydrated `replyTo` with the + * original text until the server re-sends them. Swap that `replyTo` for a + * deleted-message tombstone so the quote reflects the deletion immediately, + * matching what the server returns on refresh. + */ + private tombstoneDeletedReplyTo( + m: ChatBskyConvoDefs.MessageView, + ): ChatBskyConvoDefs.MessageView { + const {replyTo} = m + if ( + !ChatBskyConvoDefs.isMessageView(replyTo) || + !this.deletedMessages.has(replyTo.id) + ) { + return m + } + return {...m, replyTo: toDeletedMessageView(replyTo)} + } + /* * Items in reverse order, since FlatList inverts */ @@ -1327,7 +1364,7 @@ export class Convo { items.unshift({ type: 'message', key: m.id, - message: m, + message: this.tombstoneDeletedReplyTo(m), }) } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { items.unshift({ @@ -1360,7 +1397,7 @@ export class Convo { items.push({ type: 'message', key: m.id, - message: m, + message: this.tombstoneDeletedReplyTo(m), }) } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { items.push({ @@ -1378,12 +1415,17 @@ export class Convo { }) this.pendingMessages.forEach(m => { + const optimisticReplyTo = + m.optimisticReplyTo && this.deletedMessages.has(m.optimisticReplyTo.id) + ? toDeletedMessageView(m.optimisticReplyTo) + : m.optimisticReplyTo items.push({ type: 'pending-message', key: m.id, message: { ...m.message, embed: m.optimisticEmbedView, + replyTo: optimisticReplyTo, $type: 'chat.bsky.convo.defs#messageView', id: nanoid(), rev: '__fake__', diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index c83fbfb813..e330d6eea3 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -113,6 +113,7 @@ type SendMessage = ( | $Typed | $Typed | undefined, + optimisticReplyTo?: $Typed, ) => void type FetchMessageHistory = () => Promise type MarkConvoAccepted = () => void diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 787ca9e278..441297f120 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -30,6 +30,15 @@ export type ListMethods = { scrollToTop: () => void scrollToOffset: (options: {animated: boolean; offset: number}) => void scrollToEnd: (options?: {animated?: boolean}) => void + // Signature kept compatible with FlatList's scrollToIndex (the native + // ListMethods type) so callers stay platform-agnostic. viewOffset is + // accepted for parity but not currently used by the web implementation. + scrollToIndex: (params: { + animated?: boolean | null + index: number + viewOffset?: number + viewPosition?: number + }) => void } export type ListProps = Omit< FlatListProps, @@ -216,6 +225,22 @@ function ListImpl( }, [disableFullWindowScroll]) const nativeRef = useRef(null) + + // Registry of item index -> row DOM node. The list renders header/footer and + // visibility-detector siblings too, so we can't index into the container's + // children directly; each Row registers its own node here keyed by index. + const rowNodesRef = useRef>(new Map()) + const registerRowNode = useCallback( + (index: number, node: HTMLElement | null) => { + if (node) { + rowNodesRef.current.set(index, node) + } else { + rowNodesRef.current.delete(index) + } + }, + [], + ) + useImperativeHandle( ref, () => ({ @@ -239,6 +264,17 @@ function ListImpl( behavior: animated ? 'smooth' : 'instant', }) }, + + scrollToIndex({animated = true, index}) { + const node = rowNodesRef.current.get(index) + // scrollIntoView with block: 'center' roughly matches the caller's + // viewPosition of 0.3 - not exact, but close enough and it respects + // whichever element is the scroll container (window or nativeRef). + node?.scrollIntoView({ + block: 'center', + behavior: animated ? 'smooth' : 'instant', + }) + }, }), [getScrollableNode], ) @@ -392,6 +428,7 @@ function ListImpl( renderItem={renderItem} extraData={extraData} onItemSeen={onItemSeen} + registerRowNode={registerRowNode} /> ) })} @@ -470,6 +507,7 @@ let Row = function RowImpl({ renderItem, extraData: _unused, onItemSeen, + registerRowNode, }: { item: ItemT index: number @@ -479,6 +517,7 @@ let Row = function RowImpl({ | ((info: ListRenderItemInfo) => React.ReactNode) extraData: unknown onItemSeen: ((item: ItemT) => void) | undefined + registerRowNode: (index: number, node: HTMLElement | null) => void }): React.ReactNode { const rowRef = useRef(null) const intersectionTimeout = useRef | undefined>( @@ -529,6 +568,15 @@ let Row = function RowImpl({ } }, [handleIntersection, onItemSeen]) + // Register this row's DOM node so the list can scroll to it by index. + useEffect(() => { + const node: HTMLElement | null = rowRef.current + registerRowNode(index, node) + return () => { + registerRowNode(index, null) + } + }, [index, registerRowNode]) + if (!renderItem) { return null } @@ -552,6 +600,7 @@ Row = memo(Row) as (props: { | ((info: ListRenderItemInfo) => React.ReactNode) extraData: unknown onItemSeen: ((item: ItemT) => void) | undefined + registerRowNode: (index: number, node: HTMLElement | null) => void }) => React.ReactNode let Visibility = ({ From bb6847f39626dacd611826907e88813eb7da21a4 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 16 Jun 2026 20:41:05 +0300 Subject: [PATCH 03/37] Replace Add User to Lists Modal with modern Dialog (#9677) Co-authored-by: Claude Haiku 4.5 Co-authored-by: Eric Bailey --- __e2e__/flows/curate-lists.yml | 8 +- eslint-suppressions.json | 22 -- .../lists/ListAddRemoveUsersDialog.tsx | 32 +- .../lists/UserAddRemoveListsDialog.tsx | 331 ++++++++++++++++++ .../Messages/components/RequestButtons.tsx | 2 +- src/state/modals/index.tsx | 14 +- src/state/queries/list-memberships.ts | 150 +------- src/state/queries/lists-with-membership.ts | 122 +++++++ src/view/com/lists/ListMembers.tsx | 215 +++++++----- src/view/com/modals/Modal.tsx | 6 +- src/view/com/modals/Modal.web.tsx | 7 +- src/view/com/modals/UserAddRemoveLists.tsx | 299 ---------------- src/view/com/profile/ProfileMenu.tsx | 21 +- 13 files changed, 635 insertions(+), 594 deletions(-) create mode 100644 src/components/dialogs/lists/UserAddRemoveListsDialog.tsx create mode 100644 src/state/queries/lists-with-membership.ts delete mode 100644 src/view/com/modals/UserAddRemoveLists.tsx diff --git a/__e2e__/flows/curate-lists.yml b/__e2e__/flows/curate-lists.yml index 906199e979..cc97b0fd72 100644 --- a/__e2e__/flows/curate-lists.yml +++ b/__e2e__/flows/curate-lists.yml @@ -145,21 +145,21 @@ appId: xyz.blueskyweb.app id: "profileHeaderDropdownBtn" - tapOn: "Add to Lists" - assertVisible: - id: "userAddRemoveListsModal" + id: "userAddRemoveListsDialog" - tapOn: id: "user-bob.test-addBtn" - tapOn: id: "doneBtn" - assertNotVisible: - id: "userAddRemoveListsModal" + id: "userAddRemoveListsDialog" - tapOn: id: "profileHeaderDropdownBtn" - tapOn: "Add to Lists" - assertVisible: - id: "userAddRemoveListsModal" + id: "userAddRemoveListsDialog" - tapOn: id: "user-bob.test-addBtn" - tapOn: id: "doneBtn" - assertNotVisible: - id: "userAddRemoveListsModal" + id: "userAddRemoveListsDialog" diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a3cbe60000..1e8edd043e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1836,11 +1836,6 @@ "count": 1 } }, - "src/state/queries/list-memberships.ts": { - "@typescript-eslint/no-floating-promises": { - "count": 2 - } - }, "src/state/queries/list.ts": { "@typescript-eslint/no-floating-promises": { "count": 7 @@ -2163,20 +2158,6 @@ "count": 2 } }, - "src/view/com/lists/ListMembers.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - }, - "@typescript-eslint/no-floating-promises": { - "count": 1 - }, - "@typescript-eslint/no-misused-promises": { - "count": 2 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 2 - } - }, "src/view/com/lists/MyLists.tsx": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -2208,9 +2189,6 @@ }, "@typescript-eslint/require-await": { "count": 1 - }, - "react-hooks/refs": { - "count": 1 } }, "src/view/com/notifications/NotificationFeed.tsx": { diff --git a/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx b/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx index da96565655..8129e9bdb4 100644 --- a/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx +++ b/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx @@ -7,10 +7,8 @@ import {Trans} from '@lingui/react/macro' import {cleanError} from '#/lib/strings/errors' import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useAllListMembersQuery} from '#/state/queries/list-members' import { - getMembership, - type ListMembersip, - useDangerousListMembershipsQuery, useListMembershipAddMutation, useListMembershipRemoveMutation, } from '#/state/queries/list-memberships' @@ -61,7 +59,7 @@ function DialogInner({ }) { const {_} = useLingui() const moderationOpts = useModerationOpts() - const {data: memberships} = useDangerousListMembershipsQuery() + const {data: listMembers} = useAllListMembersQuery(list.uri) const renderProfileCard = useCallback( (item: ProfileItem) => { @@ -69,13 +67,13 @@ function DialogInner({ ) }, - [onChange, memberships, list, moderationOpts], + [onChange, listMembers, list, moderationOpts], ) return ( @@ -86,16 +84,30 @@ function DialogInner({ ) } +/** + * Returns undefined for pending, false for not a member, and string for a member (the URI of the membership record) + */ +function getMembership( + listMembers: AppBskyGraphDefs.ListItemView[] | undefined, + actorDid: string, +): string | false | undefined { + if (!listMembers) { + return undefined + } + const member = listMembers.find(item => item.subject.did === actorDid) + return member ? member.uri : false +} + function UserResult({ profile, list, - memberships, + listMembers, onChange, moderationOpts, }: { profile: bsky.profile.AnyProfileView list: AppBskyGraphDefs.ListView - memberships: ListMembersip[] | undefined + listMembers: AppBskyGraphDefs.ListItemView[] | undefined onChange?: ( type: 'add' | 'remove', profile: bsky.profile.AnyProfileView, @@ -104,8 +116,8 @@ function UserResult({ }) { const {_} = useLingui() const membership = useMemo( - () => getMembership(memberships, list.uri, profile.did), - [memberships, list.uri, profile.did], + () => getMembership(listMembers, profile.did), + [listMembers, profile.did], ) const {mutate: listMembershipAdd, isPending: isAddingPending} = useListMembershipAddMutation({ diff --git a/src/components/dialogs/lists/UserAddRemoveListsDialog.tsx b/src/components/dialogs/lists/UserAddRemoveListsDialog.tsx new file mode 100644 index 0000000000..de8f453d5a --- /dev/null +++ b/src/components/dialogs/lists/UserAddRemoveListsDialog.tsx @@ -0,0 +1,331 @@ +import {View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' +import {useQueryClient} from '@tanstack/react-query' + +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {isNetworkError} from '#/lib/strings/errors' +import {sanitizeHandle} from '#/lib/strings/handles' +import {logger} from '#/logger' +import { + useListMembershipAddMutation, + useListMembershipRemoveMutation, +} from '#/state/queries/list-memberships' +import { + type ListWithMembership, + removeListMembershipOptimistically, + updateListMembershipOptimistically, + useListsWithMembershipQuery, +} from '#/state/queries/lists-with-membership' +import {useSession} from '#/state/session' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {BulletList_Stroke2_Corner0_Rounded as ListIcon} from '#/components/icons/BulletList' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import {Loader} from '#/components/Loader' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +export type UserAddRemoveListsDialogProps = { + control: Dialog.DialogControlProps + profile: bsky.profile.AnyProfileView | undefined + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +} + +export function UserAddRemoveListsDialog({ + control, + profile, + onAdd, + onRemove, +}: UserAddRemoveListsDialogProps) { + return ( + + + + + ) +} + +function Empty() { + const t = useTheme() + + return ( + + + + + You have no lists. + + + + ) +} + +function ListsContent({ + profile, + onAdd, + onRemove, +}: { + profile: bsky.profile.AnyProfileView | undefined + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +}) { + const t = useTheme() + const control = Dialog.useDialogContext() + const {t: l} = useLingui() + const {gtMobile} = useBreakpoints() + + const { + data, + isError, + isLoading, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useListsWithMembershipQuery({actor: profile?.did}) + + const listItems = data?.pages.flatMap(page => page.listsWithMembership) || [] + + const onEndReached = async () => { + if (isFetchingNextPage || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + // Error handling is optional since this is just pagination + } + } + + const renderItem = ({item}: {item: ListWithMembership}) => + profile ? ( + + ) : null + + const onClose = () => { + control.close() + } + + const listHeader = ( + + + {profile ? ( + + Update {createSanitizedDisplayName(profile, true)} in Lists + + ) : ( + Update in Lists + )} + + + + ) + + return ( + ( + + + + ) + : renderItem + } + keyExtractor={ + isLoading + ? () => 'lists_dialog_loader' + : (item: ListWithMembership) => item.list.uri + } + onEndReached={() => void onEndReached()} + onEndReachedThreshold={0.1} + stickyHeaderIndices={web([0])} + ListHeaderComponent={listHeader} + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + ListEmptyComponent={!isLoading && data ? : null} + webInnerContentContainerStyle={[a.py_0]} + style={platform({ + web: [a.px_2xl, a.pb_md], + native: [a.px_2xl, a.pt_lg], + })} + /> + ) +} + +function ListItem({ + listWithMembership, + profile, + onAdd, + onRemove, +}: { + listWithMembership: ListWithMembership + profile: bsky.profile.AnyProfileView + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +}) { + const {t: l} = useLingui() + const t = useTheme() + const queryClient = useQueryClient() + const {currentAccount} = useSession() + + const list = listWithMembership.list + const listItem = listWithMembership.listItem + const isMember = !!listItem + + const {mutate: addMembership, isPending: isPendingAdd} = + useListMembershipAddMutation({ + subject: profile, + onSuccess: data => { + Toast.show(l`Added to list`) + onAdd?.(list.uri) + updateListMembershipOptimistically({ + queryClient, + actor: profile.did, + listUri: list.uri, + membershipUri: data.uri, + subject: { + did: profile.did, + handle: profile.handle, + displayName: profile.displayName, + }, + }) + }, + onError: err => { + if (!isNetworkError(err)) { + logger.error('Failed to add to list', {safeMessage: err}) + } + Toast.show(l`Failed to add to list`, {type: 'error'}) + }, + }) + + const {mutate: removeMembership, isPending: isPendingRemove} = + useListMembershipRemoveMutation({ + onSuccess: () => { + Toast.show(l`Removed from list`) + onRemove?.(list.uri) + removeListMembershipOptimistically({ + queryClient, + actor: profile.did, + listUri: list.uri, + }) + }, + onError: err => { + if (!isNetworkError(err)) { + logger.error('Failed to remove from list', {safeMessage: err}) + } + Toast.show(l`Failed to remove from list`, {type: 'error'}) + }, + }) + + const isPending = isPendingAdd || isPendingRemove + + const handleToggleMembership = () => { + if (isPending) return + + if (!isMember) { + addMembership({ + listUri: list.uri, + actorDid: profile.did, + }) + } else { + if (!listItem?.uri) { + logger.error('Cannot remove: missing membership URI') + return + } + removeMembership({ + listUri: list.uri, + actorDid: profile.did, + membershipUri: listItem.uri, + }) + } + } + + return ( + + + + + {sanitizeDisplayName(list.name)} + + + {list.purpose === 'app.bsky.graph.defs#curatelist' && + (list.creator.did === currentAccount?.did ? ( + User list by you + ) : ( + + User list by {sanitizeHandle(list.creator.handle, '@')} + + ))} + {list.purpose === 'app.bsky.graph.defs#modlist' && + (list.creator.did === currentAccount?.did ? ( + Moderation list by you + ) : ( + + Moderation list by {sanitizeHandle(list.creator.handle, '@')} + + ))} + + + + + ) +} diff --git a/src/screens/Messages/components/RequestButtons.tsx b/src/screens/Messages/components/RequestButtons.tsx index dfac74e59f..c5e0e82ece 100644 --- a/src/screens/Messages/components/RequestButtons.tsx +++ b/src/screens/Messages/components/RequestButtons.tsx @@ -1,5 +1,5 @@ import {useCallback} from 'react' -import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' +import {type ChatBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {StackActions, useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 3c545362b8..d62ba2a7a0 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -3,15 +3,6 @@ import {createContext, useContext, useEffect, useMemo, useState} from 'react' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useHotkeysContext} from '#/lib/hotkeys' -export interface UserAddRemoveListsModal { - name: 'user-add-remove-lists' - subject: string - handle: string - displayName: string - onAdd?: (listUri: string) => void - onRemove?: (listUri: string) => void -} - export interface ContentLanguagesSettingsModal { name: 'content-languages-settings' } @@ -21,10 +12,7 @@ export interface ContentLanguagesSettingsModal { */ export type Modal = // Curation - | ContentLanguagesSettingsModal - - // Lists - | UserAddRemoveListsModal + ContentLanguagesSettingsModal const ModalContext = createContext<{ isModalActive: boolean diff --git a/src/state/queries/list-memberships.ts b/src/state/queries/list-memberships.ts index dd7fde2482..75adcd5bb8 100644 --- a/src/state/queries/list-memberships.ts +++ b/src/state/queries/list-memberships.ts @@ -1,19 +1,3 @@ -/** - * NOTE - * - * This query is a temporary solution to our lack of server API for - * querying user membership in an API. It is extremely inefficient. - * - * THIS SHOULD ONLY BE USED IN MODALS FOR MODIFYING A USER'S LIST MEMBERSHIP! - * Use the list-members query for rendering a list's members. - * - * It works by fetching *all* of the user's list item records and querying - * or manipulating that cache. For users with large lists, it will fall - * down completely, so be very conservative about how you use it. - * - * -prf - */ - import { type AppBskyActorDefs, type AppBskyGraphGetStarterPacksWithMembership, @@ -22,85 +6,17 @@ import { import { type InfiniteData, useMutation, - useQuery, useQueryClient, } from '@tanstack/react-query' -import {STALE} from '#/state/queries' -import {RQKEY as LIST_MEMBERS_RQKEY} from '#/state/queries/list-members' +import { + RQKEY as LIST_MEMBERS_RQKEY, + RQKEY_ALL as LIST_MEMBERS_ALL_RQKEY, +} from '#/state/queries/list-members' import {useAgent, useSession} from '#/state/session' import type * as bsky from '#/types/bsky' import {RQKEY_WITH_MEMBERSHIP as STARTER_PACKS_WITH_MEMBERSHIPS_RKEY} from './actor-starter-packs' -// sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records -const SANITY_PAGE_LIMIT = 1000 -const PAGE_SIZE = 100 -// ...which comes 100,000k list members - -const RQKEY_ROOT = 'list-memberships' -export const RQKEY = () => [RQKEY_ROOT] - -export interface ListMembersip { - membershipUri: string - listUri: string - actorDid: string -} - -/** - * This API is dangerous! Read the note above! - */ -export function useDangerousListMembershipsQuery() { - const {currentAccount} = useSession() - const agent = useAgent() - return useQuery({ - staleTime: STALE.MINUTES.FIVE, - queryKey: RQKEY(), - async queryFn() { - if (!currentAccount) { - return [] - } - let cursor - let arr: ListMembersip[] = [] - for (let i = 0; i < SANITY_PAGE_LIMIT; i++) { - const res = await agent.app.bsky.graph.listitem.list({ - repo: currentAccount.did, - limit: PAGE_SIZE, - cursor, - }) - arr = arr.concat( - res.records.map(r => ({ - membershipUri: r.uri, - listUri: r.value.list, - actorDid: r.value.subject, - })), - ) - cursor = res.cursor - if (!cursor) { - break - } - } - return arr - }, - }) -} - -/** - * Returns undefined for pending, false for not a member, and string for a member (the URI of the membership record) - */ -export function getMembership( - memberships: ListMembersip[] | undefined, - list: string, - actor: string, -): string | false | undefined { - if (!memberships) { - return undefined - } - const membership = memberships.find( - m => m.listUri === list && m.actorDid === actor, - ) - return membership ? membership.membershipUri : false -} - export function useListMembershipAddMutation({ subject, onSuccess, @@ -133,43 +49,21 @@ export function useListMembershipAddMutation({ createdAt: new Date().toISOString(), }, ) - // TODO - // we need to wait for appview to update, but there's not an efficient - // query for that, so we use a timeout below - // -prf return res }, onSuccess: (data, variables) => { - // manually update the cache; a refetch is too expensive - let memberships = queryClient.getQueryData(RQKEY()) - if (memberships) { - memberships = memberships - // avoid dups - .filter( - m => - !( - m.actorDid === variables.actorDid && - m.listUri === variables.listUri - ), - ) - .concat([ - { - ...variables, - membershipUri: data.uri, - }, - ]) - queryClient.setQueryData(RQKEY(), memberships) - } // invalidate the members queries (used for rendering the listings) - // use a timeout to wait for the appview (see above) + // use a timeout to wait for the appview setTimeout(() => { - queryClient.invalidateQueries({ + void queryClient.invalidateQueries({ queryKey: LIST_MEMBERS_RQKEY(variables.listUri), }) + void queryClient.invalidateQueries({ + queryKey: LIST_MEMBERS_ALL_RQKEY(variables.listUri), + }) }, 1e3) - // update WITH_MEMBERSHIPS query - + // update WITH_MEMBERSHIPS query for starter packs if (subject) { queryClient.setQueryData< InfiniteData @@ -251,32 +145,20 @@ export function useListMembershipRemoveMutation({ repo: currentAccount.did, rkey: membershipUrip.rkey, }) - // TODO - // we need to wait for appview to update, but there's not an efficient - // query for that, so we use a timeout below - // -prf }, onSuccess: (data, variables) => { - // manually update the cache; a refetch is too expensive - let memberships = queryClient.getQueryData(RQKEY()) - if (memberships) { - memberships = memberships.filter( - m => - !( - m.actorDid === variables.actorDid && - m.listUri === variables.listUri - ), - ) - queryClient.setQueryData(RQKEY(), memberships) - } // invalidate the members queries (used for rendering the listings) - // use a timeout to wait for the appview (see above) + // use a timeout to wait for the appview setTimeout(() => { - queryClient.invalidateQueries({ + void queryClient.invalidateQueries({ queryKey: LIST_MEMBERS_RQKEY(variables.listUri), }) + void queryClient.invalidateQueries({ + queryKey: LIST_MEMBERS_ALL_RQKEY(variables.listUri), + }) }, 1e3) + // update WITH_MEMBERSHIPS query for starter packs queryClient.setQueryData< InfiniteData >(STARTER_PACKS_WITH_MEMBERSHIPS_RKEY(variables.actorDid), old => { diff --git a/src/state/queries/lists-with-membership.ts b/src/state/queries/lists-with-membership.ts new file mode 100644 index 0000000000..3147b8845f --- /dev/null +++ b/src/state/queries/lists-with-membership.ts @@ -0,0 +1,122 @@ +import { + type AppBskyActorDefs, + type AppBskyGraphGetListsWithMembership, +} from '@atproto/api' +import { + type InfiniteData, + type QueryClient, + type QueryKey, + useInfiniteQuery, +} from '@tanstack/react-query' + +import {createQueryKey} from '#/state/queries/util' +import {useAgent} from '#/state/session' + +export type ListWithMembership = + AppBskyGraphGetListsWithMembership.ListWithMembership + +const listsWithMembershipQueryKeyRoot = 'lists-with-membership' +export const createListsWithMembershipQueryKey = (args: {actor: string}) => + createQueryKey(listsWithMembershipQueryKeyRoot, args) + +export function useListsWithMembershipQuery({ + actor, + enabled = true, +}: { + actor: string | undefined + enabled?: boolean +}) { + const agent = useAgent() + + return useInfiniteQuery< + AppBskyGraphGetListsWithMembership.OutputSchema, + Error, + InfiniteData, + QueryKey, + string | undefined + >({ + queryKey: createListsWithMembershipQueryKey({actor: actor ?? ''}), + queryFn: async ({pageParam}: {pageParam?: string}) => { + const res = await agent.app.bsky.graph.getListsWithMembership({ + actor: actor!, // the enabled flag prevents this from running until actor is set + limit: 50, + cursor: pageParam, + }) + return res.data + }, + enabled: Boolean(actor) && enabled, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + +export function updateListMembershipOptimistically({ + queryClient, + actor, + listUri, + membershipUri, + subject, +}: { + queryClient: QueryClient + actor: string + listUri: string + membershipUri: string + subject: AppBskyActorDefs.ProfileView +}) { + queryClient.setQueryData< + InfiniteData + >(createListsWithMembershipQueryKey({actor}), old => { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + listsWithMembership: page.listsWithMembership.map(lwm => { + if (lwm.list.uri === listUri) { + return { + ...lwm, + listItem: { + uri: membershipUri, + subject, + }, + } + } + return lwm + }), + })), + } + }) +} + +export function removeListMembershipOptimistically({ + queryClient, + actor, + listUri, +}: { + queryClient: QueryClient + actor: string + listUri: string +}) { + queryClient.setQueryData< + InfiniteData + >(createListsWithMembershipQueryKey({actor}), old => { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + listsWithMembership: page.listsWithMembership.map(lwm => { + if (lwm.list.uri === listUri) { + return { + ...lwm, + listItem: undefined, + } + } + return lwm + }), + })), + } + }) +} diff --git a/src/view/com/lists/ListMembers.tsx b/src/view/com/lists/ListMembers.tsx index a443c49fd4..04876eefb2 100644 --- a/src/view/com/lists/ListMembers.tsx +++ b/src/view/com/lists/ListMembers.tsx @@ -1,4 +1,4 @@ -import {type JSX, useCallback, useMemo, useState} from 'react' +import {useCallback, useMemo, useState} from 'react' import { Dimensions, type GestureResponderEvent, @@ -13,7 +13,6 @@ import {Trans} from '@lingui/react/macro' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' -import {useModalControls} from '#/state/modals' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useListMembersQuery} from '#/state/queries/list-members' import {useSession} from '#/state/session' @@ -23,14 +22,29 @@ import {ProfileCardFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlacehol import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {UserAddRemoveListsDialog} from '#/components/dialogs/lists/UserAddRemoveListsDialog' import {ListFooter} from '#/components/Lists' import * as ProfileCard from '#/components/ProfileCard' import type * as bsky from '#/types/bsky' -const LOADING_ITEM = {_reactKey: '__loading__'} -const EMPTY_ITEM = {_reactKey: '__empty__'} -const ERROR_ITEM = {_reactKey: '__error__'} -const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} +const LOADING_ITEM = {kind: 'loading', _reactKey: '__loading__'} as const +const EMPTY_ITEM = {kind: 'empty', _reactKey: '__empty__'} as const +const ERROR_ITEM = {kind: 'error', _reactKey: '__error__'} as const +const LOAD_MORE_ERROR_ITEM = { + kind: 'load_more_error', + _reactKey: '__load_more_error__', +} as const + +type Item = + | typeof LOADING_ITEM + | typeof EMPTY_ITEM + | typeof ERROR_ITEM + | typeof LOAD_MORE_ERROR_ITEM + | { + kind: 'list_item' + listItem: AppBskyGraphDefs.ListItemView + } export function ListMembers({ list, @@ -49,8 +63,8 @@ export function ListMembers({ scrollElRef?: ListRef onScrolledDownChange: (isScrolledDown: boolean) => void onPressTryAgain?: () => void - renderHeader: () => JSX.Element - renderEmptyState: () => JSX.Element + renderHeader: () => React.ReactElement + renderEmptyState: () => React.ReactElement testID?: string headerOffset?: number desktopFixedHeightOffset?: number @@ -58,9 +72,12 @@ export function ListMembers({ const t = useTheme() const {_} = useLingui() const [isRefreshing, setIsRefreshing] = useState(false) - const {openModal} = useModalControls() const {currentAccount} = useSession() const moderationOpts = useModerationOpts() + const editListsDialogControl = useDialogControl() + const [selectedProfile, setSelectedProfile] = useState< + bsky.profile.AnyProfileView | undefined + >() const { data, @@ -78,23 +95,28 @@ export function ListMembers({ currentAccount && data?.pages[0].list.creator.did === currentAccount.did const items = useMemo(() => { - let items: any[] = [] + const items: Item[] = [] if (isFetched) { if (isEmpty && isError) { - items = items.concat([ERROR_ITEM]) + items.push(ERROR_ITEM) } if (isEmpty) { - items = items.concat([EMPTY_ITEM]) + items.push(EMPTY_ITEM) } else if (data) { for (const page of data.pages) { - items = items.concat(page.items) + items.push( + ...page.items.map(item => ({ + kind: 'list_item' as const, + listItem: item, + })), + ) } } if (!isEmpty && isError) { - items = items.concat([LOAD_MORE_ERROR_ITEM]) + items.push(LOAD_MORE_ERROR_ITEM) } } else if (isFetching) { - items = items.concat([LOADING_ITEM]) + items.push(LOADING_ITEM) } return items }, [isFetched, isEmpty, isError, data, isFetching]) @@ -122,91 +144,97 @@ export function ListMembers({ }, [isFetching, hasNextPage, isError, fetchNextPage]) const onPressRetryLoadMore = useCallback(() => { - fetchNextPage() + void fetchNextPage() }, [fetchNextPage]) const onPressEditMembership = useCallback( (e: GestureResponderEvent, profile: bsky.profile.AnyProfileView) => { e.preventDefault() - openModal({ - name: 'user-add-remove-lists', - subject: profile.did, - displayName: profile.displayName || profile.handle, - handle: profile.handle, - }) + setSelectedProfile(profile) + editListsDialogControl.open() }, - [openModal], + [editListsDialogControl], ) // rendering // = const renderItem = useCallback( - ({item}: {item: any}) => { - if (item === EMPTY_ITEM) { - return renderEmptyState() - } else if (item === ERROR_ITEM) { - return ( - - ) - } else if (item === LOAD_MORE_ERROR_ITEM) { - return ( - - ) - } else if (item === LOADING_ITEM) { - return + ({item}: {item: Item}) => { + switch (item.kind) { + case 'empty': { + return renderEmptyState() + } + case 'error': { + return ( + + ) + } + case 'load_more_error': { + return ( + + ) + } + case 'loading': { + return + } + case 'list_item': { + const profile = item.listItem.subject + if (!moderationOpts) return null + + return ( + + + + + + + {isOwner && ( + + )} + + + + + + + + + ) + } } - - const profile = (item as AppBskyGraphDefs.ListItemView).subject - if (!moderationOpts) return null - - return ( - - - - - - - {isOwner && ( - - )} - - - - - - - - - ) }, [ renderEmptyState, @@ -247,22 +275,29 @@ export function ListMembers({ testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} data={items} - keyExtractor={(item: any) => item.subject?.did || item._reactKey} + keyExtractor={(item: Item) => + item.kind === 'list_item' ? item.listItem.subject.did : item._reactKey + } renderItem={renderItem} ListHeaderComponent={!isEmpty ? renderHeader : undefined} ListFooterComponent={renderFooter} refreshing={isRefreshing} - onRefresh={onRefresh} + onRefresh={() => void onRefresh()} headerOffset={headerOffset} contentContainerStyle={{ minHeight: Dimensions.get('window').height * 1.5, }} onScrolledDownChange={onScrolledDownChange} - onEndReached={onEndReached} + onEndReached={() => void onEndReached()} onEndReachedThreshold={0.6} removeClippedSubviews={true} desktopFixedHeight={desktopFixedHeightOffset || true} /> + + ) } diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index fa95661d27..96b04a1428 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -7,7 +7,6 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useModalControls, useModals} from '#/state/modals' import {FullWindowOverlay} from '#/components/FullWindowOverlay' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' -import * as UserAddRemoveListsModal from './UserAddRemoveLists' const DEFAULT_SNAPPOINTS = ['90%'] const HANDLE_HEIGHT = 24 @@ -40,10 +39,7 @@ export function ModalsContainer() { let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS let element - if (activeModal?.name === 'user-add-remove-lists') { - snapPoints = UserAddRemoveListsModal.snapPoints - element = - } else { + { return null } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index dc080bae01..a35c2b391b 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -6,7 +6,6 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {type Modal as ModalIface} from '#/state/modals' import {useModalControls, useModals} from '#/state/modals' -import * as UserAddRemoveLists from './UserAddRemoveLists' export function ModalsContainer() { const {isModalActive, activeModals} = useModals() @@ -25,7 +24,7 @@ export function ModalsContainer() { ) } -function Modal({modal}: {modal: ModalIface}) { +function Modal({modal: _modal}: {modal: ModalIface}) { const {isModalActive} = useModals() const {closeModal} = useModalControls() const pal = usePalette('default') @@ -44,9 +43,7 @@ function Modal({modal}: {modal: ModalIface}) { } let element - if (modal.name === 'user-add-remove-lists') { - element = - } else { + { return null } diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx deleted file mode 100644 index 1648b0ff22..0000000000 --- a/src/view/com/modals/UserAddRemoveLists.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import {useCallback, useMemo, useState} from 'react' -import { - ActivityIndicator, - StyleSheet, - useWindowDimensions, - View, -} from 'react-native' -import {type AppBskyGraphDefs as GraphDefs} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' - -import {usePalette} from '#/lib/hooks/usePalette' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {cleanError} from '#/lib/strings/errors' -import {sanitizeHandle} from '#/lib/strings/handles' -import {s} from '#/lib/styles' -import {useModalControls} from '#/state/modals' -import { - getMembership, - type ListMembersip, - useDangerousListMembershipsQuery, - useListMembershipAddMutation, - useListMembershipRemoveMutation, -} from '#/state/queries/list-memberships' -import {useSession} from '#/state/session' -import {IS_ANDROID, IS_WEB, IS_WEB_MOBILE} from '#/env' -import {MyLists} from '../lists/MyLists' -import {Button} from '../util/forms/Button' -import {Text} from '../util/text/Text' -import * as Toast from '../util/Toast' -import {UserAvatar} from '../util/UserAvatar' - -export const snapPoints = ['fullscreen'] - -export function Component({ - subject, - handle, - displayName, - onAdd, - onRemove, -}: { - subject: string - handle: string - displayName: string - onAdd?: (listUri: string) => void - onRemove?: (listUri: string) => void -}) { - const {closeModal} = useModalControls() - const pal = usePalette('default') - const {height: screenHeight} = useWindowDimensions() - const {_} = useLingui() - const {data: memberships} = useDangerousListMembershipsQuery() - - const onPressDone = useCallback(() => { - closeModal() - }, [closeModal]) - - const listStyle = useMemo(() => { - if (IS_WEB_MOBILE) { - return [pal.border, {height: screenHeight / 2}] - } else if (IS_WEB) { - return [pal.border, {height: screenHeight / 1.5}] - } - - return [pal.border, {flex: 1, borderTopWidth: StyleSheet.hairlineWidth}] - }, [pal.border, screenHeight]) - - const headerStyles = [ - { - textAlign: 'center', - fontWeight: '600', - fontSize: 20, - marginBottom: 12, - paddingHorizontal: 12, - } as const, - pal.text, - ] - - return ( - - - - Update{' '} - - {displayName} - {' '} - in Lists - - - ( - - )} - style={listStyle} - /> - - - )} - - ) -} diff --git a/src/view/screens/Storybook/GalleryFallback.tsx b/src/view/screens/Storybook/GalleryFallback.tsx deleted file mode 100644 index 7cca087dba..0000000000 --- a/src/view/screens/Storybook/GalleryFallback.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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 84e8cb4397..cc11332126 100644 --- a/src/view/screens/Storybook/Storybook.tsx +++ b/src/view/screens/Storybook/Storybook.tsx @@ -18,7 +18,6 @@ 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' @@ -90,13 +89,13 @@ export default function Storybook() { @@ -133,7 +132,6 @@ export default function Storybook() { - + )} + {!chatStatus?.chatDisabled && ( + )} + )} ) } + +function ChatSettingsMenu({ + action, + children, +}: { + action: 'navigate' | 'push' + children: React.ComponentProps['children'] +}) { + const {t: l} = useLingui() + const navigation = useNavigation() + + const {mutate: markAllChatsRead} = useUpdateAllRead('accepted', { + onMutate: () => { + Toast.show(l`Marked all chats as read`, {type: 'success'}) + }, + onError: () => { + Toast.show(l`Failed to mark all chats as read`, {type: 'error'}) + }, + }) + + return ( + + {children} + + + markAllChatsRead()}> + + + Mark all chats as read + + + { + if (action === 'navigate') { + navigation.navigate('MessagesSettings') + } else { + navigation.push('MessagesSettings') + } + }}> + + + Chat settings + + + + + + ) +} From c3626c80d1c48a465851469531eb40a7d4c69e18 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:14:23 -0700 Subject: [PATCH 32/37] Fix embeds overlapping each other in chat (#10949) --- src/components/Post/Embed/index.tsx | 8 ++------ src/components/dms/MessageItemEmbed.tsx | 3 --- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index 69d92983ed..fc65bea551 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -40,11 +40,7 @@ import {ModeratedFeedEmbed} from './FeedEmbed' import {ImageEmbed} from './ImageEmbed' import {ModeratedListEmbed} from './ListEmbed' import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder' -import { - type CommonProps, - type EmbedProps, - type PostEmbedViewContext, -} from './types' +import {type CommonProps, type EmbedProps, PostEmbedViewContext} from './types' import {VideoEmbed} from './VideoEmbed' export {PostEmbedViewContext} from './types' @@ -356,7 +352,7 @@ export function QuoteEmbed({ return ( From cd4d62c75adb83667787411cdf96206d81d478f6 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:27:10 -0700 Subject: [PATCH 33/37] Remove old message composer (#10951) --- src/analytics/features/types.ts | 1 - .../Messages/components/MessageComposer.tsx | 3 +- .../Messages/components/MessageInput.tsx | 288 ------------------ .../Messages/components/MessageInput.web.tsx | 271 ---------------- .../Messages/components/MessagesList.tsx | 27 +- 5 files changed, 4 insertions(+), 586 deletions(-) delete mode 100644 src/screens/Messages/components/MessageInput.tsx delete mode 100644 src/screens/Messages/components/MessageInput.web.tsx diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index bca196c706..1526fb426a 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -10,7 +10,6 @@ export enum Features { ImportContactsSettingsDisable = 'import_contacts:settings:disable', LiveNowBetaDisable = 'live_now_beta:disable', GroupChatsDisable = 'group_chats:disable', - DmsNewMessageComposerEnable = 'dms:new_message_composer:enable', ComposerLanguageDetectionEnable = 'composer:language_detection:enable', PostGalleryEmbedEnable = 'post_gallery_embed:enable', NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable', diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index 6cdc756bb3..29e498285b 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -334,8 +334,7 @@ function SubmitButton({ ) } -// TODO: remove export when MessageInput is deleted -export function ComposerContainer({children}: {children: React.ReactNode}) { +function ComposerContainer({children}: {children: React.ReactNode}) { const {bottom: bottomInset} = useSafeAreaInsets() const {progress} = useReanimatedKeyboardAnimation() const t = useTheme() diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx deleted file mode 100644 index 441fd75fa4..0000000000 --- a/src/screens/Messages/components/MessageInput.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import {useCallback, useState} from 'react' -import {Pressable, TextInput, useWindowDimensions} from 'react-native' -import { - useFocusedInputHandler, - useKeyboardHandler, - useReanimatedKeyboardAnimation, -} from 'react-native-keyboard-controller' -import Animated, { - measure, - runOnJS, - useAnimatedProps, - useAnimatedRef, - useAnimatedStyle, - useSharedValue, -} from 'react-native-reanimated' -import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {GlassContainer} from 'expo-glass-effect' -import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api' -import {useLingui} from '@lingui/react/macro' -import {countGraphemes} from 'unicode-segmenter/grapheme' - -import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' -import {useHaptics} from '#/lib/haptics' -import {useEmail} from '#/state/email-verification' -import { - useMessageDraft, - useSaveMessageDraft, -} from '#/state/messages/message-drafts' -import {atoms as a, platform, tokens, useTheme} from '#/alf' -import {useMessageReplies} from '#/components/dms/MessageReplies' -import {GlassView} from '#/components/GlassView' -import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' -import {Loader} from '#/components/Loader' -import * as Toast from '#/components/Toast' -import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env' -import {ComposerContainer} from './MessageComposer' -import { - type MessageEmbedState, - useExtractEmbedFromFacets, -} from './MessageInputEmbed' - -const AnimatedTextInput = Animated.createAnimatedComponent(TextInput) - -const MIN_HEIGHT = 40 - -export function MessageInput({ - textInputId, - onSendMessage, - messageEmbed, - setEmbed, - children, - loading = false, -}: { - textInputId?: string - onSendMessage: ( - message: string, - embed?: MessageEmbedState, - replyTo?: $Typed, - ) => Promise | void - messageEmbed: MessageEmbedState | undefined - setEmbed: (embedUrl: string | undefined) => void - children?: React.ReactNode - loading?: boolean -}) { - const {t: l} = useLingui() - const t = useTheme() - const playHaptic = useHaptics() - const {getDraft, clearDraft} = useMessageDraft() - const {replyTo, clearReply} = useMessageReplies() - - // Input layout - const {top: topInset} = useSafeAreaInsets() - const {height: windowHeight} = useWindowDimensions() - const {height: keyboardHeight} = useReanimatedKeyboardAnimation() - const maxHeight = useSharedValue(undefined) - const isInputScrollable = useSharedValue(false) - - const [message, setMessage] = useState(getDraft) - const inputRef = useAnimatedRef() - const [shouldEnforceClear, setShouldEnforceClear] = useState(false) - - const {needsEmailVerification} = useEmail() - const editable = !needsEmailVerification && !loading - - useSaveMessageDraft(message) - useExtractEmbedFromFacets(message, setEmbed) - - const onSubmit = useCallback(() => { - if (!editable) { - return - } - if (!messageEmbed && message.trim() === '') { - return - } - if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { - Toast.show(l`Message is too long`, { - type: 'error', - }) - return - } - clearDraft() - playHaptic() - // Capture the embed before clearing - the deferred send below reads it. - const embed = messageEmbed - setEmbed(undefined) - setMessage('') - // Capture the reply before clearing - the deferred send below reads it. - const reply = replyTo - clearReply() - if (IS_IOS) { - setShouldEnforceClear(true) - } - if (IS_WEB) { - // Pressing the send button causes the text input to lose focus, so we need to - // re-focus it after sending - setTimeout(() => { - inputRef.current?.focus() - }, 100) - } - - requestAnimationFrame(() => { - void onSendMessage( - message, - embed, - reply - ? {...reply, $type: 'chat.bsky.convo.defs#messageView'} - : undefined, - ) - }) - }, [ - editable, - messageEmbed, - message, - clearDraft, - onSendMessage, - playHaptic, - setEmbed, - inputRef, - l, - replyTo, - clearReply, - ]) - - useFocusedInputHandler( - { - onChangeText: () => { - 'worklet' - const measurement = measure(inputRef) - if (!measurement) return - - const max = windowHeight - -keyboardHeight.get() - topInset - 150 - const availableSpace = max - measurement.height - - maxHeight.set(max) - isInputScrollable.set(availableSpace < 30) - }, - }, - [windowHeight, topInset], - ) - - const animatedStyle = useAnimatedStyle(() => ({ - maxHeight: maxHeight.get(), - })) - - const animatedProps = useAnimatedProps(() => ({ - scrollEnabled: isInputScrollable.get(), - })) - - const submitDisabled = - !editable || (!messageEmbed && message.trim().length === 0) - - const blur = useCallback(() => { - inputRef.current?.blur() - }, [inputRef]) - - useKeyboardHandler({ - onEnd: evt => { - 'worklet' - // small hack: interactive dismiss on Android sometimes doesn't blur the input - if (IS_ANDROID && evt.progress === 0) { - runOnJS(blur)() - } - }, - }) - - return ( - - - - {children} - { - // bit of a hack: iOS automatically accepts autocomplete suggestions when you tap anywhere on the screen - // including the button we just pressed - and this overrides clearing the input! so we watch for the - // next change and double make sure the input is cleared. It should *always* send an onChange event after - // clearing via setMessage('') that happens in onSubmit() - // -sfn - if (IS_IOS && shouldEnforceClear) { - setShouldEnforceClear(false) - setMessage('') - return - } - const text = evt.nativeEvent.text - setMessage(text) - }} - multiline={true} - style={[ - {flexBasis: 'auto', minHeight: MIN_HEIGHT}, - a.flex_shrink_0, - a.flex_grow, - a.text_md, - a.px_lg, - t.atoms.text, - platform({ - android: {paddingTop: 2, paddingBottom: 3}, - ios: {paddingTop: 10, paddingBottom: 5}, - }), - animatedStyle, - ]} - verticalAlign="middle" - keyboardAppearance={t.scheme} - submitBehavior="newline" - ref={inputRef} - hitSlop={HITSLOP_10} - animatedProps={animatedProps} - editable={editable} - /> - - - - {loading ? ( - - ) : ( - - )} - - - - - ) -} diff --git a/src/screens/Messages/components/MessageInput.web.tsx b/src/screens/Messages/components/MessageInput.web.tsx deleted file mode 100644 index 6142528f85..0000000000 --- a/src/screens/Messages/components/MessageInput.web.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import {useCallback, useRef, useState} from 'react' -import {Pressable, View} from 'react-native' -import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api' -import {useLingui} from '@lingui/react/macro' -import {flushSync} from 'react-dom' -import TextareaAutosize from 'react-textarea-autosize' -import {countGraphemes} from 'unicode-segmenter/grapheme' - -import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import { - useMessageDraft, - useSaveMessageDraft, -} from '#/state/messages/message-drafts' -import {atoms as a, flatten, useTheme} from '#/alf' -import {Button} from '#/components/Button' -import {useMessageReplies} from '#/components/dms/MessageReplies' -import * as EmojiPicker from '#/components/EmojiPicker' -import {useSharedInputStyles} from '#/components/forms/TextField' -import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' -import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' -import * as Toast from '#/components/Toast' -import {IS_WEB_SAFARI, IS_WEB_TOUCH_DEVICE} from '#/env' -import { - type MessageEmbedState, - useExtractEmbedFromFacets, -} from './MessageInputEmbed' - -export function MessageInput({ - onSendMessage, - messageEmbed, - setEmbed, - children, - loading = false, -}: { - onSendMessage: ( - message: string, - embed?: MessageEmbedState, - replyTo?: $Typed, - ) => void - messageEmbed: MessageEmbedState | undefined - setEmbed: (embedUrl: string | undefined) => void - children?: React.ReactNode - loading?: boolean -}) { - const {isMobile} = useWebMediaQueries() - const {t: l} = useLingui() - const t = useTheme() - const {getDraft, clearDraft} = useMessageDraft() - const {replyTo, clearReply} = useMessageReplies() - const [message, setMessage] = useState(getDraft) - - const inputStyles = useSharedInputStyles() - const isComposing = useRef(false) - const [isFocused, setIsFocused] = useState(false) - const [isHovered, setIsHovered] = useState(false) - const [textAreaHeight, setTextAreaHeight] = useState(38) - const textAreaRef = useRef(null) - - const onSubmit = useCallback(() => { - if (!messageEmbed && message.trim() === '') { - return - } - if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { - Toast.show(l`Message is too long`, { - type: 'error', - }) - return - } - clearDraft() - onSendMessage( - message, - messageEmbed, - replyTo - ? {...replyTo, $type: 'chat.bsky.convo.defs#messageView'} - : undefined, - ) - clearReply() - setMessage('') - setEmbed(undefined) - }, [ - message, - onSendMessage, - l, - clearDraft, - messageEmbed, - setEmbed, - replyTo, - clearReply, - ]) - - const onKeyDown = useCallback( - (e: React.KeyboardEvent) => { - // Don't submit the form when the Japanese or any other IME is composing - if (isComposing.current) return - - // see https://github.com/bluesky-social/social-app/issues/4178 - // see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/ - // see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html - // - // On Safari, the final keydown event to dismiss the IME - which is the enter key - is also "Enter" below. - // Obviously, this causes problems because the final dismissal should _not_ submit the text, but should just - // stop the IME editing. This is the behavior of Chrome and Firefox, but not Safari. - // - // Keycode is deprecated, however the alternative seems to only be to compare the timestamp from the - // onCompositionEnd event to the timestamp of the keydown event, which is not reliable. For example, this hack - // uses that method: https://github.com/ProseMirror/prosemirror-view/pull/44. However, from my 500ms resulted in - // far too long of a delay, and a subsequent enter press would often just end up doing nothing. A shorter time - // frame was also not great, since it was too short to be reliable (i.e. an older system might have a larger - // time gap between the two events firing. - if (IS_WEB_SAFARI && e.key === 'Enter' && e.keyCode === 229) { - return - } - - if (e.key === 'Enter') { - if (e.shiftKey) return - e.preventDefault() - onSubmit() - } - }, - [onSubmit], - ) - - const onChange = useCallback((e: React.ChangeEvent) => { - setMessage(e.target.value) - }, []) - - const onEmojiInserted = useCallback( - (emoji: EmojiPicker.Emoji) => { - if (!textAreaRef.current) { - return - } - const position = textAreaRef.current.selectionStart ?? 0 - flushSync(() => { - setMessage( - message => - message.slice(0, position) + emoji.native + message.slice(position), - ) - }) - textAreaRef.current.selectionStart = position + emoji.native.length - textAreaRef.current.selectionEnd = position + emoji.native.length - }, - [setMessage], - ) - - useSaveMessageDraft(message) - useExtractEmbedFromFacets(message, setEmbed) - - return ( - - {children} - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)}> - {loading ? null : ( - - - {({props, state}) => ( - - )} - - - - )} - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - onCompositionStart={() => { - isComposing.current = true - }} - onCompositionEnd={() => { - isComposing.current = false - }} - onHeightChange={height => setTextAreaHeight(height)} - onChange={onChange} - // On mobile web phones, we want to keep the same behavior as the native app. Do not submit the message - // in these cases. - onKeyDown={IS_WEB_TOUCH_DEVICE && isMobile ? undefined : onKeyDown} - /> - - - - - - ) -} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 8fbfa88e04..d549ffb904 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -57,7 +57,6 @@ import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util' 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' import {MessageListError} from '#/screens/Messages/components/MessageListError' import {atoms as a, platform, tokens, useTheme, web} from '#/alf' import {DateDivider} from '#/components/dms/DateDivider' @@ -704,9 +703,6 @@ export function MessagesList({ messageEmbed={messageEmbed} setEmbed={setEmbed} loading={loading} - useNewComposer={ax.features.enabled( - ax.features.DmsNewMessageComposerEnable, - )} /> )} @@ -735,7 +731,6 @@ function Composer({ messageEmbed, setEmbed, loading, - useNewComposer, }: { textInputId: string onSendMessage: ( @@ -746,7 +741,6 @@ function Composer({ messageEmbed: MessageEmbedState | undefined setEmbed: (embedUrl: string | undefined) => void loading?: boolean - useNewComposer: boolean }) { const handleSendMessage = useNonReactiveCallback( ( @@ -758,31 +752,16 @@ function Composer({ }, ) - const previews = ( - <> - - - - ) - - return useNewComposer ? ( + return ( - {previews} + + - ) : ( - - {previews} - ) } From 0674626f3c9e636da95c77c0587bbed332b6c07f Mon Sep 17 00:00:00 2001 From: RetroSunstar <57507616+RetroSunstar@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:33:49 +0100 Subject: [PATCH 34/37] Add light haptics to Edit Profile Button for labellers (#10948) --- src/screens/Profile/Header/ProfileHeaderLabeler.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index 565bf2edc4..5ce90205c9 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -292,7 +292,10 @@ export function HeaderLabelerButtons({ testID="profileHeaderEditProfileButton" size="small" color="secondary" - onPress={editProfileControl.open} + onPress={() => { + playHaptic('Light') + editProfileControl.open() + }} label={_(msg`Edit profile`)} style={a.rounded_full}> From 2843374ef864cd9d6e5353468b17a60ba432d261 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 24 Jun 2026 19:37:57 +0300 Subject: [PATCH 35/37] Fix setState-in-render warning from convo cache subscription (#10934) Co-authored-by: Claude Opus 4.8 (1M context) --- src/state/messages/convo/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/state/messages/convo/index.tsx b/src/state/messages/convo/index.tsx index a78ea1a038..69ad1e237c 100644 --- a/src/state/messages/convo/index.tsx +++ b/src/state/messages/convo/index.tsx @@ -132,6 +132,12 @@ export function ConvoProvider({ useEffect(() => { const [root, id] = getConvoKey(convoId) return queryClient.getQueryCache().subscribe(event => { + // Only react to data updates. Other event types (e.g. `added`) can be + // emitted synchronously while another component reads this same query + // during its render (React Query builds the query in `getOptimisticResult`), + // and committing to the convo store then would set state on this provider + // mid-render of that component. + if (event.type !== 'updated') return const queryKey = event.query.queryKey as string[] if (queryKey[0] === root && queryKey[1] === id) { const data = event.query.state.data as From f3978d1a1f7e2c75c951b67476608b5dd5f5b842 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 24 Jun 2026 19:38:44 +0300 Subject: [PATCH 36/37] Fix internal repo sync broken by actions/checkout v6 bump (#10933) Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/sync-internal.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-internal.yaml b/.github/workflows/sync-internal.yaml index 3b07ae2b90..fd7fb89893 100644 --- a/.github/workflows/sync-internal.yaml +++ b/.github/workflows/sync-internal.yaml @@ -17,6 +17,9 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 + # Don't persist the checkout auth header; the push below authenticates + # with the app token embedded in the remote URL instead + persist-credentials: false - name: Generate GitHub App Token id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 @@ -33,6 +36,5 @@ jobs: run: | git config user.name "github-actions" git config user.email "test@users.noreply.github.com" - git config --unset-all http.https://github.com/.extraheader git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git git push internal main --force From 90c010e98a4dbf9b1bfe53e7c5dab3af4a835ade Mon Sep 17 00:00:00 2001 From: surfdude29 <149612116+surfdude29@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:39:12 +0100 Subject: [PATCH 37/37] Tweak strings and context (#10928) --- src/ageAssurance/components/NoAccessScreen.tsx | 2 +- src/components/dialogs/nuxs/GroupChatsAnnouncement.tsx | 2 +- src/components/moderation/BlockDialog.tsx | 4 ++-- src/screens/Messages/JoinRequests.tsx | 6 +++--- src/screens/Messages/components/MessageComposer.tsx | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx index ac88e59f7a..44870dcffd 100644 --- a/src/ageAssurance/components/NoAccessScreen.tsx +++ b/src/ageAssurance/components/NoAccessScreen.tsx @@ -399,7 +399,7 @@ function AccessSection() { locationControl.open() })}> Tap here to update your location with GPS. - {' '} + diff --git a/src/components/dialogs/nuxs/GroupChatsAnnouncement.tsx b/src/components/dialogs/nuxs/GroupChatsAnnouncement.tsx index 0535b3478f..4cdf4a2ccf 100644 --- a/src/components/dialogs/nuxs/GroupChatsAnnouncement.tsx +++ b/src/components/dialogs/nuxs/GroupChatsAnnouncement.tsx @@ -168,7 +168,7 @@ export function GroupChatsAnnouncement() { a.font_medium, {color: t.palette.primary_500}, ]}> - New + New { onOptimisticallyRemoveConvo(view.id) removeMembers({members: [profileDid]}) }}> - Kick member + Remove member {isRemovePending ? : null} diff --git a/src/screens/Messages/JoinRequests.tsx b/src/screens/Messages/JoinRequests.tsx index ecb40ef9c3..ffe3fb219e 100644 --- a/src/screens/Messages/JoinRequests.tsx +++ b/src/screens/Messages/JoinRequests.tsx @@ -212,7 +212,7 @@ function JoinRequestsList({ useJoinRequestMutation('reject', convoId, { onSuccess: () => { ax.metric('groupchat:owner:joinRequest:reject', {convoId}) - Toast.show(l`Request ignored.`) + Toast.show(l`Request rejected.`) if (getRemainingRequestCount() < 1) { navigation.replace('MessagesConversationSettings', { conversation: convoId, @@ -220,7 +220,7 @@ function JoinRequestsList({ } }, onError: error => { - let errorMessage = l`Failed to ignore join request` + let errorMessage = l`Failed to reject join request` if (isNetworkError(error)) { errorMessage = l`A network error occurred. Please check your internet connection.` } else if ( @@ -230,7 +230,7 @@ function JoinRequestsList({ } else if ( error instanceof ChatBskyGroupRejectJoinRequest.InsufficientRoleError ) { - errorMessage = l`Only admins can ignore join requests.` + errorMessage = l`Only admins can reject join requests.` } Toast.show(errorMessage, {type: 'error'}) }, diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index 29e498285b..09a15ca2b5 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -243,7 +243,7 @@ export function MessageComposer({ placeholder={ loading ? l({message: 'Loading chat…', context: 'placeholder'}) - : l({message: 'Message', context: 'action'}) + : l({message: 'Message', context: 'description'}) } autocompletePlacement="top-start" internalApiRef={composerInternalApiRef}