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
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:
1 parent
12550c4078
commit
8ff2730676
23 files changed
+1666
-205
No files matched your search
+1
-1
@@ -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 |
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ export type IconName =
|
||||
| 'settings'
|
||||
| 'shield-alert'
|
||||
| 'shield-check'
|
||||
| 'space'
|
||||
| 'sun'
|
||||
| 'thread'
|
||||
| 'trash'
|
||||
| 'user'
|
||||
| 'users'
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user