Web client: replies, reactions, threads and spaces
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s

- Replies: Reply on a message puts it above the composer; the reply
  quotes it (sender and first line), and the quote goes to the message.
  Sent without the quoted fallback (Matrix 1.13); the fallback in older
  replies is not shown.
- Reactions: the six quick ones of the Android app or any other emoji,
  chips under the message with counts, yours highlighted; a chip adds
  yours or takes it back.
- Threads: matrix-js-sdk's thread support is on. Reply in thread opens
  the thread beside the room with its own composer; the root shows
  "N replies" and the last reply; Threads in the header lists them.
  Receipts are per thread (MSC3771): the room is marked read with its
  read marker and a separate receipt for "main", since /read_markers'
  receipt has no thread_id and would read every thread. Unread counts
  per thread (MSC3773) and a dot on unread threads.
  Two SDK quirks worked around: the thread view creates the thread
  object when a thread starts here (else the first reply's remote echo
  is dropped until reload), and the reply count is re-read from the
  root's bundled count after replies arrive (the SDK can count one
  twice).
- Spaces: kept out of the room list; a picker narrows the list to a
  space's rooms and its subspaces'. A space's page (/hierarchy) lists
  its rooms with Open or Join; admins add existing rooms, create new
  ones in it and take rooms out. New space in the + menu and picker.
- The room composer moved into RoomComposer.vue, shared by the room
  and the thread pane. New thread and space icons in apps/ui.
- Dev server: the Rust crypto's .wasm was lost to vite's pre-bundling,
  so E2EE never started under `pnpm web:dev`; the package is now served
  as it is in dev.

Server: /versions lists v1.1 to v1.14. matrix-js-sdk checks for the
exact version a feature came in, and with only v1.13 and v1.14 it kept
threads, per-thread unread counts, recursive relations and intentional
mentions off.

Tested in the browser against a local server: replies, reactions,
threads (new and existing, in plain and encrypted rooms, receipts
checked on the server) and spaces.

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

No files matched your search

