Files
Warp/apps/web/src/components/RoomComposer.vue
T
tajniak81andClaude Opus 5.5 8ff2730676
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
Web client: replies, reactions, threads and spaces
- 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>
2026-10-19 10:09:12 +02:00

157 lines
6.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>