From 8ff2730676a1078aa7b6e45aae98afbfff88509c Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:09:12 +0200 Subject: [PATCH] Web client: replies, reactions, threads and spaces - Replies: Reply on a message puts it above the composer; the reply quotes it (sender and first line), and the quote goes to the message. Sent without the quoted fallback (Matrix 1.13); the fallback in older replies is not shown. - Reactions: the six quick ones of the Android app or any other emoji, chips under the message with counts, yours highlighted; a chip adds yours or takes it back. - Threads: matrix-js-sdk's thread support is on. Reply in thread opens the thread beside the room with its own composer; the root shows "N replies" and the last reply; Threads in the header lists them. Receipts are per thread (MSC3771): the room is marked read with its read marker and a separate receipt for "main", since /read_markers' receipt has no thread_id and would read every thread. Unread counts per thread (MSC3773) and a dot on unread threads. Two SDK quirks worked around: the thread view creates the thread object when a thread starts here (else the first reply's remote echo is dropped until reload), and the reply count is re-read from the root's bundled count after replies arrive (the SDK can count one twice). - Spaces: kept out of the room list; a picker narrows the list to a space's rooms and its subspaces'. A space's page (/hierarchy) lists its rooms with Open or Join; admins add existing rooms, create new ones in it and take rooms out. New space in the + menu and picker. - The room composer moved into RoomComposer.vue, shared by the room and the thread pane. New thread and space icons in apps/ui. - Dev server: the Rust crypto's .wasm was lost to vite's pre-bundling, so E2EE never started under `pnpm web:dev`; the package is now served as it is in dev. Server: /versions lists v1.1 to v1.14. matrix-js-sdk checks for the exact version a feature came in, and with only v1.13 and v1.14 it kept threads, per-thread unread counts, recursive relations and intentional mentions off. Tested in the browser against a local server: replies, reactions, threads (new and existing, in plain and encrypted rooms, receipts checked on the server) and spaces. Co-Authored-By: Claude Opus 5.5 --- apps/README.md | 2 +- apps/ui/src/icons/index.ts | 2 + apps/ui/src/icons/space.svg | 1 + apps/ui/src/icons/thread.svg | 1 + apps/web/README.md | 25 +- apps/web/src/components/CreateRoomDialog.vue | 47 ++-- apps/web/src/components/JoinRoomDialog.vue | 2 +- apps/web/src/components/ReactionPicker.vue | 91 ++++++ apps/web/src/components/RoomComposer.vue | 156 +++++++++++ apps/web/src/components/RoomInfoPane.vue | 27 +- apps/web/src/components/RoomTimeline.vue | 255 +++++++++++++---- apps/web/src/components/SpacePicker.vue | 114 ++++++++ apps/web/src/components/SpaceView.vue | 278 +++++++++++++++++++ apps/web/src/components/ThreadPane.vue | 55 ++++ apps/web/src/components/ThreadsPane.vue | 127 +++++++++ apps/web/src/layouts/ClientShell.vue | 83 +++++- apps/web/src/matrix/client.ts | 5 +- apps/web/src/matrix/send.ts | 34 ++- apps/web/src/matrix/timeline.ts | 243 +++++++++++++++- apps/web/src/stores/chat.ts | 127 ++++++++- apps/web/src/views/RoomView.vue | 153 ++++------ apps/web/vite.config.ts | 32 ++- server/internal/setup/routes.go | 11 +- 23 files changed, 1666 insertions(+), 205 deletions(-) create mode 100644 apps/ui/src/icons/space.svg create mode 100644 apps/ui/src/icons/thread.svg create mode 100644 apps/web/src/components/ReactionPicker.vue create mode 100644 apps/web/src/components/RoomComposer.vue create mode 100644 apps/web/src/components/SpacePicker.vue create mode 100644 apps/web/src/components/SpaceView.vue create mode 100644 apps/web/src/components/ThreadPane.vue create mode 100644 apps/web/src/components/ThreadsPane.vue diff --git a/apps/README.md b/apps/README.md index 259e2b8..d27d648 100644 --- a/apps/README.md +++ b/apps/README.md @@ -4,7 +4,7 @@ |---|---|---| | [`ui/`](ui/README.md) | shared Vue 3 + Tailwind design system: tokens, fonts, icons, components | in use by admin and web | | [`admin/`](admin/README.md) | admin web panel, served by its own optional `warp-admin` binary | done for the current admin API | -| [`web/`](web/README.md) | web client (Vue SPA on matrix-js-sdk), served by its own optional `warp-web` binary | rooms, timeline, sending text and files, invites; E2EE next | +| [`web/`](web/README.md) | web client (Vue SPA on matrix-js-sdk), served by its own optional `warp-web` binary | rooms, timeline, files, replies, reactions, threads, spaces, E2EE with QR and emoji verification, calls | | `desktop/` | Wails v3 shell reusing the web frontend | next: web has a timeline | | [`mobile/`](mobile/README.md) | Flutter app on the famedly Matrix SDK, Android first | rooms, timeline, files, replies, reactions, threads, spaces, E2EE with QR and emoji verification, push, calls, sliding sync | diff --git a/apps/ui/src/icons/index.ts b/apps/ui/src/icons/index.ts index 7a108df..b929a1f 100644 --- a/apps/ui/src/icons/index.ts +++ b/apps/ui/src/icons/index.ts @@ -45,7 +45,9 @@ export type IconName = | 'settings' | 'shield-alert' | 'shield-check' + | 'space' | 'sun' + | 'thread' | 'trash' | 'user' | 'users' diff --git a/apps/ui/src/icons/space.svg b/apps/ui/src/icons/space.svg new file mode 100644 index 0000000..b30c951 --- /dev/null +++ b/apps/ui/src/icons/space.svg @@ -0,0 +1 @@ +space \ No newline at end of file diff --git a/apps/ui/src/icons/thread.svg b/apps/ui/src/icons/thread.svg new file mode 100644 index 0000000..1463971 --- /dev/null +++ b/apps/ui/src/icons/thread.svg @@ -0,0 +1 @@ +thread \ No newline at end of file diff --git a/apps/web/README.md b/apps/web/README.md index b81b1d1..77f166a 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -46,6 +46,29 @@ that one origin. then *Read* when someone else has read it; a failed one says *Not sent* with Retry and Delete. - Read receipts, typing notifications, deleting messages. +- Replies: *Reply* on a message puts it above the composer, and the + reply quotes it (sender and first line); the quote goes to the message. + Replies are sent without the old quoted fallback in their body + (Matrix 1.13), and the fallback in older ones is not shown. +- Reactions: six quick ones, or any other emoji typed in, from a + message's *React* button; chips under the message with counts, yours + highlighted. A chip adds your reaction or takes it back, and says who + reacted. +- Threads: *Reply in thread* opens the thread beside the room, its root + and replies, with a composer that writes into it. Replies stay out of + the room's timeline; the root shows "N replies", the last reply's + sender and time. *Threads* in the room header lists the room's threads + (`/threads`). Read receipts are per thread (MSC3771): the room is + marked read with `thread_id` "main", a thread with its root's ID, so + reading one leaves the others unread. Unread counts come per thread + from the homeserver (MSC3773) and threads with replies you haven't + read have a dot, on the root, in the list and on the *Threads* button. +- Spaces: when you're in any, the top of the room list picks *All chats* + or one space, which narrows the list to its rooms and its subspaces'. + A space's page (`/hierarchy`) lists its rooms and subspaces with *Open* + or *Join*; its admins add rooms you're in, create new ones in it, and + take rooms out. *New space* in the + menu and in the picker. Invites to + spaces show with the others. - End-to-end encryption, with the Rust crypto stack matrix-js-sdk loads (`matrix-sdk-crypto-wasm`): reading and sending in encrypted rooms, files included (encrypted in the browser before they are uploaded, with @@ -90,7 +113,7 @@ that one origin. - Settings: display name, avatar, theme, encryption. - Phone layout: one pane at a time, the room list is the home screen. -**Not yet:** replies, reactions, threads, spaces, notifications. +**Not yet:** editing messages, notifications. ### Encryption keys in the browser diff --git a/apps/web/src/components/CreateRoomDialog.vue b/apps/web/src/components/CreateRoomDialog.vue index 74a8a66..adcae32 100644 --- a/apps/web/src/components/CreateRoomDialog.vue +++ b/apps/web/src/components/CreateRoomDialog.vue @@ -4,8 +4,16 @@ import { WBanner, WButton, WDialog, WIcon, WSwitch, WTextArea, WTextField } from import { errorMessage } from '@/lib/format' import { useChat } from '@/stores/chat' +// A room, a room in a space (`spaceId`), or a space (`kind` "space"). +const props = withDefaults(defineProps<{ kind?: 'room' | 'space'; spaceId?: string; spaceName?: string }>(), { + kind: 'room', + spaceId: undefined, + spaceName: undefined, +}) const open = defineModel('open', { default: false }) const emit = defineEmits<{ created: [roomId: string] }>() +const isSpace = computed(() => props.kind === 'space') +const noun = computed(() => (isSpace.value ? 'space' : 'room')) const chat = useChat() const name = ref('') @@ -22,7 +30,8 @@ watch(open, (v) => { topic.value = '' isPublic.value = false alias.value = '' - encrypted.value = chat.canEncrypt + // A space holds no conversation to protect: never encrypted. + encrypted.value = chat.canEncrypt && !isSpace.value error.value = '' }) @@ -40,7 +49,7 @@ watch(name, (n, old) => { }) const aliasError = computed(() => - isPublic.value && alias.value && !/^[a-z0-9._=-]+$/.test(alias.value) ? 'Use lowercase letters, digits and . _ = -' : undefined, + isPublic.value && !isSpace.value && alias.value && !/^[a-z0-9._=-]+$/.test(alias.value) ? 'Use lowercase letters, digits and . _ = -' : undefined, ) async function submit() { @@ -48,13 +57,16 @@ async function submit() { busy.value = true error.value = '' try { - const roomId = await chat.createRoom({ - name: name.value, - topic: topic.value, - isPublic: isPublic.value, - alias: alias.value, - encrypted: encrypted.value, - }) + const roomId = isSpace.value + ? await chat.createSpace({ name: name.value, topic: topic.value, isPublic: isPublic.value }) + : await chat.createRoom({ + name: name.value, + topic: topic.value, + isPublic: isPublic.value, + alias: alias.value, + encrypted: encrypted.value, + spaceId: props.spaceId, + }) open.value = false emit('created', roomId) } catch (e) { @@ -66,19 +78,22 @@ async function submit() { - +

- You stop getting messages from {{ name }}. + +

diff --git a/apps/web/src/components/RoomTimeline.vue b/apps/web/src/components/RoomTimeline.vue index 19c32a3..713f09f 100644 --- a/apps/web/src/components/RoomTimeline.vue +++ b/apps/web/src/components/RoomTimeline.vue @@ -1,26 +1,30 @@ + + diff --git a/apps/web/src/components/SpaceView.vue b/apps/web/src/components/SpaceView.vue new file mode 100644 index 0000000..58d4c3f --- /dev/null +++ b/apps/web/src/components/SpaceView.vue @@ -0,0 +1,278 @@ + + + diff --git a/apps/web/src/components/ThreadPane.vue b/apps/web/src/components/ThreadPane.vue new file mode 100644 index 0000000..e422e50 --- /dev/null +++ b/apps/web/src/components/ThreadPane.vue @@ -0,0 +1,55 @@ + + + diff --git a/apps/web/src/components/ThreadsPane.vue b/apps/web/src/components/ThreadsPane.vue new file mode 100644 index 0000000..65776a9 --- /dev/null +++ b/apps/web/src/components/ThreadsPane.vue @@ -0,0 +1,127 @@ + + + diff --git a/apps/web/src/layouts/ClientShell.vue b/apps/web/src/layouts/ClientShell.vue index becf360..f2de6a5 100644 --- a/apps/web/src/layouts/ClientShell.vue +++ b/apps/web/src/layouts/ClientShell.vue @@ -1,5 +1,5 @@ - + + + + : unread replies + +
@@ -317,18 +280,12 @@ const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room'
- +

{{ typingText }}

-
- - Uploading {{ u.name }} - {{ u.total ? Math.round((u.loaded / u.total) * 100) : 0 }}% -
- `Message ${summary.value?.name ?? 'this room' {{ warning.kind === 'verifiedReset' ? 'Withdraw verification' : 'OK' }} - - This browser can’t read or send messages in this room. Reload the page to try again{{ cryptoError() ? ` (${cryptoError()})` : '' }}. - - You don’t have permission to post in this room. -
- -
+
@@ -364,6 +315,16 @@ const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room' - + + + diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 1804c63..f54a3dd 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,3 +1,6 @@ +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import tailwindcss from '@tailwindcss/vite' import vue from '@vitejs/plugin-vue' @@ -8,10 +11,33 @@ import { defineConfig } from 'vite' // know where the homeserver is. WARP_WEB_HOMESERVER points it elsewhere. const homeserver = process.env.WARP_WEB_HOMESERVER ?? 'http://localhost:8008' -export default defineConfig({ +// The Rust crypto loads its .wasm relative to its own module +// (new URL(..., import.meta.url)). Vite's dependency pre-bundling moves the +// module into node_modules/.vite/deps without the .wasm, so the dev server +// answered with index.html and initRustCrypto failed. The dev server serves +// the package as it is instead. It is only matrix-js-sdk's dependency, which +// pnpm doesn't make reachable from the pre-bundled matrix-js-sdk, so it is +// aliased to the very copy matrix-js-sdk resolves. A production build is +// bundled by Rollup and needs neither. +const cryptoWasm = '@matrix-org/matrix-sdk-crypto-wasm' + +function cryptoWasmDir(): string { + const sdk = createRequire(import.meta.url).resolve('matrix-js-sdk') + let dir = dirname(createRequire(sdk).resolve(cryptoWasm)) + while (!existsSync(join(dir, 'package.json'))) dir = dirname(dir) + return dir +} + +export default defineConfig(({ command }) => ({ plugins: [vue(), tailwindcss()], resolve: { - alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + ...(command === 'serve' ? { [cryptoWasm]: cryptoWasmDir() } : {}), + }, + }, + optimizeDeps: { + exclude: [cryptoWasm], }, build: { // Embedded into the warp-web binary by client/ (and the desktop app). @@ -30,4 +56,4 @@ export default defineConfig({ '/.well-known/matrix/client': { target: homeserver, changeOrigin: true }, }, }, -}) +})) diff --git a/server/internal/setup/routes.go b/server/internal/setup/routes.go index 25b5760..0f804b9 100644 --- a/server/internal/setup/routes.go +++ b/server/internal/setup/routes.go @@ -20,7 +20,16 @@ import ( // client to be used. Advertising v1.11 or later is also what makes a client // use the authenticated media endpoints, the only download endpoints Warp // serves by default. -var SpecVersions = []string{"v1.13", "v1.14"} +// +// The earlier v1.x versions are listed too: each is a subset of the +// later ones, and clients test for the exact version a feature came in. +// matrix-js-sdk only turns on threads (v1.3), unread counts per thread +// (v1.4), recursive relations (v1.10) and intentional mentions (v1.7) +// when that version is in the list. +var SpecVersions = []string{ + "v1.1", "v1.2", "v1.3", "v1.4", "v1.5", "v1.6", "v1.7", + "v1.8", "v1.9", "v1.10", "v1.11", "v1.12", "v1.13", "v1.14", +} // UnstableFeatures advertises MSC support. Simplified Sliding Sync // (MSC4186) is how Element X and the matrix-rust-sdk find out they can