416cf59dc2
- Every message now shows a Slack-style time (clock today, then 'Yesterday', weekday, else a date; full timestamp on hover). - New ConversationSettings panel for groups AND channels (public + private): rename, member list, add people (from the directory), remove, and leave. Opened from a new thread header gear; DMs show no gear. Adapter gains addMember/removeMember/renameConversation (optional, OPA still gates writes). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
127 lines
4.6 KiB
TypeScript
127 lines
4.6 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { useConversations } from '../hooks/use-conversations';
|
||
import { useAdapter } from '../provider';
|
||
import { ConversationList } from './conversation-list';
|
||
import { ChannelBrowser } from './channel-browser';
|
||
import { NewConversation } from './new-conversation';
|
||
import { Thread } from './thread';
|
||
import { ThreadPane } from './thread-pane';
|
||
|
||
/**
|
||
* The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread,
|
||
* with a channel browser when the adapter supports channels. Owns only selection + browse state;
|
||
* all data flows through the injected adapter via the hooks.
|
||
*/
|
||
export function Messenger() {
|
||
const { conversations, loading, error, refetch } = useConversations();
|
||
const adapter = useAdapter();
|
||
const channelsSupported = typeof adapter.browseChannels === 'function';
|
||
const directorySupported = typeof adapter.directory === 'function';
|
||
|
||
const [selected, setSelected] = useState<string | null>(null);
|
||
const [browsing, setBrowsing] = useState(false);
|
||
const [composing, setComposing] = useState(false); // "New message" people picker open
|
||
const [activeRoot, setActiveRoot] = useState<string | null>(null); // open thread pane's root message
|
||
|
||
useEffect(() => {
|
||
if (browsing || composing) return;
|
||
if (selected && conversations.some((c) => c.threadId === selected)) return;
|
||
setSelected(conversations[0]?.threadId ?? null);
|
||
}, [conversations, selected, browsing, composing]);
|
||
|
||
// Switching conversations (or into browse/compose) closes any open thread pane.
|
||
useEffect(() => setActiveRoot(null), [selected, browsing, composing]);
|
||
|
||
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
|
||
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
|
||
const selectedConversation = useMemo(() => conversations.find((c) => c.threadId === selected) ?? null, [conversations, selected]);
|
||
|
||
function pick(threadId: string): void {
|
||
setBrowsing(false);
|
||
setComposing(false);
|
||
setSelected(threadId);
|
||
}
|
||
|
||
function openBrowse(): void {
|
||
setComposing(false);
|
||
setBrowsing(true);
|
||
}
|
||
|
||
function openCompose(): void {
|
||
setBrowsing(false);
|
||
setComposing(true);
|
||
}
|
||
|
||
return (
|
||
<div className="miu-messenger">
|
||
<aside className="miu-sidebar">
|
||
{loading && conversations.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||
|
||
{channelsSupported ? (
|
||
<div className="miu-section">
|
||
<div className="miu-section-head">
|
||
<span>Channels</span>
|
||
<button type="button" className="miu-section-add" title="Browse channels" onClick={openBrowse}>
|
||
+
|
||
</button>
|
||
</div>
|
||
{channels.length > 0 ? (
|
||
<ConversationList conversations={channels} selectedId={browsing || composing ? null : selected} onSelect={pick} />
|
||
) : (
|
||
<div className="miu-empty miu-empty-sm">Browse to join a channel.</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="miu-section">
|
||
{channelsSupported || directorySupported ? (
|
||
<div className="miu-section-head">
|
||
<span>Direct messages</span>
|
||
{directorySupported ? (
|
||
<button type="button" className="miu-section-add" title="New message" onClick={openCompose}>
|
||
+
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
<ConversationList conversations={dms} selectedId={browsing || composing ? null : selected} onSelect={pick} />
|
||
</div>
|
||
</aside>
|
||
|
||
<section className="miu-main">
|
||
{browsing ? (
|
||
<ChannelBrowser
|
||
onJoined={(threadId) => {
|
||
refetch();
|
||
pick(threadId);
|
||
}}
|
||
/>
|
||
) : composing ? (
|
||
<NewConversation
|
||
onCreated={(threadId) => {
|
||
refetch();
|
||
pick(threadId);
|
||
}}
|
||
/>
|
||
) : (
|
||
<Thread
|
||
threadId={selected}
|
||
conversation={selectedConversation}
|
||
activeRootId={activeRoot}
|
||
onOpenThread={setActiveRoot}
|
||
onLeft={() => {
|
||
refetch();
|
||
setSelected(null);
|
||
}}
|
||
/>
|
||
)}
|
||
</section>
|
||
|
||
{!browsing && !composing && selected && activeRoot ? (
|
||
<ThreadPane threadId={selected} rootId={activeRoot} onClose={() => setActiveRoot(null)} />
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|