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
@@ -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>
|
||||
Reference in new issue
Block a user