Web client: replies, reactions, threads and spaces
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

- 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 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 10:09:12 +02:00
1 parent 12550c4078
commit 8ff2730676
23 files changed
+1666 -205

No files matched your search

+1 -1
View File
@@ -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 |
+2
View File
@@ -45,7 +45,9 @@ export type IconName =
| 'settings'
| 'shield-alert'
| 'shield-check'
| 'space'
| 'sun'
| 'thread'
| 'trash'
| 'user'
| 'users'
+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>space</title><rect x="4" y="4" width="7" height="7" rx="2"/><rect x="13" y="4" width="7" height="7" rx="2"/><rect x="4" y="13" width="7" height="7" rx="2"/><rect x="13" y="13" width="7" height="7" rx="2"/></svg>

After

Width:  |  Height:  |  Size: 379 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>thread</title><path d="M4.5 5.5h11a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H10l-3.5 3v-3h-2a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2z"/><path d="M20.5 9.5v6a2 2 0 0 1-2 2h-1v2.5l-3-2.5"/><path d="M6.5 9h7"/><path d="M6.5 11.5h4"/></svg>

After

Width:  |  Height:  |  Size: 383 B

+24 -1
View File
@@ -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
+31 -16
View File
@@ -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<boolean>('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() {
</script>
<template>
<WDialog v-model:open="open" title="Create room" :persistent="busy">
<WDialog v-model:open="open" :title="isSpace ? 'Create space' : spaceName ? `New room in ${spaceName}` : 'Create room'" :persistent="busy">
<form id="create-room" class="flex flex-col gap-4" @submit.prevent="submit">
<WTextField v-model="name" wide label="Name" maxlength="255" required autofocus />
<WTextArea v-model="topic" label="Topic (optional)" rows="2" />
<div class="flex items-start gap-3">
<div class="flex-1">
<p class="body-strong m-0">Public room</p>
<p class="small m-0 text-ink-muted">Anyone can find it in the room directory and join. Otherwise people join by invite.</p>
<p class="body-strong m-0">Public {{ noun }}</p>
<p class="small m-0 text-ink-muted">
<template v-if="isSpace">Anyone can find it in the room directory, join it and see its public rooms. Otherwise people join by invite.</template>
<template v-else>Anyone can find it in the room directory and join. Otherwise people join by invite.</template>
</p>
</div>
<WSwitch v-model="isPublic" label="Public room" />
<WSwitch v-model="isPublic" :label="`Public ${noun}`" />
</div>
<WTextField
v-if="isPublic"
v-if="isPublic && !isSpace"
v-model="alias"
wide
mono
@@ -88,7 +103,7 @@ async function submit() {
autocomplete="off"
spellcheck="false"
/>
<div class="flex items-start gap-3">
<div v-if="!isSpace" class="flex items-start gap-3">
<div class="flex-1">
<p class="body-strong m-0 flex items-center gap-1">
<WIcon :name="encrypted ? 'lock' : 'shield-alert'" :size="16" :class="encrypted ? 'text-teal-text' : 'text-ink-muted'" />
@@ -111,7 +126,7 @@ async function submit() {
<template #actions>
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton type="submit" form="create-room" variant="primary" :loading="busy" :disabled="!name.trim() || !!aliasError">
Create room
Create {{ noun }}
</WButton>
</template>
</WDialog>
+1 -1
View File
@@ -20,7 +20,7 @@ const directory = ref<IPublicRoomsChunkRoom[]>()
const directoryError = ref('')
const search = ref('')
const joined = computed(() => new Set(chat.rooms.map((r) => r.roomId)))
const joined = computed(() => new Set([...chat.rooms, ...chat.spaces].map((r) => r.roomId)))
async function loadDirectory() {
directoryError.value = ''
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
import { WIconButton } from '@warp/ui'
// Reacting to a message: the six quick reactions of the Android app, or
// any other typed (or picked with the system's emoji keyboard) into the
// field. Closes on a choice, Escape or a click outside.
const props = withDefaults(defineProps<{ align?: 'left' | 'right' }>(), { align: 'left' })
const emit = defineEmits<{ pick: [key: string] }>()
const quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏']
const id = useId()
const open = ref(false)
const other = ref('')
const root = ref<HTMLElement>()
const panel = ref<HTMLElement>()
function onDocClick(e: MouseEvent) {
if (root.value && !root.value.contains(e.target as Node)) open.value = false
}
watch(open, async (v) => {
if (v) {
other.value = ''
document.addEventListener('mousedown', onDocClick)
await nextTick()
panel.value?.querySelector<HTMLButtonElement>('button')?.focus()
} else {
document.removeEventListener('mousedown', onDocClick)
}
})
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick))
function pick(key: string) {
const k = key.trim()
if (!k) return
open.value = false
emit('pick', k)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && open.value) {
e.stopPropagation()
open.value = false
root.value?.querySelector<HTMLButtonElement>('button')?.focus()
}
}
</script>
<template>
<div ref="root" class="relative" @keydown="onKey">
<WIconButton icon="react" label="React" :aria-expanded="open" aria-haspopup="dialog" @click="open = !open" />
<div
v-if="open"
ref="panel"
role="dialog"
aria-label="React to the message"
:class="[
'absolute bottom-full z-30 mb-1 flex flex-col gap-2 rounded-md border border-line bg-surface-raised p-2 shadow-float',
props.align === 'right' ? 'right-0' : 'left-0',
]"
>
<div class="flex gap-1">
<button
v-for="key in quickReactions"
:key="key"
type="button"
class="flex h-10 w-10 items-center justify-center rounded-sm text-[22px] hover:bg-surface-sunken focus-visible:bg-surface-sunken"
:aria-label="`React with ${key}`"
@click="pick(key)"
>
{{ key }}
</button>
</div>
<form class="flex gap-1" @submit.prevent="pick(other)">
<label class="wp-sr" :for="id">Another reaction</label>
<input
:id="id"
v-model="other"
maxlength="64"
placeholder="Another emoji"
autocomplete="off"
class="h-9 min-w-0 flex-1 rounded-sm border border-line-strong bg-surface px-2 text-ink outline-none focus:border-teal-strong"
/>
<button type="submit" class="h-9 rounded-sm px-3 font-semibold text-teal-text hover:bg-surface-sunken" :disabled="!other.trim()">React</button>
</form>
</div>
</div>
</template>
+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import type { Room } from 'matrix-js-sdk'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { WBanner, WComposer, WIcon, WIconButton, WSpinner } from '@warp/ui'
import { drafts } from '@/lib/drafts'
import { errorMessage, formatBytes } from '@/lib/format'
import { client, cryptoError } from '@/matrix/client'
import { sendFile, sendText } from '@/matrix/send'
import type { MessageItem } from '@/matrix/timeline'
import { useChat } from '@/stores/chat'
import { useToasts } from '@/stores/toasts'
// Writing to a room, or to one of its threads: the text field with its
// draft, typing notifications, uploads, and the message being replied to
// above it. The room view and the thread pane each have one.
const props = defineProps<{ room: Room; threadId?: string; placeholder: string }>()
const replyTo = defineModel<MessageItem | null>('replyTo', { default: null })
const chat = useChat()
const toasts = useToasts()
const me = client().getUserId() ?? ''
const composer = ref<InstanceType<typeof WComposer>>()
const encrypted = computed(() => {
void chat.version
return props.room.hasEncryptionStateEvent()
})
// An encrypted room can't be written to without the crypto stack.
const cryptoMissing = computed(() => encrypted.value && !chat.canEncrypt)
const canSend = computed(() => {
void chat.version
return props.room.currentState.maySendMessage(me)
})
/** Whether files dropped on the room can be sent. */
const canWrite = computed(() => canSend.value && !cryptoMissing.value)
const draftKey = computed(() => (props.threadId ? `${props.room.roomId}/${props.threadId}` : props.room.roomId))
const text = ref(drafts.get(draftKey.value) ?? '')
watch(text, (v) => (v ? drafts.set(draftKey.value, v) : drafts.delete(draftKey.value)))
// Typing notifications: on while there is text, renewed every few seconds,
// off when it's sent or cleared. Matrix has them per room, threads or not.
let typingUntil = 0
function setTyping(on: boolean) {
const now = Date.now()
if (on && now < typingUntil) return
if (!on && typingUntil === 0) return
typingUntil = on ? now + 4000 : 0
client()
.sendTyping(props.room.roomId, on, on ? 6000 : 0)
.catch(() => undefined)
}
watch(text, (v) => setTyping(Boolean(v.trim())))
onBeforeUnmount(() => setTyping(false))
// Choosing a message to reply to puts the cursor in the field.
watch(replyTo, (v) => v && nextTick(() => composer.value?.focus()))
function target() {
return { threadId: props.threadId, replyTo: replyTo.value?.eventId }
}
function send(body: string) {
setTyping(false)
const to = target()
replyTo.value = null
sendText(client(), props.room.roomId, body, to).catch((e) => {
// A failed send stays in the timeline with Retry; say why once.
toasts.error(errorMessage(e))
})
}
// Uploads.
const uploads = ref<{ id: number; name: string; loaded: number; total: number }[]>([])
let uploadId = 0
let maxUpload: number | null | undefined
async function sendFiles(list: FileList | File[]) {
// Copied first: the composer clears its file input (and so the live
// FileList) as soon as it has handed the files over.
const files = Array.from(list)
if (!canWrite.value) return
if (maxUpload === undefined) {
maxUpload = await client()
.getMediaConfig(true)
.then((c) => c['m.upload.size'] ?? null)
.catch(() => null)
}
const to = target()
replyTo.value = null
for (const file of files) {
if (maxUpload && file.size > maxUpload) {
toasts.error(`${file.name} is ${formatBytes(file.size)}; this homeserver takes files up to ${formatBytes(maxUpload)}.`)
continue
}
const id = ++uploadId
uploads.value.push({ id, name: file.name, loaded: 0, total: file.size })
sendFile(
client(),
props.room,
file,
(p) => {
const u = uploads.value.find((x) => x.id === id)
if (u) Object.assign(u, { loaded: p.loaded, total: p.total })
},
to,
)
.catch((e) => toasts.error(`Can’t send ${file.name}: ${errorMessage(e)}`))
.finally(() => (uploads.value = uploads.value.filter((x) => x.id !== id)))
}
}
function onPaste(e: ClipboardEvent) {
const files = e.clipboardData?.files
if (files && files.length) {
e.preventDefault()
sendFiles(files)
}
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && replyTo.value) {
e.stopPropagation()
replyTo.value = null
}
}
defineExpose({ sendFiles, canWrite, focus: () => composer.value?.focus() })
</script>
<template>
<div class="flex flex-col gap-2">
<div v-for="u in uploads" :key="u.id" class="flex items-center gap-3 rounded-md bg-surface-raised px-3 py-2">
<WSpinner :size="16" />
<span class="small min-w-0 flex-1 truncate">Uploading {{ u.name }}</span>
<span class="caption text-ink-muted tabular-nums">{{ u.total ? Math.round((u.loaded / u.total) * 100) : 0 }}%</span>
</div>
<WBanner v-if="cryptoMissing" tone="warning" title="End-to-end encryption didn’t start">
This browser can’t read or send messages in this room. Reload the page to try again{{ cryptoError() ? ` (${cryptoError()})` : '' }}.
</WBanner>
<WBanner v-else-if="!canSend" tone="info">You don’t have permission to post in this room.</WBanner>
<div v-else class="flex flex-col gap-1" @paste="onPaste" @keydown="onKey">
<div v-if="replyTo" class="flex items-center gap-2 rounded-md border-l-2 border-teal-strong bg-surface-raised py-1 pr-1 pl-3">
<WIcon name="reply" :size="16" class="shrink-0 text-ink-muted" />
<p class="small m-0 min-w-0 flex-1 truncate">
Replying to <span class="font-semibold">{{ replyTo.own ? 'yourself' : replyTo.senderName }}</span>
<span class="text-ink-muted"> · {{ replyTo.media ? replyTo.media.filename : replyTo.body }}</span>
</p>
<WIconButton icon="close" label="Cancel reply" @click="replyTo = null" />
</div>
<WComposer ref="composer" v-model="text" :placeholder="placeholder" :encrypted="encrypted" @send="send" @attach="sendFiles" />
</div>
</div>
</template>
+18 -9
View File
@@ -35,6 +35,14 @@ const alias = computed(() => {
void chat.version
return props.room.getCanonicalAlias()
})
// "room", or what else it is: a direct chat or a space.
const noun = computed(() => {
void chat.version
if (props.isDirect) return 'chat'
return props.room.isSpaceRoom() ? 'space' : 'room'
})
const Noun = computed(() => noun.value[0]!.toUpperCase() + noun.value.slice(1))
const encrypted = computed(() => {
void chat.version
return props.room.hasEncryptionStateEvent()
@@ -149,8 +157,8 @@ function copy(text: string) {
aria-label="Room info"
>
<div class="flex h-16 shrink-0 items-center gap-2 border-b border-line px-4">
<h2 class="subheading m-0 flex-1">{{ isDirect ? 'Chat info' : 'Room info' }}</h2>
<WIconButton icon="close" label="Close room info" @click="emit('close')" />
<h2 class="subheading m-0 flex-1">{{ Noun }} info</h2>
<WIconButton icon="close" :label="`Close ${noun} info`" @click="emit('close')" />
</div>
<div class="flex-1 overflow-y-auto">
@@ -161,7 +169,7 @@ function copy(text: string) {
{{ alias }}
</button>
<p v-if="topic" class="body m-0 whitespace-pre-wrap text-ink-muted">{{ topic }}</p>
<p class="caption m-0 flex items-center gap-1" :class="encrypted ? 'text-teal-text' : 'text-ink-muted'">
<p v-if="noun !== 'space'" class="caption m-0 flex items-center gap-1" :class="encrypted ? 'text-teal-text' : 'text-ink-muted'">
<WIcon :name="encrypted ? 'lock' : 'shield-alert'" :size="14" />
{{ encrypted ? 'End-to-end encrypted' : 'Not encrypted' }}
</p>
@@ -207,11 +215,11 @@ function copy(text: string) {
<button type="button" class="caption flex items-center gap-1 text-left text-ink-muted hover:text-ink" title="Copy room ID" @click="copy(room.roomId)">
<WIcon name="copy" :size="14" /> <span class="mxid break-all">{{ room.roomId }}</span>
</button>
<WButton variant="danger" icon="logout" @click="leaveOpen = true">{{ isDirect ? 'Leave chat' : 'Leave room' }}</WButton>
<WButton variant="danger" icon="logout" @click="leaveOpen = true">Leave {{ noun }}</WButton>
</section>
</div>
<WDialog v-model:open="editing" title="Edit room" :persistent="saving">
<WDialog v-model:open="editing" :title="`Edit ${noun}`" :persistent="saving">
<form id="edit-room" class="flex flex-col gap-4" @submit.prevent="save">
<WTextField v-if="canEditName" v-model="draftName" wide label="Name" maxlength="255" />
<WTextArea v-if="canEditTopic" v-model="draftTopic" label="Topic" rows="3" />
@@ -223,7 +231,7 @@ function copy(text: string) {
</template>
</WDialog>
<WDialog v-model:open="inviteOpen" title="Invite to room" :persistent="inviting">
<WDialog v-model:open="inviteOpen" :title="`Invite to ${noun}`" :persistent="inviting">
<form id="invite-form" class="flex flex-col gap-4" @submit.prevent="invite">
<WTextField
v-model="inviteId"
@@ -252,14 +260,15 @@ function copy(text: string) {
<ConfirmDialog
v-model:open="leaveOpen"
:title="isDirect ? 'Leave chat' : 'Leave room'"
:confirm-label="isDirect ? 'Leave chat' : 'Leave room'"
:title="`Leave ${noun}`"
:confirm-label="`Leave ${noun}`"
:busy="leaving"
:error="leaveError"
@confirm="leave"
>
<p>
You stop getting messages from <span class="body-strong">{{ name }}</span>.
<template v-if="noun === 'space'">You leave <span class="body-strong">{{ name }}</span>, not the rooms in it.</template>
<template v-else>You stop getting messages from <span class="body-strong">{{ name }}</span>.</template>
<template v-if="room.getJoinRule() !== 'public'"> To come back, someone has to invite you again.</template>
</p>
</ConfirmDialog>
+203 -52
View File
@@ -1,26 +1,30 @@
<script setup lang="ts">
import type { Room } from 'matrix-js-sdk'
import { EventType, type Room } from 'matrix-js-sdk'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { WButton, WIcon, WMessageBubble, WSpinner } from '@warp/ui'
import { WButton, WIcon, WIconButton, WMessageBubble, WSpinner } from '@warp/ui'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ImageViewer from '@/components/ImageViewer.vue'
import MessageBody from '@/components/MessageBody.vue'
import MxcAvatar from '@/components/MxcAvatar.vue'
import PopMenu from '@/components/PopMenu.vue'
import ReactionPicker from '@/components/ReactionPicker.vue'
import type { MenuItem } from '@/components/menu'
import { errorMessage, formatDateTime, formatDay, formatTime } from '@/lib/format'
import { errorMessage, formatDateTime, formatDay, formatListTime, formatTime } from '@/lib/format'
import { client } from '@/matrix/client'
import type { MediaSource } from '@/matrix/media'
import { buildTimeline, canPaginateBack, lastSyncedEvent, type MessageItem } from '@/matrix/timeline'
import { sendReaction } from '@/matrix/send'
import { buildTimeline, canPaginateBack, inThread, lastSyncedEvent, type MessageItem, type ReactionChip } from '@/matrix/timeline'
import { useChat } from '@/stores/chat'
import { useToasts } from '@/stores/toasts'
// The scrolling list of a room's messages. The scroller is laid out bottom
// up (column-reverse), so it starts at the newest message, stays there as
// messages arrive, and older history loading above doesn't move what you
// are reading.
// are reading. Given a thread's root ID it shows that thread instead: the
// root, then its replies.
const props = defineProps<{ room: Room; isDirect: boolean }>()
const props = defineProps<{ room: Room; isDirect: boolean; threadId?: string }>()
const emit = defineEmits<{ reply: [item: MessageItem]; openThread: [eventId: string] }>()
const chat = useChat()
const toasts = useToasts()
@@ -37,11 +41,18 @@ const deleteError = ref('')
const items = computed(() => {
void chat.version
return buildTimeline(client(), props.room)
return buildTimeline(client(), props.room, props.threadId)
})
const hasMore = computed(() => {
void chat.version
return canPaginateBack(props.room)
return canPaginateBack(props.room, props.threadId)
})
// A thread's first replies come from the homeserver when the SDK first
// sees it; until then there is only the root.
const threadLoading = computed(() => {
void chat.version
const thread = props.threadId ? props.room.getThread(props.threadId) : null
return Boolean(thread && !thread.initialEventsFetched)
})
// Distance from the oldest loaded message, given column-reverse's
@@ -55,7 +66,9 @@ async function paginate() {
paginating.value = true
paginateError.value = false
try {
await client().scrollback(props.room, 30)
const thread = props.threadId ? props.room.getThread(props.threadId) : null
if (thread) await client().paginateEventTimeline(thread.liveTimeline, { backwards: true, limit: 30 })
else if (!props.threadId) await client().scrollback(props.room, 30)
} catch {
paginateError.value = true
} finally {
@@ -65,7 +78,7 @@ async function paginate() {
// Keep going until the view is full or history runs out.
requestAnimationFrame(() => {
const el = scroller.value
if (el && !paginateError.value && distanceFromTop(el) < 400 && canPaginateBack(props.room)) paginate()
if (el && !paginateError.value && distanceFromTop(el) < 400 && canPaginateBack(props.room, props.threadId)) paginate()
})
}
@@ -82,21 +95,28 @@ function jumpToLatest() {
}
// Read receipts: sent when you are looking at the newest message, with the
// tab in front.
// tab in front. With threads on, the SDK gives each receipt the thread it
// belongs to (MSC3771): "main" for the room, the root's ID in a thread. A
// thread has no read marker of its own, only the receipt.
let lastMarked = ''
function markRead() {
if (!atBottom.value || document.visibilityState !== 'visible' || !document.hasFocus()) return
const ev = lastSyncedEvent(props.room)
const ev = lastSyncedEvent(props.room, props.threadId)
const id = ev?.getId()
if (!ev || !id || id === lastMarked) return
if (ev.getSender() === me && props.room.getUnreadNotificationCount() === 0) {
const unread = props.threadId
? props.room.getThreadUnreadNotificationCount(props.threadId)
: props.room.getRoomUnreadNotificationCount()
if (ev.getSender() === me && unread === 0) {
lastMarked = id
return
}
lastMarked = id
client()
.setRoomReadMarkers(props.room.roomId, id, ev)
.catch(() => (lastMarked = ''))
// The receipt goes on its own: one sent with the read marker (POST
// /read_markers) has no thread_id, so it would read every thread too.
const c = client()
const sent = props.threadId ? c.sendReadReceipt(ev) : Promise.all([c.setRoomReadMarkers(props.room.roomId, id), c.sendReadReceipt(ev)])
sent.catch(() => (lastMarked = ''))
}
watch(items, () => requestAnimationFrame(markRead))
@@ -139,8 +159,29 @@ const redactLevel = computed(() => {
return typeof pl.redact === 'number' ? pl.redact : 50
})
const canSend = computed(() => {
void chat.version
return props.room.currentState.maySendMessage(me)
})
const canReact = computed(() => {
void chat.version
return props.room.currentState.maySendEvent(EventType.Reaction, me)
})
/** Whether a message can be answered or reacted to: it is on the server, not deleted. */
function answerable(item: MessageItem): boolean {
return Boolean(item.eventId && !item.eventId.startsWith('~')) && !item.redacted && !item.status?.match(/sending|failed/)
}
/** Starting a thread on a message: not from inside one, nor on a reply that is in one. */
function canThread(item: MessageItem): boolean {
return !props.threadId && canSend.value && answerable(item) && !inThread(item.event)
}
function actions(item: MessageItem): MenuItem[] {
const out: MenuItem[] = []
if (canSend.value && answerable(item)) out.push({ label: 'Reply', icon: 'reply', action: () => emit('reply', item) })
if (canThread(item)) out.push({ label: 'Reply in thread', icon: 'thread', action: () => emit('openThread', item.eventId!) })
if (item.body && !item.media && !item.encrypted) {
out.push({
label: 'Copy text',
@@ -154,6 +195,50 @@ function actions(item: MessageItem): MenuItem[] {
return out
}
// Reactions: a chip adds yours, or takes it back.
async function react(item: MessageItem, key: string) {
const mine = item.reactions.find((r) => r.key === key)?.mine
try {
if (mine) {
// Yours, still on its way: nothing to take back yet.
if (mine.status || !mine.getId()) return
await client().redactEvent(props.room.roomId, mine.getId()!)
} else {
await sendReaction(client(), item.event, key)
}
} catch (e) {
toasts.error(errorMessage(e))
}
}
function chipLabel(chip: ReactionChip): string {
const names = chip.names.length > 8 ? [...chip.names.slice(0, 8), `${chip.names.length - 8} more`] : chip.names
return `${names.join(', ')} reacted with ${chip.key}`
}
// The avatar sits by the bubble's last line, above any reactions and thread summary.
function avatarLift(item: MessageItem): number {
return 20 + (item.reactions.length ? 32 : 0) + (item.thread ? 34 : 0)
}
function threadLabel(item: MessageItem): string {
const n = item.thread?.count ?? 0
return n === 1 ? '1 reply' : `${n} replies`
}
// A quote goes to the message it quotes, when that is loaded here.
const flashed = ref('')
function jumpTo(eventId: string) {
const el = scroller.value?.querySelector<HTMLElement>(`[data-event-id="${CSS.escape(eventId)}"]`)
if (!el) {
toasts.info('That message isn’t loaded here.')
return
}
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
flashed.value = eventId
window.setTimeout(() => flashed.value === eventId && (flashed.value = ''), 1500)
}
function askDelete(item: MessageItem) {
deleteError.value = ''
deleting.value = item
@@ -180,9 +265,9 @@ async function confirmDelete() {
<div ref="scroller" class="flex h-full flex-col-reverse overflow-y-auto" @scroll.passive="onScroll">
<div class="mx-auto flex w-full max-w-4xl flex-col px-4 pt-4 pb-2" role="log" aria-live="polite" aria-relevant="additions">
<div class="flex min-h-10 items-center justify-center py-2">
<WSpinner v-if="paginating" :size="18" label="Loading older messages" />
<WSpinner v-if="paginating || threadLoading" :size="18" label="Loading older messages" />
<WButton v-else-if="paginateError" size="sm" icon="refresh" @click="paginate">Load older messages</WButton>
<p v-else-if="!hasMore" class="caption text-ink-muted">This is the beginning of the room.</p>
<p v-else-if="!hasMore && !threadId" class="caption text-ink-muted">This is the beginning of the room.</p>
</div>
<template v-for="item in items" :key="item.key">
@@ -196,48 +281,114 @@ async function confirmDelete() {
<div
v-else
:class="['group flex items-end gap-2', item.own ? 'flex-row-reverse' : '', item.first ? 'mt-4' : 'mt-2']"
:data-event-id="item.eventId"
:class="[
'group flex items-end gap-2 rounded-md transition-colors',
item.own ? 'flex-row-reverse' : '',
item.first ? 'mt-4' : 'mt-2',
flashed && flashed === item.eventId ? 'bg-teal-soft' : '',
]"
>
<template v-if="!item.own && !isDirect">
<MxcAvatar v-if="item.last" :name="item.senderName" :mxc="item.avatarMxc" :size="32" class="mb-5" />
<MxcAvatar
v-if="item.last"
:name="item.senderName"
:mxc="item.avatarMxc"
:size="32"
:style="{ marginBottom: `${avatarLift(item)}px` }"
/>
<span v-else class="w-8 shrink-0" />
</template>
<WMessageBubble
class="max-w-[min(520px,80%)] min-w-0"
:own="item.own"
:sender="item.first && !isDirect ? item.senderName : undefined"
:time="item.last || item.status === 'failed' ? formatTime(item.ts) : ''"
:edited="item.edited"
:status="item.status"
:title="formatDateTime(item.ts)"
>
<MessageBody :item="item" @view="(source, name) => (viewing = { source, name })" />
<template v-if="item.status === 'failed' || item.shield" #meta>
<span
v-if="item.shield"
:class="['inline-flex items-center gap-1', item.shield.tone === 'danger' ? 'text-danger' : 'text-warning']"
:title="item.shield.text"
<div :class="['flex max-w-[min(520px,80%)] min-w-0 flex-col gap-1', item.own ? 'items-end' : 'items-start']">
<WMessageBubble
class="max-w-full min-w-0"
:own="item.own"
:sender="item.first && !isDirect ? item.senderName : undefined"
:time="item.last || item.status === 'failed' ? formatTime(item.ts) : ''"
:edited="item.edited"
:status="item.status"
:title="formatDateTime(item.ts)"
>
<button
v-if="item.reply"
type="button"
class="mb-1 flex w-full min-w-0 flex-col rounded-sm border-l-2 border-teal-strong bg-surface px-2 py-1 text-left"
:aria-label="`Go to the message this replies to: ${item.reply.text}`"
@click="jumpTo(item.reply.eventId)"
>
<WIcon name="shield-alert" :size="14" />
<span>{{ item.shield.tone === 'danger' ? item.shield.text : 'Not verified' }}</span>
<span v-if="item.shield.tone !== 'danger'" class="wp-sr">: {{ item.shield.text }}</span>
</span>
<template v-if="item.status === 'failed'">
<button type="button" class="font-semibold text-teal-text hover:underline" @click="retry(item)">Retry</button>
<button type="button" class="font-semibold text-ink-muted hover:underline" @click="discard(item)">Delete</button>
<span v-if="item.reply.senderName" class="caption truncate font-semibold text-teal-text">{{ item.reply.senderName }}</span>
<span class="small block w-full truncate text-ink-muted">{{ item.reply.text }}</span>
</button>
<MessageBody :item="item" @view="(source, name) => (viewing = { source, name })" />
<template v-if="item.status === 'failed' || item.shield" #meta>
<span
v-if="item.shield"
:class="['inline-flex items-center gap-1', item.shield.tone === 'danger' ? 'text-danger' : 'text-warning']"
:title="item.shield.text"
>
<WIcon name="shield-alert" :size="14" />
<span>{{ item.shield.tone === 'danger' ? item.shield.text : 'Not verified' }}</span>
<span v-if="item.shield.tone !== 'danger'" class="wp-sr">: {{ item.shield.text }}</span>
</span>
<template v-if="item.status === 'failed'">
<button type="button" class="font-semibold text-teal-text hover:underline" @click="retry(item)">Retry</button>
<button type="button" class="font-semibold text-ink-muted hover:underline" @click="discard(item)">Delete</button>
</template>
</template>
</template>
</WMessageBubble>
</WMessageBubble>
<PopMenu
v-if="actions(item).length && item.status !== 'sending' && item.status !== 'failed'"
class="-my-2 self-center opacity-0 group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100"
icon="more"
label="Message actions"
:align="item.own ? 'right' : 'left'"
:items="actions(item)"
/>
<div v-if="item.reactions.length" :class="['flex flex-wrap gap-1', item.own ? 'justify-end' : '']">
<button
v-for="chip in item.reactions"
:key="chip.key"
type="button"
:class="[
'flex h-7 items-center gap-1 rounded-full border px-2 text-sm tabular-nums',
chip.mine ? 'border-teal-strong bg-teal-soft text-teal-text' : 'border-line bg-surface-raised text-ink hover:bg-surface-sunken',
]"
:title="chipLabel(chip)"
:aria-label="`${chipLabel(chip)}. ${chip.mine ? 'Take yours back' : 'React too'}`"
:aria-pressed="Boolean(chip.mine)"
:disabled="!canReact"
@click="react(item, chip.key)"
>
<span class="text-base leading-none">{{ chip.key }}</span>
<span class="font-semibold">{{ chip.count }}</span>
</button>
</div>
<button
v-if="item.thread && item.eventId"
type="button"
class="flex max-w-full items-center gap-2 rounded-md border border-line bg-surface-raised px-3 py-1 text-left hover:bg-surface-sunken"
@click="emit('openThread', item.eventId)"
>
<WIcon name="thread" :size="16" class="shrink-0 text-teal-text" />
<span class="small shrink-0 font-semibold text-teal-text">{{ threadLabel(item) }}</span>
<span class="caption truncate text-ink-muted">{{ item.thread.lastSenderName }} · {{ formatListTime(item.thread.lastTs) }}</span>
<span v-if="item.thread.unread" class="h-2 w-2 shrink-0 rounded-full bg-teal-strong" role="img" aria-label="Unread replies" />
</button>
</div>
<div
v-if="item.status !== 'sending' && item.status !== 'failed' && (actions(item).length || (canReact && answerable(item)))"
:class="[
'-my-2 flex items-center self-center opacity-0 group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100',
item.own ? 'flex-row-reverse' : '',
]"
>
<ReactionPicker v-if="canReact && answerable(item)" :align="item.own ? 'right' : 'left'" @pick="(key) => react(item, key)" />
<WIconButton v-if="canSend && answerable(item)" class="hidden sm:inline-flex" icon="reply" label="Reply" @click="emit('reply', item)" />
<WIconButton
v-if="canThread(item)"
class="hidden sm:inline-flex"
icon="thread"
label="Reply in thread"
@click="emit('openThread', item.eventId!)"
/>
<PopMenu v-if="actions(item).length" icon="more" label="Message actions" :align="item.own ? 'right' : 'left'" :items="actions(item)" />
</div>
</div>
</template>
</div>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { WIcon } from '@warp/ui'
import MxcAvatar from '@/components/MxcAvatar.vue'
import type { SpaceSummary } from '@/stores/chat'
// Which rooms the list shows: all of them, or one space's. A space with
// unread messages in its rooms has a dot.
const props = defineProps<{ spaces: SpaceSummary[]; unread: Set<string> }>()
const selected = defineModel<string>({ default: '' })
const emit = defineEmits<{ newSpace: [] }>()
const open = ref(false)
const root = ref<HTMLElement>()
const list = ref<HTMLElement>()
const current = computed(() => props.spaces.find((s) => s.roomId === selected.value))
function onDocClick(e: MouseEvent) {
if (root.value && !root.value.contains(e.target as Node)) open.value = false
}
watch(open, async (v) => {
if (v) {
document.addEventListener('mousedown', onDocClick)
await nextTick()
list.value?.querySelector<HTMLButtonElement>('[aria-checked="true"]')?.focus()
} else {
document.removeEventListener('mousedown', onDocClick)
}
})
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick))
function choose(id: string) {
selected.value = id
open.value = false
}
function onKey(e: KeyboardEvent) {
const buttons = Array.from(list.value?.querySelectorAll<HTMLButtonElement>('button') ?? [])
const i = buttons.indexOf(document.activeElement as HTMLButtonElement)
if (e.key === 'Escape') {
open.value = false
root.value?.querySelector<HTMLButtonElement>('button')?.focus()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
buttons[(i + 1) % buttons.length]?.focus()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
buttons[(i - 1 + buttons.length) % buttons.length]?.focus()
}
}
</script>
<template>
<div ref="root" class="relative px-3 pb-2" @keydown="onKey">
<button
type="button"
class="flex h-10 w-full items-center gap-2 rounded-sm px-2 text-left font-semibold text-ink hover:bg-surface-raised"
aria-haspopup="menu"
:aria-expanded="open"
@click="open = !open"
>
<MxcAvatar v-if="current" :name="current.name" :mxc="current.avatarMxc" :size="24" />
<WIcon v-else name="space" :size="20" class="text-ink-muted" />
<span class="min-w-0 flex-1 truncate">{{ current?.name ?? 'All chats' }}</span>
<span v-if="!open && [...unread].some((id) => id !== selected)" class="h-2 w-2 rounded-full bg-teal-strong" role="img" aria-label="Unread in other spaces" />
<WIcon name="chevron-right" :size="16" :class="['text-ink-muted transition-transform', open ? '-rotate-90' : 'rotate-90']" />
</button>
<div
v-if="open"
ref="list"
role="menu"
aria-label="Show rooms from"
class="absolute inset-x-3 top-full z-30 mt-1 flex max-h-96 flex-col overflow-y-auto rounded-md border border-line bg-surface-raised p-1 shadow-float"
>
<button
type="button"
role="menuitemradio"
:aria-checked="selected === ''"
:class="['flex h-10 items-center gap-3 rounded-sm px-3 text-left font-semibold hover:bg-surface-sunken focus-visible:bg-surface-sunken', selected === '' && 'text-teal-text']"
@click="choose('')"
>
<WIcon name="space" class="text-ink-muted" />
<span class="flex-1">All chats</span>
<WIcon v-if="selected === ''" name="check" :size="16" />
</button>
<button
v-for="s in spaces"
:key="s.roomId"
type="button"
role="menuitemradio"
:aria-checked="selected === s.roomId"
:class="['flex h-10 items-center gap-3 rounded-sm px-3 text-left font-semibold hover:bg-surface-sunken focus-visible:bg-surface-sunken', selected === s.roomId && 'text-teal-text']"
@click="choose(s.roomId)"
>
<MxcAvatar :name="s.name" :mxc="s.avatarMxc" :size="24" />
<span class="min-w-0 flex-1 truncate">{{ s.name }}</span>
<span v-if="unread.has(s.roomId)" class="h-2 w-2 rounded-full bg-teal-strong" role="img" aria-label="Unread" />
<WIcon v-if="selected === s.roomId" name="check" :size="16" />
</button>
<div class="my-1 h-px bg-line" role="separator" />
<button
type="button"
role="menuitem"
class="flex h-10 items-center gap-3 rounded-sm px-3 text-left font-semibold text-ink hover:bg-surface-sunken focus-visible:bg-surface-sunken"
@click="(open = false), emit('newSpace')"
>
<WIcon name="plus" class="text-ink-muted" />
New space
</button>
</div>
</div>
</template>
+278
View File
@@ -0,0 +1,278 @@
<script setup lang="ts">
import { EventType, KnownMembership, RoomType, type HierarchyRoom, type Room } from 'matrix-js-sdk'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { WBadge, WBanner, WButton, WCheckbox, WDialog, WIcon, WIconButton, WSpinner } from '@warp/ui'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import CreateRoomDialog from '@/components/CreateRoomDialog.vue'
import MxcAvatar from '@/components/MxcAvatar.vue'
import PopMenu from '@/components/PopMenu.vue'
import RoomInfoPane from '@/components/RoomInfoPane.vue'
import { errorMessage } from '@/lib/format'
import { client } from '@/matrix/client'
import { spaceChildren, useChat } from '@/stores/chat'
import { useToasts } from '@/stores/toasts'
// A space's home: the rooms and subspaces it lists (GET /hierarchy), the
// ones you're in and the ones you can join. Its admins add rooms to it,
// existing or new, and take them out.
const props = defineProps<{ space: Room }>()
const chat = useChat()
const toasts = useToasts()
const router = useRouter()
const me = client().getUserId() ?? ''
const name = computed(() => {
void chat.version
return props.space.name
})
const topic = computed(() => {
void chat.version
return String(props.space.currentState.getStateEvents(EventType.RoomTopic, '')?.getContent().topic ?? '')
})
const memberCount = computed(() => {
void chat.version
return props.space.getJoinedMemberCount()
})
const canManage = computed(() => {
void chat.version
return props.space.currentState.maySendStateEvent(EventType.SpaceChild, me)
})
const childIds = computed(() => {
void chat.version
return spaceChildren(props.space)
})
function joinedHere(roomId: string): boolean {
void chat.version
return client().getRoom(roomId)?.getMyMembership() === KnownMembership.Join
}
// The hierarchy, a page at a time. Its first room is the space itself.
const rooms = ref<HierarchyRoom[]>([])
const nextBatch = ref<string>()
const loading = ref(false)
const loadError = ref('')
// Where to join each room through: the via of the m.space.child that lists it.
const vias = new Map<string, string[]>()
async function load(more = false) {
loading.value = true
loadError.value = ''
try {
const res = await client().getRoomHierarchy(props.space.roomId, 50, undefined, false, more ? nextBatch.value : undefined)
for (const r of res.rooms) {
for (const child of r.children_state ?? []) {
if (child.content.via?.length) vias.set(child.state_key, child.content.via)
}
}
const found = res.rooms.filter((r) => r.room_id !== props.space.roomId)
rooms.value = more ? [...rooms.value, ...found] : found
nextBatch.value = res.next_batch
} catch (e) {
loadError.value = errorMessage(e)
} finally {
loading.value = false
}
}
// Again whenever the space's own list of rooms changes.
watch(() => childIds.value.join(','), () => load(), { immediate: true })
// The rooms the space lists directly first, each subspace's after.
const direct = computed(() => new Set(childIds.value))
const shown = computed(() =>
[...rooms.value].sort((a, b) => Number(direct.value.has(b.room_id)) - Number(direct.value.has(a.room_id))),
)
function roomName(r: HierarchyRoom): string {
return r.name ?? r.canonical_alias ?? r.room_id
}
const joining = ref('')
async function join(r: HierarchyRoom) {
joining.value = r.room_id
try {
await client().joinRoom(r.room_id, { viaServers: vias.get(r.room_id) })
chat.bump()
} catch (e) {
toasts.error(`Can’t join ${roomName(r)}: ${errorMessage(e)}`)
} finally {
joining.value = ''
}
}
function open(roomId: string) {
router.push({ name: 'room', params: { roomId } })
}
// Taking a room out of the space.
const removing = ref<HierarchyRoom | null>(null)
const removeBusy = ref(false)
const removeError = ref('')
async function remove() {
const r = removing.value
if (!r) return
removeBusy.value = true
removeError.value = ''
try {
await chat.removeFromSpace(props.space.roomId, r.room_id)
removing.value = null
} catch (e) {
removeError.value = errorMessage(e)
} finally {
removeBusy.value = false
}
}
// Adding rooms you're in that the space doesn't list yet.
const addOpen = ref(false)
const picked = ref<string[]>([])
const adding = ref(false)
const addError = ref('')
const addable = computed(() => [
...chat.spaces.filter((s) => s.roomId !== props.space.roomId),
...chat.rooms,
].filter((r) => !direct.value.has(r.roomId)))
watch(addOpen, (v) => {
if (v) {
picked.value = []
addError.value = ''
}
})
function togglePick(roomId: string, on: boolean) {
picked.value = on ? [...picked.value, roomId] : picked.value.filter((id) => id !== roomId)
}
async function addPicked() {
adding.value = true
addError.value = ''
try {
for (const roomId of picked.value) await chat.addToSpace(props.space.roomId, roomId)
addOpen.value = false
} catch (e) {
addError.value = errorMessage(e)
} finally {
adding.value = false
}
}
const createOpen = ref(false)
const infoOpen = ref(false)
</script>
<template>
<div class="flex min-h-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col">
<header class="flex h-16 shrink-0 items-center gap-3 border-b border-line bg-surface px-2 md:px-4">
<WIconButton class="md:hidden" icon="chevron-left" label="Back to rooms" @click="router.push('/')" />
<MxcAvatar :name="name" :mxc="space.getMxcAvatarUrl() ?? undefined" :size="36" />
<button type="button" class="min-w-0 flex-1 cursor-pointer border-0 bg-transparent p-0 text-left" @click="infoOpen = !infoOpen">
<span class="flex items-center gap-2">
<h1 class="heading m-0 truncate">{{ name }}</h1>
<WBadge tone="neutral">Space</WBadge>
</span>
<span class="small block truncate text-ink-muted">
<template v-if="topic">{{ topic }}</template>
<template v-else>{{ memberCount }} {{ memberCount === 1 ? 'member' : 'members' }}</template>
</span>
</button>
<WIconButton icon="users" :label="infoOpen ? 'Hide space info' : 'Space info'" :aria-pressed="infoOpen" @click="infoOpen = !infoOpen" />
</header>
<div class="flex-1 overflow-y-auto">
<div class="mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 py-6">
<div class="flex flex-wrap items-center gap-2">
<h2 class="subheading m-0 flex-1">Rooms</h2>
<template v-if="canManage">
<WButton size="sm" icon="plus" @click="addOpen = true">Add existing room</WButton>
<WButton size="sm" variant="primary" icon="plus" @click="createOpen = true">New room</WButton>
</template>
</div>
<WBanner v-if="loadError" tone="warning">
Can’t load the space’s rooms: {{ loadError }}
<button type="button" class="font-semibold text-teal-text hover:underline" @click="load()">Try again</button>
</WBanner>
<div v-if="loading && !rooms.length" class="flex justify-center py-8"><WSpinner /></div>
<div v-else-if="!rooms.length && !loadError" class="flex flex-col items-center gap-2 py-10 text-center">
<WIcon name="space" :size="32" class="text-ink-muted" />
<p class="body-strong m-0">No rooms in this space yet</p>
<p class="small m-0 text-ink-muted">
{{ canManage ? 'Add a room you’re in, or create a new one here.' : 'Its admins haven’t added any rooms yet.' }}
</p>
</div>
<ul v-else class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="r in shown" :key="r.room_id" class="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-surface-sunken">
<MxcAvatar :name="roomName(r)" :mxc="r.avatar_url" :size="40" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 flex items-center gap-2">
<span class="truncate">{{ roomName(r) }}</span>
<WBadge v-if="r.room_type === RoomType.Space" tone="neutral">Space</WBadge>
<WIcon v-if="!direct.has(r.room_id)" name="chevron-right" :size="14" class="shrink-0 text-ink-muted" label="In a subspace" />
</p>
<p class="small m-0 truncate text-ink-muted">
{{ r.num_joined_members }} {{ r.num_joined_members === 1 ? 'member' : 'members' }}<template v-if="r.topic"> · {{ r.topic }}</template>
</p>
</div>
<WButton v-if="joinedHere(r.room_id)" size="sm" variant="ghost" @click="open(r.room_id)">Open</WButton>
<!-- The homeserver lists the rooms you may join or look into; joining says if it can't. -->
<WButton v-else size="sm" :loading="joining === r.room_id" @click="join(r)">Join</WButton>
<PopMenu
v-if="canManage && direct.has(r.room_id)"
icon="more"
:label="`Actions for ${roomName(r)}`"
:items="[{ label: 'Remove from space', icon: 'trash', danger: true, action: () => ((removeError = ''), (removing = r)) }]"
/>
</li>
</ul>
<div v-if="nextBatch" class="flex justify-center py-2">
<WButton size="sm" :loading="loading" @click="load(true)">More rooms</WButton>
</div>
</div>
</div>
</div>
<RoomInfoPane v-if="infoOpen" :room="space" :is-direct="false" @close="infoOpen = false" />
<CreateRoomDialog v-model:open="createOpen" :space-id="space.roomId" :space-name="name" @created="open" />
<WDialog v-model:open="addOpen" :title="`Add rooms to ${name}`" :persistent="adding">
<p v-if="!addable.length" class="body m-0 text-ink-muted">Every room you’re in is in this space already.</p>
<ul v-else class="m-0 flex max-h-80 list-none flex-col overflow-y-auto p-0">
<li v-for="r in addable" :key="r.roomId" class="flex items-center gap-3 rounded-md px-2 py-1.5">
<MxcAvatar :name="r.name" :mxc="r.avatarMxc" :size="32" />
<WCheckbox
class="min-w-0 flex-1"
:model-value="picked.includes(r.roomId)"
:label="r.name"
@update:model-value="(v: boolean) => togglePick(r.roomId, v)"
/>
</li>
</ul>
<WBanner v-if="addError" tone="danger">{{ addError }}</WBanner>
<template #actions>
<WButton variant="ghost" :disabled="adding" @click="addOpen = false">Cancel</WButton>
<WButton variant="primary" :loading="adding" :disabled="!picked.length" @click="addPicked">
{{ picked.length > 1 ? `Add ${picked.length} rooms` : 'Add room' }}
</WButton>
</template>
</WDialog>
<ConfirmDialog
:open="removing !== null"
title="Remove from space"
confirm-label="Remove"
:busy="removeBusy"
:error="removeError"
@update:open="(v) => !v && (removing = null)"
@confirm="remove"
>
<p>
<span class="body-strong">{{ removing ? roomName(removing) : '' }}</span> is no longer listed in {{ name }}. The room itself stays, and
its members stay in it.
</p>
</ConfirmDialog>
</div>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { Room } from 'matrix-js-sdk'
import { computed, shallowRef } from 'vue'
import { WIconButton } from '@warp/ui'
import RoomComposer from '@/components/RoomComposer.vue'
import RoomTimeline from '@/components/RoomTimeline.vue'
import { threadCount, threadRoot, type MessageItem } from '@/matrix/timeline'
import { useChat } from '@/stores/chat'
// One thread beside its room: the root, its replies, and a composer that
// writes into it. On a phone it covers the room.
const props = defineProps<{ room: Room; threadId: string; isDirect: boolean }>()
const emit = defineEmits<{ close: []; back: [] }>()
const chat = useChat()
const replyTo = shallowRef<MessageItem | null>(null)
// A thread starting here has no thread object yet, and the SDK only files
// a reply's remote echo into one that exists: without this the first
// reply would vanish until the next reload. Element's thread view does
// the same.
if (!props.room.getThread(props.threadId)) {
const root = threadRoot(props.room, props.threadId)
if (root) props.room.createThread(props.threadId, root, [], true)
}
const count = computed(() => {
void chat.version
return threadCount(props.room, props.threadId)
})
</script>
<template>
<aside
class="fixed inset-0 z-30 flex flex-col bg-surface-sunken md:static md:z-auto md:w-[400px] md:shrink-0 md:border-l md:border-line"
aria-label="Thread"
@keydown.esc="emit('close')"
>
<div class="flex h-16 shrink-0 items-center gap-2 border-b border-line bg-surface px-2 md:px-4">
<WIconButton icon="chevron-left" label="All threads" @click="emit('back')" />
<div class="min-w-0 flex-1">
<h2 class="subheading m-0">Thread</h2>
<p class="caption m-0 truncate text-ink-muted">{{ room.name }} · {{ count === 1 ? '1 reply' : `${count} replies` }}</p>
</div>
<WIconButton icon="close" label="Close thread" @click="emit('close')" />
</div>
<RoomTimeline :key="threadId" :room="room" :is-direct="isDirect" :thread-id="threadId" @reply="(item) => (replyTo = item)" />
<div class="shrink-0 px-4 pt-2 pb-4">
<RoomComposer v-model:reply-to="replyTo" :room="room" :thread-id="threadId" placeholder="Reply in thread" />
</div>
</aside>
</template>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { Direction, type Room } from 'matrix-js-sdk'
import { computed, onMounted, ref } from 'vue'
import { WBanner, WButton, WIcon, WIconButton, WSpinner } from '@warp/ui'
import MxcAvatar from '@/components/MxcAvatar.vue'
import { errorMessage, formatListTime } from '@/lib/format'
import { client } from '@/matrix/client'
import { quoteText, threadCount, threadUnread } from '@/matrix/timeline'
import { useChat } from '@/stores/chat'
// A room's threads, the latest reply first (GET /threads). The ones with
// replies you haven't read have a dot.
const props = defineProps<{ room: Room }>()
const emit = defineEmits<{ open: [threadId: string]; close: [] }>()
const chat = useChat()
const me = client().getUserId() ?? ''
const loading = ref(true)
const loadingMore = ref(false)
const error = ref('')
const threads = computed(() => {
void chat.version
return props.room
.getThreads()
.filter((t) => threadCount(props.room, t.id) > 0)
.map((t) => {
const root = t.rootEvent
const sender = root?.getSender() ?? ''
const last = t.replyToEvent
return {
id: t.id,
senderName: props.room.getMember(sender)?.name ?? sender,
avatarMxc: props.room.getMember(sender)?.getMxcAvatarUrl() ?? undefined,
text: root ? quoteText(root) : '',
count: threadCount(props.room, t.id),
ts: last?.getTs() ?? root?.getTs() ?? 0,
unread: threadUnread(props.room, t.id, me),
}
})
.sort((a, b) => b.ts - a.ts)
})
const timeline = computed(() => {
void chat.version
return props.room.threadsTimelineSets[0]?.getLiveTimeline()
})
const hasMore = computed(() => {
void chat.version
return Boolean(timeline.value?.getPaginationToken(Direction.Backward))
})
onMounted(async () => {
try {
await props.room.createThreadsTimelineSets()
await props.room.fetchRoomThreads()
} catch (e) {
error.value = errorMessage(e)
} finally {
loading.value = false
chat.bump()
}
})
async function more() {
const tl = timeline.value
if (!tl) return
loadingMore.value = true
try {
await client().paginateEventTimeline(tl, { backwards: true, limit: 20 })
} catch (e) {
error.value = errorMessage(e)
} finally {
loadingMore.value = false
chat.bump()
}
}
</script>
<template>
<aside
class="fixed inset-0 z-30 flex flex-col bg-surface-sunken md:static md:z-auto md:w-80 md:shrink-0 md:border-l md:border-line"
aria-label="Threads"
>
<div class="flex h-16 shrink-0 items-center gap-2 border-b border-line px-4">
<h2 class="subheading m-0 flex-1">Threads</h2>
<WIconButton icon="close" label="Close threads" @click="emit('close')" />
</div>
<div class="flex-1 overflow-y-auto p-2">
<WBanner v-if="error" tone="warning" class="m-2">{{ error }}</WBanner>
<div v-if="loading && !threads.length" class="flex justify-center py-8"><WSpinner /></div>
<div v-else-if="!threads.length" class="flex flex-col items-center gap-2 px-4 py-10 text-center">
<WIcon name="thread" :size="32" class="text-ink-muted" />
<p class="body-strong m-0">No threads yet</p>
<p class="small m-0 text-ink-muted">Reply in thread on a message to start one. Its replies stay out of the room’s timeline.</p>
</div>
<ul v-else class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="t in threads" :key="t.id">
<button
type="button"
class="flex w-full items-start gap-3 rounded-md px-2 py-2 text-left hover:bg-surface-raised focus-visible:bg-surface-raised"
@click="emit('open', t.id)"
>
<MxcAvatar :name="t.senderName" :mxc="t.avatarMxc" :size="32" />
<span class="min-w-0 flex-1">
<span class="flex items-center gap-2">
<span class="body-strong min-w-0 flex-1 truncate">{{ t.senderName }}</span>
<span class="caption shrink-0 text-ink-muted">{{ formatListTime(t.ts) }}</span>
</span>
<span class="small block truncate text-ink-muted">{{ t.text }}</span>
<span class="caption flex items-center gap-1 text-teal-text">
<WIcon name="thread" :size="14" />
{{ t.count === 1 ? '1 reply' : `${t.count} replies` }}
<span v-if="t.unread" class="ml-1 h-2 w-2 rounded-full bg-teal-strong" role="img" aria-label="Unread replies" />
</span>
</span>
</button>
</li>
</ul>
<div v-if="hasMore && threads.length" class="flex justify-center py-3">
<WButton size="sm" :loading="loadingMore" @click="more">Older threads</WButton>
</div>
</div>
</aside>
</template>
+77 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watchEffect } from 'vue'
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { WBanner, WButton, WIcon, WIconButton, WLogo, WSpinner } from '@warp/ui'
import CallView from '@/components/CallView.vue'
@@ -11,6 +11,7 @@ import PopMenu from '@/components/PopMenu.vue'
import RoomRow from '@/components/RoomRow.vue'
import type { MenuItem } from '@/components/menu'
import SettingsDialog from '@/components/SettingsDialog.vue'
import SpacePicker from '@/components/SpacePicker.vue'
import StartChatDialog from '@/components/StartChatDialog.vue'
import VerificationDialog from '@/components/VerificationDialog.vue'
import { formatListTime, localpart } from '@/lib/format'
@@ -30,7 +31,41 @@ const router = useRouter()
const startError = ref('')
const filter = ref('')
const dialog = ref<'create' | 'chat' | 'join' | 'settings' | 'verify' | null>(null)
const dialog = ref<'create' | 'space' | 'chat' | 'join' | 'settings' | 'verify' | null>(null)
// The space the room list is narrowed to, '' for all chats; kept for the
// next visit in this browser.
const spaceKey = 'warp.web.space'
function savedSpace(): string {
try {
return localStorage.getItem(spaceKey) ?? ''
} catch {
return ''
}
}
const space = ref(savedSpace())
watch(space, (v) => {
try {
if (v) localStorage.setItem(spaceKey, v)
else localStorage.removeItem(spaceKey)
} catch {
// Not kept: the list starts at all chats next time.
}
})
// A space you left (or never were in, here) shows all chats.
watch(
() => chat.ready && chat.spaces.map((s) => s.roomId).join(','),
() => {
if (chat.ready && space.value && !chat.spaces.some((s) => s.roomId === space.value)) space.value = ''
},
)
const spaceRooms = computed(() => (space.value ? chat.roomsInSpace(space.value) : null))
const currentSpace = computed(() => chat.spaces.find((s) => s.roomId === space.value))
// Spaces with something unread in their rooms.
const unreadSpaces = computed(() => {
const unread = new Set(chat.rooms.filter((r) => r.unread > 0).map((r) => r.roomId))
return new Set(chat.spaces.filter((s) => [...chat.roomsInSpace(s.roomId)].some((id) => unread.has(id))).map((s) => s.roomId))
})
// The account has encryption keys but this session isn't signed by them:
// it can't read older messages and others see it as unverified.
@@ -56,8 +91,10 @@ const activeRoomId = computed(() => (route.name === 'room' ? String(route.params
const shownRooms = computed(() => {
const q = filter.value.trim().toLowerCase()
if (!q) return chat.rooms
return chat.rooms.filter((r) => r.name.toLowerCase().includes(q) || r.roomId.toLowerCase().includes(q))
const inSpace = spaceRooms.value
const rooms = inSpace ? chat.rooms.filter((r) => inSpace.has(r.roomId)) : chat.rooms
if (!q) return rooms
return rooms.filter((r) => r.name.toLowerCase().includes(q) || r.roomId.toLowerCase().includes(q))
})
const shownInvites = computed(() => {
const q = filter.value.trim().toLowerCase()
@@ -69,8 +106,14 @@ const menu: MenuItem[] = [
{ label: 'New room', icon: 'plus', action: () => (dialog.value = 'create') },
{ label: 'Start chat', icon: 'user', action: () => (dialog.value = 'chat') },
{ label: 'Join room', icon: 'room', action: () => (dialog.value = 'join') },
{ label: 'New space', icon: 'space', action: () => (dialog.value = 'space') },
]
function spaceCreated(roomId: string) {
space.value = roomId
open(roomId)
}
function open(roomId: string) {
router.push({ name: 'room', params: { roomId } })
}
@@ -78,7 +121,7 @@ function open(roomId: string) {
// The tab title counts rooms with unread messages, and invites.
watchEffect(() => {
const n = chat.totalUnread
const room = chat.rooms.find((r) => r.roomId === activeRoomId.value)
const room = chat.rooms.find((r) => r.roomId === activeRoomId.value) ?? chat.spaces.find((r) => r.roomId === activeRoomId.value)
const name = room?.name ?? chat.invites.find((r) => r.roomId === activeRoomId.value)?.name
document.title = `${n ? `(${n}) ` : ''}${name ? `${name} · ` : ''}Warp`
})
@@ -128,6 +171,8 @@ const ownName = computed(() => profile.displayName || localpart(session.userId))
<PopMenu icon="plus" label="New" :items="menu" />
</div>
<SpacePicker v-if="chat.spaces.length" v-model="space" :spaces="chat.spaces" :unread="unreadSpaces" @new-space="dialog = 'space'" />
<div class="px-3 pb-2">
<label class="flex h-10 items-center gap-2 rounded-sm border border-line-strong bg-surface-raised px-3 focus-within:border-teal-strong focus-within:shadow-focus">
<WIcon name="search" :size="18" class="text-ink-muted" />
@@ -168,6 +213,20 @@ const ownName = computed(() => profile.displayName || localpart(session.userId))
<h2 class="caption px-3 pt-3 pb-1 text-ink-muted">Rooms</h2>
</template>
<button
v-if="currentSpace"
type="button"
:class="[
'mb-1 flex w-full items-center gap-3 rounded-md px-3 py-2 text-left hover:bg-surface-raised',
activeRoomId === currentSpace.roomId && 'bg-surface-raised',
]"
@click="open(currentSpace.roomId)"
>
<WIcon name="space" class="text-ink-muted" />
<span class="body-strong min-w-0 flex-1 truncate">Browse {{ currentSpace.name }}</span>
<WIcon name="chevron-right" :size="16" class="text-ink-muted" />
</button>
<RoomRow
v-for="r in shownRooms"
:key="r.roomId"
@@ -187,6 +246,11 @@ const ownName = computed(() => profile.displayName || localpart(session.userId))
<template v-if="filter">
<p class="body text-ink-muted">No rooms match “{{ filter }}”.</p>
</template>
<template v-else-if="currentSpace">
<p class="body-strong">You’re in no rooms of {{ currentSpace.name }}</p>
<p class="body text-ink-muted">Browse the space to join its rooms.</p>
<WButton variant="primary" icon="space" @click="open(currentSpace.roomId)">Browse space</WButton>
</template>
<template v-else>
<p class="body-strong">No rooms yet</p>
<p class="body text-ink-muted">Create a room, start a chat with someone, or join a room by its address.</p>
@@ -209,7 +273,14 @@ const ownName = computed(() => profile.displayName || localpart(session.userId))
<RouterView :key="activeRoomId" />
</main>
<CreateRoomDialog :open="dialog === 'create'" @update:open="dialog = $event ? 'create' : null" @created="open" />
<CreateRoomDialog
:open="dialog === 'create'"
:space-id="currentSpace?.roomId"
:space-name="currentSpace?.name"
@update:open="dialog = $event ? 'create' : null"
@created="open"
/>
<CreateRoomDialog kind="space" :open="dialog === 'space'" @update:open="dialog = $event ? 'space' : null" @created="spaceCreated" />
<StartChatDialog :open="dialog === 'chat'" @update:open="dialog = $event ? 'chat' : null" @started="open" />
<JoinRoomDialog :open="dialog === 'join'" @update:open="dialog = $event ? 'join' : null" @joined="open" />
<SettingsDialog :open="dialog === 'settings'" @update:open="dialog = $event ? 'settings' : null" />
+4 -1
View File
@@ -100,7 +100,10 @@ export async function startClient(creds: Credentials): Promise<MatrixClient> {
// Your own messages that are still sending (or failed) are kept apart
// from the timeline, so a failed one doesn't sit between others' replies.
pendingEventOrdering: PendingEventOrdering.Detached,
threadSupport: false,
// Threads: their replies leave the room's timeline for one of their
// own, read receipts carry a thread_id (MSC3771) and the unread counts
// come per thread (MSC3773).
threadSupport: true,
})
return c
}
+30 -4
View File
@@ -1,4 +1,4 @@
import { MsgType, type MatrixClient, type Room } from 'matrix-js-sdk'
import { EventType, MsgType, RelationType, type MatrixClient, type MatrixEvent, type Room } from 'matrix-js-sdk'
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types'
import { encryptAttachment } from './attachments'
@@ -8,13 +8,38 @@ import { encryptAttachment } from './attachments'
// In an end-to-end encrypted room the SDK encrypts the message itself, and
// a file is encrypted here before it is uploaded.
export function sendText(c: MatrixClient, roomId: string, text: string) {
/**
* Where a message goes: into a thread (by its root's ID), and as a reply
* to a message. A thread reply that answers no message in particular is
* marked as falling back, which the SDK does.
*/
export interface SendTarget {
threadId?: string
replyTo?: string
}
// Replies carry no quoted fallback in their body: Matrix 1.13 dropped it
// (MSC2781), and the quote is drawn from the event replied to.
function withTarget<T extends object>(content: T, target: SendTarget): T {
if (!target.replyTo) return content
return { ...content, 'm.relates_to': { 'm.in_reply_to': { event_id: target.replyTo } } }
}
export function sendText(c: MatrixClient, roomId: string, text: string, target: SendTarget = {}) {
const body = text.replace(/\s+$/, '')
const emote = body.match(/^\/me\s+([\s\S]+)$/)
const content: RoomMessageEventContent = emote
? { msgtype: MsgType.Emote, body: emote[1]! }
: { msgtype: MsgType.Text, body: body.replace(/^\/\//, '/') }
return c.sendMessage(roomId, content)
return c.sendMessage(roomId, target.threadId ?? null, withTarget(content, target))
}
/** Reacts to a message; in a thread, the reaction goes with the thread. */
export function sendReaction(c: MatrixClient, ev: MatrixEvent, key: string) {
const threadId = ev.threadRootId && !ev.isThreadRoot ? ev.threadRootId : null
return c.sendEvent(ev.getRoomId()!, threadId, EventType.Reaction, {
'm.relates_to': { rel_type: RelationType.Annotation, event_id: ev.getId()!, key },
})
}
function imageSize(file: File): Promise<{ w: number; h: number } | undefined> {
@@ -85,6 +110,7 @@ export async function sendFile(
room: Room,
file: File,
onProgress?: (p: UploadProgress) => void,
target: SendTarget = {},
) {
const msgtype = msgtypeFor(file)
const info: Record<string, unknown> = { mimetype: file.type || 'application/octet-stream', size: file.size }
@@ -125,5 +151,5 @@ export async function sendFile(
...source,
info,
} as RoomMessageEventContent
return c.sendMessage(room.roomId, content)
return c.sendMessage(room.roomId, target.threadId ?? null, withTarget(content, target))
}
+232 -11
View File
@@ -3,6 +3,9 @@ import {
EventStatus,
EventType,
MsgType,
RelationType,
THREAD_RELATION_TYPE,
type EventTimeline,
type MatrixClient,
type MatrixEvent,
type Room,
@@ -17,7 +20,9 @@ import { shieldOf, type Shield } from './trust'
// Turns a room's live timeline into what the timeline view draws: messages
// grouped by sender, day separators and one-line notices for state changes.
// The SDK keeps the events; this is a pure projection of them.
// The SDK keeps the events; this is a pure projection of them. A thread is
// projected the same way, from its own timeline: with thread support on,
// the SDK keeps a thread's replies out of the room's.
export interface MediaInfo {
mxc?: string
@@ -34,6 +39,32 @@ export interface MediaInfo {
filename: string
}
/** The message a reply answers, quoted above it. */
export interface ReplyQuote {
eventId: string
/** Unset while it loads, or when it can't be found. */
senderName?: string
text: string
}
/** One reaction under a message: a key and who reacted with it. */
export interface ReactionChip {
key: string
count: number
/** Your own reaction with this key, to take back. */
mine?: MatrixEvent
names: string[]
}
/** What a thread's root shows under it. */
export interface ThreadSummary {
count: number
lastTs: number
lastSenderName: string
/** Has replies you haven't read. */
unread: boolean
}
export interface MessageItem {
kind: 'message'
key: string
@@ -61,6 +92,10 @@ export interface MessageItem {
first: boolean
/** Last of a run: shows the time and status. */
last: boolean
reply?: ReplyQuote
reactions: ReactionChip[]
/** Set on a thread's root in the room's timeline. */
thread?: ThreadSummary
event: MatrixEvent
}
@@ -296,17 +331,196 @@ function mediaOf(content: RoomMessageEventContent): MediaInfo | undefined {
}
}
/** Every event the view shows, oldest first: the live timeline then your pending ones. */
function displayEvents(room: Room): MatrixEvent[] {
return [...room.getLiveTimeline().getEvents(), ...room.getPendingEvents()]
/** Whether an event is a reply in a thread rather than in the room. */
export function inThread(ev: MatrixEvent): boolean {
return Boolean(ev.threadRootId) && !ev.isThreadRoot
}
export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
/** The thread's own timeline, or the room's. */
function liveTimelineOf(room: Room, threadId?: string): EventTimeline | undefined {
if (!threadId) return room.getLiveTimeline()
return room.getThread(threadId)?.liveTimeline
}
/** A thread's root event, wherever the SDK has it. */
export function threadRoot(room: Room, threadId: string): MatrixEvent | undefined {
return room.getThread(threadId)?.rootEvent ?? room.findEventById(threadId)
}
/**
* Every event the view shows, oldest first: the live timeline then your
* pending ones. A thread starts with its root, which its own timeline
* only has once it is paged back to the start.
*/
function displayEvents(room: Room, threadId?: string): MatrixEvent[] {
const pending = room.getPendingEvents()
if (!threadId) return [...room.getLiveTimeline().getEvents(), ...pending.filter((e) => !inThread(e))]
const events = liveTimelineOf(room, threadId)?.getEvents() ?? []
const root = threadRoot(room, threadId)
const head = root && !events.some((e) => e.getId() === threadId) ? [root] : []
return [...head, ...events, ...pending.filter((e) => e.threadRootId === threadId && inThread(e))]
}
// Replies sent with the old fallback start with the quoted message, a
// "> " line each, then an empty line.
function stripReplyFallback(body: string): string {
return body.replace(/^(>[^\n]*\n)+\n?/, '')
}
/** The message a reply answers, unless it is only a thread's fallback. */
function replyTarget(ev: MatrixEvent): string | undefined {
const rel = ev.getWireContent()['m.relates_to']
const id = rel?.['m.in_reply_to']?.event_id
if (!rel || typeof id !== 'string') return undefined
if (rel.rel_type === THREAD_RELATION_TYPE.name && rel.is_falling_back) return undefined
return id
}
/** One line for a quoted message. */
export function quoteText(ev: MatrixEvent): string {
if (ev.isRedacted()) return 'Message deleted'
if (ev.isDecryptionFailure() || ev.getType() === EventType.RoomMessageEncrypted) return 'Encrypted message'
if (ev.getType() === EventType.Sticker) return 'Sticker'
const content = ev.getContent()
switch (content.msgtype) {
case MsgType.Image:
return 'Image'
case MsgType.Video:
return 'Video'
case MsgType.Audio:
return 'Audio'
case MsgType.File:
return `File: ${content.body ?? ''}`
}
const body = String(content.body ?? '')
const text = replyTarget(ev) ? stripReplyFallback(body) : body
return text.split('\n').find((l) => l.trim()) ?? ''
}
function reactionsOf(room: Room, ev: MatrixEvent, me: string): ReactionChip[] {
const id = ev.getId()
if (!id || ev.isRedacted()) return []
const sorted = room.relations.getChildEventsForEvent(id, RelationType.Annotation, EventType.Reaction)?.getSortedAnnotationsByKey()
if (!sorted) return []
const out: ReactionChip[] = []
for (const [key, set] of sorted) {
const events = [...set].filter((r) => !r.isRedacted() && r.status !== EventStatus.CANCELLED)
if (!events.length) continue
out.push({
key,
count: events.length,
mine: events.find((r) => r.getSender() === me),
names: events.map((r) => (r.getSender() === me ? 'You' : nameOf(room, r.getSender() ?? ''))),
})
}
return out
}
// The SDK takes a thread's reply count from the server once, then adds
// one for each reply that comes in; a reply that lands between the two is
// counted twice. So after replies come in the count is asked for again
// (the root's bundled m.thread count), and used while the newest reply it
// counted is still the newest.
const recounted = new Map<string, { count: number; lastId: string }>()
const recountTimers = new Map<string, number>()
/** A thread's number of replies. */
export function threadCount(room: Room, threadId: string): number {
const thread = room.getThread(threadId)
if (!thread) return 0
const known = recounted.get(`${room.roomId}|${threadId}`)
const lastId = thread.replyToEvent?.getId()
return known && lastId && known.lastId === lastId ? known.count : thread.length
}
/** Asks the homeserver for a thread's reply count, a second after the last call. */
export function recountThread(c: MatrixClient, room: Room, threadId: string) {
const key = `${room.roomId}|${threadId}`
window.clearTimeout(recountTimers.get(key))
recountTimers.set(
key,
window.setTimeout(async () => {
recountTimers.delete(key)
try {
const root = await c.fetchRoomEvent(room.roomId, threadId)
const bundled = root.unsigned?.['m.relations']?.[THREAD_RELATION_TYPE.name] as
| { count?: number; latest_event?: { event_id?: string } }
| undefined
if (typeof bundled?.count === 'number' && bundled.latest_event?.event_id) {
recounted.set(key, { count: bundled.count, lastId: bundled.latest_event.event_id })
quoteLoaded()
}
} catch {
// The SDK's count stands.
}
}, 1000),
)
}
function threadSummary(room: Room, ev: MatrixEvent, me: string): ThreadSummary | undefined {
const id = ev.getId()
const thread = id ? room.getThread(id) : null
if (!thread || thread.length === 0) return undefined
return {
count: threadCount(room, thread.id),
lastTs: thread.replyToEvent?.getTs() ?? ev.getTs(),
lastSenderName: thread.replyToEvent ? nameOf(room, thread.replyToEvent.getSender() ?? '') : '',
unread: threadUnread(room, thread.id, me),
}
}
/**
* Whether a thread has replies you haven't read: the homeserver's count
* for it (MSC3773), or a reply from someone else past your receipt in it.
*/
export function threadUnread(room: Room, threadId: string, me: string): boolean {
if (room.getThreadUnreadNotificationCount(threadId) > 0) return true
const thread = room.getThread(threadId)
const last = thread?.replyToEvent
const id = last?.getId()
if (!thread || !last || !id || last.status || last.getSender() === me) return false
return !thread.hasUserReadEvent(me, id)
}
// Replied-to messages the SDK doesn't have, fetched once each: the event,
// null when it can't be had, or absent while the fetch runs.
const fetchedQuotes = new Map<string, MatrixEvent | null>()
const fetchingQuotes = new Set<string>()
let quoteLoaded: () => void = () => undefined
/** Runs when something fetched for the timeline lands: a quoted message, a thread's count. */
export function onQuoteLoaded(fn: () => void) {
quoteLoaded = fn
}
function quoteOf(c: MatrixClient, room: Room, eventId: string): ReplyQuote {
const key = `${room.roomId}|${eventId}`
const ev = room.findEventById(eventId) ?? fetchedQuotes.get(key)
if (ev) return { eventId, senderName: nameOf(room, ev.getSender() ?? ''), text: quoteText(ev) }
if (ev === null) return { eventId, text: 'The message can’t be found' }
if (!fetchingQuotes.has(key)) {
fetchingQuotes.add(key)
c.fetchRoomEvent(room.roomId, eventId)
.then(async (raw) => {
const found = c.getEventMapper()(raw)
await c.decryptEventIfNeeded(found)
fetchedQuotes.set(key, found)
})
.catch(() => fetchedQuotes.set(key, null))
.finally(() => {
fetchingQuotes.delete(key)
quoteLoaded()
})
}
return { eventId, text: 'Loading…' }
}
export function buildTimeline(c: MatrixClient, room: Room, threadId?: string): TimelineItem[] {
const me = c.getUserId() ?? ''
const cryptoRunning = Boolean(c.getCrypto())
// Messages after this were meant to be end-to-end encrypted.
const encryptionSince = room.currentState.getStateEvents(EventType.RoomEncryption, '')?.getTs() ?? Infinity
const events = displayEvents(room)
const events = displayEvents(room, threadId)
// An event is read when someone else's receipt is on it or on anything
// after it, or someone else has written after it.
@@ -377,6 +591,8 @@ export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
const encrypted = type === EventType.RoomMessageEncrypted || ev.isDecryptionFailure()
const redacted = ev.isRedacted()
const status = own ? statusOf(ev) : undefined
const replyId = redacted || encrypted ? undefined : replyTarget(ev)
const body = redacted ? '' : String(content.body ?? '')
const item: MessageItem = {
kind: 'message',
key,
@@ -387,7 +603,7 @@ export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
own,
ts,
msgtype: type === EventType.Sticker ? 'm.sticker' : String(content.msgtype ?? ''),
body: redacted ? '' : String(content.body ?? ''),
body: replyId ? stripReplyFallback(body) : body,
media: redacted || encrypted ? undefined : type === EventType.Sticker ? stickerMedia(ev) : mediaOf(content),
edited: Boolean(ev.replacingEventId()),
redacted,
@@ -398,6 +614,9 @@ export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
status: status === 'sent' && ev.getId() && readByOthers.has(ev.getId()!) ? 'read' : status,
first: true,
last: true,
reply: replyId ? quoteOf(c, room, replyId) : undefined,
reactions: reactionsOf(room, ev, me),
thread: threadId ? undefined : threadSummary(room, ev, me),
event: ev,
}
if (prev && prev.sender === sender && ts - prev.ts < groupGapMs) {
@@ -424,15 +643,17 @@ function stickerMedia(ev: MatrixEvent): MediaInfo {
}
/** Whether there is older history to page back to. */
export function canPaginateBack(room: Room): boolean {
const tl = room.getLiveTimeline()
export function canPaginateBack(room: Room, threadId?: string): boolean {
const tl = liveTimelineOf(room, threadId)
if (!tl) return false
if (threadId) return tl.getPaginationToken(Direction.Backward) !== null
if (tl.getEvents().some((e) => e.getType() === EventType.RoomCreate)) return false
return tl.getPaginationToken(Direction.Backward) !== null
}
/** The newest event that came from the server, for read receipts. */
export function lastSyncedEvent(room: Room): MatrixEvent | undefined {
const events = room.getLiveTimeline().getEvents()
export function lastSyncedEvent(room: Room, threadId?: string): MatrixEvent | undefined {
const events = liveTimelineOf(room, threadId)?.getEvents() ?? []
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i]!
if (ev.getId() && !ev.status && !ev.getId()!.startsWith('~')) return ev
+124 -3
View File
@@ -7,9 +7,11 @@ import {
NotificationCountType,
Preset,
RoomEvent,
RoomType,
RoomMemberEvent,
RoomStateEvent,
SyncState,
ThreadEvent,
UserEvent,
Visibility,
type MatrixClient,
@@ -19,7 +21,7 @@ import {
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch } from 'vue'
import { client } from '@/matrix/client'
import { isVideoCallInvite } from '@/matrix/timeline'
import { isVideoCallInvite, onQuoteLoaded, recountThread } from '@/matrix/timeline'
import { onShieldsChanged } from '@/matrix/trust'
// The room list and the client's connection state, as plain reactive data
@@ -49,9 +51,20 @@ export interface InviteSummary {
inviter?: string
inviterName?: string
isDirect: boolean
isSpace: boolean
ts: number
}
/** A space you're in: a room that groups other rooms (m.space). */
export interface SpaceSummary {
roomId: string
name: string
avatarMxc?: string
topic: string
/** The rooms and subspaces it lists (m.space.child). */
children: string[]
}
/** The text a room list shows for an event, or null when it shows nothing. */
export function describeEvent(ev: MatrixEvent, me: string, room: Room, withSender: boolean): string | null {
if (ev.isRedacted()) return 'Message deleted'
@@ -158,10 +171,30 @@ function summarizeInvite(c: MatrixClient, room: Room): InviteSummary {
inviter,
inviterName: inviter ? (room.getMember(inviter)?.name ?? inviter) : undefined,
isDirect: Boolean(memberEvent?.getContent().is_direct),
isSpace: room.isSpaceRoom(),
ts: memberEvent?.getTs() ?? 0,
}
}
/** The rooms a space lists: m.space.child events with a non-empty via. */
export function spaceChildren(space: Room): string[] {
return space.currentState
.getStateEvents(EventType.SpaceChild)
.filter((ev) => Array.isArray(ev.getContent().via) && ev.getContent().via.length > 0)
.map((ev) => ev.getStateKey() ?? '')
.filter(Boolean)
}
function summarizeSpace(space: Room): SpaceSummary {
return {
roomId: space.roomId,
name: space.name,
avatarMxc: space.getMxcAvatarUrl() ?? undefined,
topic: String(space.currentState.getStateEvents(EventType.RoomTopic, '')?.getContent().topic ?? ''),
children: spaceChildren(space),
}
}
const encryptionState = {
type: EventType.RoomEncryption,
state_key: '',
@@ -190,6 +223,20 @@ export const useChat = defineStore('chat', () => {
const timer = window.setTimeout(fire, 250)
}
// Threads and per-thread counts are only emitted on their room, not
// re-emitted by the client: each room is listened to once.
const watched = new WeakSet<Room>()
function watchRoom(room: Room) {
if (watched.has(room)) return
watched.add(room)
for (const e of [ThreadEvent.New, ThreadEvent.Update, ThreadEvent.NewReply, ThreadEvent.Delete, RoomEvent.UnreadNotifications] as const) {
room.on(e as ThreadEvent.Update, bump)
}
room.on(ThreadEvent.NewReply, (thread, ev) => {
if (!ev.status && attached) recountThread(attached, room, thread.id)
})
}
function onSync(state: SyncState) {
syncState.value = state
if (state === SyncState.Prepared || state === SyncState.Syncing) ready.value = true
@@ -205,6 +252,9 @@ export const useChat = defineStore('chat', () => {
syncState.value = c.getSyncState()
c.on(ClientEvent.Sync, onSync)
onShieldsChanged(bump)
onQuoteLoaded(bump)
for (const room of c.getRooms()) watchRoom(room)
c.on(ClientEvent.Room, watchRoom)
for (const e of [
ClientEvent.Room,
ClientEvent.DeleteRoom,
@@ -242,11 +292,38 @@ export const useChat = defineStore('chat', () => {
const direct = directRooms(c)
return c
.getRooms()
.filter((r) => r.getMyMembership() === KnownMembership.Join && !isReplaced(r))
.filter((r) => r.getMyMembership() === KnownMembership.Join && !isReplaced(r) && !r.isSpaceRoom())
.map((r) => summarize(c, r, direct))
.sort((a, b) => b.ts - a.ts)
})
const spaces = computed<SpaceSummary[]>(() => {
void version.value
if (!attached) return []
return attached
.getRooms()
.filter((r) => r.getMyMembership() === KnownMembership.Join && r.isSpaceRoom() && !isReplaced(r))
.map(summarizeSpace)
.sort((a, b) => a.name.localeCompare(b.name))
})
/** Every room in a space and in its subspaces you're in. */
function roomsInSpace(spaceId: string): Set<string> {
const bySpace = new Map(spaces.value.map((s) => [s.roomId, s]))
const out = new Set<string>()
const seen = new Set<string>()
const walk = (id: string) => {
if (seen.has(id)) return
seen.add(id)
for (const child of bySpace.get(id)?.children ?? []) {
if (bySpace.has(child)) walk(child)
else out.add(child)
}
}
walk(spaceId)
return out
}
const invites = computed<InviteSummary[]>(() => {
void version.value
if (!attached) return []
@@ -278,24 +355,63 @@ export const useChat = defineStore('chat', () => {
else if (!offline.value) offlineTimer = window.setTimeout(() => (offline.value = true), 5000)
})
/** The servers to reach a room through: this homeserver. */
function via(): string[] {
const server = client().getDomain()
return server ? [server] : []
}
async function createRoom(opts: {
name: string
topic?: string
isPublic: boolean
alias?: string
encrypted: boolean
/** A space to create it in: the room names it as its parent, the space lists it. */
spaceId?: string
}): Promise<string> {
const initialState: { type: string; state_key: string; content: Record<string, unknown> }[] = []
if (opts.encrypted) initialState.push(encryptionState)
if (opts.spaceId) initialState.push({ type: EventType.SpaceParent, state_key: opts.spaceId, content: { via: via(), canonical: true } })
const res = await client().createRoom({
name: opts.name.trim(),
topic: opts.topic?.trim() || undefined,
preset: opts.isPublic ? Preset.PublicChat : Preset.PrivateChat,
visibility: opts.isPublic ? Visibility.Public : Visibility.Private,
room_alias_name: opts.isPublic && opts.alias ? opts.alias.trim() : undefined,
initial_state: opts.encrypted ? [encryptionState] : undefined,
initial_state: initialState.length ? initialState : undefined,
})
if (opts.spaceId) await addToSpace(opts.spaceId, res.room_id)
return res.room_id
}
/**
* A new space. A public one is in the room directory and anyone can
* join; a private one is by invite. Only admins post in it: a space's
* timeline holds its settings, not a conversation.
*/
async function createSpace(opts: { name: string; topic?: string; isPublic: boolean }): Promise<string> {
const res = await client().createRoom({
name: opts.name.trim(),
topic: opts.topic?.trim() || undefined,
preset: opts.isPublic ? Preset.PublicChat : Preset.PrivateChat,
visibility: opts.isPublic ? Visibility.Public : Visibility.Private,
creation_content: { type: RoomType.Space },
power_level_content_override: { events_default: 100, invite: opts.isPublic ? 0 : 50 },
})
return res.room_id
}
/** Lists a room in a space. */
async function addToSpace(spaceId: string, roomId: string) {
await client().sendStateEvent(spaceId, EventType.SpaceChild, { via: via() }, roomId)
}
/** Takes a room out of a space: an empty m.space.child. */
async function removeFromSpace(spaceId: string, roomId: string) {
await client().sendStateEvent(spaceId, EventType.SpaceChild, {}, roomId)
}
/** Whether this session can encrypt a new room: the crypto stack is running. */
const canEncrypt = computed(() => {
void version.value
@@ -374,13 +490,18 @@ export const useChat = defineStore('chat', () => {
syncState,
offline,
rooms,
spaces,
invites,
totalUnread,
roomsInSpace,
canEncrypt,
attach,
detach,
bump,
createRoom,
createSpace,
addToSpace,
removeFromSpace,
startDirect,
sharedRoomWith,
join,
+57 -96
View File
@@ -1,16 +1,19 @@
<script setup lang="ts">
import { KnownMembership } from 'matrix-js-sdk'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { useRouter } from 'vue-router'
import { WBanner, WButton, WComposer, WIcon, WIconButton, WSpinner } from '@warp/ui'
import { WBanner, WButton, WIcon, WIconButton } from '@warp/ui'
import MxcAvatar from '@/components/MxcAvatar.vue'
import RoomComposer from '@/components/RoomComposer.vue'
import RoomInfoPane from '@/components/RoomInfoPane.vue'
import RoomTimeline from '@/components/RoomTimeline.vue'
import { drafts } from '@/lib/drafts'
import { errorMessage, formatBytes } from '@/lib/format'
import { client, cryptoError } from '@/matrix/client'
import SpaceView from '@/components/SpaceView.vue'
import ThreadPane from '@/components/ThreadPane.vue'
import ThreadsPane from '@/components/ThreadsPane.vue'
import { errorMessage } from '@/lib/format'
import { client } from '@/matrix/client'
import { acceptIdentity, identityWarnings, withdrawVerification, type IdentityWarning } from '@/matrix/crypto'
import { sendFile, sendText } from '@/matrix/send'
import type { MessageItem } from '@/matrix/timeline'
import { useCall } from '@/stores/call'
import { useChat } from '@/stores/chat'
import { useSecurity } from '@/stores/security'
@@ -46,13 +49,26 @@ const memberCount = computed(() => {
void chat.version
return room.value?.getJoinedMemberCount() ?? 0
})
const canSend = computed(() => {
const isSpace = computed(() => {
void chat.version
const r = room.value
return r ? r.currentState.maySendMessage(me) : false
return Boolean(room.value?.isSpaceRoom())
})
const infoOpen = ref(false)
// The side pane: room info, the room's threads, or one thread.
const pane = ref<'info' | 'threads' | 'thread' | null>(null)
const infoOpen = computed(() => pane.value === 'info')
const threadId = ref('')
function toggle(which: 'info' | 'threads') {
pane.value = pane.value === which ? null : which
}
function openThread(id: string) {
threadId.value = id
pane.value = 'thread'
}
const threadsUnread = computed(() => {
void chat.version
return Boolean(room.value?.hasThreadUnreadNotification())
})
// Calls, in any room with someone else in it: starting one in a room of
// two rings the other person. One call at a time, wherever it is.
@@ -113,24 +129,6 @@ async function ackWarning(verifyAgain = false) {
}
}
const text = ref(drafts.get(props.roomId) ?? '')
watch(text, (v) => (v ? drafts.set(props.roomId, v) : drafts.delete(props.roomId)))
// Typing notifications: on while there is text, renewed every few seconds,
// off when it's sent or cleared.
let typingUntil = 0
function setTyping(on: boolean) {
const now = Date.now()
if (on && now < typingUntil) return
if (!on && typingUntil === 0) return
typingUntil = on ? now + 4000 : 0
client()
.sendTyping(props.roomId, on, on ? 6000 : 0)
.catch(() => undefined)
}
watch(text, (v) => setTyping(Boolean(v.trim())))
onBeforeUnmount(() => setTyping(false))
const typingNames = computed(() => {
void chat.version
return (room.value?.getMembers() ?? []).filter((m) => m.typing && m.userId !== me).map((m) => m.name)
@@ -143,60 +141,14 @@ const typingText = computed(() => {
return `${n.length} people are typing…`
})
function send(body: string) {
setTyping(false)
sendText(client(), props.roomId, body).catch((e) => {
// A failed send stays in the timeline with Retry; say why once.
toasts.error(errorMessage(e))
})
}
// Uploads.
const uploads = ref<{ id: number; name: string; loaded: number; total: number }[]>([])
let uploadId = 0
let maxUpload: number | null | undefined
async function sendFiles(list: FileList | File[]) {
// Copied first: the composer clears its file input (and so the live
// FileList) as soon as it has handed the files over.
const files = Array.from(list)
const r = room.value
if (!r) return
if (maxUpload === undefined) {
maxUpload = await client()
.getMediaConfig(true)
.then((c) => c['m.upload.size'] ?? null)
.catch(() => null)
}
for (const file of Array.from(files)) {
if (maxUpload && file.size > maxUpload) {
toasts.error(`${file.name} is ${formatBytes(file.size)}; this homeserver takes files up to ${formatBytes(maxUpload)}.`)
continue
}
const id = ++uploadId
uploads.value.push({ id, name: file.name, loaded: 0, total: file.size })
sendFile(client(), r, file, (p) => {
const u = uploads.value.find((x) => x.id === id)
if (u) Object.assign(u, { loaded: p.loaded, total: p.total })
})
.catch((e) => toasts.error(`Can’t send ${file.name}: ${errorMessage(e)}`))
.finally(() => (uploads.value = uploads.value.filter((x) => x.id !== id)))
}
}
function onPaste(e: ClipboardEvent) {
const files = e.clipboardData?.files
if (files && files.length) {
e.preventDefault()
sendFiles(files)
}
}
const replyTo = shallowRef<MessageItem | null>(null)
const composer = ref<InstanceType<typeof RoomComposer>>()
const dragging = ref(false)
function onDrop(e: DragEvent) {
dragging.value = false
const files = e.dataTransfer?.files
if (files && files.length && canSend.value && !cryptoMissing.value) sendFiles(files)
if (files && files.length && composer.value?.canWrite) composer.value.sendFiles(files)
}
// Invites and rooms you're not in.
@@ -237,6 +189,10 @@ async function joinHere() {
}
const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room'}`)
const invitedTo = computed(() => {
if (invite.value?.isDirect) return 'to chat'
return invite.value?.isSpace ? 'to this space' : 'to this room'
})
</script>
<template>
@@ -250,7 +206,7 @@ const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room'
<template v-if="invite.inviter">
<span class="body-strong text-ink">{{ invite.inviterName }}</span>
<span v-if="invite.inviterName !== invite.inviter" class="mxid"> ({{ invite.inviter }})</span>
invited you {{ invite.isDirect ? 'to chat' : 'to this room' }}.
invited you {{ invitedTo }}.
</template>
<template v-else>You’re invited to this room.</template>
</p>
@@ -277,17 +233,19 @@ const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room'
</div>
</div>
<SpaceView v-else-if="isSpace" :space="room" />
<div v-else class="flex min-h-0 flex-1">
<div
class="relative flex min-w-0 flex-1 flex-col"
@dragover.prevent="dragging = canSend && !cryptoMissing"
@dragover.prevent="dragging = Boolean(composer?.canWrite)"
@dragleave.self="dragging = false"
@drop.prevent="onDrop"
>
<header class="flex h-16 shrink-0 items-center gap-3 border-b border-line bg-surface px-2 md:px-4">
<WIconButton class="md:hidden" icon="chevron-left" label="Back to rooms" @click="router.push('/')" />
<MxcAvatar :name="summary?.name ?? room.name" :mxc="summary?.avatarMxc" :size="36" :presence="summary?.presence" />
<button type="button" class="min-w-0 flex-1 cursor-pointer border-0 bg-transparent p-0 text-left" @click="infoOpen = !infoOpen">
<button type="button" class="min-w-0 flex-1 cursor-pointer border-0 bg-transparent p-0 text-left" @click="toggle('info')">
<span class="flex items-center gap-2">
<h1 class="heading m-0 truncate">{{ summary?.name ?? room.name }}</h1>
<WIcon v-if="encrypted" name="lock" :size="16" class="shrink-0 text-teal-text" label="End-to-end encrypted" />
@@ -304,7 +262,12 @@ const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room'
<WIconButton icon="phone" label="Voice call" :disabled="inCall" @click="joinCall(false)" />
<WIconButton icon="video" label="Video call" :disabled="inCall" @click="joinCall(true)" />
</template>
<WIconButton icon="users" :label="infoOpen ? 'Hide room info' : 'Room info'" :aria-pressed="infoOpen" @click="infoOpen = !infoOpen" />
<span class="relative">
<WIconButton icon="thread" label="Threads" :aria-pressed="pane === 'threads' || pane === 'thread'" @click="toggle('threads')" />
<span v-if="threadsUnread" class="pointer-events-none absolute top-2 right-2 h-2 w-2 rounded-full bg-teal-strong" />
<span v-if="threadsUnread" class="wp-sr">: unread replies</span>
</span>
<WIconButton icon="users" :label="infoOpen ? 'Hide room info' : 'Room info'" :aria-pressed="infoOpen" @click="toggle('info')" />
</header>
<div v-if="callHere && canCall && !inThisCall" class="shrink-0 border-b border-line bg-surface px-4 py-2">
@@ -317,18 +280,12 @@ const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room'
</WBanner>
</div>
<RoomTimeline :room="room" :is-direct="summary?.isDirect ?? false" />
<RoomTimeline :room="room" :is-direct="summary?.isDirect ?? false" @reply="(item) => (replyTo = item)" @open-thread="openThread" />
<div class="shrink-0 bg-surface-sunken px-4 pt-2 pb-4">
<div class="mx-auto flex max-w-4xl flex-col gap-2">
<p class="caption m-0 h-4 text-ink-muted" aria-live="polite">{{ typingText }}</p>
<div v-for="u in uploads" :key="u.id" class="flex items-center gap-3 rounded-md bg-surface-raised px-3 py-2">
<WSpinner :size="16" />
<span class="small min-w-0 flex-1 truncate">Uploading {{ u.name }}</span>
<span class="caption text-ink-muted tabular-nums">{{ u.total ? Math.round((u.loaded / u.total) * 100) : 0 }}%</span>
</div>
<WBanner
v-if="warning && !cryptoMissing"
:tone="warning.kind === 'verifiedReset' ? 'danger' : 'warning'"
@@ -346,13 +303,7 @@ const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room'
<WButton size="sm" :loading="acking" @click="ackWarning()">{{ warning.kind === 'verifiedReset' ? 'Withdraw verification' : 'OK' }}</WButton>
</span>
</WBanner>
<WBanner v-if="cryptoMissing" tone="warning" title="End-to-end encryption didn’t start">
This browser can’t read or send messages in this room. Reload the page to try again{{ cryptoError() ? ` (${cryptoError()})` : '' }}.
</WBanner>
<WBanner v-else-if="!canSend" tone="info">You don’t have permission to post in this room.</WBanner>
<div v-else @paste="onPaste">
<WComposer v-model="text" :placeholder="placeholder" :encrypted="encrypted" @send="send" @attach="sendFiles" />
</div>
<RoomComposer ref="composer" v-model:reply-to="replyTo" :room="room" :placeholder="placeholder" />
</div>
</div>
@@ -364,6 +315,16 @@ const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room'
</div>
</div>
<RoomInfoPane v-if="infoOpen" :room="room" :is-direct="summary?.isDirect ?? false" @close="infoOpen = false" />
<RoomInfoPane v-if="pane === 'info'" :room="room" :is-direct="summary?.isDirect ?? false" @close="pane = null" />
<ThreadsPane v-else-if="pane === 'threads'" :room="room" @open="openThread" @close="pane = null" />
<ThreadPane
v-else-if="pane === 'thread' && threadId"
:key="threadId"
:room="room"
:thread-id="threadId"
:is-direct="summary?.isDirect ?? false"
@back="pane = 'threads'"
@close="pane = null"
/>
</div>
</template>
+29 -3
View File
@@ -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 },
},
},
})
}))
+10 -1
View File
@@ -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