feat(messaging-ui): channels — browse, join, leave, create (contract + mock + UI)
Adds a third membership beyond dm/group: a discoverable, joinable room. - contract: Membership += 'channel'; Conversation.topic; ChannelSummary + CreateChannelInput; optional adapter methods browseChannels/createChannel/ joinChannel/leaveChannel (capability-degrading — absent hides the UI) - MockAdapter implements them (seeds a joined public, a joinable public, and a private channel); listConversations now returns only threads I'm in - useChannels hook (browse/create/join/leave + supported flag) - UI: sidebar sections (Channels vs Direct messages), a + that opens a ChannelBrowser (join + create), # / lock glyphs; themeable styles - KernelClientAdapter passes channel membership + topic through - 4 channel tests (mock browse/join/create + render browse→join); 52 total green Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAdapter } from '../provider';
|
||||
import type { ChannelSummary, CreateChannelInput } from '../types';
|
||||
|
||||
export interface ChannelsState {
|
||||
/** Discoverable channels (public + private-I'm-in), each flagged `joined`. */
|
||||
browsable: ChannelSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** Whether the adapter implements channels at all — drives showing/hiding the channels UI. */
|
||||
supported: boolean;
|
||||
refetch: () => void;
|
||||
create: (input: CreateChannelInput) => Promise<string>;
|
||||
join: (threadId: string) => Promise<void>;
|
||||
leave: (threadId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useChannels(): ChannelsState {
|
||||
const adapter = useAdapter();
|
||||
const supported = typeof adapter.browseChannels === 'function';
|
||||
const [browsable, setBrowsable] = useState<ChannelSummary[]>([]);
|
||||
const [loading, setLoading] = useState(supported);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adapter.browseChannels) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
adapter
|
||||
.browseChannels()
|
||||
.then((list) => {
|
||||
if (!alive) return;
|
||||
setBrowsable(list);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!alive) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setBrowsable([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [adapter, nonce]);
|
||||
|
||||
const refetch = useCallback(() => setNonce((n) => n + 1), []);
|
||||
|
||||
const create = useCallback(
|
||||
async (input: CreateChannelInput) => {
|
||||
if (!adapter.createChannel) throw new Error('channels not supported by this adapter');
|
||||
const { threadId } = await adapter.createChannel(input);
|
||||
setNonce((n) => n + 1);
|
||||
return threadId;
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
const join = useCallback(
|
||||
async (threadId: string) => {
|
||||
if (!adapter.joinChannel) throw new Error('channels not supported by this adapter');
|
||||
await adapter.joinChannel(threadId);
|
||||
setNonce((n) => n + 1);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
const leave = useCallback(
|
||||
async (threadId: string) => {
|
||||
if (!adapter.leaveChannel) throw new Error('channels not supported by this adapter');
|
||||
await adapter.leaveChannel(threadId);
|
||||
setNonce((n) => n + 1);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
return { browsable, loading, error, supported, refetch, create, join, leave };
|
||||
}
|
||||
Reference in New Issue
Block a user