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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Matrix space hierarchy in the channel column: subspaces as collapsible
category headers (chevron toggle), rooms grouped under `m.space.child`
order, and a root-level "General" segment for direct space children (#5)
- Drag-and-drop reordering of category blocks and rooms (within and across
categories) with persistence via `m.space.child` / `order`, gated by
`m.room.power_levels` for `m.space.child` (no DnD when not allowed)
- `vue-draggable-plus` dependency for the space channel sidebar
- Helpers and unit tests for space category mapping, space state writes, and
hierarchy permission checks (`matrixSpaceHierarchyPermissions`)
- reCAPTCHA UIA stage (`m.login.recaptcha`) in signup with consent-gated
script load and optional iubenda hooks (#57)
- Optional Synapse-backed E2E for registration captcha (`@recaptcha_signup`);
Expand Down Expand Up @@ -104,10 +113,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Space rail (first column) lists only root spaces; nested subspaces appear
only as categories in the second channel column
- Room grouping under a selected space no longer uses `/` prefixes in room
names; ordering follows Matrix space state
- Direct message resolution now consults `m.direct` account data before
falling back to two-member heuristics for existing rooms
- Global auth middleware now also protects the `/rooms` route prefix

- Chat timeline now opens with a bounded initial message window to reduce
startup scroll depth and perceived room-load latency
- Login page now shows a post-signup success banner (#52)
Expand Down
292 changes: 273 additions & 19 deletions app/components/Chat/RoomCategoryList.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { VueDraggable } from 'vue-draggable-plus'
import { useAppI18n } from '~/composables/useAppI18n'

interface RoomItem {
Expand All @@ -9,25 +10,221 @@ interface RoomItem {
interface RoomSectionItem {
id: string
name: string
kind?: 'root' | 'subspace'
subspaceRoomId?: string
rootChildAnchorIds?: string[]
canReorderRooms: boolean
rooms: RoomItem[]
}

defineProps<{
const props = defineProps<{
selectedSpaceName: string
categories: RoomSectionItem[]
selectedRoomId: string | null
/** Reorder category blocks (m.space.child on root) — power-level gated */
canReorderCategories: boolean
/** When null (e.g. Home), hierarchy DnD is off */
selectedRootSpaceId: string | null
}>()

const emit = defineEmits<{
selectRoom: [roomId: string]
openSpaceSettings: []
persistRoomOrder: [
payload: { parentSpaceId: string; orderedRoomIds: string[] },
]
moveRoomBetweenCategories: [
payload: {
roomId: string
previousParentSpaceId: string
nextParentSpaceId: string
insertIndex: number
},
]
reorderRootCategories: [orderedRootChildIds: string[]]
}>()

const { translateText } = useAppI18n()

const localCategories = ref<RoomSectionItem[]>([])
/** Collapsed category ids (default: all expanded) */
const collapsedCategoryIds = ref<Set<string>>(new Set())

const categoryDragEnabled = computed(
() => Boolean(props.selectedRootSpaceId) && props.canReorderCategories,
)

watch(
() => props.categories,
(nextCategories) => {
localCategories.value = nextCategories.map((category) => ({
...category,
rooms: [...category.rooms],
rootChildAnchorIds: category.rootChildAnchorIds
? [...category.rootChildAnchorIds]
: [],
}))
},
{ deep: true, immediate: true },
)

function isCategoryCollapsed(categoryId: string): boolean {
return collapsedCategoryIds.value.has(categoryId)
}

function toggleCategoryCollapsed(categoryId: string) {
const next = new Set(collapsedCategoryIds.value)
if (next.has(categoryId)) {
next.delete(categoryId)
} else {
next.add(categoryId)
}
collapsedCategoryIds.value = next
}

function parentSpaceIdFor(category: RoomSectionItem): string {
if (category.kind === 'subspace' && category.subspaceRoomId) {
return category.subspaceRoomId
}
return props.selectedRootSpaceId ?? ''
}

function roomListDragEnabled(category: RoomSectionItem): boolean {
return Boolean(props.selectedRootSpaceId) && category.canReorderRooms
}

function selectRoom(roomId: string) {
emit('selectRoom', roomId)
}

/**
* Identify which category a Sortable list element belongs to (VueDraggable
* root does not receive our data-attrs). Empty lists use a sentinel node.
*/
function categoryIdFromRoomListEl(
listElement: HTMLElement | null | undefined,
): string {
if (!listElement) {
return ''
}
const emptyMarker = listElement.querySelector('[data-category-empty]')
const emptyId = emptyMarker?.getAttribute('data-category-empty')
if (emptyId) {
return emptyId
}
const firstRoomButton = listElement.querySelector(
'[data-room-id]',
) as HTMLElement | null
const roomId = firstRoomButton?.dataset?.roomId
if (!roomId) {
return ''
}
const category = localCategories.value.find((entry) =>
entry.rooms.some((room) => room.roomId === roomId),
)
return category?.id ?? ''
}

/**
* Block cross-list moves unless both parents allow m.space.child.
*/
function onRoomSortableMove(event: {
from?: HTMLElement
to?: HTMLElement
}): boolean {
if (!props.selectedRootSpaceId) {
return false
}
const fromId = categoryIdFromRoomListEl(event.from ?? null)
const toId = categoryIdFromRoomListEl(event.to ?? null)
const fromCategory = localCategories.value.find(
(entry) => entry.id === fromId,
)
const toCategory = localCategories.value.find((entry) => entry.id === toId)
if (!fromCategory || !toCategory) {
return false
}
if (!fromCategory.canReorderRooms || !toCategory.canReorderRooms) {
return false
}
return true
}

function onCategoryDragEnd() {
if (!categoryDragEnabled.value || !props.selectedRootSpaceId) {
return
}
const previousIds = props.categories.map((category) => category.id).join(',')
const nextIds = localCategories.value.map((category) => category.id).join(',')
if (previousIds === nextIds) {
return
}
const orderedRootChildIds = localCategories.value.flatMap(
(category) => category.rootChildAnchorIds ?? [],
)
emit('reorderRootCategories', orderedRootChildIds)
}

function onRoomDragEnd(_category: RoomSectionItem, rawEvent: unknown) {
if (!props.selectedRootSpaceId) {
return
}
const event = rawEvent as {
from: HTMLElement
to: HTMLElement
oldIndex: number
newIndex: number
item?: HTMLElement
}
if (event.oldIndex === event.newIndex && event.from === event.to) {
return
}

const fromCategoryId = categoryIdFromRoomListEl(event.from)
const toCategoryId = categoryIdFromRoomListEl(event.to)

const fromCategory = localCategories.value.find(
(entry) => entry.id === fromCategoryId,
)
const toCategory = localCategories.value.find(
(entry) => entry.id === toCategoryId,
)

if (!fromCategory || !toCategory) {
return
}
if (!fromCategory.canReorderRooms || !toCategory.canReorderRooms) {
return
}

const roomIdFromDom = event.item?.dataset?.roomId
const roomId =
typeof roomIdFromDom === 'string' && roomIdFromDom.length > 0
? roomIdFromDom
: toCategory.rooms[event.newIndex]?.roomId

if (!roomId) {
return
}

const previousParentSpaceId = parentSpaceIdFor(fromCategory)
const nextParentSpaceId = parentSpaceIdFor(toCategory)

if (previousParentSpaceId === nextParentSpaceId) {
emit('persistRoomOrder', {
parentSpaceId: previousParentSpaceId,
orderedRoomIds: toCategory.rooms.map((room) => room.roomId),
})
return
}

emit('moveRoomBetweenCategories', {
roomId,
previousParentSpaceId,
nextParentSpaceId,
insertIndex: event.newIndex,
})
}
</script>

<template>
Expand Down Expand Up @@ -61,39 +258,96 @@ function selectRoom(roomId: string) {
</header>

<div class="flex-1 overflow-y-auto px-2 py-3">
<template v-if="categories.length === 0">
<template v-if="localCategories.length === 0">
<p class="px-2 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ translateText('layout.noRoomsInSpace') }}
</p>
</template>
<template v-else>
<VueDraggable
v-else
v-model="localCategories"
:handle="categoryDragEnabled ? '.decentra-category-title' : undefined"
:disabled="!categoryDragEnabled"
:animation="150"
class="space-y-4"
@end="onCategoryDragEnd"
>
<div
v-for="category in categories"
v-for="category in localCategories"
:key="category.id"
class="mb-4"
>
<p
class="px-2 pb-1 text-xs font-semibold uppercase tracking-wide
text-gray-500 dark:text-gray-400"
<div
class="flex items-center gap-1 px-2 pb-1 select-none"
>
{{ category.name }}
</p>
<div class="space-y-1">
<button
v-for="room in category.rooms"
:key="room.roomId"
type="button"
class="w-full rounded-lg px-2 py-2 text-left text-sm transition"
:class="selectedRoomId === room.roomId
? 'bg-primary-500/15 text-primary-500'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800'"
@click="selectRoom(room.roomId)"
class="shrink-0 rounded p-0.5 text-gray-500 transition
hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
:aria-expanded="!isCategoryCollapsed(category.id)"
:aria-label="isCategoryCollapsed(category.id)
? translateText('layout.expandCategory')
: translateText('layout.collapseCategory')"
@click.stop="toggleCategoryCollapsed(category.id)"
>
<span class="truncate"># {{ room.name }}</span>
<span
class="block w-4 text-center text-xs font-semibold leading-none
transition-transform duration-150"
:class="isCategoryCollapsed(category.id) ? '' : 'rotate-90'"
>></span>
</button>
<p
class="decentra-category-title min-w-0 flex-1 text-xs font-semibold
uppercase tracking-wide text-gray-500 dark:text-gray-400"
:class="categoryDragEnabled
? 'cursor-grab touch-none active:cursor-grabbing'
: ''"
>
{{ category.name }}
</p>
</div>
<div
v-show="!isCategoryCollapsed(category.id)"
class="space-y-1"
>
<VueDraggable
v-model="category.rooms"
group="decentra-space-channels"
:disabled="!roomListDragEnabled(category)"
:animation="150"
class="space-y-1"
@move="onRoomSortableMove"
@end="(event: unknown) => onRoomDragEnd(category, event as {
from: HTMLElement
to: HTMLElement
oldIndex: number
newIndex: number
item?: HTMLElement
})"
>
<button
v-for="room in category.rooms"
:key="room.roomId"
type="button"
class="w-full rounded-lg px-2 py-2 text-left text-sm transition"
:class="selectedRoomId === room.roomId
? 'bg-primary-500/15 text-primary-500'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800'"
:data-room-id="room.roomId"
@click="selectRoom(room.roomId)"
>
<span class="truncate"># {{ room.name }}</span>
</button>
<div
v-if="category.rooms.length === 0"
:data-category-empty="category.id"
class="pointer-events-none h-px w-full opacity-0"
aria-hidden="true"
/>
</VueDraggable>
</div>
</div>
</template>
</VueDraggable>
</div>
</section>
</template>
Loading
Loading