Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,6 @@ chat-frontend/dist/
# Vitest cache (created when vitest is run from a non-package directory)
.vite/
chat-frontend/.vite/

# Vitest junit reporter output (CI artifact, not source)
chat-frontend/junit.xml
6 changes: 0 additions & 6 deletions chat-frontend/src/api/_transport/subjects.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
orgMembers,
userSubscriptionGetCurrent,
userSubscriptionGetApps,
userSubscriptionGetRooms,
} from './subjects'

describe('subjects', () => {
Expand Down Expand Up @@ -123,9 +122,4 @@ describe('subjects', () => {
)
})

it('userSubscriptionGetRooms builds the user-service getRooms subject', () => {
expect(userSubscriptionGetRooms('alice', 'site-A')).toBe(
'chat.user.alice.request.user.site-A.subscription.getRooms'
)
})
})
13 changes: 4 additions & 9 deletions chat-frontend/src/api/_transport/subjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,10 @@ export function orgMembers(account: string, orgId: string): string {
return `chat.user.${account}.request.orgs.${orgId}.members`
}

// userSubscriptionGetCurrent fetches the caller's current subscriptions, optionally
// filtered server-side. The sidebar passes `{ favorite: true }` to drive the
// userSubscriptionGetCurrent fetches the caller's subscriptions. Called twice
// by the sidebar bootstrap: once with no payload (canonical full list — feeds
// `state.subscriptions` and the Channels and DMs section, partitioned by
// roomType at render time) and once with `{ favorite: true }` to drive the
// Favorite section. Mirrors pkg/subject/subject.go::UserSubscriptionGetCurrent.
export function userSubscriptionGetCurrent(account: string, siteId: string): string {
return `chat.user.${account}.request.user.${siteId}.subscription.getCurrent`
Expand All @@ -118,10 +120,3 @@ export function userSubscriptionGetCurrent(account: string, siteId: string): str
export function userSubscriptionGetApps(account: string, siteId: string): string {
return `chat.user.${account}.request.user.${siteId}.subscription.getApps`
}

// userSubscriptionGetRooms fetches the caller's non-app room subscriptions
// (channels, DMs, discussions). Drives the Channels and DMs section of the
// sidebar. Mirrors pkg/subject/subject.go::UserSubscriptionGetRooms.
export function userSubscriptionGetRooms(account: string, siteId: string): string {
Comment thread
GITMateuszCharczuk marked this conversation as resolved.
return `chat.user.${account}.request.user.${siteId}.subscription.getRooms`
}
79 changes: 59 additions & 20 deletions chat-frontend/src/api/fetchSidebarBuckets/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
userSubscriptionGetCurrent,
userSubscriptionGetApps,
userSubscriptionGetRooms,
} from '../_transport/subjects'
import type { Nats, DMSubscription } from '../types'

Expand Down Expand Up @@ -32,39 +31,79 @@ export interface SidebarBuckets {
}

