From 74e477f16a9e7dc7711a94866ef868459bc3b7a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:19:07 +0000 Subject: [PATCH 1/6] =?UTF-8?q?fix(mobile):=20Android=20hardware=20Enter?= =?UTF-8?q?=20with=20WeType,=20attachment=20=E2=9C=95,=20flick-up=20and=20?= =?UTF-8?q?shortcut=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Treat an input-method Enter that echoes a hardware Enter as that key, so Enter and Ctrl+Enter send with WeType and similar input methods. - Stop the hidden measuring text in the composer from taking taps meant for the attachment ✕ and the mention list. - Lower the Android flick-up speed to 0.3 dp/ms. - Use the launcher icon for Android app shortcuts, which cannot draw SF Symbols. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TYsJ1DZQ7Vd8sKVs6MMhdF --- apps/mobile/DESIGN.md | 6 +-- apps/mobile/modules/hardware-send/README.md | 7 +++- .../modules/hardwaresend/HardwareSendView.kt | 40 ++++++++++++++++++- .../modules/hardwaresend/HardwareSendTest.kt | 24 +++++++++++ apps/mobile/src/ui/chat/composer.tsx | 15 ++++--- apps/mobile/src/ui/design/tokens.ts | 2 +- apps/mobile/src/ui/shell/recent-chats.ts | 9 ++++- apps/mobile/test/recent-chats.test.ts | 8 +++- changes/android-qol-fixes.md | 11 +++++ 9 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 changes/android-qol-fixes.md diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index 771db82544..daded6d207 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -154,7 +154,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - Opening a conversation marks it read on the Host (`channelMarkRead`) whenever the Human is at the newest message, so the chip clears on the phone and on desktop. - Swipe for pin and mute; long-press opens a native menu. - On iOS a long-press on a Host conversation row peeks at the chat, like Messages: the row lifts into the system context-menu preview (expo-router `Link.Preview`) showing the conversation itself, and tapping the preview opens it. A plain tap opens the conversation as before. -- A long-press on the app icon lists the three chats opened most recently (Home Screen quick actions on iOS, app shortcuts on Android), newest first, and tapping one opens that chat on its Host. The list updates whenever a chat opens and is kept with the device's saved Host list. +- A long-press on the app icon lists the three chats opened most recently (Home Screen quick actions on iOS, app shortcuts on Android), newest first, and tapping one opens that chat on its Host. iOS marks each with a chat-bubble symbol; Android, which has no SF Symbols, shows the app icon. The list updates whenever a chat opens and is kept with the device's saved Host list. - **Share to a Bot.** DeepSeekBot is a target in the system share sheet (an iOS Share Extension, Android `SEND` / `SEND_MULTIPLE`). Sharing text, a link, photos, videos or files opens **Send to**: a preview of what arrived above the paired Host's chats, in the Chats order. Picking a chat opens it with the text added below any unsent draft and the files waiting as attachment tiles; nothing is sent until the Human taps Send. Cloud channels are not targets yet, and with no paired Host the screen says to pair first. **Count chip** @@ -175,7 +175,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - 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. - A top-to-bottom list (not inverted), oldest first, on one code path for iOS and Android. It opens hidden and is shown once it has been scrolled to the end and its content has settled for a frame, so it never flashes the oldest messages first. Short conversations sit at the bottom, above the composer. Older history loads when the Human nears the top (`onStartReached`), and `maintainVisibleContentPosition` keeps the messages on screen where they were while it is prepended. Messages from the same author within 5 minutes form a group, and a time separator appears after a 30-minute gap. - At the newest message the list follows a streaming reply: content growth pins it to the end with a non-animated scroll, at most once per frame. Once the Human begins a drag or scrolls up into history, nothing moves the list under them: the visual playback cursor holds while received text continues to queue and persist. Only finishing a return gesture near the bottom or choosing the jump button resumes playback; a layout-driven scroll callback can neither rearm nor release following. Only a drag, the status-bar scroll to top or a jump to a linked message leaves the newest message, so the inset adjustment, content-size reports and `maintainVisibleContentPosition` corrections while the list first fills cannot strand it at the oldest message. A round jump button with a ⌄ then floats at the trailing edge `space.x2` above the composer, aligned with its trailing edge, like Grok (a 36pt Liquid Glass circle like **+** on iOS 26 with Reduce Transparency off, otherwise `raised`), with an accent badge counting the Bot messages that arrived since they left (99+ at most), like the Web Client's "Return to latest messages". Tapping it, sending a message, or scrolling back down to the newest message follows again and hides the button. - - **Pull up to type (iOS).** At the newest message, pulling the list further up past its end (the rubber-band bounce) and letting go focuses the composer and raises the keyboard, like pull-to-refresh in reverse. A drag counts only when it begins at the newest message (within the follow slop), so reading history never triggers it. A capsule with a keyboard symbol fades in above the composer as the pull grows, reading "Pull up to type" and switching to "Release to type" with one light haptic once the pull passes `pullToCompose.threshold` (40pt of offset, about 95pt of finger travel against the rubber band); letting go follows the newest message again first, so the keyboard does not cover it; easing back below it before release cancels. It is off where there is no composer (a Bot-to-Bot DM) or the Host is not connected. Android's overscroll stretches without moving the offset, so there is nothing to measure while the finger pulls: on Android an upward flick at the newest message (finger speed at least `pullToCompose.flick`, 1 dp/ms, with the list still at the newest message on release) focuses the composer with the same haptic, and no hint is drawn. + - **Pull up to type (iOS).** At the newest message, pulling the list further up past its end (the rubber-band bounce) and letting go focuses the composer and raises the keyboard, like pull-to-refresh in reverse. A drag counts only when it begins at the newest message (within the follow slop), so reading history never triggers it. A capsule with a keyboard symbol fades in above the composer as the pull grows, reading "Pull up to type" and switching to "Release to type" with one light haptic once the pull passes `pullToCompose.threshold` (40pt of offset, about 95pt of finger travel against the rubber band); letting go follows the newest message again first, so the keyboard does not cover it; easing back below it before release cancels. It is off where there is no composer (a Bot-to-Bot DM) or the Host is not connected. Android's overscroll stretches without moving the offset, so there is nothing to measure while the finger pulls: on Android an upward flick at the newest message (finger speed at least `pullToCompose.flick`, 0.3 dp/ms, so a calm upward swipe counts as well as a quick flick, with the list still at the newest message on release) focuses the composer with the same haptic, and no hint is drawn. - Settings → **Message display** offers **Streaming** and **Complete bubbles**, saved on this device. Streaming uses a local playback queue with a short prebuffer and reserve, releasing graphemes on a bounded clock. Rate changes follow queue depth gradually; clauses, sentences and paragraphs add progressively longer pauses. Android fades the active prose tail on the UI thread (180ms fade, up to 72ms stagger, at most 48 animated spans, with up to eight new spans per update); earlier text stays visible. Source commitment preserves the displayed cursor and row while the rest drains, then enables message actions. Historical messages open directly. Reduce Motion skips the fade. Code/table/native-only Markdown keep their established renderer; their content still follows the playback cursor. Complete bubbles keeps the working indicator and reveals each committed Channel Message independently. Switching modes changes presentation only; received drafts, committed messages and interruption semantics remain owned by Conversation and the Host. A draft-only append updates its row subscription while the timeline snapshot and historical message references stay stable. The bottom fade belongs to the keyboard-sticky composer. - Android Markdown uses synchronous native text/layout for prose, headings, lists, quotes, code and tables. Completed source blocks retain their keys and avoid rerendering when only the tail changes. Images and native-only extensions retain the enriched renderer. Drafts and committed messages keep the same renderer to avoid a settlement-only layout switch; iOS keeps its existing enriched implementation. - Append-only Android Markdown reparses the changing suffix with boundary lookback, retaining completed block identities. Blocks retain the lexer result with its exact source; rendering and window-support checks reuse it only while that source matches. A repaired streaming tail or a source-only block uses a fresh parse. Corrections and document-wide references rebuild the document. Prose reveal segmentation likewise retains a grapheme boundary and revisits the changing suffix, including a joined emoji or combining mark; it must not repeatedly segment the complete paragraph. @@ -222,7 +222,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - **+** sits outside the capsule, bottom-aligned as the field grows. While dictating it becomes **×** (cancel), and while the mic is held, a red dot. With one trailing button (the mic or Send) the capsule is one row: the field and the button. When the text no longer fits that one row, or with more (attached files, or the mic next to Send), the capsule becomes a rounded rectangle with the same corner radius and stacks like the web composer: the field on top at full width, and a row below with expand on the left and the buttons on the right. - **+** opens Photos, Camera and File below the composer. Picked files show as removable tiles inside the capsule, up to 10 per message. Send appears once there is text or a file. - While the field is focused and the clipboard holds an image, a panel above the composer, styled like the @ suggestions (Liquid Glass on iOS 26, otherwise the `sheet` layer), reads "Image on the clipboard" with a paste control. On iOS it is the system Paste button (`UIPasteControl`, through `expo-clipboard`'s `ClipboardPasteButton`, image only, icon only at accessibility text sizes), so no paste-permission prompt appears. On Android it is a text `Button` "Paste image" that reads the clipboard; Android only shows its own brief "pasted from your clipboard" notice. The image attaches as a JPEG tile and the row hides until something new is copied. The @ suggestions take that slot first. A text field cannot take an image paste on its own in React Native, so this is the paste path. - - With a hardware keyboard, **⌘↩** sends on iOS and iPadOS (listed in the ⌘-hold shortcut overlay as "Send"), and on Android **Enter** sends while **Shift+Enter** adds a line. **Ctrl+Enter** also sends on Android. In the full-screen draft editor plain Enter adds a line, and only ⌘↩ or Ctrl+Enter sends. The on-screen keyboard never sends; its return key adds a line as before. The local `hardware-send` module listens on the view around the field, so it only acts while the field is focused and the draft can be sent. + - With a hardware keyboard, **⌘↩** sends on iOS and iPadOS (listed in the ⌘-hold shortcut overlay as "Send"), and on Android **Enter** sends while **Shift+Enter** adds a line. **Ctrl+Enter** also sends on Android. In the full-screen draft editor plain Enter adds a line, and only ⌘↩ or Ctrl+Enter sends. The on-screen keyboard never sends; its return key adds a line as before. Input methods that take a hardware Enter and send it again as their own key, such as WeType, still send. The local `hardware-send` module listens on the view around the field, so it only acts while the field is focused and the draft can be sent. - Sending uploads each file to the Host first. A retry keeps the uploads that already finished. - Nothing sits under the composer, so the chat stays clean. A DM's Model and Workspace settings live in the Bot profile, in a **Bot settings** group under the profile actions: a **Model** row (the Bot's current model name, or "Loading…", "No model set" or "Unavailable") and a **Workspace** row (the folder count, "N folders"), each with a chevron. - **Model** opens the native Models and presets page inside the profile drawer: Orchestrator, Assignment default, allowed Assignment models, reasoning effort, restore global default, save as preset and apply a shared preset. The searchable chooser pushes inside that same drawer and returns only after an accepted save and canonical refresh. Editing only the Orchestrator preserves Assignment policy. Assignment edits retain other allowed models, efforts and notes; the current default cannot be removed until another is chosen. Existing preset copies and per-Bot plans retain the Host's revision checks. Global default updates use the Host's existing profile-default contract; mobile does not create a separate global Assignment authority. diff --git a/apps/mobile/modules/hardware-send/README.md b/apps/mobile/modules/hardware-send/README.md index 0967c4fa32..5659a07a35 100644 --- a/apps/mobile/modules/hardware-send/README.md +++ b/apps/mobile/modules/hardware-send/README.md @@ -12,7 +12,12 @@ draft and the send action stay in React Native, and the view only reports `EditText`. Enter sends when `enter` is set, Shift+Enter always adds a line, and Ctrl+Enter or Meta+Enter always sends. Key events flagged as coming from the on-screen keyboard, or from a virtual input device, pass through - unchanged. Like `stream-reveal`, the view keeps the size Yoga gives it and + unchanged, with one exception. Some input methods, such as WeType, take a + hardware Enter and send it again as their own key event. The view notes each + hardware Enter in `dispatchKeyEventPreIme`, before the input method sees it, + and treats an input-method Enter that follows within 500 ms as that key, with + its original modifiers. When the input method uses the Enter to confirm text + it is composing, it sends no Enter, so nothing is sent. Like `stream-reveal`, the view keeps the size Yoga gives it and leaves child layout to React Native. When `enabled` is false the view does nothing, so an empty draft or a diff --git a/apps/mobile/modules/hardware-send/android/src/main/java/expo/modules/hardwaresend/HardwareSendView.kt b/apps/mobile/modules/hardware-send/android/src/main/java/expo/modules/hardwaresend/HardwareSendView.kt index 7795755e7a..700b7abb85 100644 --- a/apps/mobile/modules/hardware-send/android/src/main/java/expo/modules/hardwaresend/HardwareSendView.kt +++ b/apps/mobile/modules/hardware-send/android/src/main/java/expo/modules/hardwaresend/HardwareSendView.kt @@ -1,6 +1,7 @@ package expo.modules.hardwaresend import android.content.Context +import android.os.SystemClock import android.view.KeyEvent import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher @@ -17,16 +18,49 @@ internal fun sendsOnKey(keyCode: Int, meta: Int, enter: Boolean, soft: Boolean): internal fun fromSoftKeyboard(event: KeyEvent): Boolean = event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0 || event.device?.isVirtual == true +internal class ImeEcho(private val window: Long) { + private var meta = 0 + private var at = Long.MIN_VALUE + + fun pressed(keyCode: Int, meta: Int, at: Long) { + if (keyCode != KeyEvent.KEYCODE_ENTER && keyCode != KeyEvent.KEYCODE_NUMPAD_ENTER) return + this.meta = meta + this.at = at + } + + fun echoed(keyCode: Int, now: Long): Int? { + if (keyCode != KeyEvent.KEYCODE_ENTER && keyCode != KeyEvent.KEYCODE_NUMPAD_ENTER) return null + if (at == Long.MIN_VALUE || now - at > window) return null + return meta + } + + fun clear() { + at = Long.MIN_VALUE + } +} + class HardwareSendView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { var sending = false var enter = false private val onSend by EventDispatcher() + private val echo = ImeEcho(ECHO_WINDOW) + + override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN && !fromSoftKeyboard(event)) { + echo.pressed(event.keyCode, event.metaState, event.eventTime) + } + return super.dispatchKeyEventPreIme(event) + } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (!sending || !sendsOnKey(event.keyCode, event.metaState, enter, fromSoftKeyboard(event))) { + val soft = fromSoftKeyboard(event) + val meta = if (soft) echo.echoed(event.keyCode, SystemClock.uptimeMillis()) else event.metaState + if (!sending || meta == null || !sendsOnKey(event.keyCode, meta, enter, false)) { return super.dispatchKeyEvent(event) } if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) onSend(mapOf()) + if (event.action == KeyEvent.ACTION_UP) echo.clear() return true } @@ -34,5 +68,9 @@ class HardwareSendView(context: Context, appContext: AppContext) : ExpoView(cont setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)) } + private companion object { + const val ECHO_WINDOW = 500L + } + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) = Unit } diff --git a/apps/mobile/modules/hardware-send/android/src/test/java/expo/modules/hardwaresend/HardwareSendTest.kt b/apps/mobile/modules/hardware-send/android/src/test/java/expo/modules/hardwaresend/HardwareSendTest.kt index 41d8269e9a..1332842b7c 100644 --- a/apps/mobile/modules/hardware-send/android/src/test/java/expo/modules/hardwaresend/HardwareSendTest.kt +++ b/apps/mobile/modules/hardware-send/android/src/test/java/expo/modules/hardwaresend/HardwareSendTest.kt @@ -1,7 +1,9 @@ package expo.modules.hardwaresend import android.view.KeyEvent +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -35,4 +37,26 @@ class HardwareSendTest { assertFalse(sendsOnKey(enterKey, 0, enter = true, soft = true)) assertFalse(sendsOnKey(enterKey, KeyEvent.META_CTRL_ON, enter = true, soft = true)) } + + @Test fun anInputMethodEchoOfAHardwareEnterKeepsItsModifiers() { + val echo = ImeEcho(500) + echo.pressed(enterKey, KeyEvent.META_CTRL_ON, at = 1_000) + assertEquals(KeyEvent.META_CTRL_ON, echo.echoed(enterKey, now = 1_020)) + } + + @Test fun anOnScreenEnterWithNoHardwareEnterBeforeItIsNoEcho() { + val echo = ImeEcho(500) + assertNull(echo.echoed(enterKey, now = 1_000)) + echo.pressed(KeyEvent.KEYCODE_A, 0, at = 1_000) + assertNull(echo.echoed(enterKey, now = 1_010)) + } + + @Test fun anEchoTooLateOrAfterTheKeyIsReleasedIsNoEcho() { + val echo = ImeEcho(500) + echo.pressed(enterKey, 0, at = 1_000) + assertNull(echo.echoed(enterKey, now = 1_501)) + echo.pressed(enterKey, 0, at = 2_000) + echo.clear() + assertNull(echo.echoed(enterKey, now = 2_010)) + } } diff --git a/apps/mobile/src/ui/chat/composer.tsx b/apps/mobile/src/ui/chat/composer.tsx index dfc6931da2..67c80f0aa4 100644 --- a/apps/mobile/src/ui/chat/composer.tsx +++ b/apps/mobile/src/ui/chat/composer.tsx @@ -580,6 +580,7 @@ export function Composer({ { const height = event.nativeEvent.lines[0]?.height; if (height !== undefined && height > 0) @@ -652,15 +653,19 @@ export function Composer({ {compactWidth > 0 ? ( - setWraps(event.nativeEvent.layout.height > textLine * 1.5)} style={{ position: 'absolute', opacity: 0, width: compactWidth, left: 0, top: 0 }} > - {value === '' ? ' ' : value} - + setWraps(event.nativeEvent.layout.height > textLine * 1.5)} + > + {value === '' ? ' ' : value} + + ) : null} diff --git a/apps/mobile/src/ui/design/tokens.ts b/apps/mobile/src/ui/design/tokens.ts index 6454fae2a4..6589b2e6bf 100644 --- a/apps/mobile/src/ui/design/tokens.ts +++ b/apps/mobile/src/ui/design/tokens.ts @@ -123,7 +123,7 @@ export const motion = { menu: 160, } as const; -export const pullToCompose = { threshold: 40, flick: 1 } as const; +export const pullToCompose = { threshold: 40, flick: 0.3 } as const; export const doubleTap = { window: 300, emoji: '👍' } as const; diff --git a/apps/mobile/src/ui/shell/recent-chats.ts b/apps/mobile/src/ui/shell/recent-chats.ts index 562a151dff..d376a3fd61 100644 --- a/apps/mobile/src/ui/shell/recent-chats.ts +++ b/apps/mobile/src/ui/shell/recent-chats.ts @@ -1,6 +1,7 @@ import * as QuickActions from 'expo-quick-actions'; import * as SecureStore from 'expo-secure-store'; import { useCallback, type RefCallback } from 'react'; +import { Platform } from 'react-native'; import { recentChats } from '../design/tokens'; @@ -39,11 +40,17 @@ export function parseRecentChats(raw: string | null): RecentChat[] { } } +export function shortcutIcon(os: string): string { + return os === 'android' ? 'ic_launcher' : 'symbol:bubble.left'; +} + +const icon = shortcutIcon(Platform.OS); + export function quickAction(chat: RecentChat): QuickActions.Action { return { id: chat.id, title: chat.title, - icon: 'symbol:bubble.left', + icon, params: { href: chat.href }, }; } diff --git a/apps/mobile/test/recent-chats.test.ts b/apps/mobile/test/recent-chats.test.ts index 6b0269937c..9cf7773d67 100644 --- a/apps/mobile/test/recent-chats.test.ts +++ b/apps/mobile/test/recent-chats.test.ts @@ -9,13 +9,14 @@ vi.mock('expo-secure-store', () => ({ stored.set(key, value); }, })); +vi.mock('react-native', () => import('./react-native-shim')); vi.mock('expo-quick-actions', () => ({ setItems: async (items: unknown[]) => { shortcuts.push(items); }, })); -const { chatHref, parseRecentChats, quickAction, recordRecentChat, rememberChat } = +const { chatHref, parseRecentChats, quickAction, recordRecentChat, rememberChat, shortcutIcon } = await import('../src/ui/shell/recent-chats'); const chat = (id: string, title = id) => ({ id, title, href: `/dm/${id}` }); @@ -40,6 +41,11 @@ describe('recent chats on the home-screen icon', () => { }); }); + it('uses a resource Android can draw, since it has no SF Symbols', () => { + expect(shortcutIcon('android')).toBe('ic_launcher'); + expect(shortcutIcon('ios')).toBe('symbol:bubble.left'); + }); + it('ignores stored entries that are damaged or point outside the app', () => { expect(parseRecentChats('not json')).toEqual([]); expect( diff --git a/changes/android-qol-fixes.md b/changes/android-qol-fixes.md new file mode 100644 index 0000000000..f3c55be756 --- /dev/null +++ b/changes/android-qol-fixes.md @@ -0,0 +1,11 @@ +--- +section: Fixed +--- + +## en + +- On Android, Enter and Ctrl+Enter on a hardware keyboard now send while WeType or a similar input method is active, instead of adding a line. The ✕ on an attachment above the message box removes it again, an upward swipe at the newest message opens the keyboard more reliably, and the recent chats on the app icon show the app icon instead of a blank placeholder (PR_LINK). + +## zh + +- Android 上使用微信输入法等输入法时,实体键盘的 Enter 和 Ctrl+Enter 现在会发送消息,不再只换行。输入框上方附件的 ✕ 可以正常删除附件,在最新消息处上滑拉起键盘更容易触发,长按应用图标列出的最近会话也显示应用图标,不再是空白占位图(PR_LINK)。 From 57878bb403bee22e4f45616277a947f30a61b6aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:19:32 +0000 Subject: [PATCH 2/6] docs(changes): link #2422 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TYsJ1DZQ7Vd8sKVs6MMhdF --- changes/{android-qol-fixes.md => 2422-android-qol-fixes.md} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename changes/{android-qol-fixes.md => 2422-android-qol-fixes.md} (78%) diff --git a/changes/android-qol-fixes.md b/changes/2422-android-qol-fixes.md similarity index 78% rename from changes/android-qol-fixes.md rename to changes/2422-android-qol-fixes.md index f3c55be756..969e7cfe35 100644 --- a/changes/android-qol-fixes.md +++ b/changes/2422-android-qol-fixes.md @@ -4,8 +4,8 @@ section: Fixed ## en -- On Android, Enter and Ctrl+Enter on a hardware keyboard now send while WeType or a similar input method is active, instead of adding a line. The ✕ on an attachment above the message box removes it again, an upward swipe at the newest message opens the keyboard more reliably, and the recent chats on the app icon show the app icon instead of a blank placeholder (PR_LINK). +- On Android, Enter and Ctrl+Enter on a hardware keyboard now send while WeType or a similar input method is active, instead of adding a line. The ✕ on an attachment above the message box removes it again, an upward swipe at the newest message opens the keyboard more reliably, and the recent chats on the app icon show the app icon instead of a blank placeholder ([#2422](https://github.com/BotHarness/DeepSeekBot/pull/2422)). ## zh -- Android 上使用微信输入法等输入法时,实体键盘的 Enter 和 Ctrl+Enter 现在会发送消息,不再只换行。输入框上方附件的 ✕ 可以正常删除附件,在最新消息处上滑拉起键盘更容易触发,长按应用图标列出的最近会话也显示应用图标,不再是空白占位图(PR_LINK)。 +- Android 上使用微信输入法等输入法时,实体键盘的 Enter 和 Ctrl+Enter 现在会发送消息,不再只换行。输入框上方附件的 ✕ 可以正常删除附件,在最新消息处上滑拉起键盘更容易触发,长按应用图标列出的最近会话也显示应用图标,不再是空白占位图([#2422](https://github.com/BotHarness/DeepSeekBot/pull/2422))。 From 3e76368e30ff8adc2fb3360901e6f7f6302ca178 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:23:06 +0000 Subject: [PATCH 3/6] fix(ci): keep the recent-chats test out of the root typecheck recent-chats.ts now imports react-native, whose timer globals clash with Node's in the root program, like the other excluded mobile tests. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TYsJ1DZQ7Vd8sKVs6MMhdF --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 887df97cec..5015bcc03c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -52,6 +52,7 @@ "apps/mobile/test/open-conversation.test.ts", "apps/mobile/test/quick-action-routing.test.ts", "apps/mobile/test/incoming-share.test.ts", + "apps/mobile/test/recent-chats.test.ts", "apps/mobile/test/grant-request-card.test.ts", "apps/mobile/test/attention-actions.test.ts", "apps/mobile/test/reanimated-shim.ts", From 9b0bfe271f04484deafc2a0c7ea2ca25d2700b5c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:14:03 +0000 Subject: [PATCH 4/6] fix(mobile): show shared image thumbnails and hide the keyboard on Send to Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TYsJ1DZQ7Vd8sKVs6MMhdF --- apps/mobile/DESIGN.md | 2 +- apps/mobile/src/ui/share/share-screen.tsx | 49 ++++++++++++++++++++++- changes/2422-android-qol-fixes.md | 4 +- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index daded6d207..11cf044fbc 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -155,7 +155,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - Swipe for pin and mute; long-press opens a native menu. - On iOS a long-press on a Host conversation row peeks at the chat, like Messages: the row lifts into the system context-menu preview (expo-router `Link.Preview`) showing the conversation itself, and tapping the preview opens it. A plain tap opens the conversation as before. - A long-press on the app icon lists the three chats opened most recently (Home Screen quick actions on iOS, app shortcuts on Android), newest first, and tapping one opens that chat on its Host. iOS marks each with a chat-bubble symbol; Android, which has no SF Symbols, shows the app icon. The list updates whenever a chat opens and is kept with the device's saved Host list. -- **Share to a Bot.** DeepSeekBot is a target in the system share sheet (an iOS Share Extension, Android `SEND` / `SEND_MULTIPLE`). Sharing text, a link, photos, videos or files opens **Send to**: a preview of what arrived above the paired Host's chats, in the Chats order. Picking a chat opens it with the text added below any unsent draft and the files waiting as attachment tiles; nothing is sent until the Human taps Send. Cloud channels are not targets yet, and with no paired Host the screen says to pair first. +- **Share to a Bot.** DeepSeekBot is a target in the system share sheet (an iOS Share Extension, Android `SEND` / `SEND_MULTIPLE`). Sharing text, a link, photos, videos or files opens **Send to**: a preview of what arrived (the text, a thumbnail per image, a file tile per other file, and the count) above the paired Host's chats, in the Chats order. Opening it hides the keyboard, so the list is never covered. Files keep the order the sharing app sends them in. Picking a chat opens it with the text added below any unsent draft and the files waiting as attachment tiles; nothing is sent until the Human taps Send. Cloud channels are not targets yet, and with no paired Host the screen says to pair first. **Count chip** diff --git a/apps/mobile/src/ui/share/share-screen.tsx b/apps/mobile/src/ui/share/share-screen.tsx index 7bbdfc53e9..3ff9536350 100644 --- a/apps/mobile/src/ui/share/share-screen.tsx +++ b/apps/mobile/src/ui/share/share-screen.tsx @@ -1,7 +1,7 @@ import { useIncomingShare } from 'expo-sharing'; import { useRouter } from 'expo-router'; import { useMemo, useState } from 'react'; -import { ActivityIndicator, FlatList, View } from 'react-native'; +import { ActivityIndicator, FlatList, Image, Keyboard, ScrollView, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useHostSession, useHostSnapshot, useQuietChannels } from '../../host/host-session'; @@ -13,6 +13,8 @@ import { radius, space } from '../design/tokens'; import { Button } from '../primitives/button'; import { DrawerHeader } from '../primitives/drawer-header'; import { Text } from '../primitives/text'; +import { useMounted } from '../mounted'; +import { FileIcon } from '../shell/icons'; import { conversationHref } from '../shell/open-conversation'; import { t } from '../strings'; import { deliverShare, incomingShare } from './incoming-share'; @@ -40,6 +42,8 @@ export default function ShareScreen() { [snapshot.roster, quiet], ); const [sending, setSending] = useState(false); + const mount = useMounted>(() => Keyboard.dismiss(), []); + const thumb = theme.touch + space.x5; const empty = !incoming.isResolving && share.text === '' && share.files.length === 0; const send = async (row: Conversation) => { if (saved === undefined || saved === null || sending || empty) return; @@ -55,6 +59,7 @@ export default function ShareScreen() { }; return ( {share.text !== '' && {share.text}} + {share.files.length > 0 && ( + + {share.files.map((file) => + file.kind === 'image' ? ( + + ) : ( + + + + {file.name} + + + ), + )} + + )} {share.files.length > 0 && ( {t.share.files(share.files.length)} diff --git a/changes/2422-android-qol-fixes.md b/changes/2422-android-qol-fixes.md index 969e7cfe35..0d1090cfb9 100644 --- a/changes/2422-android-qol-fixes.md +++ b/changes/2422-android-qol-fixes.md @@ -4,8 +4,8 @@ section: Fixed ## en -- On Android, Enter and Ctrl+Enter on a hardware keyboard now send while WeType or a similar input method is active, instead of adding a line. The ✕ on an attachment above the message box removes it again, an upward swipe at the newest message opens the keyboard more reliably, and the recent chats on the app icon show the app icon instead of a blank placeholder ([#2422](https://github.com/BotHarness/DeepSeekBot/pull/2422)). +- On Android, Enter and Ctrl+Enter on a hardware keyboard now send while WeType or a similar input method is active, instead of adding a line. The ✕ on an attachment above the message box removes it again, an upward swipe at the newest message opens the keyboard more reliably, and the recent chats on the app icon show the app icon instead of a blank placeholder. **Send to** shows thumbnails of shared images and hides the keyboard when it opens ([#2422](https://github.com/BotHarness/DeepSeekBot/pull/2422)). ## zh -- Android 上使用微信输入法等输入法时,实体键盘的 Enter 和 Ctrl+Enter 现在会发送消息,不再只换行。输入框上方附件的 ✕ 可以正常删除附件,在最新消息处上滑拉起键盘更容易触发,长按应用图标列出的最近会话也显示应用图标,不再是空白占位图([#2422](https://github.com/BotHarness/DeepSeekBot/pull/2422))。 +- Android 上使用微信输入法等输入法时,实体键盘的 Enter 和 Ctrl+Enter 现在会发送消息,不再只换行。输入框上方附件的 ✕ 可以正常删除附件,在最新消息处上滑拉起键盘更容易触发,长按应用图标列出的最近会话也显示应用图标,不再是空白占位图。「发送到」会显示分享图片的缩略图,打开时收起键盘([#2422](https://github.com/BotHarness/DeepSeekBot/pull/2422))。 From f4bcfbfae6165e61d6d737f28a3c4a7a4fc22f7f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 18:41:42 +0000 Subject: [PATCH 5/6] fix(mobile): send when an input method types the newline for a hardware Enter WeType on HyperOS types a newline into the field instead of sending the Enter key again. The view now watches the focused field and turns a newline typed within 500 ms of a sending hardware Enter into a send. Also recheck the clipboard once Android gives the window focus back, and name shared files that arrive as bare media ids after their type. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TYsJ1DZQ7Vd8sKVs6MMhdF --- apps/mobile/modules/hardware-send/README.md | 8 ++- .../modules/hardwaresend/HardwareSendView.kt | 61 +++++++++++++++++++ .../modules/hardwaresend/HardwareSendTest.kt | 7 +++ apps/mobile/src/ui/chat/clipboard-image.tsx | 10 ++- apps/mobile/src/ui/share/incoming-share.ts | 8 ++- apps/mobile/test/incoming-share.test.ts | 14 +++++ changes/2422-android-qol-fixes.md | 4 +- 7 files changed, 106 insertions(+), 6 deletions(-) diff --git a/apps/mobile/modules/hardware-send/README.md b/apps/mobile/modules/hardware-send/README.md index 5659a07a35..d170fd844e 100644 --- a/apps/mobile/modules/hardware-send/README.md +++ b/apps/mobile/modules/hardware-send/README.md @@ -16,8 +16,12 @@ draft and the send action stay in React Native, and the view only reports hardware Enter and send it again as their own key event. The view notes each hardware Enter in `dispatchKeyEventPreIme`, before the input method sees it, and treats an input-method Enter that follows within 500 ms as that key, with - its original modifiers. When the input method uses the Enter to confirm text - it is composing, it sends no Enter, so nothing is sent. Like `stream-reveal`, the view keeps the size Yoga gives it and + its original modifiers. Other input methods, including WeType on HyperOS, + type a newline into the field instead of sending a key. The view watches the + focused field inside it, and a single newline typed within 500 ms of a + hardware Enter that would send is removed and sends instead. When the input + method uses the Enter to confirm text it is composing, it types that text and + no newline, so nothing is sent. Like `stream-reveal`, the view keeps the size Yoga gives it and leaves child layout to React Native. When `enabled` is false the view does nothing, so an empty draft or a diff --git a/apps/mobile/modules/hardware-send/android/src/main/java/expo/modules/hardwaresend/HardwareSendView.kt b/apps/mobile/modules/hardware-send/android/src/main/java/expo/modules/hardwaresend/HardwareSendView.kt index 700b7abb85..e0767b91f0 100644 --- a/apps/mobile/modules/hardware-send/android/src/main/java/expo/modules/hardwaresend/HardwareSendView.kt +++ b/apps/mobile/modules/hardware-send/android/src/main/java/expo/modules/hardwaresend/HardwareSendView.kt @@ -2,7 +2,12 @@ package expo.modules.hardwaresend import android.content.Context import android.os.SystemClock +import android.text.Editable +import android.text.TextWatcher import android.view.KeyEvent +import android.view.View +import android.view.ViewTreeObserver +import android.widget.EditText import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView @@ -18,6 +23,9 @@ internal fun sendsOnKey(keyCode: Int, meta: Int, enter: Boolean, soft: Boolean): internal fun fromSoftKeyboard(event: KeyEvent): Boolean = event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0 || event.device?.isVirtual == true +internal fun insertedNewline(text: CharSequence, start: Int, before: Int, count: Int): Boolean = + before == 0 && count == 1 && start < text.length && text[start] == '\n' + internal class ImeEcho(private val window: Long) { private var meta = 0 private var at = Long.MIN_VALUE @@ -64,6 +72,59 @@ class HardwareSendView(context: Context, appContext: AppContext) : ExpoView(cont return true } + private var watched: EditText? = null + private var newline = -1 + + private val watcher = object : TextWatcher { + override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) { + if (!sending || !insertedNewline(text, start, before, count)) return + val meta = echo.echoed(KeyEvent.KEYCODE_ENTER, SystemClock.uptimeMillis()) ?: return + if (sendsOnKey(KeyEvent.KEYCODE_ENTER, meta, enter, false)) newline = start + } + + override fun afterTextChanged(text: Editable) { + val at = newline + if (at < 0) return + newline = -1 + echo.clear() + if (at < text.length && text[at] == '\n') text.delete(at, at + 1) + onSend(mapOf()) + } + } + + private val focus = ViewTreeObserver.OnGlobalFocusChangeListener { _, next -> watch(next) } + + private fun watch(next: View?) { + val field = (next as? EditText)?.takeIf { isAncestorOf(it) } + if (field === watched) return + watched?.removeTextChangedListener(watcher) + watched = field + field?.addTextChangedListener(watcher) + } + + private fun isAncestorOf(view: View): Boolean { + var parent = view.parent + while (parent != null) { + if (parent === this) return true + parent = parent.parent + } + return false + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + viewTreeObserver.addOnGlobalFocusChangeListener(focus) + watch(findFocus()) + } + + override fun onDetachedFromWindow() { + viewTreeObserver.removeOnGlobalFocusChangeListener(focus) + watch(null) + super.onDetachedFromWindow() + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)) } diff --git a/apps/mobile/modules/hardware-send/android/src/test/java/expo/modules/hardwaresend/HardwareSendTest.kt b/apps/mobile/modules/hardware-send/android/src/test/java/expo/modules/hardwaresend/HardwareSendTest.kt index 1332842b7c..ca4e12058c 100644 --- a/apps/mobile/modules/hardware-send/android/src/test/java/expo/modules/hardwaresend/HardwareSendTest.kt +++ b/apps/mobile/modules/hardware-send/android/src/test/java/expo/modules/hardwaresend/HardwareSendTest.kt @@ -59,4 +59,11 @@ class HardwareSendTest { echo.clear() assertNull(echo.echoed(enterKey, now = 2_010)) } + + @Test fun onlyASingleTypedNewlineCountsAsAnInputMethodEnter() { + assertTrue(insertedNewline("hi\n", start = 2, before = 0, count = 1)) + assertFalse(insertedNewline("hi\nthere", start = 2, before = 0, count = 7)) + assertFalse(insertedNewline("hb", start = 1, before = 0, count = 1)) + assertFalse(insertedNewline("h\n", start = 1, before = 1, count = 1)) + } } diff --git a/apps/mobile/src/ui/chat/clipboard-image.tsx b/apps/mobile/src/ui/chat/clipboard-image.tsx index b4f0fa33a0..f93ac9db93 100644 --- a/apps/mobile/src/ui/chat/clipboard-image.tsx +++ b/apps/mobile/src/ui/chat/clipboard-image.tsx @@ -13,6 +13,8 @@ import { ComposerFieldMaterial } from './composer-chrome'; type ClipboardState = { image: boolean; generation: number }; +const FOCUS_SETTLE = 500; + let state: ClipboardState = { image: false, generation: 0 }; const readClipboard = () => state; @@ -41,12 +43,18 @@ function subscribeClipboard(notify: () => void) { () => {}, ); }; + let settle: ReturnType | undefined; const foreground = AppState.addEventListener('change', (next) => { - if (next === 'active') refresh(true); + if (next !== 'active') return; + refresh(true); + if (Platform.OS !== 'android') return; + clearTimeout(settle); + settle = setTimeout(() => refresh(!state.image), FOCUS_SETTLE); }); refresh(false); return () => { active = false; + clearTimeout(settle); subscription.remove(); foreground.remove(); }; diff --git a/apps/mobile/src/ui/share/incoming-share.ts b/apps/mobile/src/ui/share/incoming-share.ts index e8232e4068..8b7b3c002b 100644 --- a/apps/mobile/src/ui/share/incoming-share.ts +++ b/apps/mobile/src/ui/share/incoming-share.ts @@ -5,6 +5,10 @@ import { sourceDrafts } from '../../host/native-source-drafts'; export type IncomingShare = { text: string; files: PickedFile[] }; +export function fileName(name: string | null): string | null { + return name !== null && /\.[^./\s]+$/u.test(name) ? name : null; +} + export function incomingShare(payloads: readonly ResolvedSharePayload[]): IncomingShare { const text: string[] = []; const files: PickedFile[] = []; @@ -18,7 +22,9 @@ export function incomingShare(payloads: readonly ResolvedSharePayload[]): Incomi payload.contentType !== 'text'; if (attachment) { if (files.length < MAX_PICKED) - files.push(fromSharedFile(uri, payload.originalName, payload.contentMimeType ?? null)); + files.push( + fromSharedFile(uri, fileName(payload.originalName), payload.contentMimeType ?? null), + ); } else if (payload.value.trim() !== '') text.push(payload.value.trim()); } return { text: text.join('\n'), files }; diff --git a/apps/mobile/test/incoming-share.test.ts b/apps/mobile/test/incoming-share.test.ts index a56124d416..6bc46f0e08 100644 --- a/apps/mobile/test/incoming-share.test.ts +++ b/apps/mobile/test/incoming-share.test.ts @@ -72,6 +72,20 @@ describe('share to Bot', () => { ]); }); + it('names a shared file after its type when the gallery gives only a media id', () => { + const share = incomingShare([ + payload({ + value: 'content://media/1000044168', + shareType: 'image', + contentUri: 'content://media/1000044168', + contentType: 'image', + contentMimeType: 'image/jpeg', + originalName: '1000044168', + }), + ]); + expect(share.files[0]?.name).toMatch(/^shared-\d+\.jpe?g$/u); + }); + it('caps attachments at the composer limit', () => { const many = Array.from({ length: 12 }, (_, index) => payload({ diff --git a/changes/2422-android-qol-fixes.md b/changes/2422-android-qol-fixes.md index 0d1090cfb9..bb4f232cdf 100644 --- a/changes/2422-android-qol-fixes.md +++ b/changes/2422-android-qol-fixes.md @@ -4,8 +4,8 @@ section: Fixed ## en -- On Android, Enter and Ctrl+Enter on a hardware keyboard now send while WeType or a similar input method is active, instead of adding a line. The ✕ on an attachment above the message box removes it again, an upward swipe at the newest message opens the keyboard more reliably, and the recent chats on the app icon show the app icon instead of a blank placeholder. **Send to** shows thumbnails of shared images and hides the keyboard when it opens ([#2422](https://github.com/BotHarness/DeepSeekBot/pull/2422)). +- On Android, Enter and Ctrl+Enter on a hardware keyboard now send while WeType or a similar input method is active, instead of adding a line. The ✕ on an attachment above the message box removes it again, an upward swipe at the newest message opens the keyboard more reliably, and the recent chats on the app icon show the app icon instead of a blank placeholder. **Send to** shows thumbnails of shared images and hides the keyboard when it opens, and an image copied in another app is offered for pasting as soon as you come back ([#2422](https://github.com/BotHarness/DeepSeekBot/pull/2422)). ## zh -- Android 上使用微信输入法等输入法时,实体键盘的 Enter 和 Ctrl+Enter 现在会发送消息,不再只换行。输入框上方附件的 ✕ 可以正常删除附件,在最新消息处上滑拉起键盘更容易触发,长按应用图标列出的最近会话也显示应用图标,不再是空白占位图。「发送到」会显示分享图片的缩略图,打开时收起键盘([#2422](https://github.com/BotHarness/DeepSeekBot/pull/2422))。 +- Android 上使用微信输入法等输入法时,实体键盘的 Enter 和 Ctrl+Enter 现在会发送消息,不再只换行。输入框上方附件的 ✕ 可以正常删除附件,在最新消息处上滑拉起键盘更容易触发,长按应用图标列出的最近会话也显示应用图标,不再是空白占位图。「发送到」会显示分享图片的缩略图,打开时收起键盘;在其他应用里复制图片后回来,马上就能粘贴([#2422](https://github.com/BotHarness/DeepSeekBot/pull/2422))。 From 9b4a13fd15baa2e8bc8044ed7b44c9129ddd6efc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:42:57 +0000 Subject: [PATCH 6/6] fix(mobile): keep the keyboard down while Send to is open Android can bring the keyboard back for the chat underneath after the share sheet opens. Hide it again whenever it shows or the app becomes active. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TYsJ1DZQ7Vd8sKVs6MMhdF --- apps/mobile/DESIGN.md | 2 +- apps/mobile/src/ui/share/share-screen.tsx | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index 11cf044fbc..a244eb1b46 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -155,7 +155,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - Swipe for pin and mute; long-press opens a native menu. - On iOS a long-press on a Host conversation row peeks at the chat, like Messages: the row lifts into the system context-menu preview (expo-router `Link.Preview`) showing the conversation itself, and tapping the preview opens it. A plain tap opens the conversation as before. - A long-press on the app icon lists the three chats opened most recently (Home Screen quick actions on iOS, app shortcuts on Android), newest first, and tapping one opens that chat on its Host. iOS marks each with a chat-bubble symbol; Android, which has no SF Symbols, shows the app icon. The list updates whenever a chat opens and is kept with the device's saved Host list. -- **Share to a Bot.** DeepSeekBot is a target in the system share sheet (an iOS Share Extension, Android `SEND` / `SEND_MULTIPLE`). Sharing text, a link, photos, videos or files opens **Send to**: a preview of what arrived (the text, a thumbnail per image, a file tile per other file, and the count) above the paired Host's chats, in the Chats order. Opening it hides the keyboard, so the list is never covered. Files keep the order the sharing app sends them in. Picking a chat opens it with the text added below any unsent draft and the files waiting as attachment tiles; nothing is sent until the Human taps Send. Cloud channels are not targets yet, and with no paired Host the screen says to pair first. +- **Share to a Bot.** DeepSeekBot is a target in the system share sheet (an iOS Share Extension, Android `SEND` / `SEND_MULTIPLE`). Sharing text, a link, photos, videos or files opens **Send to**: a preview of what arrived (the text, a thumbnail per image, a file tile per other file, and the count) above the paired Host's chats, in the Chats order. Opening it hides the keyboard, and it hides the keyboard again if the system brings it back while the screen is open, so the list is never covered. Files keep the order the sharing app sends them in. Picking a chat opens it with the text added below any unsent draft and the files waiting as attachment tiles; nothing is sent until the Human taps Send. Cloud channels are not targets yet, and with no paired Host the screen says to pair first. **Count chip** diff --git a/apps/mobile/src/ui/share/share-screen.tsx b/apps/mobile/src/ui/share/share-screen.tsx index 3ff9536350..031e6c1769 100644 --- a/apps/mobile/src/ui/share/share-screen.tsx +++ b/apps/mobile/src/ui/share/share-screen.tsx @@ -1,7 +1,15 @@ import { useIncomingShare } from 'expo-sharing'; import { useRouter } from 'expo-router'; import { useMemo, useState } from 'react'; -import { ActivityIndicator, FlatList, Image, Keyboard, ScrollView, View } from 'react-native'; +import { + ActivityIndicator, + AppState, + FlatList, + Image, + Keyboard, + ScrollView, + View, +} from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useHostSession, useHostSnapshot, useQuietChannels } from '../../host/host-session'; @@ -42,7 +50,17 @@ export default function ShareScreen() { [snapshot.roster, quiet], ); const [sending, setSending] = useState(false); - const mount = useMounted>(() => Keyboard.dismiss(), []); + const mount = useMounted>(() => { + Keyboard.dismiss(); + const shown = Keyboard.addListener('keyboardDidShow', () => Keyboard.dismiss()); + const active = AppState.addEventListener('change', (next) => { + if (next === 'active') Keyboard.dismiss(); + }); + return () => { + shown.remove(); + active.remove(); + }; + }, []); const thumb = theme.touch + space.x5; const empty = !incoming.isResolving && share.text === '' && share.files.length === 0; const send = async (row: Conversation) => {