diff --git a/.github/pr-screenshots/2355-native-composer/1-dm-rest-dark.png b/.github/pr-screenshots/2355-native-composer/1-dm-rest-dark.png new file mode 100644 index 0000000000..5f21db1fea Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/1-dm-rest-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/1-dm-rest-light.png b/.github/pr-screenshots/2355-native-composer/1-dm-rest-light.png new file mode 100644 index 0000000000..941e8493ba Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/1-dm-rest-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/1-rest-behind-glass-dark.png b/.github/pr-screenshots/2355-native-composer/1-rest-behind-glass-dark.png new file mode 100644 index 0000000000..163e90ca3a Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/1-rest-behind-glass-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/1-rest-behind-glass-light.png b/.github/pr-screenshots/2355-native-composer/1-rest-behind-glass-light.png new file mode 100644 index 0000000000..2afe8ccadd Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/1-rest-behind-glass-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/1-rest-group-dark.png b/.github/pr-screenshots/2355-native-composer/1-rest-group-dark.png new file mode 100644 index 0000000000..2367f6238a Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/1-rest-group-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/1-rest-group-light.png b/.github/pr-screenshots/2355-native-composer/1-rest-group-light.png new file mode 100644 index 0000000000..55b9d8d5eb Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/1-rest-group-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/15e7-1-dm-mid-dark.png b/.github/pr-screenshots/2355-native-composer/15e7-1-dm-mid-dark.png new file mode 100644 index 0000000000..d949ab95e0 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/15e7-1-dm-mid-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/15e7-1-dm-mid-light.png b/.github/pr-screenshots/2355-native-composer/15e7-1-dm-mid-light.png new file mode 100644 index 0000000000..0874ba802f Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/15e7-1-dm-mid-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/15e7-1-dm-rest-dark.png b/.github/pr-screenshots/2355-native-composer/15e7-1-dm-rest-dark.png new file mode 100644 index 0000000000..d949ab95e0 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/15e7-1-dm-rest-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/15e7-1-dm-rest-light.png b/.github/pr-screenshots/2355-native-composer/15e7-1-dm-rest-light.png new file mode 100644 index 0000000000..b090490a9b Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/15e7-1-dm-rest-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/15e7-2-multiline-keyboard-dark.png b/.github/pr-screenshots/2355-native-composer/15e7-2-multiline-keyboard-dark.png new file mode 100644 index 0000000000..e3ab791677 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/15e7-2-multiline-keyboard-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/15e7-2-multiline-keyboard-light.png b/.github/pr-screenshots/2355-native-composer/15e7-2-multiline-keyboard-light.png new file mode 100644 index 0000000000..979be61872 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/15e7-2-multiline-keyboard-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/15e7-3-group-rest-dark.png b/.github/pr-screenshots/2355-native-composer/15e7-3-group-rest-dark.png new file mode 100644 index 0000000000..5d36e0f56a Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/15e7-3-group-rest-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/15e7-3-group-rest-light.png b/.github/pr-screenshots/2355-native-composer/15e7-3-group-rest-light.png new file mode 100644 index 0000000000..1e34ea1f06 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/15e7-3-group-rest-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/15e7-4-group-reduce-transparency-dark.png b/.github/pr-screenshots/2355-native-composer/15e7-4-group-reduce-transparency-dark.png new file mode 100644 index 0000000000..485a249bce Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/15e7-4-group-reduce-transparency-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/2-one-line-dark.png b/.github/pr-screenshots/2355-native-composer/2-one-line-dark.png new file mode 100644 index 0000000000..fd1fb44f96 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/2-one-line-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/2-one-line-light.png b/.github/pr-screenshots/2355-native-composer/2-one-line-light.png new file mode 100644 index 0000000000..3622564284 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/2-one-line-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/2c48-2-draft4-mid-dark.png b/.github/pr-screenshots/2355-native-composer/2c48-2-draft4-mid-dark.png new file mode 100644 index 0000000000..e5f8bbd899 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/2c48-2-draft4-mid-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/2c48-3-draft4-kbd-light.png b/.github/pr-screenshots/2355-native-composer/2c48-3-draft4-kbd-light.png new file mode 100644 index 0000000000..42d453d305 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/2c48-3-draft4-kbd-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/2c48-5-jump-rest-light.png b/.github/pr-screenshots/2355-native-composer/2c48-5-jump-rest-light.png new file mode 100644 index 0000000000..2d95471540 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/2c48-5-jump-rest-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/2c48-6-typed-send-light.png b/.github/pr-screenshots/2355-native-composer/2c48-6-typed-send-light.png new file mode 100644 index 0000000000..923bc47af0 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/2c48-6-typed-send-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/2c48-a-oneline-kbd-light.png b/.github/pr-screenshots/2355-native-composer/2c48-a-oneline-kbd-light.png new file mode 100644 index 0000000000..4ca87d2a77 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/2c48-a-oneline-kbd-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/2c48-b-draft4-nokbd-light.png b/.github/pr-screenshots/2355-native-composer/2c48-b-draft4-nokbd-light.png new file mode 100644 index 0000000000..3a912ba78f Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/2c48-b-draft4-nokbd-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/3-multiline-dark.png b/.github/pr-screenshots/2355-native-composer/3-multiline-dark.png new file mode 100644 index 0000000000..9658d90d0f Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/3-multiline-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/3-multiline-light.png b/.github/pr-screenshots/2355-native-composer/3-multiline-light.png new file mode 100644 index 0000000000..0dfee7da19 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/3-multiline-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/4-attachments-dark.png b/.github/pr-screenshots/2355-native-composer/4-attachments-dark.png new file mode 100644 index 0000000000..10d616c3d8 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/4-attachments-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/4-attachments-light.png b/.github/pr-screenshots/2355-native-composer/4-attachments-light.png new file mode 100644 index 0000000000..57fe141dc3 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/4-attachments-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/5-dictation-light.png b/.github/pr-screenshots/2355-native-composer/5-dictation-light.png new file mode 100644 index 0000000000..c1ef539f16 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/5-dictation-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/6-mention-dark.png b/.github/pr-screenshots/2355-native-composer/6-mention-dark.png new file mode 100644 index 0000000000..4896f44024 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/6-mention-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/6-mention-light.png b/.github/pr-screenshots/2355-native-composer/6-mention-light.png new file mode 100644 index 0000000000..d94de8c4af Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/6-mention-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/7-multiline-ax5-dark.png b/.github/pr-screenshots/2355-native-composer/7-multiline-ax5-dark.png new file mode 100644 index 0000000000..250a736a8d Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/7-multiline-ax5-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/7-multiline-ax5-light.png b/.github/pr-screenshots/2355-native-composer/7-multiline-ax5-light.png new file mode 100644 index 0000000000..d067197acb Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/7-multiline-ax5-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/7-rest-ax5-dark.png b/.github/pr-screenshots/2355-native-composer/7-rest-ax5-dark.png new file mode 100644 index 0000000000..3b71596ada Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/7-rest-ax5-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/7-rest-ax5-light.png b/.github/pr-screenshots/2355-native-composer/7-rest-ax5-light.png new file mode 100644 index 0000000000..fbc8e1ccb1 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/7-rest-ax5-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/779b-1-dm-mid-dark.png b/.github/pr-screenshots/2355-native-composer/779b-1-dm-mid-dark.png new file mode 100644 index 0000000000..ea045331cd Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/779b-1-dm-mid-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/779b-1-dm-mid-light.png b/.github/pr-screenshots/2355-native-composer/779b-1-dm-mid-light.png new file mode 100644 index 0000000000..5127480545 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/779b-1-dm-mid-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/779b-2-keyboard-light.png b/.github/pr-screenshots/2355-native-composer/779b-2-keyboard-light.png new file mode 100644 index 0000000000..0b89b6e07a Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/779b-2-keyboard-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/779b-3-reduce-transparency-dark.png b/.github/pr-screenshots/2355-native-composer/779b-3-reduce-transparency-dark.png new file mode 100644 index 0000000000..6664d8c61b Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/779b-3-reduce-transparency-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/779b-5-jump-rest-light.png b/.github/pr-screenshots/2355-native-composer/779b-5-jump-rest-light.png new file mode 100644 index 0000000000..5284e007b1 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/779b-5-jump-rest-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/779b-6-typed-send-light.png b/.github/pr-screenshots/2355-native-composer/779b-6-typed-send-light.png new file mode 100644 index 0000000000..b3ab54baf7 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/779b-6-typed-send-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/8-rest-reduce-transparency-dark.png b/.github/pr-screenshots/2355-native-composer/8-rest-reduce-transparency-dark.png new file mode 100644 index 0000000000..6c8eefa35d Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/8-rest-reduce-transparency-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/8-rest-reduce-transparency-light.png b/.github/pr-screenshots/2355-native-composer/8-rest-reduce-transparency-light.png new file mode 100644 index 0000000000..a8e1c02ed2 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/8-rest-reduce-transparency-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/a82b-1-draft4-dismissed-light.png b/.github/pr-screenshots/2355-native-composer/a82b-1-draft4-dismissed-light.png new file mode 100644 index 0000000000..05272f38c1 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/a82b-1-draft4-dismissed-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/a82b-2-draft4-reopened-light.png b/.github/pr-screenshots/2355-native-composer/a82b-2-draft4-reopened-light.png new file mode 100644 index 0000000000..cb6328f0a1 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/a82b-2-draft4-reopened-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/a82b-3-draft4-dismissed-dark.png b/.github/pr-screenshots/2355-native-composer/a82b-3-draft4-dismissed-dark.png new file mode 100644 index 0000000000..09358f808c Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/a82b-3-draft4-dismissed-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/a82b-4-draft4-kbd-light.png b/.github/pr-screenshots/2355-native-composer/a82b-4-draft4-kbd-light.png new file mode 100644 index 0000000000..ca35217ee2 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/a82b-4-draft4-kbd-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/a82b-5-mention-panel-dark.png b/.github/pr-screenshots/2355-native-composer/a82b-5-mention-panel-dark.png new file mode 100644 index 0000000000..dbb4233dce Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/a82b-5-mention-panel-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/a82b-5-mention-panel-light.png b/.github/pr-screenshots/2355-native-composer/a82b-5-mention-panel-light.png new file mode 100644 index 0000000000..d1a9ec56fa Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/a82b-5-mention-panel-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/c17b-2-draft4-nokbd-dark.png b/.github/pr-screenshots/2355-native-composer/c17b-2-draft4-nokbd-dark.png new file mode 100644 index 0000000000..7ba3b8f7c0 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/c17b-2-draft4-nokbd-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/c17b-3-draft4-kbd-light.png b/.github/pr-screenshots/2355-native-composer/c17b-3-draft4-kbd-light.png new file mode 100644 index 0000000000..9af7b86254 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/c17b-3-draft4-kbd-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/c17b-5-empty-light.png b/.github/pr-screenshots/2355-native-composer/c17b-5-empty-light.png new file mode 100644 index 0000000000..ccf02f3a0f Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/c17b-5-empty-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/c17b-a-oneline-kbd-light.png b/.github/pr-screenshots/2355-native-composer/c17b-a-oneline-kbd-light.png new file mode 100644 index 0000000000..e82dd4cd84 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/c17b-a-oneline-kbd-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/c17b-b-draft4-nokbd-light.png b/.github/pr-screenshots/2355-native-composer/c17b-b-draft4-nokbd-light.png new file mode 100644 index 0000000000..be9105a6f3 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/c17b-b-draft4-nokbd-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/c17b-c-empty-kbd-light.png b/.github/pr-screenshots/2355-native-composer/c17b-c-empty-kbd-light.png new file mode 100644 index 0000000000..9c4d0c0128 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/c17b-c-empty-kbd-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/d256-2-draft4-nokbd-dark.png b/.github/pr-screenshots/2355-native-composer/d256-2-draft4-nokbd-dark.png new file mode 100644 index 0000000000..7bc07fdaf6 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/d256-2-draft4-nokbd-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/d256-3-draft4-kbd-light.png b/.github/pr-screenshots/2355-native-composer/d256-3-draft4-kbd-light.png new file mode 100644 index 0000000000..0c49fc82a2 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/d256-3-draft4-kbd-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/d256-4-reduce-transparency-dark.png b/.github/pr-screenshots/2355-native-composer/d256-4-reduce-transparency-dark.png new file mode 100644 index 0000000000..4aa4feabc4 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/d256-4-reduce-transparency-dark.png differ diff --git a/.github/pr-screenshots/2355-native-composer/d256-5-empty-light.png b/.github/pr-screenshots/2355-native-composer/d256-5-empty-light.png new file mode 100644 index 0000000000..bc9bb249a6 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/d256-5-empty-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/d256-a-oneline-kbd-light.png b/.github/pr-screenshots/2355-native-composer/d256-a-oneline-kbd-light.png new file mode 100644 index 0000000000..cc3458bb3b Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/d256-a-oneline-kbd-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/d256-b-draft4-nokbd-light.png b/.github/pr-screenshots/2355-native-composer/d256-b-draft4-nokbd-light.png new file mode 100644 index 0000000000..08de41ec0d Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/d256-b-draft4-nokbd-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/d256-c-empty-kbd-light.png b/.github/pr-screenshots/2355-native-composer/d256-c-empty-kbd-light.png new file mode 100644 index 0000000000..8985041f03 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/d256-c-empty-kbd-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/eae7-5-jump-rest-light.png b/.github/pr-screenshots/2355-native-composer/eae7-5-jump-rest-light.png new file mode 100644 index 0000000000..98b5eda381 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/eae7-5-jump-rest-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/eae7-6-typed-send-light.png b/.github/pr-screenshots/2355-native-composer/eae7-6-typed-send-light.png new file mode 100644 index 0000000000..e5c6b4dde9 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/eae7-6-typed-send-light.png differ diff --git a/.github/pr-screenshots/2355-native-composer/eae7-draft4-mid-dark.png b/.github/pr-screenshots/2355-native-composer/eae7-draft4-mid-dark.png new file mode 100644 index 0000000000..351de0bd10 Binary files /dev/null and b/.github/pr-screenshots/2355-native-composer/eae7-draft4-mid-dark.png differ diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index b1cfab90de..b93ba42cdd 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -161,12 +161,12 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - Android: a Material top app bar, opaque on `base`, with no shadow: the navigation icon, then the chip at the start (`headerTitleAlign: 'left'`), then the computer action. Messages begin below it. - The chip holds, in one row, the Bot's avatar (28pt, round, no state ring) and its name in `body` strong; a group shows its mark and name. It is `theme.touch` tall with padding concentric with its corners. The Bot's state is read out in its accessibility label only. Pressing shows the `raised` pressed fill. Icons in the header are `fg`, never the Bot's accent. - 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. 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; its pill keeps an opaque `surface` fill, so nothing shows through where the Human types; messages pass around and under it. 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 26 with Reduce Transparency off, the bottom fade also carries the soft `EdgeMaterial` blur over the composer's measured height. 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. + - 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 **+** button and field capsule. On iOS 26 with Reduce Transparency off both shapes are Liquid Glass, like iOS Messages; older iOS and Reduce Transparency give them 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. 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 26 the bottom edge is UIKit's own scroll edge effect, like the top: the composer's container carries a `UIScrollEdgeElementContainerInteraction` for the message list (the local `scroll-edge-element` module; UIKit shapes the edge once when the interaction is added, so the container re-adds it whenever the composer's height or the keyboard changes), so UIKit softens messages under the composer the way it does under a system toolbar, and adapts it to Reduce Transparency itself. No fade is drawn there. A build without that module, older iOS and Android keep the drawn bottom fade over the composer's measured height (with the soft `EdgeMaterial` blur on iOS 26 with Reduce Transparency off). 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: - 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. Missing avatars use the person icon. - 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 (`raised`, with a ⌄) then floats centered above the composer, 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. + - 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. - 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. @@ -206,11 +206,13 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - A decided card collapses to one line: a coloured dot, then "subject · outcome" (for example "bash · Allowed once"). A card decided on another device or expired says so. - The collapsed line is a Bot bubble with different content: the same left edge, `surface` fill, padding, 80% width cap and radius `lg`, and it joins the Bot's message group (`xs` corners on the joined side, the tighter group gap), so it lines up with the bubbles above and below it. In a group Channel it stands alone, so the next bubble still carries the Bot's name. - While the Bot works (thinking or working), a working status sits at the bottom of the message list, after the newest message, like the Web Client's. It has no background and no name: the Bot's avatar, then its current step with "…" in muted text. There is no Stop. Nothing sits fixed above the composer, so the composer never moves when a Bot starts or stops; scrolled up, the status is out of view like any new message. -- The composer is a pill: **+** for attachments, the placeholder "Message {Bot}", and a mic that becomes Send once there is text. - - With one trailing button (the mic or Send) the pill is one row: **+**, the field and the button, bottom-aligned as the field grows. When the text no longer fits that one row, or with more (attached files, or the mic next to Send), it stacks like the web composer: the field on top at full width, and a row below with **+** (or **×**) on the left and the buttons on the right. - - **+** opens Photos, Camera and File below the pill. Picked files show as removable tiles inside the pill, up to 10 per message. Send appears once there is text or a file. +- The composer follows each platform's own compose bar: a round **+** button for attachments on the left, and beside it, a small gap apart, a field capsule with the placeholder "Message {Bot}" and a mic inside on the right that becomes Send once there is text. + - iOS follows iOS 26 Messages: on iOS 26 with Reduce Transparency off, **+** is its own Liquid Glass circle and the field its own Liquid Glass capsule, both 40pt tall (`touch − space.x2`, like Grok's compose bar), and **+** keeps a 44pt hit area. Both use `glassEffect`, regular, tinted with the opaque `surface`, so they read as near-opaque white glass like Grok's compose bar and messages passing under the field never compete with the draft. They float over the messages with no bar behind them. Older iOS and Reduce Transparency keep the same layout on an opaque `surface` fill with a `borderStrong` hairline. Pressing **+** tints it like the header buttons. The trailing button is always a filled `fg` circle with a `base` glyph, like Grok's compose bar, not the Bot's accent: the mic at rest and while recording, Send once there is text. Android keeps the plain mic and the accent fill for Send. Every text field opens the keyboard in the app's chosen appearance, so a light app on a dark system gets the light keyboard. + - Android follows Material 3 (Google Messages): **+** is a standard icon button with no container and a borderless ripple, and the field is a filled capsule on the `raised` fill with no edge and no glass. The mic, Send and expand buttons ripple too. + - **+** 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. - Sending uploads each file to the Host first. A retry keeps the uploads that already finished. - - Nothing sits under the pill, 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. + - 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. - **Workspace** opens a sheet titled "Workspace · {Bot}" with three sections, the same actions as the Web composer's workspace panel: 1. **Folders**: each granted folder as a row (name, then "Can edit" or "Read only" and its path) with a × that removes access, then **Add folder**. Add folder turns the sheet into a folder browser of the computer: folders registered on the computer, the path as tappable crumbs, and the subfolders; **Authorize this folder** resolves the current folder and asks for a scoped confirmation ("Confirm authorization for {Bot}" with the resolved path) before granting it. @@ -223,7 +225,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - `accent` stays the app's accent, since no one Bot owns the chat. - Working member Bots share one row: up to three avatars stacked (each ringed in `base`), then "Working…"; one Bot shows its own step. - The composer says "Message #{Channel}" and has no Model and Workspace footer, since those belong to one Bot. A question card keeps its own Other field instead of "Or reply in the chat below.", because a message in a group goes to every member. - - Typing `@` opens a suggestion list above the composer pill, on the `sheet` layer: **All Bots** (the Channel's active members, as the Web Client's "@All Bots") and the active member Bots with avatar and name, filtered by what follows the `@`. Choosing one inserts `@Name ` as a mention token, sent with the message like the Web Client's, so the mentioned Bots are woken even under a mentions-only wake policy. Editing inside a token turns it back into plain text. + - Typing `@` opens a suggestion list above the composer: on iOS 26 with Reduce Transparency off a floating Liquid Glass panel with the composer capsule's glass and an `xl` corner, like an iOS 26 menu, otherwise on the `sheet` layer: **All Bots** (the Channel's active members, as the Web Client's "@All Bots") and the active member Bots with avatar and name, filtered by what follows the `@`. Choosing one inserts `@Name ` as a mention token, sent with the message like the Web Client's, so the mentioned Bots are woken even under a mentions-only wake policy. Editing inside a token turns it back into plain text. - A notification or Activity item for a group Channel reply, mention or card opens this chat, at the card when there is one. **Group info** (a form sheet, opened from a group Channel's header chip) @@ -264,8 +266,8 @@ Every fill comes from a layer, like the Web Client's DSH layers. `theme.layer. '16.4' } + s.swift_version = '5.9' + s.source = { git: 'https://github.com/BotHarness/DeepSeekBot.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.source_files = '**/*.{h,m,swift}' + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } +end diff --git a/apps/mobile/modules/scroll-edge-element/ios/ScrollEdgeElementModule.swift b/apps/mobile/modules/scroll-edge-element/ios/ScrollEdgeElementModule.swift new file mode 100644 index 0000000000..cb83f1f142 --- /dev/null +++ b/apps/mobile/modules/scroll-edge-element/ios/ScrollEdgeElementModule.swift @@ -0,0 +1,85 @@ +import ExpoModulesCore +import UIKit + +public final class ScrollEdgeElementModule: Module { + public func definition() -> ModuleDefinition { + Name("ScrollEdgeElement") + + View(ScrollEdgeElementView.self) { + Prop("edge") { (view: ScrollEdgeElementView, edge: String) in + view.edge = edge == "top" ? .top : .bottom + } + Prop("revision") { (view: ScrollEdgeElementView, _: String) in + view.refresh() + } + } + } +} + +final class ScrollEdgeElementView: ExpoView { + var edge: UIRectEdge = .bottom { + didSet { attach() } + } + + private var interaction: UIInteraction? + + override func didMoveToWindow() { + super.didMoveToWindow() + attach() + } + + override func layoutSubviews() { + super.layoutSubviews() + attach() + } + + func refresh() { + if let interaction { removeInteraction(interaction) } + interaction = nil + DispatchQueue.main.async { [weak self] in self?.attach() } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + guard let self, let interaction = self.interaction else { return } + self.removeInteraction(interaction) + self.interaction = nil + self.attach() + } + } + + private func attach() { + guard #available(iOS 26.0, *) else { return } + guard window != nil, let scrollView = nearestScrollView() else { return } + let current = interaction as? UIScrollEdgeElementContainerInteraction + if let current, current.scrollView === scrollView, current.edge == edge { return } + if let current { removeInteraction(current) } + let next = UIScrollEdgeElementContainerInteraction() + next.scrollView = scrollView + next.edge = edge + addInteraction(next) + interaction = next + } + + private func nearestScrollView() -> UIScrollView? { + var child: UIView = self + while let parent = child.superview { + for sibling in parent.subviews where sibling !== child { + if let found = largestScrollView(in: sibling) { return found } + } + child = parent + } + return nil + } + + private func largestScrollView(in root: UIView) -> UIScrollView? { + var best: UIScrollView? + var queue: [UIView] = [root] + while !queue.isEmpty { + let view = queue.removeFirst() + if let scrollView = view as? UIScrollView, !(view is UITextView) { + if best == nil || scrollView.bounds.height > best!.bounds.height { best = scrollView } + continue + } + queue.append(contentsOf: view.subviews) + } + return best + } +} diff --git a/apps/mobile/src/ui/chat/composer-chrome.ios.tsx b/apps/mobile/src/ui/chat/composer-chrome.ios.tsx new file mode 100644 index 0000000000..9cdf8b3c91 --- /dev/null +++ b/apps/mobile/src/ui/chat/composer-chrome.ios.tsx @@ -0,0 +1,126 @@ +import { Circle, Host, RoundedRectangle } from '@expo/ui/swift-ui'; +import { accessibilityHidden, foregroundStyle, glassEffect } from '@expo/ui/swift-ui/modifiers'; +import type { ReactNode } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { pressTint, useDesignTheme, type DesignTheme } from '../design/theme'; +import { edge, radius, space } from '../design/tokens'; +import { useLiquidGlass } from '../primitives/drawer-material'; + +export function composerPrimary(theme: DesignTheme): { fill: string; ink: string } { + return { fill: theme.color.fg, ink: theme.color.base }; +} + +export function composerBar(theme: DesignTheme): number { + return theme.touch - space.x2; +} + +export const micAlwaysFilled = true; + +export const rippleFeedback = false; + +function Glass({ cornerRadius }: { cornerRadius?: number }) { + const theme = useDesignTheme(); + return ( + + {cornerRadius === undefined ? ( + + ) : ( + + )} + + ); +} + +export function ComposerCircleButton({ + label, + testID, + size, + onPress, + children, +}: { + label: string; + testID?: string; + size: number; + onPress(): void; + children: ReactNode; +}) { + const theme = useDesignTheme(); + const glass = useLiquidGlass(); + return ( + ({ + width: size, + height: size, + borderRadius: radius.round, + alignItems: 'center', + justifyContent: 'center', + ...(glass + ? { backgroundColor: pressed ? pressTint(theme.color.fg) : 'transparent' } + : { + borderWidth: edge.hairline, + borderColor: theme.color.borderStrong, + backgroundColor: pressed ? theme.layer.raised.pressed : theme.color.surface, + }), + })} + > + {glass ? : null} + {children} + + ); +} + +export function ComposerFieldMaterial({ cornerRadius }: { cornerRadius: number }) { + const theme = useDesignTheme(); + const glass = useLiquidGlass(); + return ( + + {glass ? : null} + + ); +} diff --git a/apps/mobile/src/ui/chat/composer-chrome.tsx b/apps/mobile/src/ui/chat/composer-chrome.tsx new file mode 100644 index 0000000000..3d3150b0b2 --- /dev/null +++ b/apps/mobile/src/ui/chat/composer-chrome.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { pressTint, useDesignTheme, type DesignTheme } from '../design/theme'; +import { radius } from '../design/tokens'; + +export function composerPrimary(theme: DesignTheme): { fill: string; ink: string } { + return { fill: theme.color.accent, ink: theme.color.onAccent }; +} + +export function composerBar(theme: DesignTheme): number { + return theme.touch; +} + +export const micAlwaysFilled = false; + +export const rippleFeedback = true; + +export function ComposerCircleButton({ + label, + testID, + size, + onPress, + children, +}: { + label: string; + testID?: string; + size: number; + onPress(): void; + children: ReactNode; +}) { + const theme = useDesignTheme(); + return ( + + {children} + + ); +} + +export function ComposerFieldMaterial({ cornerRadius }: { cornerRadius: number }) { + const theme = useDesignTheme(); + return ( + + ); +} diff --git a/apps/mobile/src/ui/chat/composer.tsx b/apps/mobile/src/ui/chat/composer.tsx index 4b2d689ce6..f7ac2eb7a3 100644 --- a/apps/mobile/src/ui/chat/composer.tsx +++ b/apps/mobile/src/ui/chat/composer.tsx @@ -19,7 +19,7 @@ import Animated, { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { PickSource, PickedFile } from '../../host/pick-files'; -import { useDesignTheme } from '../design/theme'; +import { pressTint, useDesignTheme } from '../design/theme'; import { edge, motion, radius, space } from '../design/tokens'; import { Text } from '../primitives/text'; import { @@ -34,6 +34,14 @@ import { PlusIcon, RecordingStopIcon, } from '../shell/icons'; +import { + ComposerCircleButton, + ComposerFieldMaterial, + composerBar, + composerPrimary, + micAlwaysFilled, + rippleFeedback, +} from './composer-chrome'; import { DraftEditor } from './draft-editor'; import { haptic } from '../haptics'; import { t } from '../strings'; @@ -75,6 +83,15 @@ function RoundButton({ accessibilityLabel={label} onPress={onPress} hitSlop={space.x2} + android_ripple={ + rippleFeedback + ? { + color: pressTint(filled ? composerPrimary(theme).ink : theme.color.fg), + borderless: true, + radius: size / 2, + } + : undefined + } style={({ pressed }) => ({ width: size, height: size, @@ -82,11 +99,11 @@ function RoundButton({ alignItems: 'center', justifyContent: 'center', backgroundColor: filled - ? theme.color.accent - : pressed - ? theme.layer.raised.pressed + ? composerPrimary(theme).fill + : pressed && !rippleFeedback + ? pressTint(theme.color.fg) : undefined, - opacity: filled && pressed ? 0.7 : 1, + opacity: filled && pressed && !rippleFeedback ? 0.7 : 1, })} > {children} @@ -209,7 +226,7 @@ function VoiceButton({ onPanResponderTerminate: () => hold(false), }), ).current; - const filled = active || held; + const filled = micAlwaysFilled || active || held; return ( {active && !held ? ( - + ) : ( )} @@ -242,7 +259,7 @@ function VoiceButton({ function HoldDot() { const theme = useDesignTheme(); - const size = theme.touch - space.x4; + const size = composerBar(theme); return ( ({ height: pillHeight.value })); - const settlePill = (content: number) => { - const next = content + 2 * edge.hairline; + const settlePill = (next: number) => { if (pill.height === 0) pillHeight.value = next; else pillHeight.value = withTiming(next, { @@ -414,28 +433,31 @@ export function Composer({ const expanded = theme.largeText || wraps; const stacked = composerStacked(actions, files.length, expanded); const icon = theme.type.title.line; - const button = theme.touch - space.x4; - const compactWidth = pill.width - 2 * (inset + button + space.x2); const leading = held ? ( ) : listening ? ( - - - + + + ) : canAttach ? ( - {menu ? ( - + ) : ( - + )} - + ) : null; const field = ( - + ) : ( ), ); + const compactWidth = + pill.width - 2 * (inset + space.x3) - (trailing.length === 0 ? 0 : button + space.x2); + const expand = + expanded && !listening ? ( + setEditing(true)}> + + + ) : null; return ( M - - { - const { width, height } = event.nativeEvent.layout; - settlePill(height); - setPill({ width, height: height + 2 * edge.hairline }); - }} - > - {files.length > 0 ? ( - - {files.map((file) => ( - onRemove(file.id)} /> - ))} - - ) : null} - - - {field} - - - {leading} - {expanded && !listening ? ( - setEditing(true)}> - - - ) : null} - - {trailing} + + {leading} + + + { + const { width, height } = event.nativeEvent.layout; + settlePill(height); + setPill({ width, height }); + }} + > + {files.length > 0 ? ( + + {files.map((file) => ( + onRemove(file.id)} /> + ))} + + ) : null} + + + {field} + + {stacked && expand === null && trailing.length === 0 ? null : ( + + {expand} + + {trailing} + + )} - - {compactWidth > 0 ? ( - setWraps(event.nativeEvent.layout.height > textLine * 1.5)} - style={{ position: 'absolute', opacity: 0, width: compactWidth, left: 0, top: 0 }} - > - {value === '' ? ' ' : value} - - ) : null} - + {compactWidth > 0 ? ( + setWraps(event.nativeEvent.layout.height > textLine * 1.5)} + style={{ position: 'absolute', opacity: 0, width: compactWidth, left: 0, top: 0 }} + > + {value === '' ? ' ' : value} + + ) : null} + + {footer === undefined ? null : ( setFooterHeight(event.nativeEvent.layout.height)} @@ -639,7 +658,7 @@ export function Composer({ setComposerHeight(event.nativeEvent.layout.height)} composer={ - + {botDm ? ( @@ -811,7 +812,7 @@ function ChatConversation({ onCancelDictation={dictation.cancel} /> )} - + } > {notice === undefined ? null : ( @@ -948,16 +949,18 @@ function ChatConversation({ /> - + {nativeScrollEdge ? null : ( + + )} {latest.away ? ( diff --git a/apps/mobile/src/ui/chat/jump-to-latest.tsx b/apps/mobile/src/ui/chat/jump-to-latest.tsx index 7f59a127af..f7c7277663 100644 --- a/apps/mobile/src/ui/chat/jump-to-latest.tsx +++ b/apps/mobile/src/ui/chat/jump-to-latest.tsx @@ -5,16 +5,48 @@ import { edge, radius, space } from '../design/tokens'; import { badgeCount } from '../primitives/badge-count'; import { Text } from '../primitives/text'; import { ChevronDownIcon } from '../shell/icons'; +import { useLiquidGlass } from '../primitives/drawer-material'; +import { ComposerCircleButton, composerBar } from './composer-chrome'; import { t } from '../strings'; export function JumpToLatest({ unseen, onPress }: { unseen: number; onPress(): void }) { const theme = useDesignTheme(); const layer = theme.layer.raised; const badge = theme.type.meta.line; + const glass = useLiquidGlass(); + const label = unseen > 0 ? t.unseenLatest(unseen) : t.jumpToLatest; + const count = + unseen > 0 ? ( + + + {badgeCount(unseen)} + + + ) : null; + if (glass) + return ( + + + {count} + + ); return ( 0 ? t.unseenLatest(unseen) : t.jumpToLatest} + accessibilityLabel={label} onPress={onPress} hitSlop={space.x2} style={({ pressed }) => ({ @@ -34,26 +66,7 @@ export function JumpToLatest({ unseen, onPress }: { unseen: number; onPress(): v })} > - {unseen > 0 ? ( - - - {badgeCount(unseen)} - - - ) : null} + {count} ); } diff --git a/apps/mobile/src/ui/chat/mention-suggestions.tsx b/apps/mobile/src/ui/chat/mention-suggestions.tsx index cf2a4dac64..8d1fb750a1 100644 --- a/apps/mobile/src/ui/chat/mention-suggestions.tsx +++ b/apps/mobile/src/ui/chat/mention-suggestions.tsx @@ -3,8 +3,10 @@ import { Pressable, View } from 'react-native'; import { BotAvatar } from '../bot-avatar'; import { useDesignTheme } from '../design/theme'; import { edge, radius, space } from '../design/tokens'; +import { useLiquidGlass } from '../primitives/drawer-material'; import { Text } from '../primitives/text'; import { t } from '../strings'; +import { ComposerFieldMaterial } from './composer-chrome'; import type { MentionCandidate } from './mention-draft'; const AVATAR = 24; @@ -18,20 +20,27 @@ export function MentionSuggestions({ onChoose(candidate: MentionCandidate): void; }) { const theme = useDesignTheme(); + const glass = useLiquidGlass(); + const corner = glass ? radius.xl : radius.lg; if (candidates.length === 0) return null; return ( + {glass ? : null} {candidates.map((candidate) => { const name = candidate.kind === 'bot' ? candidate.bot.displayName : candidate.label; const detail = @@ -50,7 +59,7 @@ export function MentionSuggestions({ flexDirection: 'row', alignItems: 'center', gap: space.x3, - paddingHorizontal: space.x4, + paddingHorizontal: glass ? space.x5 : space.x4, backgroundColor: pressed ? theme.layer.sheet.pressed : undefined, })} > diff --git a/apps/mobile/src/ui/primitives/scroll-edge-element.ios.tsx b/apps/mobile/src/ui/primitives/scroll-edge-element.ios.tsx new file mode 100644 index 0000000000..65c3d5d184 --- /dev/null +++ b/apps/mobile/src/ui/primitives/scroll-edge-element.ios.tsx @@ -0,0 +1,37 @@ +import { requireNativeView, requireOptionalNativeModule } from 'expo'; +import { useState, type ReactNode } from 'react'; +import { Platform, View, type ViewProps } from 'react-native'; +import { useKeyboardState } from 'react-native-keyboard-controller'; + +type EdgeProps = ViewProps & { edge: 'top' | 'bottom'; revision: string }; + +export const nativeScrollEdge = + Number.parseInt(String(Platform.Version), 10) >= 26 && + requireOptionalNativeModule('ScrollEdgeElement') !== null; +const NativeEdge = nativeScrollEdge ? requireNativeView('ScrollEdgeElement') : undefined; + +export function ScrollEdgeElement({ + edge, + children, +}: { + edge: 'top' | 'bottom'; + children: ReactNode; +}) { + if (NativeEdge === undefined) return {children}; + return {children}; +} + +function NativeScrollEdge({ edge, children }: { edge: 'top' | 'bottom'; children: ReactNode }) { + const keyboard = useKeyboardState((state) => state.isVisible); + const [height, setHeight] = useState(0); + if (NativeEdge === undefined) return null; + return ( + setHeight(Math.round(event.nativeEvent.layout.height))} + > + {children} + + ); +} diff --git a/apps/mobile/src/ui/primitives/scroll-edge-element.tsx b/apps/mobile/src/ui/primitives/scroll-edge-element.tsx new file mode 100644 index 0000000000..b2f0e0a36c --- /dev/null +++ b/apps/mobile/src/ui/primitives/scroll-edge-element.tsx @@ -0,0 +1,8 @@ +import type { ReactNode } from 'react'; +import { View } from 'react-native'; + +export const nativeScrollEdge = false; + +export function ScrollEdgeElement({ children }: { edge: 'top' | 'bottom'; children: ReactNode }) { + return {children}; +} diff --git a/apps/mobile/src/ui/primitives/system-text.tsx b/apps/mobile/src/ui/primitives/system-text.tsx index 77683fc016..5705f2ad23 100644 --- a/apps/mobile/src/ui/primitives/system-text.tsx +++ b/apps/mobile/src/ui/primitives/system-text.tsx @@ -9,6 +9,7 @@ import { } from 'react-native'; import { textRamp } from '../design/system-type'; +import { useDesignTheme } from '../design/theme'; import { typeScale } from '../design/tokens'; const TextAncestor = createContext(false); @@ -43,8 +44,10 @@ type InputProps = ComponentPropsWithRef & export function TextInput({ style, dynamicTypeRamp = 'body', ...props }: InputProps) { const ios = Platform.OS === 'ios'; + const { scheme } = useDesignTheme(); return ( ({ glass: true, effects: [] as unknown[] })); +vi.mock('react-native', async () => import('./react-native-shim')); +vi.mock('../src/ui/primitives/drawer-material', () => ({ useLiquidGlass: () => native.glass })); +vi.mock('@expo/ui/swift-ui/modifiers', () => ({ + foregroundStyle: () => ({}), + accessibilityHidden: () => ({}), + glassEffect: (params: unknown) => { + native.effects.push(params); + return {}; + }, +})); +vi.mock('@expo/ui/swift-ui', () => ({ + Host: ({ children }: { children: ReactNode }) => createElement('div', null, children), + Circle: () => createElement('div', { 'data-material': 'circle' }), + RoundedRectangle: ({ cornerRadius }: { cornerRadius: number }) => + createElement('div', { 'data-material': `rounded-${cornerRadius}` }), +})); +const { ComposerCircleButton, ComposerFieldMaterial } = + await import('../src/ui/chat/composer-chrome.ios'); + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +let container: HTMLElement; +let root: ReturnType; + +beforeEach(() => { + native.glass = true; + native.effects = []; + container = document.createElement('div'); + root = createRoot(container); +}); + +afterEach(() => act(() => root.unmount())); + +function Bar() { + return ( + <> + undefined}> + + + + + + ); +} + +const surfaceTint = expect.stringMatching(/^#[0-9a-f]{6}$/iu); + +it('floats + and the field on separate Liquid Glass shapes on iOS 26', async () => { + await act(async () => root.render()); + const button = container.querySelector('button[aria-label="Attach"]')!; + expect(button.querySelector('[data-material="circle"]')).not.toBeNull(); + expect(container.querySelector('[data-material="rounded-22"]')).not.toBeNull(); + expect(native.effects).toEqual([ + { glass: { variant: 'regular', tint: surfaceTint }, shape: 'circle' }, + { + glass: { variant: 'regular', tint: surfaceTint }, + shape: 'roundedRectangle', + cornerRadius: 22, + }, + ]); +}); + +it('keeps the same layout on opaque fills without glass', async () => { + native.glass = false; + await act(async () => root.render()); + expect(container.querySelector('button[aria-label="Attach"]')).not.toBeNull(); + expect(container.querySelector('[data-material]')).toBeNull(); + expect(native.effects).toEqual([]); +}); diff --git a/apps/mobile/test/composer-dictation.test.tsx b/apps/mobile/test/composer-dictation.test.tsx index 12883a5404..80aac1b7ac 100644 --- a/apps/mobile/test/composer-dictation.test.tsx +++ b/apps/mobile/test/composer-dictation.test.tsx @@ -297,6 +297,31 @@ describe('Holding the mic', () => { }); }); +describe('native compose bar', () => { + it('keeps + outside the field capsule and the mic or Send inside it', async () => { + const attach = button(t.attach)!; + const capsule = attach.nextElementSibling!; + expect(capsule.contains(field())).toBe(true); + expect(capsule.contains(button(t.dictate))).toBe(true); + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call( + field(), + 'Hello', + ); + field().dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(capsule.contains(button(t.send))).toBe(true); + expect(button(t.attach)).toBe(attach); + }); + + it('puts cancel in the + slot while listening', async () => { + const slot = button(t.attach)!.parentElement!; + await press(t.dictate); + expect(button(t.attach)).toBeNull(); + expect(button(t.cancelDictation)!.parentElement).toBe(slot); + }); +}); + describe('expanded draft', () => { it('shares edits with the existing composer and sends the current draft once', async () => { const original = field(); diff --git a/changes/2355-native-composer.md b/changes/2355-native-composer.md new file mode 100644 index 0000000000..5377876558 --- /dev/null +++ b/changes/2355-native-composer.md @@ -0,0 +1,11 @@ +--- +section: Changed +--- + +## en + +- The phone's chat composer now follows each platform's own compose bar. On iOS, **+** is its own round button beside a separate field capsule with the mic or Send inside, like iOS 26 Messages; on iOS 26 both float on Liquid Glass over the messages. On Android, **+** is a Material 3 icon button next to a filled rounded field, with ripple feedback. On iOS 26, messages under the composer now soften with the system's own scroll edge effect, matching the header ([#2355](https://github.com/BotHarness/DeepSeekBot/pull/2355)). + +## zh + +- 手机端聊天输入框改为各平台原生的输入栏样式。iOS 上,**+** 是独立的圆形按钮,旁边是单独的输入胶囊,麦克风或发送按钮在胶囊内,与 iOS 26 信息 App 一致;在 iOS 26 上两者以液态玻璃悬浮在消息上方。Android 上,**+** 是 Material 3 图标按钮,旁边是填充圆角输入框,点按有水波纹反馈。在 iOS 26 上,输入框下方的消息改用系统自带的滚动边缘效果柔化,与顶栏一致([#2355](https://github.com/BotHarness/DeepSeekBot/pull/2355))。