Web client (warp-web) with E2EE, and two sync fixes
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s

apps/web: a Matrix client for the browser on Vue 3, Tailwind and
matrix-js-sdk, built on @warp/ui. Like the admin panel it is an optional,
separate service: warp-web embeds the built app, serves /config.json and
proxies only /_matrix/client and /_matrix/media to the homeserver (or,
with -public-homeserver, lets the browser call it directly).
Sign-in and sign-up with registration tokens, room list, timeline with
back-pagination, text, images, video and files over authenticated media,
local echo with sent/read/failed states, receipts, typing, invites,
creating, joining and leaving rooms, room info, settings, phone layout.
End-to-end encryption through the Rust crypto: encrypted messages and
files, emoji verification, key backup setup and restore.

apps/ui: WRoomItem, WMessageBubble and WComposer from the brand package,
and the icons the client needs.

syncapi fixes found with the client:
- A room joined inside the sync window was sent as the join alone with
  limited: false, so clients thought they had the whole room. It is now
  read like an initial sync; a profile change is not mistaken for a join.
- A live typing notification was reported on every sync, so every long
  poll returned at once while anybody typed. Typing now has a position
  in the sync token and is reported when it changes, including the empty
  list when the last person stops.

Also: Dockerfile, a `web` compose profile, Makefile targets, a CI job
with a proxy smoke test, docs. PLAN.md moves out of the repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-18 16:22:11 +02:00
1 parent adc18e7cf3
commit b6f3be6ac4
90 files changed
+6608 -472

No files matched your search

