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
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
1 change: 1 addition & 0 deletions chat-frontend/src/api/_transport/subjects.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,5 @@ describe('subjects', () => {
'chat.user.alice.request.user.site-A.subscription.getRooms'
)
})

})
2 changes: 1 addition & 1 deletion chat-frontend/src/api/_transport/subjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export function orgMembers(account: string, orgId: string): string {
return `chat.user.${account}.request.orgs.${orgId}.members`
}

// userSubscriptionGetCurrent fetches the caller's current subscriptions, optionally
// userSubscriptionGetCurrent fetches the caller's subscriptions, optionally
// filtered server-side. The sidebar passes `{ favorite: true }` to drive the
// Favorite section. Mirrors pkg/subject/subject.go::UserSubscriptionGetCurrent.
export function userSubscriptionGetCurrent(account: string, siteId: string): string {
Expand Down
99 changes: 84 additions & 15 deletions chat-frontend/src/api/fetchSidebarBuckets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
userSubscriptionGetApps,
userSubscriptionGetRooms,
} from '../_transport/subjects'
import type { Nats, DMSubscription } from '../types'
import type { Nats, DMSubscription, Room } from '../types'

/** Wire shape of every `subscription.get*` reply.
* Both fields are non-omitempty on the Go side
Expand All @@ -14,7 +14,9 @@ import type { Nats, DMSubscription } from '../types'
* match Go's flattened JSON for both subscription kinds: channels/groups
* ship plain Subscription (hrInfo absent ⇒ typed `undefined`), DM rooms
* ship DMSubscription (hrInfo present). One type covers both since
* DMSubscription extends Subscription. */
* DMSubscription extends Subscription. The real user-service additionally
* embeds room-level metadata (userCount, lastMsgAt, lastMsgId) inline so
* the frontend doesn't need a separate `rooms.list` call. */
interface SidebarBucketReply {
subscriptions: DMSubscription[]
total: number
Expand All @@ -29,33 +31,79 @@ export interface SidebarBuckets {
* stores this directly under `state.subscriptions` so components
* consume the live per-room state via `useSubscription(roomId)`. */
subscriptions: Record<string, DMSubscription>
/** Room records derived from the union of the three subscription
* responses, deduped by roomId. The reducer's BUCKETS_LOADED case
* consumes this to build `state.summaries` — no separate rooms.list
* RPC is needed because the real user-service embeds room metadata
* inline on each subscription reply. */
rooms: Room[]
}

/**
* 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({ favorite: true })` — favorited subscriptions, drives
* the Favorite section.
* 2. `getApps()` — app subscriptions, drives the Apps section.
* 3. `getRooms()` — non-app room subscriptions (channels / DMs /
* discussions), drives the Channels and DMs section.
*
* 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.
* Each subscription record carries its room metadata inline on the real
* user-service, so we derive `rooms` from the union of all three replies
* (deduped by roomId). 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 more than one bucket without double-render.
*
* Uses `Promise.allSettled` so a single bucket RPC failure degrades that
* one bucket to empty rather than black-holing the whole bootstrap.
*/
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 favSubject = userSubscriptionGetCurrent(user.account, user.siteId)
const appsSubject = userSubscriptionGetApps(user.account, user.siteId)
const roomsSubject = userSubscriptionGetRooms(user.account, user.siteId)
const results = await Promise.allSettled([
request<SidebarBucketReply>(favSubject, { favorite: true }),
request<SidebarBucketReply>(appsSubject, {}),
request<SidebarBucketReply>(roomsSubject, {}),
])
const empty: SidebarBucketReply = { subscriptions: [], total: 0 }
const unwrap = (
result: PromiseSettledResult<SidebarBucketReply>,
label: string,
): SidebarBucketReply => {
if (result.status === 'fulfilled') {
// TEMP DEBUG: compact summary so we can verify what each
// subscription RPC returns on cold start. Remove once verified.
console.log('[sidebar-bootstrap]', label, {
count: result.value.subscriptions.length,
roomIds: result.value.subscriptions.map((s) => s.roomId),
})
return result.value
}
const err = result.reason
console.warn(
'[sidebar-bootstrap]',
label,
'FAILED:',
err?.message ?? err,
)
return empty
}
const favResp = unwrap(results[0], `${favSubject} {favorite:true}`)
const appResp = unwrap(results[1], appsSubject)
const roomResp = unwrap(results[2], roomsSubject)

const subscriptions: Record<string, DMSubscription> = {}
const rooms: Room[] = []
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.
const first = subscriptions[s.roomId] === undefined
subscriptions[s.roomId] = s
if (first) rooms.push(subToRoom(s, user.siteId))
}
}
collect(favResp)
Expand All @@ -66,5 +114,26 @@ export async function fetchSidebarBuckets({ user, request }: Nats): Promise<Side
appIds: appResp.subscriptions.map((s) => s.roomId),
channelDmIds: roomResp.subscriptions.map((s) => s.roomId),
Comment thread
GITMateuszCharczuk marked this conversation as resolved.
subscriptions,
rooms,
}
}

/** Derive a `Room` from a subscription record. The real user-service
* embeds the fields we actually need (userCount, lastMsgAt, lastMsgId);
* fields the reducer's `toSummary` doesn't read default to neutral
* zero/empty values so the type contract is satisfied. */
function subToRoom(sub: DMSubscription, fallbackSiteId: string): Room {
return {
id: sub.roomId,
name: sub.name ?? '',
type: sub.roomType,
siteId: sub.siteId ?? fallbackSiteId,
userCount: sub.userCount ?? 0,
appCount: 0,
lastMsgId: sub.lastMsgId ?? '',
lastMsgAt: sub.lastMsgAt ?? undefined,
createdBy: '',
createdAt: '',
updatedAt: '',
}
}
3 changes: 2 additions & 1 deletion chat-frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ export { getRoom } from './getRoom'
export { leaveRoom } from './leaveRoom'
export { listOrgMembers } from './listOrgMembers'
export { listRoomMembers } from './listRoomMembers'
export { listRooms } from './listRooms'
export { markRoomRead } from './markRoomRead'
export { removeMember } from './removeMember'
export { searchMessages } from './searchMessages'
Expand All @@ -51,6 +50,8 @@ export type {
Nats,
NatsSubscription,
SubscriptionCallback,
SubscriptionUpdateEvent,
SubscriptionUpdateAction,
AsyncJobOptions,
AsyncJobResult,
// Domain types
Expand Down
11 changes: 0 additions & 11 deletions chat-frontend/src/api/listRooms/index.ts

This file was deleted.

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)
}
27 changes: 27 additions & 0 deletions chat-frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ export interface Subscription {
hasMention: boolean
threadUnread?: string[]
alert: boolean
/** Room-level metadata that the real user-service embeds on subscription
* replies (the three `subscription.get*` RPCs return joined Room data
* inline so the frontend doesn't need a separate `rooms.list` call).
* Optional because mock-user-service and live `subscription.update`
* events don't carry them today; default to 0 / null at the consumer. */
userCount?: number
lastMsgAt?: string | null
lastMsgId?: string
}

/**
Expand Down Expand Up @@ -235,6 +243,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