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>
277 lines
10 KiB
Vue
277 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()
|
||
})
|
||
// "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>
|