+7 -2
View File
@@ -46,6 +46,9 @@ The stylesheet brings in:
| `WBanner` | feedback; always icon and words, never colour alone |
| `WDialog` | modal on the native `<dialog>` |
| `WSpinner` | progress |
| `WRoomItem` | a row of the room list: avatar, name, preview, time, unread counter |
| `WMessageBubble` | a message with sender, time and delivery status (`sending`, `sent`, `read`, `failed`) |
| `WComposer` | the message field: attach, Enter sends, Shift+Enter adds a line |
## Where things come from
@@ -58,5 +61,7 @@ apps on the same grid: 24px, 1.75 stroke, round caps and joins.
The fonts are Outfit, Figtree and JetBrains Mono under the SIL Open Font
License.
The Matrix store (Pinia over matrix-js-sdk) joins this package with the web
client.
The three chat components are the brand package's `RoomItem`,
`MessageBubble` and `Composer`. The Matrix state itself is not here: it
lives with the web client (`apps/web/src/stores`, `apps/web/src/matrix`),
which the desktop app will reuse as a whole.
+2
View File
@@ -0,0 +1,2 @@
/** Delivery state of your own message, as the bubble shows it. */
export type MessageStatus = 'sending' | 'sent' | 'read' | 'failed'
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { nextTick, ref, useId, watch } from 'vue'
import WIcon from './WIcon.vue'
import WIconButton from './WIconButton.vue'
// The message input at the bottom of a room. Enter sends, Shift+Enter adds
// a line; the field grows with its text up to a few lines. Send stays
// disabled until there is something to send.
const props = withDefaults(
defineProps<{
/** Specific to the room: "Message #homelab" or "Message Maja". */
placeholder?: string
encrypted?: boolean
disabled?: boolean
/** Shows the attach button; the app handles the file. */
attach?: boolean
}>(),
{ placeholder: 'Message', encrypted: false, disabled: false, attach: true },
)
const model = defineModel<string>({ default: '' })
const emit = defineEmits<{ send: [text: string]; attach: [files: FileList] }>()
const id = useId()
const field = ref<HTMLTextAreaElement>()
const picker = ref<HTMLInputElement>()
function grow() {
const el = field.value
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 176)}px`
}
watch(model, () => nextTick(grow))
function send() {
const text = model.value
if (!text.trim() || props.disabled) return
emit('send', text)
model.value = ''
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault()
send()
}
}
function onPick() {
const files = picker.value?.files
if (files && files.length) emit('attach', files)
if (picker.value) picker.value.value = ''
}
defineExpose({ focus: () => field.value?.focus() })
</script>
<template>
<form class="wp-composer items-end" @submit.prevent="send">
<template v-if="attach">
<WIconButton icon="attach" label="Attach a file" :disabled="disabled" @click="picker?.click()" />
<input ref="picker" type="file" class="wp-sr" tabindex="-1" aria-hidden="true" multiple @change="onPick" />
</template>
<label :for="id" class="wp-sr">Message</label>
<textarea
:id="id"
ref="field"
v-model="model"
class="wp-composer-input resize-none self-center"
rows="1"
:placeholder="placeholder"
:disabled="disabled"
autocomplete="off"
@keydown="onKey"
/>
<WIcon v-if="encrypted" name="lock" :size="16" class="wp-composer-lock mb-3" label="End-to-end encrypted" />
<button type="submit" class="wp-composer-send" aria-label="Send" :disabled="disabled || !model.trim()">
<WIcon name="send" :size="20" />
</button>
</form>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import WIcon from './WIcon.vue'
import type { IconName } from '../icons'
import type { MessageStatus } from '../chat'
// A chat message: bubble, sender name on the first of a group, time and,
// for your own messages, the delivery status in an icon and a word.
const props = withDefaults(
defineProps<{
own?: boolean
/** Others' messages, first in a group. */
sender?: string
time?: string
edited?: boolean
status?: MessageStatus
}>(),
{ own: false, sender: undefined, time: '', edited: false, status: undefined },
)
const statuses: Record<MessageStatus, [IconName, string]> = {
sending: ['more', 'Sending'],
sent: ['check', 'Sent'],
read: ['read', 'Read'],
failed: ['shield-alert', 'Not sent'],
}
const shown = computed(() => (props.status ? statuses[props.status] : undefined))
</script>
<template>
<div :class="['wp-msg', own ? 'wp-msg-own' : 'wp-msg-other', status === 'failed' && 'wp-msg-failed']">
<p v-if="!own && sender" class="wp-msg-sender">{{ sender }}</p>
<div class="wp-msg-bubble"><slot /></div>
<p v-if="time || shown || $slots.meta" class="wp-msg-meta">
{{ time }}<template v-if="edited"> · edited</template>
<span v-if="shown" class="wp-msg-status">
<WIcon :name="shown[0]" :size="14" />
{{ shown[1] }}
</span>
<slot name="meta" />
</p>
</div>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import WAvatar from './WAvatar.vue'
import WBadge from './WBadge.vue'
import WIcon from './WIcon.vue'
// One row of the room list: avatar, name, last message, time and unread
// counter. Lives on surface-sunken.
withDefaults(
defineProps<{
name: string
preview?: string
/** Already formatted: "14:32" today, weekday this week, "12 Oct" older. */
time?: string
avatar?: string
/** Unread count; 0 or absent shows none. */
unread?: number
/** The counter turns brand when the person was mentioned. */
mention?: boolean
encrypted?: boolean
active?: boolean
presence?: 'online' | 'away' | 'busy' | 'offline'
}>(),
{
preview: '',
time: '',
avatar: undefined,
unread: 0,
mention: false,
encrypted: false,
active: false,
presence: undefined,
},
)
</script>
<template>
<button
type="button"
:class="['wp-room', active && 'wp-room-active', unread > 0 && 'wp-room-unread']"
:aria-current="active ? 'true' : undefined"
>
<WAvatar :name="name" :src="avatar" :size="40" :presence="presence" />
<span class="wp-room-main">
<span class="wp-room-top">
<span class="wp-room-name">{{ name }}</span>
<WIcon v-if="encrypted" name="lock" :size="13" class="wp-room-lock" label="End-to-end encrypted" />
<span class="wp-room-time">{{ time }}</span>
</span>
<span class="wp-room-bottom">
<span class="wp-room-preview">{{ preview }}</span>
<WBadge v-if="unread > 0" :tone="mention ? 'brand' : 'neutral'" :count="unread" />
</span>
</span>
</button>
</template>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>arrow-down</title><path d="M12 5v14"/><path d="M6 13l6 6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 238 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>download</title><path d="M12 4v11"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 19.5h14"/></svg>

After

Width:  |  Height:  |  Size: 268 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>file</title><path d="M13.5 3.5H7A1.5 1.5 0 0 0 5.5 5v14A1.5 1.5 0 0 0 7 20.5h10a1.5 1.5 0 0 0 1.5-1.5V8.5z"/><path d="M13.5 3.5v5h5"/></svg>

After

Width:  |  Height:  |  Size: 309 B

+4
View File
@@ -2,6 +2,7 @@
// on the same grid (24px, 1.75 stroke, round caps, currentColor).
export type IconName =
| 'arrow-down'
| 'attach'
| 'bell'
| 'check'
@@ -9,7 +10,9 @@ export type IconName =
| 'chevron-right'
| 'close'
| 'copy'
| 'download'
| 'edit'
| 'file'
| 'globe'
| 'history'
| 'home'
@@ -38,6 +41,7 @@ export type IconName =
| 'sun'
| 'trash'
| 'user'
| 'users'
| 'video'
const files = import.meta.glob<string>('./*.svg', { query: '?raw', import: 'default', eager: true })
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>users</title><circle cx="9" cy="8.5" r="3.5"/><path d="M3 19.5c1.2-3.4 3.4-5.2 6-5.2s4.8 1.8 6 5.2"/><path d="M15 5.2a3.5 3.5 0 0 1 0 6.6"/><path d="M17.5 14.6c1.6.7 2.8 2.3 3.5 4.9"/></svg>

After

Width:  |  Height:  |  Size: 359 B

+4
View File
@@ -2,11 +2,14 @@ export { default as WAvatar } from './components/WAvatar.vue'
export { default as WBadge } from './components/WBadge.vue'
export { default as WBanner } from './components/WBanner.vue'
export { default as WButton } from './components/WButton.vue'
export { default as WComposer } from './components/WComposer.vue'
export { default as WCheckbox } from './components/WCheckbox.vue'
export { default as WDialog } from './components/WDialog.vue'
export { default as WIcon } from './components/WIcon.vue'
export { default as WIconButton } from './components/WIconButton.vue'
export { default as WLogo } from './components/WLogo.vue'
export { default as WMessageBubble } from './components/WMessageBubble.vue'
export { default as WRoomItem } from './components/WRoomItem.vue'
export { default as WSelect } from './components/WSelect.vue'
export { default as WSpinner } from './components/WSpinner.vue'
export { default as WSwitch } from './components/WSwitch.vue'
@@ -15,3 +18,4 @@ export { default as WTextField } from './components/WTextField.vue'
export { iconNames, type IconName } from './icons'
export { useTheme, type ThemeChoice } from './theme'
export type { MessageStatus } from './chat'
+4
View File
@@ -62,6 +62,10 @@
.wp-segmented button[aria-pressed="true"] { background: var(--teal-soft); color: var(--teal-text); }
.wp-segmented button:focus-visible { outline: 2px solid transparent; box-shadow: var(--focus-ring); }
/* The composer field sits inside a bordered box that shows the focus;
a second ring around the field itself would be noise. */
.wp-composer-input:focus-visible { box-shadow: none; }
/* Danger text button */
.wp-btn-danger-solid { background: var(--danger); color: var(--surface); border-color: var(--danger); }
.wp-btn-danger-solid:hover { filter: brightness(1.06); }