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>
157 lines
6.0 KiB
Vue
157 lines
6.0 KiB
Vue
<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>
|