745a23823a
Add optional MessagingAdapter.setFocus(threadId) and subscribeActivity(cb) so hosts can report the foregrounded thread (backend suppresses push for it) and drive live conversation-list refresh on any thread's activity. Messenger wires focus/blur presence + activity-driven refetch. Published as 0.1.7. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
5.9 KiB
TypeScript
158 lines
5.9 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({ focusThreadId }: { focusThreadId?: string | null } = {}) {
|
||
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]);
|
||
|
||
// Deep link (e.g. from global search): focus the requested conversation.
|
||
useEffect(() => {
|
||
if (!focusThreadId) return;
|
||
setBrowsing(false);
|
||
setComposing(false);
|
||
setSelected(focusThreadId);
|
||
}, [focusThreadId]);
|
||
|
||
// Presence: tell the backend which thread is foregrounded so it suppresses push for it. Null while
|
||
// browsing/composing, when the window is blurred, and on unmount (leaving the messenger).
|
||
useEffect(() => {
|
||
const active = browsing || composing ? null : selected;
|
||
adapter.setFocus?.(active);
|
||
const onBlur = (): void => adapter.setFocus?.(null);
|
||
const onFocus = (): void => adapter.setFocus?.(active);
|
||
window.addEventListener('blur', onBlur);
|
||
window.addEventListener('focus', onFocus);
|
||
return () => {
|
||
adapter.setFocus?.(null);
|
||
window.removeEventListener('blur', onBlur);
|
||
window.removeEventListener('focus', onFocus);
|
||
};
|
||
}, [adapter, selected, browsing, composing]);
|
||
|
||
// Live unread + ordering: refresh the conversation list when any of the caller's threads gets a
|
||
// message (not just the open one).
|
||
useEffect(() => {
|
||
if (!adapter.subscribeActivity) return;
|
||
return adapter.subscribeActivity(() => refetch());
|
||
}, [adapter, refetch]);
|
||
|
||
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>
|
||
);
|
||
}
|