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
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>
268 lines
10 KiB
Vue
268 lines
10 KiB
Vue
<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>
|