+31 -16
View File
@@ -4,8 +4,16 @@ import { WBanner, WButton, WDialog, WIcon, WSwitch, WTextArea, WTextField } from
import { errorMessage } from '@/lib/format'
import { useChat } from '@/stores/chat'
// A room, a room in a space (`spaceId`), or a space (`kind` "space").
const props = withDefaults(defineProps<{ kind?: 'room' | 'space'; spaceId?: string; spaceName?: string }>(), {
kind: 'room',
spaceId: undefined,
spaceName: undefined,
})
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ created: [roomId: string] }>()
const isSpace = computed(() => props.kind === 'space')
const noun = computed(() => (isSpace.value ? 'space' : 'room'))
const chat = useChat()
const name = ref('')
@@ -22,7 +30,8 @@ watch(open, (v) => {
topic.value = ''
isPublic.value = false
alias.value = ''
encrypted.value = chat.canEncrypt
// A space holds no conversation to protect: never encrypted.
encrypted.value = chat.canEncrypt && !isSpace.value
error.value = ''
})
@@ -40,7 +49,7 @@ watch(name, (n, old) => {
})
const aliasError = computed(() =>
isPublic.value && alias.value && !/^[a-z0-9._=-]+$/.test(alias.value) ? 'Use lowercase letters, digits and . _ = -' : undefined,
isPublic.value && !isSpace.value && alias.value && !/^[a-z0-9._=-]+$/.test(alias.value) ? 'Use lowercase letters, digits and . _ = -' : undefined,
)
async function submit() {
@@ -48,13 +57,16 @@ async function submit() {
busy.value = true
error.value = ''
try {
const roomId = await chat.createRoom({
name: name.value,
topic: topic.value,
isPublic: isPublic.value,
alias: alias.value,
encrypted: encrypted.value,
})
const roomId = isSpace.value
? await chat.createSpace({ name: name.value, topic: topic.value, isPublic: isPublic.value })
: await chat.createRoom({
name: name.value,
topic: topic.value,
isPublic: isPublic.value,
alias: alias.value,
encrypted: encrypted.value,
spaceId: props.spaceId,
})
open.value = false
emit('created', roomId)
} catch (e) {
@@ -66,19 +78,22 @@ async function submit() {
</script>
<template>
<WDialog v-model:open="open" title="Create room" :persistent="busy">
<WDialog v-model:open="open" :title="isSpace ? 'Create space' : spaceName ? `New room in ${spaceName}` : 'Create room'" :persistent="busy">
<form id="create-room" class="flex flex-col gap-4" @submit.prevent="submit">
<WTextField v-model="name" wide label="Name" maxlength="255" required autofocus />
<WTextArea v-model="topic" label="Topic (optional)" rows="2" />
<div class="flex items-start gap-3">
<div class="flex-1">
<p class="body-strong m-0">Public room</p>
<p class="small m-0 text-ink-muted">Anyone can find it in the room directory and join. Otherwise people join by invite.</p>
<p class="body-strong m-0">Public {{ noun }}</p>
<p class="small m-0 text-ink-muted">
<template v-if="isSpace">Anyone can find it in the room directory, join it and see its public rooms. Otherwise people join by invite.</template>
<template v-else>Anyone can find it in the room directory and join. Otherwise people join by invite.</template>
</p>
</div>
<WSwitch v-model="isPublic" label="Public room" />
<WSwitch v-model="isPublic" :label="`Public ${noun}`" />
</div>
<WTextField
v-if="isPublic"
v-if="isPublic && !isSpace"
v-model="alias"
wide
mono
@@ -88,7 +103,7 @@ async function submit() {
autocomplete="off"
spellcheck="false"
/>
<div class="flex items-start gap-3">
<div v-if="!isSpace" class="flex items-start gap-3">
<div class="flex-1">
<p class="body-strong m-0 flex items-center gap-1">
<WIcon :name="encrypted ? 'lock' : 'shield-alert'" :size="16" :class="encrypted ? 'text-teal-text' : 'text-ink-muted'" />
@@ -111,7 +126,7 @@ async function submit() {
<template #actions>
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton type="submit" form="create-room" variant="primary" :loading="busy" :disabled="!name.trim() || !!aliasError">
Create room
Create {{ noun }}
</WButton>
</template>
</WDialog>
+1 -1
View File
@@ -20,7 +20,7 @@ const directory = ref<IPublicRoomsChunkRoom[]>()
const directoryError = ref('')
const search = ref('')
const joined = computed(() => new Set(chat.rooms.map((r) => r.roomId)))
const joined = computed(() => new Set([...chat.rooms, ...chat.spaces].map((r) => r.roomId)))
async function loadDirectory() {
directoryError.value = ''
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
import { WIconButton } from '@warp/ui'
// Reacting to a message: the six quick reactions of the Android app, or
// any other typed (or picked with the system's emoji keyboard) into the
// field. Closes on a choice, Escape or a click outside.
const props = withDefaults(defineProps<{ align?: 'left' | 'right' }>(), { align: 'left' })
const emit = defineEmits<{ pick: [key: string] }>()
const quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏']
const id = useId()
const open = ref(false)
const other = ref('')
const root = ref<HTMLElement>()
const panel = ref<HTMLElement>()
function onDocClick(e: MouseEvent) {
if (root.value && !root.value.contains(e.target as Node)) open.value = false
}
watch(open, async (v) => {
if (v) {
other.value = ''
document.addEventListener('mousedown', onDocClick)
await nextTick()
panel.value?.querySelector<HTMLButtonElement>('button')?.focus()
} else {
document.removeEventListener('mousedown', onDocClick)
}
})
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick))
function pick(key: string) {
const k = key.trim()
if (!k) return
open.value = false
emit('pick', k)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && open.value) {
e.stopPropagation()
open.value = false
root.value?.querySelector<HTMLButtonElement>('button')?.focus()
}
}
</script>
<template>
<div ref="root" class="relative" @keydown="onKey">
<WIconButton icon="react" label="React" :aria-expanded="open" aria-haspopup="dialog" @click="open = !open" />
<div
v-if="open"
ref="panel"
role="dialog"
aria-label="React to the message"
:class="[
'absolute bottom-full z-30 mb-1 flex flex-col gap-2 rounded-md border border-line bg-surface-raised p-2 shadow-float',
props.align === 'right' ? 'right-0' : 'left-0',
]"
>
<div class="flex gap-1">
<button
v-for="key in quickReactions"
:key="key"
type="button"
class="flex h-10 w-10 items-center justify-center rounded-sm text-[22px] hover:bg-surface-sunken focus-visible:bg-surface-sunken"
:aria-label="`React with ${key}`"
@click="pick(key)"
>
{{ key }}
</button>
</div>
<form class="flex gap-1" @submit.prevent="pick(other)">
<label class="wp-sr" :for="id">Another reaction</label>
<input
:id="id"
v-model="other"
maxlength="64"
placeholder="Another emoji"
autocomplete="off"
class="h-9 min-w-0 flex-1 rounded-sm border border-line-strong bg-surface px-2 text-ink outline-none focus:border-teal-strong"
/>
<button type="submit" class="h-9 rounded-sm px-3 font-semibold text-teal-text hover:bg-surface-sunken" :disabled="!other.trim()">React</button>
</form>
</div>
</div>
</template>
+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import type { Room } from 'matrix-js-sdk'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { WBanner, WComposer, WIcon, WIconButton, WSpinner } from '@warp/ui'
import { drafts } from '@/lib/drafts'
import { errorMessage, formatBytes } from '@/lib/format'
import { client, cryptoError } from '@/matrix/client'
import { sendFile, sendText } from '@/matrix/send'
import type { MessageItem } from '@/matrix/timeline'
import { useChat } from '@/stores/chat'
import { useToasts } from '@/stores/toasts'
// Writing to a room, or to one of its threads: the text field with its
// draft, typing notifications, uploads, and the message being replied to
// above it. The room view and the thread pane each have one.
const props = defineProps<{ room: Room; threadId?: string; placeholder: string }>()
const replyTo = defineModel<MessageItem | null>('replyTo', { default: null })
const chat = useChat()
const toasts = useToasts()
const me = client().getUserId() ?? ''
const composer = ref<InstanceType<typeof WComposer>>()
const encrypted = computed(() => {
void chat.version
return props.room.hasEncryptionStateEvent()
})
// An encrypted room can't be written to without the crypto stack.
const cryptoMissing = computed(() => encrypted.value && !chat.canEncrypt)
const canSend = computed(() => {
void chat.version
return props.room.currentState.maySendMessage(me)
})
/** Whether files dropped on the room can be sent. */
const canWrite = computed(() => canSend.value && !cryptoMissing.value)
const draftKey = computed(() => (props.threadId ? `${props.room.roomId}/${props.threadId}` : props.room.roomId))
const text = ref(drafts.get(draftKey.value) ?? '')
watch(text, (v) => (v ? drafts.set(draftKey.value, v) : drafts.delete(draftKey.value)))
// Typing notifications: on while there is text, renewed every few seconds,
// off when it's sent or cleared. Matrix has them per room, threads or not.
let typingUntil = 0
function setTyping(on: boolean) {
const now = Date.now()
if (on && now < typingUntil) return
if (!on && typingUntil === 0) return
typingUntil = on ? now + 4000 : 0
client()
.sendTyping(props.room.roomId, on, on ? 6000 : 0)
.catch(() => undefined)
}
watch(text, (v) => setTyping(Boolean(v.trim())))
onBeforeUnmount(() => setTyping(false))
// Choosing a message to reply to puts the cursor in the field.
watch(replyTo, (v) => v && nextTick(() => composer.value?.focus()))
function target() {
return { threadId: props.threadId, replyTo: replyTo.value?.eventId }
}
function send(body: string) {
setTyping(false)
const to = target()
replyTo.value = null
sendText(client(), props.room.roomId, body, to).catch((e) => {
// A failed send stays in the timeline with Retry; say why once.
toasts.error(errorMessage(e))
})
}
// Uploads.
const uploads = ref<{ id: number; name: string; loaded: number; total: number }[]>([])
let uploadId = 0
let maxUpload: number | null | undefined
async function sendFiles(list: FileList | File[]) {
// Copied first: the composer clears its file input (and so the live
// FileList) as soon as it has handed the files over.
const files = Array.from(list)
if (!canWrite.value) return
if (maxUpload === undefined) {
maxUpload = await client()
.getMediaConfig(true)
.then((c) => c['m.upload.size'] ?? null)
.catch(() => null)
}
const to = target()
replyTo.value = null
for (const file of files) {
if (maxUpload && file.size > maxUpload) {
toasts.error(`${file.name} is ${formatBytes(file.size)}; this homeserver takes files up to ${formatBytes(maxUpload)}.`)
continue
}
const id = ++uploadId
uploads.value.push({ id, name: file.name, loaded: 0, total: file.size })
sendFile(
client(),
props.room,
file,
(p) => {
const u = uploads.value.find((x) => x.id === id)
if (u) Object.assign(u, { loaded: p.loaded, total: p.total })
},
to,
)
.catch((e) => toasts.error(`Can’t send ${file.name}: ${errorMessage(e)}`))
.finally(() => (uploads.value = uploads.value.filter((x) => x.id !== id)))
}
}
function onPaste(e: ClipboardEvent) {
const files = e.clipboardData?.files
if (files && files.length) {
e.preventDefault()
sendFiles(files)
}
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && replyTo.value) {
e.stopPropagation()
replyTo.value = null
}
}
defineExpose({ sendFiles, canWrite, focus: () => composer.value?.focus() })
</script>
<template>
<div class="flex flex-col gap-2">
<div v-for="u in uploads" :key="u.id" class="flex items-center gap-3 rounded-md bg-surface-raised px-3 py-2">
<WSpinner :size="16" />
<span class="small min-w-0 flex-1 truncate">Uploading {{ u.name }}</span>
<span class="caption text-ink-muted tabular-nums">{{ u.total ? Math.round((u.loaded / u.total) * 100) : 0 }}%</span>
</div>
<WBanner v-if="cryptoMissing" tone="warning" title="End-to-end encryption didn’t start">
This browser can’t read or send messages in this room. Reload the page to try again{{ cryptoError() ? ` (${cryptoError()})` : '' }}.
</WBanner>
<WBanner v-else-if="!canSend" tone="info">You don’t have permission to post in this room.</WBanner>
<div v-else class="flex flex-col gap-1" @paste="onPaste" @keydown="onKey">
<div v-if="replyTo" class="flex items-center gap-2 rounded-md border-l-2 border-teal-strong bg-surface-raised py-1 pr-1 pl-3">
<WIcon name="reply" :size="16" class="shrink-0 text-ink-muted" />
<p class="small m-0 min-w-0 flex-1 truncate">
Replying to <span class="font-semibold">{{ replyTo.own ? 'yourself' : replyTo.senderName }}</span>
<span class="text-ink-muted"> · {{ replyTo.media ? replyTo.media.filename : replyTo.body }}</span>
</p>
<WIconButton icon="close" label="Cancel reply" @click="replyTo = null" />
</div>
<WComposer ref="composer" v-model="text" :placeholder="placeholder" :encrypted="encrypted" @send="send" @attach="sendFiles" />
</div>
</div>
</template>
+18 -9
View File
@@ -35,6 +35,14 @@ const alias = computed(() => {
void chat.version
return props.room.getCanonicalAlias()
})
// "room", or what else it is: a direct chat or a space.
const noun = computed(() => {
void chat.version
if (props.isDirect) return 'chat'
return props.room.isSpaceRoom() ? 'space' : 'room'
})
const Noun = computed(() => noun.value[0]!.toUpperCase() + noun.value.slice(1))
const encrypted = computed(() => {
void chat.version
return props.room.hasEncryptionStateEvent()
@@ -149,8 +157,8 @@ function copy(text: string) {
aria-label="Room info"
>
<div class="flex h-16 shrink-0 items-center gap-2 border-b border-line px-4">
<h2 class="subheading m-0 flex-1">{{ isDirect ? 'Chat info' : 'Room info' }}</h2>
<WIconButton icon="close" label="Close room info" @click="emit('close')" />
<h2 class="subheading m-0 flex-1">{{ Noun }} info</h2>
<WIconButton icon="close" :label="`Close ${noun} info`" @click="emit('close')" />
</div>
<div class="flex-1 overflow-y-auto">
@@ -161,7 +169,7 @@ function copy(text: string) {
{{ alias }}
</button>
<p v-if="topic" class="body m-0 whitespace-pre-wrap text-ink-muted">{{ topic }}</p>
<p class="caption m-0 flex items-center gap-1" :class="encrypted ? 'text-teal-text' : 'text-ink-muted'">
<p v-if="noun !== 'space'" class="caption m-0 flex items-center gap-1" :class="encrypted ? 'text-teal-text' : 'text-ink-muted'">
<WIcon :name="encrypted ? 'lock' : 'shield-alert'" :size="14" />
{{ encrypted ? 'End-to-end encrypted' : 'Not encrypted' }}
</p>
@@ -207,11 +215,11 @@ function copy(text: string) {
<button type="button" class="caption flex items-center gap-1 text-left text-ink-muted hover:text-ink" title="Copy room ID" @click="copy(room.roomId)">
<WIcon name="copy" :size="14" /> <span class="mxid break-all">{{ room.roomId }}</span>
</button>
<WButton variant="danger" icon="logout" @click="leaveOpen = true">{{ isDirect ? 'Leave chat' : 'Leave room' }}</WButton>
<WButton variant="danger" icon="logout" @click="leaveOpen = true">Leave {{ noun }}</WButton>
</section>
</div>
<WDialog v-model:open="editing" title="Edit room" :persistent="saving">
<WDialog v-model:open="editing" :title="`Edit ${noun}`" :persistent="saving">
<form id="edit-room" class="flex flex-col gap-4" @submit.prevent="save">
<WTextField v-if="canEditName" v-model="draftName" wide label="Name" maxlength="255" />
<WTextArea v-if="canEditTopic" v-model="draftTopic" label="Topic" rows="3" />
@@ -223,7 +231,7 @@ function copy(text: string) {
</template>
</WDialog>
<WDialog v-model:open="inviteOpen" title="Invite to room" :persistent="inviting">
<WDialog v-model:open="inviteOpen" :title="`Invite to ${noun}`" :persistent="inviting">
<form id="invite-form" class="flex flex-col gap-4" @submit.prevent="invite">
<WTextField
v-model="inviteId"
@@ -252,14 +260,15 @@ function copy(text: string) {
<ConfirmDialog
v-model:open="leaveOpen"
:title="isDirect ? 'Leave chat' : 'Leave room'"
:confirm-label="isDirect ? 'Leave chat' : 'Leave room'"
:title="`Leave ${noun}`"
:confirm-label="`Leave ${noun}`"
:busy="leaving"
:error="leaveError"
@confirm="leave"
>
<p>
You stop getting messages from <span class="body-strong">{{ name }}</span>.
<template v-if="noun === 'space'">You leave <span class="body-strong">{{ name }}</span>, not the rooms in it.</template>
<template v-else>You stop getting messages from <span class="body-strong">{{ name }}</span>.</template>
<template v-if="room.getJoinRule() !== 'public'"> To come back, someone has to invite you again.</template>
</p>
</ConfirmDialog>
+203 -52
View File
@@ -1,26 +1,30 @@
<script setup lang="ts">
import type { Room } from 'matrix-js-sdk'
import { EventType, type Room } from 'matrix-js-sdk'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { WButton, WIcon, WMessageBubble, WSpinner } from '@warp/ui'
import { WButton, WIcon, WIconButton, WMessageBubble, WSpinner } from '@warp/ui'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ImageViewer from '@/components/ImageViewer.vue'
import MessageBody from '@/components/MessageBody.vue'
import MxcAvatar from '@/components/MxcAvatar.vue'
import PopMenu from '@/components/PopMenu.vue'
import ReactionPicker from '@/components/ReactionPicker.vue'
import type { MenuItem } from '@/components/menu'
import { errorMessage, formatDateTime, formatDay, formatTime } from '@/lib/format'
import { errorMessage, formatDateTime, formatDay, formatListTime, formatTime } from '@/lib/format'
import { client } from '@/matrix/client'
import type { MediaSource } from '@/matrix/media'
import { buildTimeline, canPaginateBack, lastSyncedEvent, type MessageItem } from '@/matrix/timeline'
import { sendReaction } from '@/matrix/send'
import { buildTimeline, canPaginateBack, inThread, lastSyncedEvent, type MessageItem, type ReactionChip } from '@/matrix/timeline'
import { useChat } from '@/stores/chat'
import { useToasts } from '@/stores/toasts'
// The scrolling list of a room's messages. The scroller is laid out bottom
// up (column-reverse), so it starts at the newest message, stays there as
// messages arrive, and older history loading above doesn't move what you
// are reading.
// are reading. Given a thread's root ID it shows that thread instead: the
// root, then its replies.
const props = defineProps<{ room: Room; isDirect: boolean }>()
const props = defineProps<{ room: Room; isDirect: boolean; threadId?: string }>()
const emit = defineEmits<{ reply: [item: MessageItem]; openThread: [eventId: string] }>()
const chat = useChat()
const toasts = useToasts()
@@ -37,11 +41,18 @@ const deleteError = ref('')
const items = computed(() => {
void chat.version
return buildTimeline(client(), props.room)
return buildTimeline(client(), props.room, props.threadId)
})
const hasMore = computed(() => {
void chat.version
return canPaginateBack(props.room)
return canPaginateBack(props.room, props.threadId)
})
// A thread's first replies come from the homeserver when the SDK first
// sees it; until then there is only the root.
const threadLoading = computed(() => {
void chat.version
const thread = props.threadId ? props.room.getThread(props.threadId) : null
return Boolean(thread && !thread.initialEventsFetched)
})
// Distance from the oldest loaded message, given column-reverse's
@@ -55,7 +66,9 @@ async function paginate() {
paginating.value = true
paginateError.value = false
try {
await client().scrollback(props.room, 30)
const thread = props.threadId ? props.room.getThread(props.threadId) : null
if (thread) await client().paginateEventTimeline(thread.liveTimeline, { backwards: true, limit: 30 })
else if (!props.threadId) await client().scrollback(props.room, 30)
} catch {
paginateError.value = true
} finally {
@@ -65,7 +78,7 @@ async function paginate() {
// Keep going until the view is full or history runs out.
requestAnimationFrame(() => {
const el = scroller.value
if (el && !paginateError.value && distanceFromTop(el) < 400 && canPaginateBack(props.room)) paginate()
if (el && !paginateError.value && distanceFromTop(el) < 400 && canPaginateBack(props.room, props.threadId)) paginate()
})
}
@@ -82,21 +95,28 @@ function jumpToLatest() {
}
// Read receipts: sent when you are looking at the newest message, with the
// tab in front.
// tab in front. With threads on, the SDK gives each receipt the thread it
// belongs to (MSC3771): "main" for the room, the root's ID in a thread. A
// thread has no read marker of its own, only the receipt.
let lastMarked = ''
function markRead() {
if (!atBottom.value || document.visibilityState !== 'visible' || !document.hasFocus()) return
const ev = lastSyncedEvent(props.room)
const ev = lastSyncedEvent(props.room, props.threadId)
const id = ev?.getId()
if (!ev || !id || id === lastMarked) return
if (ev.getSender() === me && props.room.getUnreadNotificationCount() === 0) {
const unread = props.threadId
? props.room.getThreadUnreadNotificationCount(props.threadId)
: props.room.getRoomUnreadNotificationCount()
if (ev.getSender() === me && unread === 0) {
lastMarked = id
return
}
lastMarked = id
client()
.setRoomReadMarkers(props.room.roomId, id, ev)
.catch(() => (lastMarked = ''))
// The receipt goes on its own: one sent with the read marker (POST
// /read_markers) has no thread_id, so it would read every thread too.
const c = client()
const sent = props.threadId ? c.sendReadReceipt(ev) : Promise.all([c.setRoomReadMarkers(props.room.roomId, id), c.sendReadReceipt(ev)])
sent.catch(() => (lastMarked = ''))
}
watch(items, () => requestAnimationFrame(markRead))
@@ -139,8 +159,29 @@ const redactLevel = computed(() => {
return typeof pl.redact === 'number' ? pl.redact : 50
})
const canSend = computed(() => {
void chat.version
return props.room.currentState.maySendMessage(me)
})
const canReact = computed(() => {
void chat.version
return props.room.currentState.maySendEvent(EventType.Reaction, me)
})
/** Whether a message can be answered or reacted to: it is on the server, not deleted. */
function answerable(item: MessageItem): boolean {
return Boolean(item.eventId && !item.eventId.startsWith('~')) && !item.redacted && !item.status?.match(/sending|failed/)
}
/** Starting a thread on a message: not from inside one, nor on a reply that is in one. */
function canThread(item: MessageItem): boolean {
return !props.threadId && canSend.value && answerable(item) && !inThread(item.event)
}
function actions(item: MessageItem): MenuItem[] {
const out: MenuItem[] = []
if (canSend.value && answerable(item)) out.push({ label: 'Reply', icon: 'reply', action: () => emit('reply', item) })
if (canThread(item)) out.push({ label: 'Reply in thread', icon: 'thread', action: () => emit('openThread', item.eventId!) })
if (item.body && !item.media && !item.encrypted) {
out.push({
label: 'Copy text',
@@ -154,6 +195,50 @@ function actions(item: MessageItem): MenuItem[] {
return out
}
// Reactions: a chip adds yours, or takes it back.
async function react(item: MessageItem, key: string) {
const mine = item.reactions.find((r) => r.key === key)?.mine
try {
if (mine) {
// Yours, still on its way: nothing to take back yet.
if (mine.status || !mine.getId()) return
await client().redactEvent(props.room.roomId, mine.getId()!)
} else {
await sendReaction(client(), item.event, key)
}
} catch (e) {
toasts.error(errorMessage(e))
}
}
function chipLabel(chip: ReactionChip): string {
const names = chip.names.length > 8 ? [...chip.names.slice(0, 8), `${chip.names.length - 8} more`] : chip.names
return `${names.join(', ')} reacted with ${chip.key}`
}
// The avatar sits by the bubble's last line, above any reactions and thread summary.
function avatarLift(item: MessageItem): number {
return 20 + (item.reactions.length ? 32 : 0) + (item.thread ? 34 : 0)
}
function threadLabel(item: MessageItem): string {
const n = item.thread?.count ?? 0
return n === 1 ? '1 reply' : `${n} replies`
}
// A quote goes to the message it quotes, when that is loaded here.
const flashed = ref('')
function jumpTo(eventId: string) {
const el = scroller.value?.querySelector<HTMLElement>(`[data-event-id="${CSS.escape(eventId)}"]`)
if (!el) {
toasts.info('That message isn’t loaded here.')
return
}
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
flashed.value = eventId
window.setTimeout(() => flashed.value === eventId && (flashed.value = ''), 1500)
}
function askDelete(item: MessageItem) {
deleteError.value = ''
deleting.value = item
@@ -180,9 +265,9 @@ async function confirmDelete() {
<div ref="scroller" class="flex h-full flex-col-reverse overflow-y-auto" @scroll.passive="onScroll">
<div class="mx-auto flex w-full max-w-4xl flex-col px-4 pt-4 pb-2" role="log" aria-live="polite" aria-relevant="additions">
<div class="flex min-h-10 items-center justify-center py-2">
<WSpinner v-if="paginating" :size="18" label="Loading older messages" />
<WSpinner v-if="paginating || threadLoading" :size="18" label="Loading older messages" />
<WButton v-else-if="paginateError" size="sm" icon="refresh" @click="paginate">Load older messages</WButton>
<p v-else-if="!hasMore" class="caption text-ink-muted">This is the beginning of the room.</p>
<p v-else-if="!hasMore && !threadId" class="caption text-ink-muted">This is the beginning of the room.</p>
</div>
<template v-for="item in items" :key="item.key">
@@ -196,48 +281,114 @@ async function confirmDelete() {
<div
v-else
:class="['group flex items-end gap-2', item.own ? 'flex-row-reverse' : '', item.first ? 'mt-4' : 'mt-2']"
:data-event-id="item.eventId"
:class="[
'group flex items-end gap-2 rounded-md transition-colors',
item.own ? 'flex-row-reverse' : '',
item.first ? 'mt-4' : 'mt-2',
flashed && flashed === item.eventId ? 'bg-teal-soft' : '',
]"
>
<template v-if="!item.own && !isDirect">
<MxcAvatar v-if="item.last" :name="item.senderName" :mxc="item.avatarMxc" :size="32" class="mb-5" />
<MxcAvatar
v-if="item.last"
:name="item.senderName"
:mxc="item.avatarMxc"
:size="32"
:style="{ marginBottom: `${avatarLift(item)}px` }"
/>
<span v-else class="w-8 shrink-0" />
</template>
<WMessageBubble
class="max-w-[min(520px,80%)] min-w-0"
:own="item.own"
:sender="item.first && !isDirect ? item.senderName : undefined"
:time="item.last || item.status === 'failed' ? formatTime(item.ts) : ''"
:edited="item.edited"
:status="item.status"
:title="formatDateTime(item.ts)"
>
<MessageBody :item="item" @view="(source, name) => (viewing = { source, name })" />
<template v-if="item.status === 'failed' || item.shield" #meta>
<span
v-if="item.shield"
:class="['inline-flex items-center gap-1', item.shield.tone === 'danger' ? 'text-danger' : 'text-warning']"
:title="item.shield.text"
<div :class="['flex max-w-[min(520px,80%)] min-w-0 flex-col gap-1', item.own ? 'items-end' : 'items-start']">
<WMessageBubble
class="max-w-full min-w-0"
:own="item.own"
:sender="item.first && !isDirect ? item.senderName : undefined"
:time="item.last || item.status === 'failed' ? formatTime(item.ts) : ''"
:edited="item.edited"
:status="item.status"
:title="formatDateTime(item.ts)"
>
<button
v-if="item.reply"
type="button"
class="mb-1 flex w-full min-w-0 flex-col rounded-sm border-l-2 border-teal-strong bg-surface px-2 py-1 text-left"
:aria-label="`Go to the message this replies to: ${item.reply.text}`"
@click="jumpTo(item.reply.eventId)"
>
<WIcon name="shield-alert" :size="14" />
<span>{{ item.shield.tone === 'danger' ? item.shield.text : 'Not verified' }}</span>
<span v-if="item.shield.tone !== 'danger'" class="wp-sr">: {{ item.shield.text }}</span>
</span>
<template v-if="item.status === 'failed'">
<button type="button" class="font-semibold text-teal-text hover:underline" @click="retry(item)">Retry</button>
<button type="button" class="font-semibold text-ink-muted hover:underline" @click="discard(item)">Delete</button>
<span v-if="item.reply.senderName" class="caption truncate font-semibold text-teal-text">{{ item.reply.senderName }}</span>
<span class="small block w-full truncate text-ink-muted">{{ item.reply.text }}</span>
</button>
<MessageBody :item="item" @view="(source, name) => (viewing = { source, name })" />
<template v-if="item.status === 'failed' || item.shield" #meta>
<span
v-if="item.shield"
:class="['inline-flex items-center gap-1', item.shield.tone === 'danger' ? 'text-danger' : 'text-warning']"
:title="item.shield.text"
>
<WIcon name="shield-alert" :size="14" />
<span>{{ item.shield.tone === 'danger' ? item.shield.text : 'Not verified' }}</span>
<span v-if="item.shield.tone !== 'danger'" class="wp-sr">: {{ item.shield.text }}</span>
</span>
<template v-if="item.status === 'failed'">
<button type="button" class="font-semibold text-teal-text hover:underline" @click="retry(item)">Retry</button>
<button type="button" class="font-semibold text-ink-muted hover:underline" @click="discard(item)">Delete</button>
</template>
</template>
</template>
</WMessageBubble>
</WMessageBubble>
<PopMenu
v-if="actions(item).length && item.status !== 'sending' && item.status !== 'failed'"
class="-my-2 self-center opacity-0 group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100"
icon="more"
label="Message actions"
:align="item.own ? 'right' : 'left'"
:items="actions(item)"
/>
<div v-if="item.reactions.length" :class="['flex flex-wrap gap-1', item.own ? 'justify-end' : '']">
<button
v-for="chip in item.reactions"
:key="chip.key"
type="button"
:class="[
'flex h-7 items-center gap-1 rounded-full border px-2 text-sm tabular-nums',
chip.mine ? 'border-teal-strong bg-teal-soft text-teal-text' : 'border-line bg-surface-raised text-ink hover:bg-surface-sunken',
]"
:title="chipLabel(chip)"
:aria-label="`${chipLabel(chip)}. ${chip.mine ? 'Take yours back' : 'React too'}`"
:aria-pressed="Boolean(chip.mine)"
:disabled="!canReact"
@click="react(item, chip.key)"
>
<span class="text-base leading-none">{{ chip.key }}</span>
<span class="font-semibold">{{ chip.count }}</span>
</button>
</div>
<button
v-if="item.thread && item.eventId"
type="button"
class="flex max-w-full items-center gap-2 rounded-md border border-line bg-surface-raised px-3 py-1 text-left hover:bg-surface-sunken"
@click="emit('openThread', item.eventId)"
>
<WIcon name="thread" :size="16" class="shrink-0 text-teal-text" />
<span class="small shrink-0 font-semibold text-teal-text">{{ threadLabel(item) }}</span>
<span class="caption truncate text-ink-muted">{{ item.thread.lastSenderName }} · {{ formatListTime(item.thread.lastTs) }}</span>
<span v-if="item.thread.unread" class="h-2 w-2 shrink-0 rounded-full bg-teal-strong" role="img" aria-label="Unread replies" />
</button>
</div>
<div
v-if="item.status !== 'sending' && item.status !== 'failed' && (actions(item).length || (canReact && answerable(item)))"
:class="[
'-my-2 flex items-center self-center opacity-0 group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100',
item.own ? 'flex-row-reverse' : '',
]"
>
<ReactionPicker v-if="canReact && answerable(item)" :align="item.own ? 'right' : 'left'" @pick="(key) => react(item, key)" />
<WIconButton v-if="canSend && answerable(item)" class="hidden sm:inline-flex" icon="reply" label="Reply" @click="emit('reply', item)" />
<WIconButton
v-if="canThread(item)"
class="hidden sm:inline-flex"
icon="thread"
label="Reply in thread"
@click="emit('openThread', item.eventId!)"
/>
<PopMenu v-if="actions(item).length" icon="more" label="Message actions" :align="item.own ? 'right' : 'left'" :items="actions(item)" />
</div>
</div>
</template>
</div>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { WIcon } from '@warp/ui'
import MxcAvatar from '@/components/MxcAvatar.vue'
import type { SpaceSummary } from '@/stores/chat'
// Which rooms the list shows: all of them, or one space's. A space with
// unread messages in its rooms has a dot.
const props = defineProps<{ spaces: SpaceSummary[]; unread: Set<string> }>()
const selected = defineModel<string>({ default: '' })
const emit = defineEmits<{ newSpace: [] }>()
const open = ref(false)
const root = ref<HTMLElement>()
const list = ref<HTMLElement>()
const current = computed(() => props.spaces.find((s) => s.roomId === selected.value))
function onDocClick(e: MouseEvent) {
if (root.value && !root.value.contains(e.target as Node)) open.value = false
}
watch(open, async (v) => {
if (v) {
document.addEventListener('mousedown', onDocClick)
await nextTick()
list.value?.querySelector<HTMLButtonElement>('[aria-checked="true"]')?.focus()
} else {
document.removeEventListener('mousedown', onDocClick)
}
})
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick))
function choose(id: string) {
selected.value = id
open.value = false
}
function onKey(e: KeyboardEvent) {
const buttons = Array.from(list.value?.querySelectorAll<HTMLButtonElement>('button') ?? [])
const i = buttons.indexOf(document.activeElement as HTMLButtonElement)
if (e.key === 'Escape') {
open.value = false
root.value?.querySelector<HTMLButtonElement>('button')?.focus()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
buttons[(i + 1) % buttons.length]?.focus()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
buttons[(i - 1 + buttons.length) % buttons.length]?.focus()
}
}
</script>
<template>
<div ref="root" class="relative px-3 pb-2" @keydown="onKey">
<button
type="button"
class="flex h-10 w-full items-center gap-2 rounded-sm px-2 text-left font-semibold text-ink hover:bg-surface-raised"
aria-haspopup="menu"
:aria-expanded="open"
@click="open = !open"
>
<MxcAvatar v-if="current" :name="current.name" :mxc="current.avatarMxc" :size="24" />
<WIcon v-else name="space" :size="20" class="text-ink-muted" />
<span class="min-w-0 flex-1 truncate">{{ current?.name ?? 'All chats' }}</span>
<span v-if="!open && [...unread].some((id) => id !== selected)" class="h-2 w-2 rounded-full bg-teal-strong" role="img" aria-label="Unread in other spaces" />
<WIcon name="chevron-right" :size="16" :class="['text-ink-muted transition-transform', open ? '-rotate-90' : 'rotate-90']" />
</button>
<div
v-if="open"
ref="list"
role="menu"
aria-label="Show rooms from"
class="absolute inset-x-3 top-full z-30 mt-1 flex max-h-96 flex-col overflow-y-auto rounded-md border border-line bg-surface-raised p-1 shadow-float"
>
<button
type="button"
role="menuitemradio"
:aria-checked="selected === ''"
:class="['flex h-10 items-center gap-3 rounded-sm px-3 text-left font-semibold hover:bg-surface-sunken focus-visible:bg-surface-sunken', selected === '' && 'text-teal-text']"
@click="choose('')"
>
<WIcon name="space" class="text-ink-muted" />
<span class="flex-1">All chats</span>
<WIcon v-if="selected === ''" name="check" :size="16" />
</button>
<button
v-for="s in spaces"
:key="s.roomId"
type="button"
role="menuitemradio"
:aria-checked="selected === s.roomId"
:class="['flex h-10 items-center gap-3 rounded-sm px-3 text-left font-semibold hover:bg-surface-sunken focus-visible:bg-surface-sunken', selected === s.roomId && 'text-teal-text']"
@click="choose(s.roomId)"
>
<MxcAvatar :name="s.name" :mxc="s.avatarMxc" :size="24" />
<span class="min-w-0 flex-1 truncate">{{ s.name }}</span>
<span v-if="unread.has(s.roomId)" class="h-2 w-2 rounded-full bg-teal-strong" role="img" aria-label="Unread" />
<WIcon v-if="selected === s.roomId" name="check" :size="16" />
</button>
<div class="my-1 h-px bg-line" role="separator" />
<button
type="button"
role="menuitem"
class="flex h-10 items-center gap-3 rounded-sm px-3 text-left font-semibold text-ink hover:bg-surface-sunken focus-visible:bg-surface-sunken"
@click="(open = false), emit('newSpace')"
>
<WIcon name="plus" class="text-ink-muted" />
New space
</button>
</div>
</div>
</template>
+278
View File
@@ -0,0 +1,278 @@
<script setup lang="ts">
import { EventType, KnownMembership, RoomType, type HierarchyRoom, type Room } from 'matrix-js-sdk'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { WBadge, WBanner, WButton, WCheckbox, WDialog, WIcon, WIconButton, WSpinner } from '@warp/ui'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import CreateRoomDialog from '@/components/CreateRoomDialog.vue'
import MxcAvatar from '@/components/MxcAvatar.vue'
import PopMenu from '@/components/PopMenu.vue'
import RoomInfoPane from '@/components/RoomInfoPane.vue'
import { errorMessage } from '@/lib/format'
import { client } from '@/matrix/client'
import { spaceChildren, useChat } from '@/stores/chat'
import { useToasts } from '@/stores/toasts'
// A space's home: the rooms and subspaces it lists (GET /hierarchy), the
// ones you're in and the ones you can join. Its admins add rooms to it,
// existing or new, and take them out.
const props = defineProps<{ space: Room }>()
const chat = useChat()
const toasts = useToasts()
const router = useRouter()
const me = client().getUserId() ?? ''
const name = computed(() => {
void chat.version
return props.space.name
})
const topic = computed(() => {
void chat.version
return String(props.space.currentState.getStateEvents(EventType.RoomTopic, '')?.getContent().topic ?? '')
})
const memberCount = computed(() => {
void chat.version
return props.space.getJoinedMemberCount()
})
const canManage = computed(() => {
void chat.version
return props.space.currentState.maySendStateEvent(EventType.SpaceChild, me)
})
const childIds = computed(() => {
void chat.version
return spaceChildren(props.space)
})
function joinedHere(roomId: string): boolean {
void chat.version
return client().getRoom(roomId)?.getMyMembership() === KnownMembership.Join
}
// The hierarchy, a page at a time. Its first room is the space itself.
const rooms = ref<HierarchyRoom[]>([])
const nextBatch = ref<string>()
const loading = ref(false)
const loadError = ref('')
// Where to join each room through: the via of the m.space.child that lists it.
const vias = new Map<string, string[]>()
async function load(more = false) {
loading.value = true
loadError.value = ''
try {
const res = await client().getRoomHierarchy(props.space.roomId, 50, undefined, false, more ? nextBatch.value : undefined)
for (const r of res.rooms) {
for (const child of r.children_state ?? []) {
if (child.content.via?.length) vias.set(child.state_key, child.content.via)
}
}
const found = res.rooms.filter((r) => r.room_id !== props.space.roomId)
rooms.value = more ? [...rooms.value, ...found] : found
nextBatch.value = res.next_batch
} catch (e) {
loadError.value = errorMessage(e)
} finally {
loading.value = false
}
}
// Again whenever the space's own list of rooms changes.
watch(() => childIds.value.join(','), () => load(), { immediate: true })
// The rooms the space lists directly first, each subspace's after.
const direct = computed(() => new Set(childIds.value))
const shown = computed(() =>
[...rooms.value].sort((a, b) => Number(direct.value.has(b.room_id)) - Number(direct.value.has(a.room_id))),
)
function roomName(r: HierarchyRoom): string {
return r.name ?? r.canonical_alias ?? r.room_id
}
const joining = ref('')
async function join(r: HierarchyRoom) {
joining.value = r.room_id
try {
await client().joinRoom(r.room_id, { viaServers: vias.get(r.room_id) })
chat.bump()
} catch (e) {
toasts.error(`Can’t join ${roomName(r)}: ${errorMessage(e)}`)
} finally {
joining.value = ''
}
}
function open(roomId: string) {
router.push({ name: 'room', params: { roomId } })
}
// Taking a room out of the space.
const removing = ref<HierarchyRoom | null>(null)
const removeBusy = ref(false)
const removeError = ref('')
async function remove() {
const r = removing.value
if (!r) return
removeBusy.value = true
removeError.value = ''
try {
await chat.removeFromSpace(props.space.roomId, r.room_id)
removing.value = null
} catch (e) {
removeError.value = errorMessage(e)
} finally {
removeBusy.value = false
}
}
// Adding rooms you're in that the space doesn't list yet.
const addOpen = ref(false)
const picked = ref<string[]>([])
const adding = ref(false)
const addError = ref('')
const addable = computed(() => [
...chat.spaces.filter((s) => s.roomId !== props.space.roomId),
...chat.rooms,
].filter((r) => !direct.value.has(r.roomId)))
watch(addOpen, (v) => {
if (v) {
picked.value = []
addError.value = ''
}
})
function togglePick(roomId: string, on: boolean) {
picked.value = on ? [...picked.value, roomId] : picked.value.filter((id) => id !== roomId)
}
async function addPicked() {
adding.value = true
addError.value = ''
try {
for (const roomId of picked.value) await chat.addToSpace(props.space.roomId, roomId)
addOpen.value = false
} catch (e) {
addError.value = errorMessage(e)
} finally {
adding.value = false
}
}
const createOpen = ref(false)
const infoOpen = ref(false)
</script>
<template>
<div class="flex min-h-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col">
<header class="flex h-16 shrink-0 items-center gap-3 border-b border-line bg-surface px-2 md:px-4">
<WIconButton class="md:hidden" icon="chevron-left" label="Back to rooms" @click="router.push('/')" />
<MxcAvatar :name="name" :mxc="space.getMxcAvatarUrl() ?? undefined" :size="36" />
<button type="button" class="min-w-0 flex-1 cursor-pointer border-0 bg-transparent p-0 text-left" @click="infoOpen = !infoOpen">
<span class="flex items-center gap-2">
<h1 class="heading m-0 truncate">{{ name }}</h1>
<WBadge tone="neutral">Space</WBadge>
</span>
<span class="small block truncate text-ink-muted">
<template v-if="topic">{{ topic }}</template>
<template v-else>{{ memberCount }} {{ memberCount === 1 ? 'member' : 'members' }}</template>
</span>
</button>
<WIconButton icon="users" :label="infoOpen ? 'Hide space info' : 'Space info'" :aria-pressed="infoOpen" @click="infoOpen = !infoOpen" />
</header>
<div class="flex-1 overflow-y-auto">
<div class="mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 py-6">
<div class="flex flex-wrap items-center gap-2">
<h2 class="subheading m-0 flex-1">Rooms</h2>
<template v-if="canManage">
<WButton size="sm" icon="plus" @click="addOpen = true">Add existing room</WButton>
<WButton size="sm" variant="primary" icon="plus" @click="createOpen = true">New room</WButton>
</template>
</div>
<WBanner v-if="loadError" tone="warning">
Can’t load the space’s rooms: {{ loadError }}
<button type="button" class="font-semibold text-teal-text hover:underline" @click="load()">Try again</button>
</WBanner>
<div v-if="loading && !rooms.length" class="flex justify-center py-8"><WSpinner /></div>
<div v-else-if="!rooms.length && !loadError" class="flex flex-col items-center gap-2 py-10 text-center">
<WIcon name="space" :size="32" class="text-ink-muted" />
<p class="body-strong m-0">No rooms in this space yet</p>
<p class="small m-0 text-ink-muted">
{{ canManage ? 'Add a room you’re in, or create a new one here.' : 'Its admins haven’t added any rooms yet.' }}
</p>
</div>
<ul v-else class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="r in shown" :key="r.room_id" class="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-surface-sunken">
<MxcAvatar :name="roomName(r)" :mxc="r.avatar_url" :size="40" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 flex items-center gap-2">
<span class="truncate">{{ roomName(r) }}</span>
<WBadge v-if="r.room_type === RoomType.Space" tone="neutral">Space</WBadge>
<WIcon v-if="!direct.has(r.room_id)" name="chevron-right" :size="14" class="shrink-0 text-ink-muted" label="In a subspace" />
</p>
<p class="small m-0 truncate text-ink-muted">
{{ r.num_joined_members }} {{ r.num_joined_members === 1 ? 'member' : 'members' }}<template v-if="r.topic"> · {{ r.topic }}</template>
</p>
</div>
<WButton v-if="joinedHere(r.room_id)" size="sm" variant="ghost" @click="open(r.room_id)">Open</WButton>
<!-- The homeserver lists the rooms you may join or look into; joining says if it can't. -->
<WButton v-else size="sm" :loading="joining === r.room_id" @click="join(r)">Join</WButton>
<PopMenu
v-if="canManage && direct.has(r.room_id)"
icon="more"
:label="`Actions for ${roomName(r)}`"
:items="[{ label: 'Remove from space', icon: 'trash', danger: true, action: () => ((removeError = ''), (removing = r)) }]"
/>
</li>
</ul>
<div v-if="nextBatch" class="flex justify-center py-2">
<WButton size="sm" :loading="loading" @click="load(true)">More rooms</WButton>
</div>
</div>
</div>
</div>
<RoomInfoPane v-if="infoOpen" :room="space" :is-direct="false" @close="infoOpen = false" />
<CreateRoomDialog v-model:open="createOpen" :space-id="space.roomId" :space-name="name" @created="open" />
<WDialog v-model:open="addOpen" :title="`Add rooms to ${name}`" :persistent="adding">
<p v-if="!addable.length" class="body m-0 text-ink-muted">Every room you’re in is in this space already.</p>
<ul v-else class="m-0 flex max-h-80 list-none flex-col overflow-y-auto p-0">
<li v-for="r in addable" :key="r.roomId" class="flex items-center gap-3 rounded-md px-2 py-1.5">
<MxcAvatar :name="r.name" :mxc="r.avatarMxc" :size="32" />
<WCheckbox
class="min-w-0 flex-1"
:model-value="picked.includes(r.roomId)"
:label="r.name"
@update:model-value="(v: boolean) => togglePick(r.roomId, v)"
/>
</li>
</ul>
<WBanner v-if="addError" tone="danger">{{ addError }}</WBanner>
<template #actions>
<WButton variant="ghost" :disabled="adding" @click="addOpen = false">Cancel</WButton>
<WButton variant="primary" :loading="adding" :disabled="!picked.length" @click="addPicked">
{{ picked.length > 1 ? `Add ${picked.length} rooms` : 'Add room' }}
</WButton>
</template>
</WDialog>
<ConfirmDialog
:open="removing !== null"
title="Remove from space"
confirm-label="Remove"
:busy="removeBusy"
:error="removeError"
@update:open="(v) => !v && (removing = null)"
@confirm="remove"
>
<p>
<span class="body-strong">{{ removing ? roomName(removing) : '' }}</span> is no longer listed in {{ name }}. The room itself stays, and
its members stay in it.
</p>
</ConfirmDialog>
</div>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { Room } from 'matrix-js-sdk'
import { computed, shallowRef } from 'vue'
import { WIconButton } from '@warp/ui'
import RoomComposer from '@/components/RoomComposer.vue'
import RoomTimeline from '@/components/RoomTimeline.vue'
import { threadCount, threadRoot, type MessageItem } from '@/matrix/timeline'
import { useChat } from '@/stores/chat'
// One thread beside its room: the root, its replies, and a composer that
// writes into it. On a phone it covers the room.
const props = defineProps<{ room: Room; threadId: string; isDirect: boolean }>()
const emit = defineEmits<{ close: []; back: [] }>()
const chat = useChat()
const replyTo = shallowRef<MessageItem | null>(null)
// A thread starting here has no thread object yet, and the SDK only files
// a reply's remote echo into one that exists: without this the first
// reply would vanish until the next reload. Element's thread view does
// the same.
if (!props.room.getThread(props.threadId)) {
const root = threadRoot(props.room, props.threadId)
if (root) props.room.createThread(props.threadId, root, [], true)
}
const count = computed(() => {
void chat.version
return threadCount(props.room, props.threadId)
})
</script>
<template>
<aside
class="fixed inset-0 z-30 flex flex-col bg-surface-sunken md:static md:z-auto md:w-[400px] md:shrink-0 md:border-l md:border-line"
aria-label="Thread"
@keydown.esc="emit('close')"
>
<div class="flex h-16 shrink-0 items-center gap-2 border-b border-line bg-surface px-2 md:px-4">
<WIconButton icon="chevron-left" label="All threads" @click="emit('back')" />
<div class="min-w-0 flex-1">
<h2 class="subheading m-0">Thread</h2>
<p class="caption m-0 truncate text-ink-muted">{{ room.name }} · {{ count === 1 ? '1 reply' : `${count} replies` }}</p>
</div>
<WIconButton icon="close" label="Close thread" @click="emit('close')" />
</div>
<RoomTimeline :key="threadId" :room="room" :is-direct="isDirect" :thread-id="threadId" @reply="(item) => (replyTo = item)" />
<div class="shrink-0 px-4 pt-2 pb-4">
<RoomComposer v-model:reply-to="replyTo" :room="room" :thread-id="threadId" placeholder="Reply in thread" />
</div>
</aside>
</template>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { Direction, type Room } from 'matrix-js-sdk'
import { computed, onMounted, ref } from 'vue'
import { WBanner, WButton, WIcon, WIconButton, WSpinner } from '@warp/ui'
import MxcAvatar from '@/components/MxcAvatar.vue'
import { errorMessage, formatListTime } from '@/lib/format'
import { client } from '@/matrix/client'
import { quoteText, threadCount, threadUnread } from '@/matrix/timeline'
import { useChat } from '@/stores/chat'
// A room's threads, the latest reply first (GET /threads). The ones with
// replies you haven't read have a dot.
const props = defineProps<{ room: Room }>()
const emit = defineEmits<{ open: [threadId: string]; close: [] }>()
const chat = useChat()
const me = client().getUserId() ?? ''
const loading = ref(true)
const loadingMore = ref(false)
const error = ref('')
const threads = computed(() => {
void chat.version
return props.room
.getThreads()
.filter((t) => threadCount(props.room, t.id) > 0)
.map((t) => {
const root = t.rootEvent
const sender = root?.getSender() ?? ''
const last = t.replyToEvent
return {
id: t.id,
senderName: props.room.getMember(sender)?.name ?? sender,
avatarMxc: props.room.getMember(sender)?.getMxcAvatarUrl() ?? undefined,
text: root ? quoteText(root) : '',
count: threadCount(props.room, t.id),
ts: last?.getTs() ?? root?.getTs() ?? 0,
unread: threadUnread(props.room, t.id, me),
}
})
.sort((a, b) => b.ts - a.ts)
})
const timeline = computed(() => {
void chat.version
return props.room.threadsTimelineSets[0]?.getLiveTimeline()
})
const hasMore = computed(() => {
void chat.version
return Boolean(timeline.value?.getPaginationToken(Direction.Backward))
})
onMounted(async () => {
try {
await props.room.createThreadsTimelineSets()
await props.room.fetchRoomThreads()
} catch (e) {
error.value = errorMessage(e)
} finally {
loading.value = false
chat.bump()
}
})
async function more() {
const tl = timeline.value
if (!tl) return
loadingMore.value = true
try {
await client().paginateEventTimeline(tl, { backwards: true, limit: 20 })
} catch (e) {
error.value = errorMessage(e)
} finally {
loadingMore.value = false
chat.bump()
}
}
</script>
<template>
<aside
class="fixed inset-0 z-30 flex flex-col bg-surface-sunken md:static md:z-auto md:w-80 md:shrink-0 md:border-l md:border-line"
aria-label="Threads"
>
<div class="flex h-16 shrink-0 items-center gap-2 border-b border-line px-4">
<h2 class="subheading m-0 flex-1">Threads</h2>
<WIconButton icon="close" label="Close threads" @click="emit('close')" />
</div>
<div class="flex-1 overflow-y-auto p-2">
<WBanner v-if="error" tone="warning" class="m-2">{{ error }}</WBanner>
<div v-if="loading && !threads.length" class="flex justify-center py-8"><WSpinner /></div>
<div v-else-if="!threads.length" class="flex flex-col items-center gap-2 px-4 py-10 text-center">
<WIcon name="thread" :size="32" class="text-ink-muted" />
<p class="body-strong m-0">No threads yet</p>
<p class="small m-0 text-ink-muted">Reply in thread on a message to start one. Its replies stay out of the room’s timeline.</p>
</div>
<ul v-else class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="t in threads" :key="t.id">
<button
type="button"
class="flex w-full items-start gap-3 rounded-md px-2 py-2 text-left hover:bg-surface-raised focus-visible:bg-surface-raised"
@click="emit('open', t.id)"
>
<MxcAvatar :name="t.senderName" :mxc="t.avatarMxc" :size="32" />
<span class="min-w-0 flex-1">
<span class="flex items-center gap-2">
<span class="body-strong min-w-0 flex-1 truncate">{{ t.senderName }}</span>
<span class="caption shrink-0 text-ink-muted">{{ formatListTime(t.ts) }}</span>
</span>
<span class="small block truncate text-ink-muted">{{ t.text }}</span>
<span class="caption flex items-center gap-1 text-teal-text">
<WIcon name="thread" :size="14" />
{{ t.count === 1 ? '1 reply' : `${t.count} replies` }}
<span v-if="t.unread" class="ml-1 h-2 w-2 rounded-full bg-teal-strong" role="img" aria-label="Unread replies" />
</span>
</span>
</button>
</li>
</ul>
<div v-if="hasMore && threads.length" class="flex justify-center py-3">
<WButton size="sm" :loading="loadingMore" @click="more">Older threads</WButton>
</div>
</div>
</aside>
</template>