diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index fb66297ef8..2a3a72bae6 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -410,7 +410,7 @@ The two-tab shell follows [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two- - iOS: messages run under the transparent header and the status bar, and UIKit's scroll edge effect (left on `automatic`) softens them there. The list uses `contentInsetAdjustmentBehavior="automatic"`, so UIKit, not a manual padding, keeps the header's height free above the oldest message at rest; the edge effect needs that automatic inset. The edge effect and React Native Screens both find the content scroll view through the first-subview chain of the screen, so the message list is the first thing in that chain: a default or colour chat background is the frame's own `backgroundColor`, not a view in front of the list. An image background still needs a view behind the list, and with one the soft edge does not reach the list. UIKit also adds the home-indicator safe area to the bottom inset while the list reaches the screen bottom (keyboard closed); the list cancels it with a negative `contentInset.bottom`, because the composer's height already includes that padding. Without this, scrolling to the very end left 34pt more space below the newest message than the resting position. On iOS 26 there is no painted top gradient. A transparent header dimmed the whole message list on iOS 26 while the list was inverted (a scaleY flip); the list now runs top to bottom, which this header depends on. - The list runs to the bottom of the screen, and the composer floats over it with no bar of its own; messages pass around and under its composer island. On iOS the **+** control and field share one taller rounded surface; on iOS 26 with Reduce Transparency off it is Liquid Glass. Older iOS and Reduce Transparency give it an opaque `surface` fill with a `borderStrong` hairline, and Android gives the field the opaque `raised` fill, so on those nothing shows through where the Human types. On Android the field is a floating island like the header's (Human decision, 2026-11-11, after Telegram Android): the same hairline rim, one-point inner top highlight and soft drop shadow (`chatComposerSurface`), drawn with React Native `boxShadow` so nothing is captured or blurred. The round jump-to-latest button above it is the same island, in place of its Material elevation. The bottom of the screen fades out with the same light gradient over the composer and `edgeFade.bottom` above it. The list keeps that much space below the newest message, so at rest it sits just above the composer; scrolled up, messages pass behind the composer. Dragging the list dismisses the keyboard: interactively on iOS (the keyboard follows the finger once it reaches it), on drag on Android. On iOS and Android the bottom edge is a drawn mask layer between the list and the composer, like Grok's chat: one soft curve (`(1 − d)^edgeFade.curve`, sampled `edgeFade.samples` times) painted in the chat's own background colour (`base` with an image background). On Android the bottom edge is not held solid: it uses the header's curve (`chatComposerSurface.fade`), from `peak` at the screen edge to transparent `edgeFade.bottom` above the capsule, so messages show faintly beside and below the floating composer as in Telegram. On iOS, at the bottom it is held solid from the screen edge up to `space.x2` below the top of the composer's field capsule, whatever the draft's line count, then the curve clears over `edgeFade.bottom` just above the capsule, so messages stay readable right up to the composer and are hidden under it. At the top (Android's floating header, and iOS before 26) it is held solid across the status-bar safe area, then the curve clears `edgeFade.reach` below the header. iOS 26 hides UIKit's bottom scroll edge effect for the chat (`scrollEdgeEffects.bottom: 'hidden'`) and does not use a `UIScrollEdgeElementContainerInteraction` on the composer: UIKit picked the hard style there, an opaque band in a different colour from the list with a hairline across its top, at a height that did not follow a wrapped draft, and neither re-adding the interaction nor pinning the `soft` style fixed it. The top edge stays UIKit's automatic scroll edge effect. With the keyboard open the composer's pill sits `space.x3` above it: the keyboard already covers the home indicator, so the chat frame takes the composer's safe-area padding back out of the keyboard offset instead of counting it twice. - Messages: - - **Swipe left to reply; swipe right is always Back** (Human decision, 2026-11-03, from user feedback, replacing the swipe right to reply: a swipe right is the system Back on iOS, so a bubble must never claim it). A swipe left on a committed Human or Bot message (not a pending or failed one, a card, an activity row, a notice or the welcome) moves its row with the finger, up to `swipeReply.reach` (72pt) and then with resistance, while a round reply glyph fades and grows in behind its trailing edge. Passing `swipeReply.commit` (56pt) gives one `selection` haptic; letting go there starts a reply, and the row springs back either way. The recognizer starts only leftward after `swipeReply.start` (12pt), fails on 10pt of vertical travel and on `swipeReply.backFail` (8pt) of rightward travel, so a swipe right that starts on a bubble is still Back. A replyable row carries the native marker `horizontal-swipe-owner` (`nativeID`), and the containers that turn a swipe left into the profile skip a drag that starts on it: the iOS chat/profile pager (a gate recognizer in our `react-native-pager-view` patch that the page drag requires to fail) and the Android profile side sheet (`EndSheet`, which already skipped horizontally scrollable targets). A swipe left anywhere else in the chat opens the profile. The chat screens enable the iOS full-screen back swipe (`fullScreenGestureEnabled: true`), and the same pager gate leaves a swipe right on the chat page to that back swipe; on the profile page a swipe right returns to the chat. Android Back stays the system gesture, which the chat no longer competes with. RTL mirrors both directions. Starting a reply focuses the composer and shows a reply bar above it (on Android inside the composer island, above the text row): a reply glyph, "Replying to {name}" ("You" for the Human's own message) and one line of the message, with ✕ (Cancel reply) to drop it. Sending carries `replyTo` to the Host's `channelSend`, which refuses a target outside the Channel; the bar clears on send. The swipe is off while selecting messages, without a composer (Bot-to-Bot DMs) and while the Host is not connected. CLI: `deepseekbot send --body … --reply-to `. + - **Swipe left to reply; swipe right is always Back** (Human decision, 2026-11-03, from user feedback, replacing the swipe right to reply: a swipe right is the system Back on iOS, so a bubble must never claim it). A swipe left on a committed Human or Bot message (not a pending or failed one, a card, an activity row, a notice or the welcome) moves its row with the finger, up to `swipeReply.reach` (72pt) and then with resistance, while a round reply glyph fades and grows in behind its trailing edge. Passing `swipeReply.commit` (56pt) gives one `selection` haptic; letting go there starts a reply, and the row springs back either way. A drag that starts on a horizontally scrollable part of the bubble (a code block or a table) scrolls it instead: each such scroller sits in a native gesture that blocks the reply pan (`HorizontalScroll`). The recognizer starts only leftward after `swipeReply.start` (12pt), fails on 10pt of vertical travel and on `swipeReply.backFail` (8pt) of rightward travel, so a swipe right that starts on a bubble is still Back. A replyable row carries the native marker `horizontal-swipe-owner` (`nativeID`), and the containers that turn a swipe left into the profile skip a drag that starts on it: the iOS chat/profile pager (a gate recognizer in our `react-native-pager-view` patch that the page drag requires to fail) and the Android profile side sheet (`EndSheet`, which already skipped horizontally scrollable targets). A swipe left anywhere else in the chat opens the profile. The chat screens enable the iOS full-screen back swipe (`fullScreenGestureEnabled: true`), and the same pager gate leaves a swipe right on the chat page to that back swipe; on the profile page a swipe right returns to the chat. Android Back stays the system gesture, which the chat no longer competes with. RTL mirrors both directions. Starting a reply focuses the composer and shows a reply bar above it (on Android inside the composer island, above the text row): a reply glyph, "Replying to {name}" ("You" for the Human's own message) and one line of the message, with ✕ (Cancel reply) to drop it. Sending carries `replyTo` to the Host's `channelSend`, which refuses a target outside the Channel; the bar clears on send. The swipe is off while selecting messages, without a composer (Bot-to-Bot DMs) and while the Host is not connected. CLI: `deepseekbot send --body … --reply-to `. - A reply shows its quote as a tinted block at the top of its bubble (option B2 of #3857, Telegram-style): a `swipeReply.bar` (3pt) bar down its leading edge and the quoted author's name in `meta` strong in that author's colour, then one line of the original in `meta` (three lines at large text sizes), from the Host's `replyToPreview`. The author colour is the Bot's primary colour (`botColor`) and `accent` for the Human, both put through the shared `botNameColor` contrast check against the bubble; a bridged author uses `fg`. The block's fill is that colour mixed 13% (`replyQuote.tint`) into the bubble's own fill, as an opaque colour, at radius `sm`. On the Human's `accent` bubble the bar, name and fill use `onAccent` instead, so the quote stays readable on any Bot's colour. Tapping the block jumps to the original (the same scroll-and-load path as a notification's linked message); screen readers hear it as one button, "Reply to {name}: {text}", and reach the jump through the bubble's "Shows the original message" action. A reply whose original is gone reads "Original message unavailable" in a `muted` block that is not a button. The pending copy of a reply quotes the loaded message until the Host's entry arrives. When the preview carries a media hint, the line says what was quoted: an image shows a 32pt thumbnail (`replyThumb`, radius `xs`) beside the name and its caption, or "Photo" / "3 photos" without one; voice reads "Voice · 0:07" ("语音 · 0:07"); a file without text reads "File · Q3.pdf". - Activity rows show Bot DMs, posts elsewhere, memory commits, Bot creation, departures and stopped turns as one left-aligned meta line with a kind icon and tail ellipsis. They use the same `bubbleRow(true)` inset and single `space.x4` (12pt) top gap as a new message group, with no bottom padding or vertically centred minimum-height box: bubble → activity, activity → activity and activity → new message group keep the same boundary gap. Icons and avatars match the meta line height. In groups, the actor avatar occupies the existing speaker gutter, followed by the icon at the bubble's left edge and the description. In a Human DM, omit the current Bot's avatar and redundant name; Bot-DM actions show the other Bot's avatar after the icon. When the actor has no avatar (a Human, such as a Human stopping a Session), the kind icon itself sits in the speaker gutter and is not repeated, so the row shows one symbol. Stopped turns use an outlined stop-in-circle, the same symbol as the Stop item in the ⋯ menu. - Activity rows do not navigate on tap. Long-press (or the accessibility details action) opens the message sheet with the complete plain-text content in a scrollable, selectable block capped at `messageSheet.textHeightRatio` of the window height, then Copy/Share and an explicit Open conversation, Open memory or Open new Bot action when a destination exists; there are no reactions. Consecutive posts by the same Bot into the same Channel on the same day fold into a counted row whose menu opens the latest post. Memory opens the Bot's memory files, not a commit diff. Bot-to-Bot DMs remain read-only. Keep full actor/action/detail text in the accessibility label and menu even when the compact memory line uses only the icon and commit subject. diff --git a/apps/mobile/src/ui/chat/follow-latest.ts b/apps/mobile/src/ui/chat/follow-latest.ts index 1db51668ed..becc71a5a3 100644 --- a/apps/mobile/src/ui/chat/follow-latest.ts +++ b/apps/mobile/src/ui/chat/follow-latest.ts @@ -12,6 +12,7 @@ export const LATEST_SLOP = 40; export const SETTLE_FRAMES = 45; export const STABLE_FRAMES = 6; export const CHECK_BUDGET = SETTLE_FRAMES * 4; +export const NATIVE_GLIDE_MS = 450; const KEYBOARD_SETTLE_MS = 1000; export const MOMENTUM_WAIT_MS = 120; @@ -100,6 +101,7 @@ export function useFollowLatest( const checks = useRef(0); const keyboardTimer = useRef | undefined>(undefined); const momentumTimer = useRef | undefined>(undefined); + const catchUpTimer = useRef | undefined>(undefined); useLayoutEffect(() => { if (!followingNow.current) onFollowingChange?.(false); }, [onFollowingChange]); @@ -111,6 +113,7 @@ export function useFollowLatest( confirmation.current = undefined; clearTimeout(keyboardTimer.current); clearTimeout(momentumTimer.current); + clearTimeout(catchUpTimer.current); }, [], ); @@ -224,6 +227,15 @@ export function useFollowLatest( pin(); }, [rowsPresent, pin]); + const catchUp = useCallback(() => { + clearTimeout(catchUpTimer.current); + catchUpTimer.current = setTimeout(() => { + catchUpTimer.current = undefined; + if (!followingNow.current || dragging.current || keyboardMoving.current) return; + if (distanceFromLatest(metrics.current, inverted) > 1) scrollToLatest(true); + }, NATIVE_GLIDE_MS); + }, [inverted, scrollToLatest]); + const onKeyboardStart = useCallback(() => { keyboardMoving.current = true; if (frame.current !== undefined) cancelAnimationFrame(frame.current); @@ -329,10 +341,13 @@ export function useFollowLatest( attempts.current = 0; if (checks.current < CHECK_BUDGET) setCanRetry(false); } - if (nativeFollow && settled.current) return; + if (nativeFollow && settled.current) { + if (height !== previous) catchUp(); + return; + } pin(animateScroll && settled.current && height > previous); }, - [pin, animateScroll, nativeFollow], + [pin, animateScroll, nativeFollow, catchUp], ); const onLayout = useCallback( diff --git a/apps/mobile/src/ui/chat/reply-swipe-context.ts b/apps/mobile/src/ui/chat/reply-swipe-context.ts new file mode 100644 index 0000000000..2ff438cbf2 --- /dev/null +++ b/apps/mobile/src/ui/chat/reply-swipe-context.ts @@ -0,0 +1,5 @@ +import { createContext, type ComponentType, type ReactNode } from 'react'; + +export type SwipeBlocker = ComponentType<{ children: ReactNode }>; + +export const ReplySwipeBlocker = createContext(undefined); diff --git a/apps/mobile/src/ui/chat/reply-swipe.tsx b/apps/mobile/src/ui/chat/reply-swipe.tsx index 243d911af6..b55c9943a4 100644 --- a/apps/mobile/src/ui/chat/reply-swipe.tsx +++ b/apps/mobile/src/ui/chat/reply-swipe.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { useCallback, useMemo, useRef, type ReactNode } from 'react'; import { I18nManager, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { @@ -12,6 +12,7 @@ import { spring } from '../design/motion'; import { space, swipeReply } from '../design/tokens'; import { haptic } from '../haptics'; import { ReplyIcon } from '../shell/icons'; +import { ReplySwipeBlocker, type SwipeBlocker } from './reply-swipe-context'; import { horizontalSwipeOwner, repliesAt, replyTravel } from './reply-swipe-model'; export function ReplySwipe({ @@ -27,29 +28,44 @@ export function ReplySwipe({ const travel = useSharedValue(0); const armed = useSharedValue(false); const direction = I18nManager.isRTL ? 1 : -1; - const arm = () => haptic('selection'); - const pan = Gesture.Pan() - .enabled(enabled) - .activeOffsetX(direction * swipeReply.start) - .failOffsetX(-direction * swipeReply.backFail) - .failOffsetY([-swipeReply.verticalFail, swipeReply.verticalFail]) - .onStart(() => { - armed.value = false; - }) - .onUpdate((event) => { - const x = event.translationX * direction; - travel.value = replyTravel(x); - if (armed.value === repliesAt(x)) return; - armed.value = repliesAt(x); - if (armed.value) runOnJS(arm)(); - }) - .onEnd((event) => { - if (repliesAt(event.translationX * direction)) runOnJS(onReply)(); - }) - .onFinalize(() => { - armed.value = false; - travel.value = withSpring(0, spring.spatial.fast); - }); + const reply = useRef(onReply); + reply.current = onReply; + const arm = useCallback(() => haptic('selection'), []); + const fire = useCallback(() => reply.current(), []); + const pan = useMemo( + () => + Gesture.Pan() + .enabled(enabled) + .activeOffsetX(direction * swipeReply.start) + .failOffsetX(-direction * swipeReply.backFail) + .failOffsetY([-swipeReply.verticalFail, swipeReply.verticalFail]) + .onStart(() => { + armed.value = false; + }) + .onUpdate((event) => { + const x = event.translationX * direction; + travel.value = replyTravel(x); + if (armed.value === repliesAt(x)) return; + armed.value = repliesAt(x); + if (armed.value) runOnJS(arm)(); + }) + .onEnd((event) => { + if (repliesAt(event.translationX * direction)) runOnJS(fire)(); + }) + .onFinalize(() => { + armed.value = false; + travel.value = withSpring(0, spring.spatial.fast); + }), + [enabled, direction, arm, fire, armed, travel], + ); + const blocker = useMemo( + () => + function SwipeBlocker({ children: scroller }: { children: ReactNode }) { + const native = useMemo(() => Gesture.Native().blocksExternalGesture(pan), []); + return {scroller}; + }, + [pan], + ); const moving = useAnimatedStyle(() => ({ transform: [{ translateX: travel.value * direction }], })); @@ -86,7 +102,11 @@ export function ReplySwipe({ - {children} + + + {children} + + ); diff --git a/apps/mobile/src/ui/markdown-table.tsx b/apps/mobile/src/ui/markdown-table.tsx index 66e6a805a8..fd92ba6ef3 100644 --- a/apps/mobile/src/ui/markdown-table.tsx +++ b/apps/mobile/src/ui/markdown-table.tsx @@ -1,7 +1,8 @@ import type { Tokens } from 'marked'; import { memo, useMemo, type ReactNode } from 'react'; import type { Token } from 'marked'; -import { ScrollView, View } from 'react-native'; +import { View } from 'react-native'; +import { HorizontalScroll } from './primitives/horizontal-scroll'; import { edge, markdownTable, radius, space } from './design/tokens'; import { useDesignTheme } from './design/theme'; @@ -107,7 +108,7 @@ export function MarkdownTable({ ); const align = JSON.stringify(table.align); return ( - + ))} - + ); } diff --git a/apps/mobile/src/ui/markdown-text.tsx b/apps/mobile/src/ui/markdown-text.tsx index 5102810d4c..9d998eee4b 100644 --- a/apps/mobile/src/ui/markdown-text.tsx +++ b/apps/mobile/src/ui/markdown-text.tsx @@ -1,7 +1,8 @@ import { decodeHTML } from 'entities'; import type { Token } from 'marked'; import type { ReactNode } from 'react'; -import { Pressable, ScrollView, View } from 'react-native'; +import { Pressable, View } from 'react-native'; +import { HorizontalScroll } from './primitives/horizontal-scroll'; import { copyMessage } from './chat/message-menu'; import { edge, radius, space } from './design/tokens'; @@ -102,15 +103,11 @@ export function MarkdownText({ - + {token.text} - + ); if (token.type === 'list') diff --git a/apps/mobile/src/ui/primitives/horizontal-scroll.tsx b/apps/mobile/src/ui/primitives/horizontal-scroll.tsx new file mode 100644 index 0000000000..d899aa5f38 --- /dev/null +++ b/apps/mobile/src/ui/primitives/horizontal-scroll.tsx @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { ScrollView, type ScrollViewProps } from 'react-native'; + +import { ReplySwipeBlocker } from '../chat/reply-swipe-context'; + +export function HorizontalScroll(props: ScrollViewProps) { + const Blocker = useContext(ReplySwipeBlocker); + const view = ; + return Blocker === undefined ? view : {view}; +} diff --git a/apps/mobile/test/follow-latest-gestures.test.tsx b/apps/mobile/test/follow-latest-gestures.test.tsx index 4d21066343..0a070a2d44 100644 --- a/apps/mobile/test/follow-latest-gestures.test.tsx +++ b/apps/mobile/test/follow-latest-gestures.test.tsx @@ -13,6 +13,7 @@ import type { ConversationMessage } from '../src/host/conversation'; import { CHECK_BUDGET, MOMENTUM_WAIT_MS, + NATIVE_GLIDE_MS, STABLE_FRAMES, useFollowLatest, } from '../src/ui/chat/follow-latest'; @@ -893,6 +894,53 @@ describe('sending a message', () => { } }); + it('finishes the glide when the native anchor stops short of the newest message', () => { + const view = settled(true, true); + vi.useFakeTimers(); + try { + act(() => { + view.current.send(); + }); + view.rerender([message('latest', 'bot'), message('sent', 'human')]); + act(() => { + view.current.listProps.onContentSizeChange(0, CONTENT + 60); + view.current.listProps.onScroll(event(48, 0, CONTENT + 60)); + }); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(NATIVE_GLIDE_MS)); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ + offset: CONTENT + 60 - LAYOUT, + animated: true, + }); + } finally { + view.unmount(); + vi.useRealTimers(); + } + }); + + it('returns to the newest message when a send leaves the content shorter and the list shifted', () => { + const view = settled(true, true); + vi.useFakeTimers(); + try { + act(() => { + view.current.send(); + }); + view.rerender([message('latest', 'bot'), message('sent', 'human')]); + act(() => { + view.current.listProps.onContentSizeChange(0, CONTENT - 20); + view.current.listProps.onScroll(event(42, 0, CONTENT - 20)); + }); + act(() => vi.advanceTimersByTime(NATIVE_GLIDE_MS)); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ + offset: CONTENT - 20 - LAYOUT, + animated: true, + }); + } finally { + view.unmount(); + vi.useRealTimers(); + } + }); + it('requests no fly when Reduce Motion is on but still follows the new message', () => { const view = settled(false); try { diff --git a/apps/mobile/test/horizontal-scroll.test.tsx b/apps/mobile/test/horizontal-scroll.test.tsx new file mode 100644 index 0000000000..ecbcbb465e --- /dev/null +++ b/apps/mobile/test/horizontal-scroll.test.tsx @@ -0,0 +1,28 @@ +import { createElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { expect, it, vi } from 'vitest'; + +vi.mock('react-native', async () => import('./react-native-shim')); + +const { HorizontalScroll } = await import('../src/ui/primitives/horizontal-scroll'); +const { ReplySwipeBlocker } = await import('../src/ui/chat/reply-swipe-context'); + +it('lets a scroller inside a reply-swipe bubble claim its own horizontal drags', () => { + const Blocker = ({ children }: { children: ReactNode }) => + createElement('section', { 'data-blocker': 'reply' }, children); + const html = renderToStaticMarkup( + createElement( + ReplySwipeBlocker.Provider, + { value: Blocker }, + createElement(HorizontalScroll, null, 'wide table'), + ), + ); + expect(html).toContain('data-blocker="reply"'); + expect(html).toContain('wide table'); +}); + +it('is a plain horizontal scroller outside a bubble', () => { + const html = renderToStaticMarkup(createElement(HorizontalScroll, null, 'code')); + expect(html).not.toContain('data-blocker'); + expect(html).toContain('code'); +}); diff --git a/apps/mobile/test/reply-swipe.test.tsx b/apps/mobile/test/reply-swipe.test.tsx index 925601e51d..2b76fdb5a1 100644 --- a/apps/mobile/test/reply-swipe.test.tsx +++ b/apps/mobile/test/reply-swipe.test.tsx @@ -14,7 +14,12 @@ type Pan = { finalize(): void; }; -const state = vi.hoisted(() => ({ pans: [] as Pan[], haptics: [] as string[] })); +const state = vi.hoisted(() => ({ + pans: [] as Pan[], + haptics: [] as string[], + detected: [] as unknown[], + natives: [] as { blocks: unknown[] }[], +})); vi.mock('react-native', () => import('./react-native-shim')); vi.mock('react-native-reanimated', () => import('./reanimated-shim')); @@ -47,14 +52,28 @@ vi.mock('react-native-gesture-handler', async () => { }; return builder; }, + Native: () => { + const native = { blocks: [] as unknown[] }; + state.natives.push(native); + const builder = { + blocksExternalGesture: (...gestures: unknown[]) => ( + native.blocks.push(...gestures), + builder + ), + }; + return builder; + }, + }, + GestureDetector: ({ gesture, children }: { gesture: unknown; children: unknown }) => { + state.detected.push(gesture); + return createElement(Fragment, null, children as never); }, - GestureDetector: ({ children }: { children: unknown }) => - createElement(Fragment, null, children as never), }; }); const { ReplySwipe } = await import('../src/ui/chat/reply-swipe'); const { ReplyBar } = await import('../src/ui/chat/reply-bar'); +const { HorizontalScroll } = await import('../src/ui/primitives/horizontal-scroll'); const { horizontalSwipeOwner, replyTravel, repliesAt } = await import('../src/ui/chat/reply-swipe-model'); const { swipeReply } = await import('../src/ui/design/tokens'); @@ -69,6 +88,8 @@ describe('swipe left to reply', () => { beforeEach(() => { state.pans.length = 0; state.haptics.length = 0; + state.detected.length = 0; + state.natives.length = 0; container = document.createElement('div'); root = createRoot(container); }); @@ -77,6 +98,31 @@ describe('swipe left to reply', () => { await act(async () => root.unmount()); }); + it('lets a horizontal scroller in the bubble block the reply pan', async () => { + await act(async () => + root.render( + undefined}> + wide table + , + ), + ); + const pan = state.detected[0]; + expect(state.natives).toHaveLength(1); + expect(state.natives[0]!.blocks).toEqual([pan]); + expect(state.detected.at(-1)).not.toBe(pan); + }); + + it('leaves a scroller plain when the bubble cannot be replied to', async () => { + await act(async () => + root.render( + undefined}> + wide table + , + ), + ); + expect(state.natives).toHaveLength(0); + }); + it('follows the finger up to its reach, then resists, and commits past the threshold', () => { expect(replyTravel(-40)).toBe(0); expect(replyTravel(30)).toBe(30); diff --git a/changes/3955-android-chat-scroll-gestures.md b/changes/3955-android-chat-scroll-gestures.md new file mode 100644 index 0000000000..81531318cb --- /dev/null +++ b/changes/3955-android-chat-scroll-gestures.md @@ -0,0 +1,11 @@ +--- +section: Fixed +--- + +## en + +- On Android, wide code blocks and tables inside a chat message scroll sideways again instead of starting a swipe-to-reply, and a message sent with the keyboard open now lands fully above the composer instead of half behind it ([#3955](https://github.com/BotHarness/DeepSeekBot/issues/3955)). + +## zh + +- Android 上,聊天消息里的宽代码块和表格又可以横向滚动,不会再误触发左滑回复;键盘打开时发出的消息会完整显示在输入框上方,不再有一半被挡住([#3955](https://github.com/BotHarness/DeepSeekBot/issues/3955))。