Files
Warp/apps/web/src/components/RoomInfoPane.vue
T
tajniak81andClaude Opus 5.5 b6f3be6ac4
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 (warp-web) with E2EE, and two sync fixes
apps/web: a Matrix client for the browser on Vue 3, Tailwind and
matrix-js-sdk, built on @warp/ui. Like the admin panel it is an optional,
separate service: warp-web embeds the built app, serves /config.json and
proxies only /_matrix/client and /_matrix/media to the homeserver (or,
with -public-homeserver, lets the browser call it directly).
Sign-in and sign-up with registration tokens, room list, timeline with
back-pagination, text, images, video and files over authenticated media,
local echo with sent/read/failed states, receipts, typing, invites,
creating, joining and leaving rooms, room info, settings, phone layout.
End-to-end encryption through the Rust crypto: encrypted messages and
files, emoji verification, key backup setup and restore.

apps/ui: WRoomItem, WMessageBubble and WComposer from the brand package,
and the icons the client needs.

syncapi fixes found with the client:
- A room joined inside the sync window was sent as the join alone with
  limited: false, so clients thought they had the whole room. It is now
  read like an initial sync; a profile change is not mistaken for a join.
- A live typing notification was reported on every sync, so every long
  poll returned at once while anybody typed. Typing now has a position
  in the sync token and is reported when it changes, including the empty
  list when the last person stops.

Also: Dockerfile, a `web` compose profile, Makefile targets, a CI job
with a proxy smoke test, docs. PLAN.md moves out of the repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:22:11 +02:00

268 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()
})
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">{{ isDirect ? 'Chat info' : 'Room info' }}</h2>
<WIconButton icon="close" label="Close room 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 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">{{ isDirect ? 'Leave chat' : 'Leave room' }}</WButton>
</section>
</div>
<WDialog v-model:open="editing" title="Edit room" :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 room" :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="isDirect ? 'Leave chat' : 'Leave room'"
:confirm-label="isDirect ? 'Leave chat' : 'Leave room'"
:busy="leaving"
:error="leaveError"
@confirm="leave"
>
<p>
You stop getting messages from <span class="body-strong">{{ name }}</span>.
<template v-if="room.getJoinRule() !== 'public'"> To come back, someone has to invite you again.</template>
</p>
</ConfirmDialog>
</aside>
</template>