Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Leave Matrix rooms from the chat sidebar: confirmation dialog,
`leaveRoom` via matrix-js-sdk, `m.direct` cleanup for DMs, neutral
chat view after leaving the active room; Cucumber E2E for ephemeral
group, space channel, and DM leave flows
([#94](https://github.com/mjkatgithub/Decentra/issues/94))
- Project architecture map for developers and agents:
`docs/architecture.md`, `docs/README.md`, issue documentation
checklist; `AGENTS.md` requires doc updates on structural changes
Expand Down
98 changes: 98 additions & 0 deletions app/components/Chat/LeaveRoomConfirmPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { useAppI18n } from '~/composables/useAppI18n'
import { useMatrixClient } from '~/composables/useMatrixClient'

const props = defineProps<{
targetRoomId: string
targetLabel: string
}>()

const emit = defineEmits<{
close: []
confirming: [roomId: string]
left: [roomId: string]
}>()

const { translateText } = useAppI18n()
const { leaveRoom } = useMatrixClient()

const submitting = ref(false)
const errorMessage = ref('')

async function handleConfirm() {
submitting.value = true
errorMessage.value = ''
emit('confirming', props.targetRoomId)
try {
await leaveRoom(props.targetRoomId)
emit('left', props.targetRoomId)
} catch (thrownError) {
errorMessage.value =
thrownError instanceof Error
? thrownError.message
: translateText('layout.leaveRoomFailed')
} finally {
submitting.value = false
}
}
</script>

<template>
<div
class="mx-auto flex w-full max-w-lg flex-col gap-4 rounded-xl border
border-gray-200 bg-white p-5 shadow-lg dark:border-gray-800
dark:bg-gray-900"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-50">
{{ translateText('layout.leaveRoomConfirmTitle') }}
</h2>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">
{{
translateText('layout.leaveRoomConfirmBody', {
name: targetLabel,
})
}}
</p>
</div>
<UButton
size="xs"
color="neutral"
variant="ghost"
icon="i-lucide-x"
:aria-label="translateText('layout.leaveRoomCancel')"
@click="emit('close')"
/>
</div>

<UAlert
v-if="errorMessage"
color="error"
variant="soft"
:title="errorMessage"
/>

<div class="flex flex-wrap justify-end gap-2">
<UButton
type="button"
color="neutral"
variant="ghost"
:disabled="submitting"
@click="emit('close')"
>
{{ translateText('layout.leaveRoomCancel') }}
</UButton>
<UButton
type="button"
color="error"
variant="soft"
icon="i-lucide-log-out"
:loading="submitting"
@click="handleConfirm"
>
{{ translateText('layout.leaveRoomConfirmAction') }}
</UButton>
</div>
</div>
</template>
27 changes: 21 additions & 6 deletions app/components/Chat/RoomCategoryList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const props = defineProps<{
resolveRoomInsertIndex?: (category: RoomSectionItem) => number | undefined
canInviteToRoom?: (roomId: string) => boolean
canOpenRoomSettings?: (roomId: string) => boolean
canLeaveRoom?: (roomId: string) => boolean
canInviteToSpace?: boolean
/** When null (e.g. Home), hierarchy DnD is off */
selectedRootSpaceId: string | null
Expand All @@ -63,6 +64,7 @@ const emit = defineEmits<{
inviteSpace: []
inviteRoom: [roomId: string]
openRoomSettings: [roomId: string]
leaveRoom: [roomId: string]
setRoomNotification: [payload: { roomId: string; level: RoomNotificationLevel }]
setSpaceNotification: [level: RoomNotificationLevel]
openHomeStartDm: []
Expand Down Expand Up @@ -418,34 +420,47 @@ function roomNotificationMenuItems(roomId: string) {
function showRoomActionMenu(roomId: string): boolean {
return Boolean(
props.canInviteToRoom?.(roomId) ||
props.canOpenRoomSettings?.(roomId),
props.canOpenRoomSettings?.(roomId) ||
props.canLeaveRoom?.(roomId),
)
}

function roomActionMenuItems(roomId: string) {
const items: Array<{
const primaryItems: Array<{
label: string
icon: string
onSelect: () => void
}> = []
if (props.canInviteToRoom?.(roomId)) {
items.push({
primaryItems.push({
label: translateText('invite.roomMenu'),
icon: 'i-lucide-user-plus',
onSelect: () => emit('inviteRoom', roomId),
})
}
if (props.canOpenRoomSettings?.(roomId)) {
items.push({
primaryItems.push({
label: translateText('layout.openRoomSettings'),
icon: 'i-lucide-settings-2',
onSelect: () => emit('openRoomSettings', roomId),
})
}
if (items.length === 0) {
if (primaryItems.length === 0 && !props.canLeaveRoom?.(roomId)) {
return []
}
return [items]
if (!props.canLeaveRoom?.(roomId)) {
return [primaryItems]
}
const leaveItem = {
label: translateText('layout.leaveRoomMenu'),
icon: 'i-lucide-log-out',
color: 'error' as const,
onSelect: () => emit('leaveRoom', roomId),
}
if (primaryItems.length === 0) {
return [[leaveItem]]
}
return [primaryItems, [leaveItem]]
}

function spaceNotificationItems() {
Expand Down
29 changes: 29 additions & 0 deletions app/composables/chat/useChatRoomSidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function useChatRoomSidebar(options: {
refreshRooms: () => void;
}) {
const inviteTarget = ref<{ roomId: string; label: string } | null>(null);
const leaveTarget = ref<{ roomId: string; label: string } | null>(null);

const canInviteToSpace = computed(() => {
const spaceId = options.selectedSpaceId.value;
Expand Down Expand Up @@ -93,6 +94,18 @@ export function useChatRoomSidebar(options: {
return isJoinedRoom(options.client.value, roomId);
}

function canLeaveRoom(roomId: string): boolean {
const matrixClient = options.client.value;
if (!isJoinedRoom(matrixClient, roomId)) {
return false;
}
const matrixRoom = matrixClient?.getRoom(roomId);
if (!matrixRoom) {
return false;
}
return getRoomType(matrixRoom) !== "m.space";
}

function openRoomSettings(roomId: string) {
const query: Record<string, string> = { room: roomId };
const rootId = options.selectedSpaceId.value;
Expand Down Expand Up @@ -128,6 +141,18 @@ export function useChatRoomSidebar(options: {
inviteTarget.value = null;
}

function openLeaveRoom(roomId: string) {
const matrixClient = options.client.value;
const label =
matrixClient?.getRoom(roomId)?.name ||
options.translateText("layout.roomFallback");
leaveTarget.value = { roomId, label };
}

function closeLeaveOverlay() {
leaveTarget.value = null;
}

function buildHomeSections(): RoomCategoryGroup[] {
const personalRooms = options.visibleRoomsForSidebar.value.filter(
(room) =>
Expand Down Expand Up @@ -383,13 +408,17 @@ export function useChatRoomSidebar(options: {

return {
inviteTarget,
leaveTarget,
canInviteToSpace,
canInviteToRoom,
canOpenRoomSettings,
canLeaveRoom,
openRoomSettings,
openInviteToRoom,
openInviteToSpace,
closeInviteOverlay,
openLeaveRoom,
closeLeaveOverlay,
buildHomeSections,
buildSpaceSections,
canManageChildrenOnSpace,
Expand Down
14 changes: 14 additions & 0 deletions app/composables/chat/useChatSpaceRail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function useChatSpaceRail(options: {
>;
allMessages: Ref<unknown[]>;
messages: Ref<unknown[]>;
suppressAutoRoomSelect?: Ref<boolean>;
}) {
const joinedSpaceIds = computed(() =>
getJoinedSpaceRoomIds(
Expand Down Expand Up @@ -258,6 +259,19 @@ export function useChatSpaceRail(options: {
const selectedExists = rooms.some(
(room) => room.roomId === options.selectedRoomId.value,
);
if (
options.suppressAutoRoomSelect?.value &&
options.selectedRoomId.value === null
) {
return;
}
if (!selectedExists && options.selectedRoomId.value !== null) {
const firstRoom = rooms[0];
if (firstRoom) {
options.selectedRoomId.value = firstRoom.roomId;
}
return;
}
if (!selectedExists) {
const firstRoom = rooms[0];
if (firstRoom) {
Expand Down
7 changes: 7 additions & 0 deletions app/composables/i18n/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,13 @@ const messages: LocaleMessages = {
'layout.openAccountSettings': 'Account-Einstellungen',
'layout.openSpaceSettings': 'Space-Einstellungen',
'layout.openRoomSettings': 'Kanal-Einstellungen',
'layout.leaveRoomMenu': 'Kanal verlassen',
'layout.leaveRoomConfirmTitle': 'Kanal verlassen?',
'layout.leaveRoomConfirmBody':
'Du verlässt {name}. Ein erneuter Beitritt ist nur mit Einladung möglich.',
'layout.leaveRoomConfirmAction': 'Verlassen',
'layout.leaveRoomCancel': 'Abbrechen',
'layout.leaveRoomFailed': 'Kanal konnte nicht verlassen werden',
'layout.roomActionsMenu': 'Kanaloptionen',
'layout.roomNotifications': 'Benachrichtigungen',
'layout.spaceNotifications': 'Space-Benachrichtigungen',
Expand Down
7 changes: 7 additions & 0 deletions app/composables/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,13 @@ const messages: LocaleMessages = {
'layout.openAccountSettings': 'Account settings',
'layout.openSpaceSettings': 'Space settings',
'layout.openRoomSettings': 'Channel settings',
'layout.leaveRoomMenu': 'Leave channel',
'layout.leaveRoomConfirmTitle': 'Leave channel?',
'layout.leaveRoomConfirmBody':
'You will leave {name}. You can rejoin only if invited again.',
'layout.leaveRoomConfirmAction': 'Leave',
'layout.leaveRoomCancel': 'Cancel',
'layout.leaveRoomFailed': 'Could not leave channel',
'layout.roomActionsMenu': 'Channel options',
'layout.roomNotifications': 'Notifications',
'layout.spaceNotifications': 'Space notifications',
Expand Down
68 changes: 68 additions & 0 deletions app/composables/matrix/roomsOrDirectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,44 @@ import type {
UserDirectoryResultItem,
} from './matrixClientTypes'

function readDirectAccountContent(
matrixClient: MatrixClient,
): Record<string, string[]> {
const directEvent = matrixClient.getAccountData(EventType.Direct)
return (directEvent?.getContent() as
| Record<string, string[]>
| undefined) ?? {}
}

export function isRoomListedInDirectAccountData(
matrixClient: MatrixClient,
roomId: string,
): boolean {
const content = readDirectAccountContent(matrixClient)
return Object.values(content).some((ids) => ids?.includes(roomId))
}

export async function removeDirectAccountData(
matrixClient: MatrixClient,
roomId: string,
): Promise<void> {
const previous = readDirectAccountContent(matrixClient)
let changed = false
const next: Record<string, string[]> = {}
for (const [peerUserId, roomIds] of Object.entries(previous)) {
const filtered = (roomIds ?? []).filter((id) => id !== roomId)
if (filtered.length !== (roomIds?.length ?? 0)) {
changed = true
}
if (filtered.length > 0) {
next[peerUserId] = filtered
}
}
if (changed) {
await matrixClient.setAccountData(EventType.Direct, next)
}
}

export async function mergeDirectAccountData(
matrixClient: MatrixClient,
peerUserId: string,
Expand Down Expand Up @@ -311,6 +349,36 @@ export async function joinRoomByIdOrAlias(
}
}

export async function leaveRoom(
matrixClient: MatrixClient,
roomId: string,
): Promise<void> {
const trimmed = roomId.trim()
if (!trimmed) {
throw new Error('Room id is required')
}
const shouldPruneDirect = isRoomListedInDirectAccountData(
matrixClient,
trimmed,
)
try {
await matrixClient.leave(trimmed)
if (shouldPruneDirect) {
await removeDirectAccountData(matrixClient, trimmed)
}
try {
await matrixClient.forget(trimmed)
} catch {
// Left rooms may linger until sync; getRooms() filters non-join.
}
} catch (error) {
if (isTransportFailureWithoutMatrixBody(error)) {
throw new Error(HOMESERVER_CONNECTION_HINT_ERROR)
}
throwMappedMatrixError(error, 'Could not leave room')
}
}

export async function searchPublicRooms(
matrixClient: MatrixClient,
options: {
Expand Down
Loading
Loading