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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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))
- Space home / sitemap view in the main pane when a Matrix Space is
selected but no channel is active; rail `selectSpace` clears room
selection; leaving a space channel lands on space home; lobby lists
unjoined channels with join actions and a sidebar Lobby entry; no
auto-select of first channel on load or space change
([#124](https://github.com/mjkatgithub/Decentra/issues/124))
- 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
24 changes: 24 additions & 0 deletions app/components/Chat/RoomCategoryList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const emit = defineEmits<{
openHomeStartDm: []
openHomeCreateRoom: []
openHomeExplorePublic: []
openSpaceLobby: []
persistRoomOrder: [
payload: { parentSpaceId: string; orderedRoomIds: string[] },
]
Expand All @@ -94,6 +95,14 @@ const categoryDragEnabled = computed(
() => Boolean(props.selectedRootSpaceId) && props.canReorderCategories,
)

const lobbyButtonActiveClass = computed(() =>
!props.selectedRoomId
? 'bg-primary-100 text-primary-800 dark:bg-primary-900/40'
+ ' dark:text-primary-200'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200'
+ ' dark:hover:bg-gray-800',
)

const sortableDragOptions = {
ghostClass: 'decentra-drag-ghost',
chosenClass: 'decentra-drag-chosen',
Expand Down Expand Up @@ -622,6 +631,21 @@ function onRoomDragEnd(_category: RoomSectionItem, rawEvent: unknown) {
</header>

<div class="flex-1 overflow-y-auto px-2 py-3">
<button
v-if="selectedRootSpaceId"
type="button"
class="mb-3 flex w-full items-center gap-2 rounded-md px-2 py-2
text-left text-sm font-medium transition"
:class="lobbyButtonActiveClass"
data-space-lobby-button
@click="emit('openSpaceLobby')"
>
<UIcon
name="i-lucide-flag"
class="size-4 shrink-0"
/>
<span>{{ translateText('spaceHome.lobby') }}</span>
</button>
<template v-if="localCategories.length === 0">
<p class="px-2 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ translateText('layout.noRoomsInSpace') }}
Expand Down
290 changes: 290 additions & 0 deletions app/components/Chat/SpaceHomePanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
<script setup lang="ts">
import { useAppI18n } from '~/composables/useAppI18n'
import type { RoomCategoryGroup } from '~/composables/chat/chatPageTypes'

const { translateText } = useAppI18n()

const props = defineProps<{
spaceName: string
spaceAvatarUrl?: string
spaceTopic?: string
memberCountLabel: string
categories: RoomCategoryGroup[]
canInvite: boolean
joiningRoomId?: string | null
}>()

const emit = defineEmits<{
'select-room': [roomId: string]
'join-room': [roomId: string]
invite: []
'open-settings': []
}>()

const channelCategories = computed(() =>
props.categories.filter(
(category) =>
category.rooms.length > 0 || category.kind === 'subspace',
),
)

const hasLobbyEntries = computed(() => channelCategories.value.length > 0)

function categoryIndentStyle(category: RoomCategoryGroup) {
const depth = category.nestingDepth ?? 0
if (category.kind !== 'subspace' || depth <= 1) {
return undefined
}
const indentRem = (depth - 1) * 1.25
return { marginLeft: `${indentRem}rem` }
}

function subspaceInitial(name: string): string {
const trimmed = name.trim()
return trimmed ? trimmed.charAt(0).toUpperCase() : '?'
}

function roomMemberLabel(count: number | undefined): string | null {
if (count === undefined || count <= 0) {
return null
}
return translateText('layout.spaceMembersCount', {
count: String(count),
})
}

function categoryTitleClass(category: RoomCategoryGroup): string {
if (category.kind === 'root') {
return 'truncate text-xs font-semibold uppercase tracking-wide'
+ ' text-gray-500 dark:text-gray-400'
}
return 'truncate text-sm font-semibold text-gray-800'
+ ' dark:text-gray-100'
}

function onRoomAction(roomId: string, isJoined: boolean) {
if (isJoined) {
emit('select-room', roomId)
return
}
emit('join-room', roomId)
}

function onSubspaceAction(category: RoomCategoryGroup) {
if (!category.subspaceRoomId) {
return
}
if (category.isSubspaceJoined !== false) {
return
}
emit('join-room', category.subspaceRoomId)
}
</script>

<template>
<div
class="mx-auto flex w-full max-w-2xl flex-col gap-4 overflow-auto p-2"
data-space-home-panel
>
<div
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm
dark:border-gray-800 dark:bg-gray-900"
>
<div class="flex items-center gap-3">
<div
v-if="spaceAvatarUrl"
class="size-12 shrink-0 overflow-hidden rounded-lg bg-gray-200
dark:bg-gray-700"
>
<img
:src="spaceAvatarUrl"
:alt="spaceName"
class="size-full object-cover"
>
</div>
<div
v-else
class="flex size-12 shrink-0 items-center justify-center rounded-lg
bg-primary-100 text-primary-700 dark:bg-primary-900/40
dark:text-primary-300"
>
<UIcon name="i-lucide-layers" class="size-6" />
</div>
<div class="min-w-0 flex-1">
<h2
class="truncate text-lg font-semibold text-gray-900
dark:text-gray-50"
>
{{
translateText('spaceHome.welcome', { space: spaceName })
}}
</h2>
<p class="mt-0.5 text-sm text-gray-600 dark:text-gray-400">
{{ memberCountLabel }}
</p>
<p
v-if="spaceTopic"
class="mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{ spaceTopic }}
</p>
</div>
</div>

<p class="mt-4 text-sm text-gray-600 dark:text-gray-400">
{{ translateText('spaceHome.subtitle') }}
</p>

<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<UButton
v-if="canInvite"
color="primary"
class="flex-1"
@click="emit('invite')"
>
{{ translateText('invite.spaceMenu') }}
</UButton>
<UButton
color="neutral"
variant="soft"
class="flex-1"
@click="emit('open-settings')"
>
{{ translateText('layout.openSpaceSettings') }}
</UButton>
</div>
</div>

<div v-if="hasLobbyEntries" class="flex flex-col gap-3">
<section
v-for="category in channelCategories"
:key="category.id"
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm
dark:border-gray-800 dark:bg-gray-900"
:style="categoryIndentStyle(category)"
:data-lobby-category-depth="category.nestingDepth ?? 0"
>
<div class="mb-3 flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<template v-if="category.kind === 'subspace'">
<div
v-if="category.subspaceAvatarUrl"
class="size-8 shrink-0 overflow-hidden rounded-md
bg-gray-200 dark:bg-gray-700"
>
<img
:src="category.subspaceAvatarUrl"
:alt="category.name"
class="size-full object-cover"
>
</div>
<div
v-else
class="flex size-8 shrink-0 items-center justify-center
rounded-md bg-primary-100 text-sm font-semibold
text-primary-700 dark:bg-primary-900/40
dark:text-primary-300"
>
{{ subspaceInitial(category.name) }}
</div>
</template>
<h3 :class="categoryTitleClass(category)">
{{ category.name }}
</h3>
</div>
<UButton
v-if="
category.kind === 'subspace' &&
category.isSubspaceJoined === false &&
category.subspaceRoomId
"
size="xs"
color="primary"
variant="soft"
:loading="joiningRoomId === category.subspaceRoomId"
@click="onSubspaceAction(category)"
>
{{ translateText('spaceHome.joinSubspace') }}
</UButton>
</div>

<div
v-if="category.rooms.length === 0"
class="text-sm text-gray-500 dark:text-gray-400"
>
{{ translateText('spaceHome.emptySubspaceChannels') }}
</div>

<div
v-for="room in category.rooms"
:key="room.roomId"
class="flex items-center gap-2 border-b border-gray-100 py-2
last:border-b-0 dark:border-gray-800"
>
<div
v-if="room.avatarUrl"
class="size-7 shrink-0 overflow-hidden rounded-md bg-gray-200
dark:bg-gray-700"
>
<img
:src="room.avatarUrl"
:alt="room.name"
class="size-full object-cover"
>
</div>
<UIcon
v-else
name="i-lucide-hash"
class="size-4 shrink-0 text-gray-500 dark:text-gray-400"
/>
<div class="min-w-0 flex-1">
<span
class="block truncate text-sm text-gray-800
dark:text-gray-100"
>
{{ room.name }}
</span>
<span
v-if="roomMemberLabel(room.memberCount)"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ roomMemberLabel(room.memberCount) }}
</span>
</div>
<UButton
v-if="room.isJoined !== false"
size="xs"
color="neutral"
variant="ghost"
icon="i-lucide-arrow-right"
:data-space-home-room-id="room.roomId"
:aria-label="
translateText('spaceHome.openChannel', { name: room.name })
"
@click="onRoomAction(room.roomId, true)"
/>
<UButton
v-else
size="xs"
color="primary"
variant="soft"
:loading="joiningRoomId === room.roomId"
:data-space-home-room-id="room.roomId"
@click="onRoomAction(room.roomId, false)"
>
{{ translateText('spaceHome.joinChannel') }}
</UButton>
</div>
</section>
</div>

<p
v-else
class="rounded-lg border border-dashed border-gray-300 px-4 py-6
text-center text-sm text-gray-500 dark:border-gray-700
dark:text-gray-400"
>
{{ translateText('spaceHome.emptyChannels') }}
</p>
</div>
</template>
7 changes: 7 additions & 0 deletions app/composables/chat/chatPageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,14 @@ export interface RoomCategoryGroup {
name: string;
hasUnread?: boolean;
hasMentionUnread?: boolean;
isJoined?: boolean;
avatarUrl?: string;
memberCount?: number;
}>;
/** Lobby: user joined this subspace room */
isSubspaceJoined?: boolean;
/** Lobby: subspace avatar from hierarchy or local room */
subspaceAvatarUrl?: string;
}

export interface MemberItem {
Expand Down
7 changes: 7 additions & 0 deletions app/composables/chat/useChatPageShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function useChatPageShell(options: {
isMobile: Ref<boolean>;
spaceRailExpanded: Ref<boolean>;
onboardingSubView: Ref<null | "dm" | "public">;
suppressAutoRoomSelect: Ref<boolean>;
refreshRooms: () => void;
clearLoadMessagesTimer: () => void;
clearThreadNavRefreshTimer: () => void;
Expand Down Expand Up @@ -80,6 +81,9 @@ export function useChatPageShell(options: {
void navigateTo({ path: "/chat", query: {} }, { replace: true });
scheduleSpaceHierarchyRefresh();
} else if (rootSpaceId) {
options.selectedRoomId.value = null;
options.onboardingSubView.value = null;
options.suppressAutoRoomSelect.value = true;
options.refreshRooms();
void navigateTo({ path: "/chat", query: {} }, { replace: true });
scheduleSpaceHierarchyRefresh();
Expand Down Expand Up @@ -125,6 +129,9 @@ export function useChatPageShell(options: {

function selectSpace(spaceId: string) {
options.selectedSpaceId.value = spaceId;
options.selectedRoomId.value = null;
options.onboardingSubView.value = null;
options.suppressAutoRoomSelect.value = true;
if (options.isMobile.value) {
options.leftSidebarOpen.value = false;
}
Expand Down
Loading
Loading