Files
Warp/apps/web/src/components/RoomInfoPane.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

277 lines
10 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 { EventType, KnownMembership, type Room, type RoomMember } from 'matrix-js-sdk'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { WBadge, WBanner, WButton, WDialog, WIcon, WIconButton, WTextArea, WTextField } from '@warp/ui'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MxcAvatar from '@/components/MxcAvatar.vue'
import SessionsDialog from '@/components/SessionsDialog.vue'
import { errorMessage, isUserId } from '@/lib/format'
import { client } from '@/matrix/client'
import { useChat } from '@/stores/chat'
import { useToasts } from '@/stores/toasts'
// The side pane of a room: what it is, who is in it, and leaving it. On a
// phone it covers the room.
const props = defineProps<{ room: Room; isDirect: boolean }>()
const emit = defineEmits<{ close: [] }>()
const chat = useChat()
const toasts = useToasts()
const router = useRouter()
const me = client().getUserId() ?? ''
const state = computed(() => {
void chat.version
return props.room.currentState
})
const name = computed(() => {
void chat.version
return props.room.name
})
const topic = computed(() => String(state.value.getStateEvents(EventType.RoomTopic, '')?.getContent().topic ?? ''))
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()
})
const canEditName = computed(() => state.value.maySendStateEvent(EventType.RoomName, me))
const canEditTopic = computed(() => state.value.maySendStateEvent(EventType.RoomTopic, me))
const canInvite = computed(() => {
void chat.version
return props.room.canInvite(me)
})
function roleOf(m: RoomMember): string | undefined {
if (m.powerLevel >= 100) return 'Admin'
if (m.powerLevel >= 50) return 'Moderator'
return undefined
}
const members = computed(() => {
void chat.version
const sort = (a: RoomMember, b: RoomMember) => b.powerLevel - a.powerLevel || a.name.localeCompare(b.name)
return {
joined: props.room.getMembersWithMembership(KnownMembership.Join).sort(sort),
invited: props.room.getMembersWithMembership(KnownMembership.Invite).sort(sort),
}
})
// The full member list isn't in the first sync (lazy loading); fetch it
// when the pane opens.
props.room.loadMembersIfNeeded().then(() => chat.bump()).catch(() => undefined)
// Editing name and topic.
const editing = ref(false)
const draftName = ref('')
const draftTopic = ref('')
const saving = ref(false)
const saveError = ref('')
function startEdit() {
draftName.value = name.value
draftTopic.value = topic.value
saveError.value = ''
editing.value = true
}
async function save() {
saving.value = true
saveError.value = ''
try {
const c = client()
if (canEditName.value && draftName.value.trim() !== name.value) await c.setRoomName(props.room.roomId, draftName.value.trim())
if (canEditTopic.value && draftTopic.value.trim() !== topic.value) await c.setRoomTopic(props.room.roomId, draftTopic.value.trim())
editing.value = false
} catch (e) {
saveError.value = errorMessage(e)
} finally {
saving.value = false
}
}
// Inviting.
const inviteOpen = ref(false)
const inviteId = ref('')
const inviting = ref(false)
const inviteError = ref('')
watch(inviteOpen, (v) => {
if (v) {
inviteId.value = ''
inviteError.value = ''
}
})
async function invite() {
inviting.value = true
inviteError.value = ''
try {
await client().invite(props.room.roomId, inviteId.value.trim())
toasts.success(`Invited ${inviteId.value.trim()}.`)
inviteOpen.value = false
} catch (e) {
inviteError.value = errorMessage(e)
} finally {
inviting.value = false
}
}
// Leaving.
const leaveOpen = ref(false)
const leaving = ref(false)
const leaveError = ref('')
async function leave() {
leaving.value = true
leaveError.value = ''
try {
await chat.leave(props.room.roomId)
leaveOpen.value = false
router.replace('/')
} catch (e) {
leaveError.value = errorMessage(e)
} finally {
leaving.value = false
}
}
// Someone's sessions, to verify them.
const sessionsOf = ref<RoomMember | null>(null)
function copy(text: string) {
navigator.clipboard.writeText(text).then(() => toasts.success('Copied.'))
}
</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="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">{{ Noun }} info</h2>
<WIconButton icon="close" :label="`Close ${noun} info`" @click="emit('close')" />
</div>
<div class="flex-1 overflow-y-auto">
<section class="flex flex-col items-center gap-2 px-4 pt-6 pb-4 text-center">
<MxcAvatar :name="name" :mxc="room.getMxcAvatarUrl() ?? undefined" :size="72" />
<h3 class="heading m-0 break-words">{{ name }}</h3>
<button v-if="alias" type="button" class="mxid text-ink-muted hover:text-ink" title="Copy address" @click="copy(alias)">
{{ alias }}
</button>
<p v-if="topic" class="body m-0 whitespace-pre-wrap text-ink-muted">{{ topic }}</p>
<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>
<WButton v-if="canEditName || canEditTopic" size="sm" icon="edit" class="mt-2" @click="startEdit">Edit</WButton>
</section>
<section class="border-t border-line px-2 py-3">
<div class="flex items-center gap-2 px-2 pb-2">
<h3 class="body-strong m-0 flex-1">Members · {{ members.joined.length }}</h3>
<WButton v-if="canInvite" size="sm" icon="plus" @click="inviteOpen = true">Invite</WButton>
</div>
<ul class="m-0 list-none p-0">
<li v-for="m in members.joined" :key="m.userId" class="flex items-center gap-3 rounded-md px-2 py-1.5">
<MxcAvatar :name="m.name" :mxc="m.getMxcAvatarUrl() ?? undefined" :size="32" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 truncate">{{ m.name }}<span v-if="m.userId === me" class="font-normal text-ink-muted"> (you)</span></p>
<p class="mxid m-0 truncate text-ink-muted">{{ m.userId }}</p>
</div>
<WBadge v-if="roleOf(m)" :tone="roleOf(m) === 'Admin' ? 'success' : 'neutral'">{{ roleOf(m) }}</WBadge>
<WIconButton
v-if="encrypted && chat.canEncrypt"
icon="shield-check"
:label="m.userId === me ? 'Your sessions' : `Verify ${m.name}’s sessions`"
@click="sessionsOf = m"
/>
</li>
</ul>
<template v-if="members.invited.length">
<h3 class="caption m-0 px-2 pt-3 pb-1 text-ink-muted">Invited</h3>
<ul class="m-0 list-none p-0">
<li v-for="m in members.invited" :key="m.userId" class="flex items-center gap-3 rounded-md px-2 py-1.5 opacity-80">
<MxcAvatar :name="m.name" :mxc="m.getMxcAvatarUrl() ?? undefined" :size="32" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 truncate">{{ m.name }}</p>
<p class="mxid m-0 truncate text-ink-muted">{{ m.userId }}</p>
</div>
</li>
</ul>
</template>
</section>
<section class="flex flex-col gap-2 border-t border-line p-4">
<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">Leave {{ noun }}</WButton>
</section>
</div>
<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" />
<WBanner v-if="saveError" tone="danger">{{ saveError }}</WBanner>
</form>
<template #actions>
<WButton variant="ghost" :disabled="saving" @click="editing = false">Cancel</WButton>
<WButton type="submit" form="edit-room" variant="primary" :loading="saving">Save</WButton>
</template>
</WDialog>
<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"
wide
mono
label="Matrix ID"
placeholder="@maja:warp.example"
autocomplete="off"
spellcheck="false"
autofocus
/>
<WBanner v-if="inviteError" tone="danger">{{ inviteError }}</WBanner>
</form>
<template #actions>
<WButton variant="ghost" :disabled="inviting" @click="inviteOpen = false">Cancel</WButton>
<WButton type="submit" form="invite-form" variant="primary" :loading="inviting" :disabled="!isUserId(inviteId)">Invite</WButton>
</template>
</WDialog>
<SessionsDialog
:open="sessionsOf !== null"
:user-id="sessionsOf?.userId ?? ''"
:name="sessionsOf?.name ?? ''"
@update:open="(v) => !v && (sessionsOf = null)"
/>
<ConfirmDialog
v-model:open="leaveOpen"
:title="`Leave ${noun}`"
:confirm-label="`Leave ${noun}`"
:busy="leaving"
:error="leaveError"
@confirm="leave"
>
<p>
<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>
</aside>
</template>