/**
* Fetch the three sidebar bucket lists in parallel: favorites, apps,
* and non-app rooms (channels / DMs / discussions). Each comes from
* its own user-service RPC.
* Bootstrap the sidebar by fetching three lists from user-service in
* parallel:
* 1. `getCurrent()` — canonical full subscription list (every roomType).
* Becomes `state.subscriptions` (source of truth for
* `useSubscription`) and seeds `channelDmIds`. The Channels and DMs
* section is partitioned by roomType at render time from this list.
* 2. `getCurrent({ favorite: true })` — favorited room IDs for the
* Favorite section.
* 3. `getApps()` — app subscription IDs for the Apps section.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
*
* Returns a single merged result so the caller doesn't have to know
* about the three underlying subjects. The reducer's `BUCKETS_LOADED`
* action consumes this shape directly.
* The reducer's `BUCKETS_LOADED` action consumes this shape directly.
* Partition exclusivity (favorite > apps > channelDm) is enforced at
* render time by `useSidebarSections`, so a room ID can appear in
* `channelDmIds` and one of the other Sets without double-render.
*/
export async function fetchSidebarBuckets({ user, request }: Nats): Promise<SidebarBuckets> {
const [favResp, appResp, roomResp] = await Promise.all([
request<SidebarBucketReply>(
userSubscriptionGetCurrent(user.account, user.siteId),
{ favorite: true },
),
request<SidebarBucketReply>(userSubscriptionGetApps(user.account, user.siteId), {}),
request<SidebarBucketReply>(userSubscriptionGetRooms(user.account, user.siteId), {}),
const currentSubject = userSubscriptionGetCurrent(user.account, user.siteId)
const appsSubject = userSubscriptionGetApps(user.account, user.siteId)
// Favorite RPC is commented out for now — `pkg/model.Subscription` has no
// `Favorite` field, so the user-service `{favorite:true}` filter is
// unenforceable end-to-end. Keep the call commented (not deleted) so the
// path is easy to re-enable once the backend supports favorites. The
// sidebar's Favorite section renders a "Favorites are not yet supported"
// note in the meantime — see `useSidebarSections`.
// const favSubject = userSubscriptionGetCurrent(user.account, user.siteId)
// const favPromise = request<SidebarBucketReply>(favSubject, { favorite: true })

// Promise.allSettled so a single RPC failure degrades to an empty bucket
// instead of black-holing the entire sidebar bootstrap (which would have
// happened with Promise.all). Each failure is logged with its subject for
// diagnosis.
const results = await Promise.allSettled([
request<SidebarBucketReply>(currentSubject, {}),
request<SidebarBucketReply>(appsSubject, {}),
])
const empty: SidebarBucketReply = { subscriptions: [], total: 0 }
const unwrap = (
result: PromiseSettledResult<SidebarBucketReply>,
label: string,
): SidebarBucketReply => {
if (result.status === 'fulfilled') {
// TEMP DEBUG: log each subscription RPC reply so we can see exactly
// what the user-service returns on cold start. Remove once the live
// backend behaviour is verified.
console.log('[sidebar-bootstrap]', label, result.value)
return result.value
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
const err = result.reason
console.warn(
'[sidebar-bootstrap]',
label,
'FAILED:',
err?.message ?? err,
)
return empty
}
const allResp = unwrap(results[0], currentSubject)
const appResp = unwrap(results[1], appsSubject)
const subscriptions: Record<string, DMSubscription> = {}
const collect = (resp: SidebarBucketReply) => {
for (const s of resp.subscriptions) {
if (!s?.roomId) continue
// Later sources overwrite earlier ones, but the three responses
// describe the same Subscription record so collisions are benign.
// Later sources overwrite earlier ones, but the responses describe
// the same Subscription record so collisions are benign.
subscriptions[s.roomId] = s
}
}
collect(favResp)
collect(allResp)
collect(appResp)
collect(roomResp)
return {
favoriteIds: favResp.subscriptions.map((s) => s.roomId),
favoriteIds: [],
appIds: appResp.subscriptions.map((s) => s.roomId),
channelDmIds: roomResp.subscriptions.map((s) => s.roomId),
Comment thread
GITMateuszCharczuk marked this conversation as resolved.
channelDmIds: allResp.subscriptions.map((s) => s.roomId),
subscriptions,
}
}
2 changes: 2 additions & 0 deletions chat-frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export type {
Nats,
NatsSubscription,
SubscriptionCallback,
SubscriptionUpdateEvent,
SubscriptionUpdateAction,
AsyncJobOptions,
AsyncJobResult,
// Domain types
Expand Down
8 changes: 7 additions & 1 deletion chat-frontend/src/api/listRooms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,11 @@ export interface ListRoomsResponse {

/** List all rooms the current user is subscribed to. */
export async function listRooms({ user, request }: Nats): Promise<ListRoomsResponse> {
Comment thread
GITMateuszCharczuk marked this conversation as resolved.
Outdated
return request<ListRoomsResponse>(roomsList(user.account), {})
const subject = roomsList(user.account)
const resp = await request<ListRoomsResponse>(subject, {})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
// TEMP DEBUG: pair with the [sidebar-bootstrap] logs in
// fetchSidebarBuckets so we can diff what listRooms returns vs
// what the subscription RPCs return. Remove once verified.
console.log('[sidebar-bootstrap]', subject, resp)
return resp
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
20 changes: 16 additions & 4 deletions chat-frontend/src/api/subscribeToSubscriptionUpdates/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { subscriptionUpdate } from '../_transport/subjects'
import type { Nats, NatsSubscription, SubscriptionCallback } from '../types'
import type { Nats, NatsSubscription, SubscriptionUpdateEvent } from '../types'

/** Callback fired for every `subscription.update` event addressed to
* the current user. Payload is the parsed
* `pkg/model.SubscriptionUpdateEvent` — narrow on `.action` to handle
* added / removed / role_updated (and any future actions the backend
* publishes; the reducer's SUBSCRIPTION_UPSERTED handles them all). */
export type SubscriptionUpdateCallback = (event: SubscriptionUpdateEvent) => void

/** Subscribe to per-user subscription updates (added / removed
* membership events from room-worker). */
* membership events from room-worker, role_updated, …). */
export function subscribeToSubscriptionUpdates(
{ user, subscribe }: Nats,
callback: SubscriptionCallback,
callback: SubscriptionUpdateCallback,
): NatsSubscription {
return subscribe(subscriptionUpdate(user.account), callback)
// The transport's `subscribe` is typed with the generic
// `SubscriptionCallback = (event: unknown) => void`. We're locally
// narrower for this op — cast once at the boundary so consumers
// (useRoomSubscriptions) get the typed event without each call site
// re-narrowing.
return subscribe(subscriptionUpdate(user.account), callback as (event: unknown) => void)
}
19 changes: 19 additions & 0 deletions chat-frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,25 @@ export interface NatsSubscription {
* bypass the discriminator. */
export type SubscriptionCallback = (event: unknown) => void

/** Known values of `SubscriptionUpdateEvent.action`. Backend (room-worker
* `handler.go`) emits `"added"` on member-add / room-create / DM-sync,
* `"removed"` on member-remove, and `"role_updated"` on role change.
* Typed as a union ∪ `string` so consumers get autocomplete for the
* known values but forward-compat with any new action the backend
* introduces. */
export type SubscriptionUpdateAction = 'added' | 'removed' | 'role_updated' | (string & {})

/** Wire shape of `chat.user.{account}.event.subscription.update` events.
* Mirrors `pkg/model.SubscriptionUpdateEvent`. Subscription is typed
* as the DM variant so callers can read `hrInfo` without narrowing
* (channels/groups omit the field; DMs carry it). */
export interface SubscriptionUpdateEvent {
userId: string
subscription: DMSubscription
action: SubscriptionUpdateAction
timestamp: number
}

/** Two-phase async-job result returned by `requestWithAsyncResult`. */
export interface AsyncJobResult<S = unknown, A = unknown> {
requestId: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ export default function RoomList({ selectedRoomId, onSelectRoom }) {
{error && <div className="room-list-error">{error}</div>}
<div className="room-list-items">
{sections.map((section) => {
if (section.rooms.length === 0) return null
const isCollapsed = !!collapsed[section.key]
const sectionClasses = ['room-list-section']
if (isCollapsed) sectionClasses.push('room-list-section-collapsed')
Expand All @@ -49,9 +48,16 @@ export default function RoomList({ selectedRoomId, onSelectRoom }) {
className="room-list-section-header"
onClick={() => toggle(section.key)}
>
<span className="room-list-section-chevron" aria-hidden="true">▾</span>
{section.title}
</div>
{!isCollapsed &&
{!isCollapsed && section.note && (
<div className="room-list-section-note">{section.note}</div>
)}
{!isCollapsed && !section.note && section.rooms.length === 0 && (
<div className="room-list-section-empty">No rooms</div>
)}
{!isCollapsed && !section.note &&
section.rooms.map((room) => (
<RoomItem
key={room.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,63 @@ describe('RoomList: three-section render', () => {
})
const { container } = render(<RoomList selectedRoomId={null} onSelectRoom={vi.fn()} />)
const headers = Array.from(container.querySelectorAll('.room-list-section-header')).map(
(el) => el.textContent
(el) => el.textContent.replace(/^▾/, '')
)
expect(headers).toEqual(['Favorite', 'Apps', 'Channels and DMs'])
})

it('hides empty sections (no header rendered)', () => {
it('always renders all three section headers even when a section is empty', () => {
setupSections({ favorite: [], apps: [], channelDm: [summary('c1')] })
render(<RoomList selectedRoomId={null} onSelectRoom={vi.fn()} />)
expect(screen.queryByText('Favorite')).not.toBeInTheDocument()
expect(screen.queryByText('Apps')).not.toBeInTheDocument()
expect(screen.getByText('Favorite')).toBeInTheDocument()
expect(screen.getByText('Apps')).toBeInTheDocument()
expect(screen.getByText('Channels and DMs')).toBeInTheDocument()
})

it('shows a "No rooms" placeholder under an empty (expanded) section', () => {
setupSections({ favorite: [], apps: [], channelDm: [summary('c1')] })
const { container } = render(<RoomList selectedRoomId={null} onSelectRoom={vi.fn()} />)
const emptyPlaceholders = container.querySelectorAll('.room-list-section-empty')
// Favorite + Apps are empty; Channels and DMs has c1.
expect(emptyPlaceholders.length).toBe(2)
expect(emptyPlaceholders[0].textContent).toBe('No rooms')
})

it('renders a section `note` in place of room items / empty placeholder when present', () => {
useRoomSummaries.mockReturnValue({ summaries: [], setActiveRoom: vi.fn(), error: null })
useSidebarSections.mockReturnValue([
{ key: 'favorite', title: 'Favorite', rooms: [], note: 'Favorites are not yet supported' },
{ key: 'apps', title: 'Apps', rooms: [] },
{ key: 'channelDm', title: 'Channels and DMs', rooms: [summary('c1')] },
])
const { container } = render(<RoomList selectedRoomId={null} onSelectRoom={vi.fn()} />)
// The note shows in the Favorite section…
expect(screen.getByText('Favorites are not yet supported')).toBeInTheDocument()
// …and the "No rooms" empty placeholder must NOT also render in that section
// (note overrides empty). Apps is still empty without a note → still shows "No rooms".
const notes = container.querySelectorAll('.room-list-section-note')
expect(notes).toHaveLength(1)
const empties = container.querySelectorAll('.room-list-section-empty')
expect(empties).toHaveLength(1)
})

it('renders a chevron in every section header that rotates when the section is collapsed', () => {
setupSections({
favorite: [summary('f1')],
apps: [summary('a1', { type: 'botDM' })],
channelDm: [summary('c1')],
})
const { container } = render(<RoomList selectedRoomId={null} onSelectRoom={vi.fn()} />)
expect(container.querySelectorAll('.room-list-section-chevron').length).toBe(3)
// Headers start expanded — no collapsed class.
expect(container.querySelectorAll('.room-list-section-collapsed').length).toBe(0)
fireEvent.click(screen.getByText('Apps'))
// After click, Apps' section carries the collapsed class (rotates the chevron via CSS).
const collapsed = container.querySelectorAll('.room-list-section-collapsed')
expect(collapsed.length).toBe(1)
expect(collapsed[0].textContent).toContain('Apps')
})

it('toggles section collapse on header click', () => {
setupSections({
favorite: [],
Expand Down
29 changes: 29 additions & 0 deletions chat-frontend/src/components/MainApp/Sidebar/RoomList/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@
}

.room-list-section-header {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
font-weight: var(--font-semibold);
font-size: var(--text-xs);
Expand All @@ -165,3 +168,29 @@
.room-list-section-collapsed .room-list-section-header {
opacity: 0.7;
}

.room-list-section-chevron {
display: inline-block;
font-size: 0.85em;
line-height: 1;
transition: transform var(--motion-fast) var(--easing);
}

.room-list-section-collapsed .room-list-section-chevron {
transform: rotate(-90deg);
}

.room-list-section-empty {
padding: var(--space-xs) var(--space-md) var(--space-sm) calc(var(--space-md) + var(--space-md));
font-size: var(--text-xs);
font-style: italic;
color: var(--text-muted);
}

.room-list-section-note {
padding: var(--space-xs) var(--space-md) var(--space-sm) calc(var(--space-md) + var(--space-md));
font-size: var(--text-xs);
font-style: italic;
color: var(--text-muted);
opacity: 0.8;
}
Loading
Loading