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
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:
1 parent
adc18e7cf3
commit
b6f3be6ac4
90 files changed
+6608
-472
No files matched your search
+7
-2
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Delivery state of your own message, as the bubble shows it. */
|
||||
export type MessageStatus = 'sending' | 'sent' | 'read' | 'failed'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 })
|
||||
|
||||
@@ -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 |
@@ -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'
|
||||
@@ -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); }
|
||||
|
||||
Reference in new issue
Block a user