Skip to content

Commit 1db47b8

Browse files
committed
refactor: extract deeplink navigation between sent-in-channel and thread messages
1 parent f7b4996 commit 1db47b8

4 files changed

Lines changed: 171 additions & 75 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {
2+
MessageAlsoSentInChannelIndicator,
3+
useChatViewNavigation,
4+
useMessageAlsoSentInChannelNavigation,
5+
} from 'stream-chat-react';
6+
import { useSlotGeometry } from 'stream-chat-react/slot-geometry';
7+
8+
import { CHANNEL_THREAD_SLOT, MAIN_CHANNEL_SLOT } from './constants';
9+
10+
/**
11+
* Coverage-aware "Also sent in channel" indicator. It reuses the SDK default component and its
12+
* navigation hook — no logic or markup is copied — and only augments the click: when jumping to the
13+
* channel from inside the reply thread, it first releases the thread slot **if** the geometry module
14+
* reports the channel column is actually obscured (collapsed under / covered by the thread) right
15+
* now. On wide side-by-side layouts the channel isn't obscured, so the thread stays open.
16+
*
17+
* Coverage is app-layout knowledge (this example's responsive CSS), so it lives here rather than in
18+
* the SDK — measured from real DOM rects, no breakpoint or class-name coupling.
19+
*/
20+
export const AlsoSentInChannelIndicator = () => {
21+
const { isInThread, viewReference } = useMessageAlsoSentInChannelNavigation();
22+
const { close } = useChatViewNavigation();
23+
const { isObscured } = useSlotGeometry();
24+
25+
const onView = async () => {
26+
if (isInThread && isObscured(MAIN_CHANNEL_SLOT)) close(CHANNEL_THREAD_SLOT);
27+
await viewReference();
28+
};
29+
30+
return <MessageAlsoSentInChannelIndicator onView={onView} />;
31+
};

src/components/Message/MessageAlsoSentInChannelIndicator.tsx

Lines changed: 21 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,40 @@
11
import React from 'react';
22

33
import { IconArrowUpRight } from '../Icons';
4-
import {
5-
useChannel,
6-
useChatContext,
7-
useMessageContext,
8-
useTranslationContext,
9-
} from '../../context';
10-
import { useStateStore } from '../../store';
11-
import { useChatViewContext, useChatViewNavigation } from '../ChatView';
12-
import { useThreadContext } from '../Threads';
13-
14-
const activeViewSelector = ({ activeView }: { activeView: string }) => ({ activeView });
4+
import { useTranslationContext } from '../../context';
5+
import { useMessageAlsoSentInChannelNavigation } from './hooks';
6+
7+
export type MessageAlsoSentInChannelIndicatorProps = {
8+
/**
9+
* Overrides the "View" click handler. Defaults to the built-in reference navigation
10+
* (`useMessageAlsoSentInChannelNavigation().viewReference`). Integrators can supply their own —
11+
* e.g. to run extra layout side effects around the jump — by composing that hook, without
12+
* re-implementing the component. Returning a promise is supported.
13+
*/
14+
onView?: () => void | Promise<void>;
15+
};
1516

1617
/**
1718
* Indicator shown when the message was also sent to the main channel (show_in_channel === true).
19+
* The navigation lives in {@link useMessageAlsoSentInChannelNavigation} so it can be reused.
1820
*/
19-
export const MessageAlsoSentInChannelIndicator = () => {
20-
const { client } = useChatContext();
21+
export const MessageAlsoSentInChannelIndicator = ({
22+
onView,
23+
}: MessageAlsoSentInChannelIndicatorProps = {}) => {
2124
const { t } = useTranslationContext();
22-
const channel = useChannel();
23-
const { open } = useChatViewNavigation();
24-
const { layoutController } = useChatViewContext();
25-
const thread = useThreadContext();
26-
const { message } = useMessageContext('MessageAlsoSentInChannelIndicator');
27-
const { activeView } = useStateStore(layoutController.state, activeViewSelector) ?? {
28-
activeView: layoutController.state.getLatestValue().activeView,
29-
};
30-
31-
const addThreadNotFoundNotification = (error: Error) => {
32-
client.notifications.addError({
33-
message: t('Thread has not been found'),
34-
options: {
35-
originalError: error,
36-
type: 'api:message:search:not-found',
37-
},
38-
origin: {
39-
context: { threadReply: message },
40-
emitter: 'MessageIsThreadReplyInChannelButtonIndicator',
41-
},
42-
});
43-
};
44-
45-
const jumpToReplyInChannelMessages = async (id: string) => {
46-
if (activeView === 'threads') {
47-
// todo: switching views to a specific channel will work only with ChannelListOrchestrator because it will not force us to reload the whole channel list
48-
open({ key: channel.cid ?? undefined, kind: 'channel', source: channel });
49-
// TODO: Use ChannelListOrchestrator to check whether this channel is already loaded before querying.
50-
await channel.query({ messages: { limit: 0 } });
51-
}
52-
53-
await channel.messagePaginator.jumpToMessage(id);
54-
};
55-
56-
const jumpToReplyInThread = async (replyId: string, parentId: string) => {
57-
let targetThread = client.threads.threadsById[parentId];
58-
59-
if (!targetThread) {
60-
try {
61-
targetThread = await client.getThread(parentId, { watch: true });
62-
} catch (error) {
63-
addThreadNotFoundNotification(error as Error);
64-
return;
65-
}
66-
}
67-
68-
open({ key: targetThread.id ?? undefined, kind: 'thread', source: targetThread });
69-
await targetThread.messagePaginator.jumpToMessage(replyId);
70-
};
71-
72-
const handleClickViewReference = async () => {
73-
if (thread) {
74-
await jumpToReplyInChannelMessages(message.id);
75-
return;
76-
}
77-
78-
if (!message.parent_id) return;
79-
await jumpToReplyInThread(message.id, message.parent_id);
80-
};
25+
const { isInThread, isShownInChannel, viewReference } =
26+
useMessageAlsoSentInChannelNavigation();
8127

82-
if (!message?.show_in_channel) return null;
28+
if (!isShownInChannel) return null;
8329

8430
return (
8531
<div className='str-chat__message-also-sent-in-channel' role='status'>
8632
<IconArrowUpRight />
87-
<span>{thread ? t('Also sent in channel') : t('Replied to a thread')}</span>
33+
<span>{isInThread ? t('Also sent in channel') : t('Replied to a thread')}</span>
8834
<span> · </span>
8935
<button
9036
className='str-chat__message-also-sent-in-channel__link-button'
91-
onClick={handleClickViewReference}
37+
onClick={onView ?? viewReference}
9238
type='button'
9339
>
9440
{t('View')}

src/components/Message/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from './useDeleteHandler';
33
export * from './useFlagHandler';
44
export * from './useMentionsHandler';
55
export * from './useMarkUnreadHandler';
6+
export * from './useMessageAlsoSentInChannelNavigation';
67
export * from './useMuteHandler';
78
export * from './useOpenThreadHandler';
89
export * from './usePinHandler';
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import {
2+
useChannel,
3+
useChatContext,
4+
useMessageContext,
5+
useTranslationContext,
6+
} from '../../../context';
7+
import { useStateStore } from '../../../store';
8+
import { useChatViewContext, useChatViewNavigation } from '../../ChatView';
9+
import { useThreadContext } from '../../Threads';
10+
11+
const activeViewSelector = ({ activeView }: { activeView: string }) => ({ activeView });
12+
13+
export type MessageAlsoSentInChannelNavigation = {
14+
/** True when rendered inside a thread (the reply was "also sent in channel"); false in a
15+
* channel message list (the message is a thread reply, "replied to a thread"). */
16+
isInThread: boolean;
17+
/** Whether the current message is shown in the channel (i.e. the indicator should render). */
18+
isShownInChannel: boolean;
19+
/** Composed handler used by the default indicator: jumps to the referenced message, picking the
20+
* channel or the thread depending on where the indicator is rendered. */
21+
viewReference: () => Promise<void>;
22+
/** Jump to the reply in the channel message list (used from inside a thread). */
23+
viewReplyInChannel: (messageId?: string) => Promise<void>;
24+
/** Open the reply's parent thread and jump to the reply (used from a channel message list). */
25+
viewReplyInThread: (replyId?: string, parentId?: string) => Promise<void>;
26+
};
27+
28+
/**
29+
* Encapsulates the navigation behind {@link MessageAlsoSentInChannelIndicator} so integrators can
30+
* reuse it (or compose extra behavior around it) without re-implementing the component. Returns the
31+
* composed `viewReference` handler plus the granular `viewReplyInChannel` / `viewReplyInThread`
32+
* actions and the `isInThread` / `isShownInChannel` flags used for rendering.
33+
*/
34+
export const useMessageAlsoSentInChannelNavigation =
35+
(): MessageAlsoSentInChannelNavigation => {
36+
const { channelPaginatorsOrchestrator, client } = useChatContext();
37+
const { t } = useTranslationContext();
38+
const channel = useChannel();
39+
const { open } = useChatViewNavigation();
40+
const { layoutController } = useChatViewContext();
41+
const thread = useThreadContext();
42+
const { message } = useMessageContext('useMessageAlsoSentInChannelNavigation');
43+
const { activeView } = useStateStore(layoutController.state, activeViewSelector) ?? {
44+
activeView: layoutController.state.getLatestValue().activeView,
45+
};
46+
47+
const addThreadNotFoundNotification = (error: Error) => {
48+
client.notifications.addError({
49+
message: t('Thread has not been found'),
50+
options: {
51+
originalError: error,
52+
type: 'api:message:search:not-found',
53+
},
54+
origin: {
55+
context: { threadReply: message },
56+
emitter: 'useMessageAlsoSentInChannelNavigation',
57+
},
58+
});
59+
};
60+
61+
const viewReplyInChannel = async (messageId = message?.id) => {
62+
if (!messageId) return;
63+
if (activeView === 'threads') {
64+
// Switch to the channels view and bind this specific channel. Backed by the
65+
// ChannelPaginatorsOrchestrator, `ingestChannel` surfaces it in the channel list(s)
66+
// incrementally (deduped, in sort order) instead of forcing a full list re-query.
67+
open({ key: channel.cid ?? undefined, kind: 'channel', source: channel });
68+
// Only initialize the channel if it hasn't been loaded yet — a channel already held by the
69+
// orchestrator / active session is initialized, so we skip the redundant query.
70+
if (!channel.initialized) {
71+
await channel.query({ messages: { limit: 0 } });
72+
}
73+
// Surface the (now initialized) channel in the list(s) so its data is available for filter
74+
// matching; `ingestChannel` dedupes by cid and inserts in sort order — safe to call always.
75+
channelPaginatorsOrchestrator.ingestChannel(channel);
76+
}
77+
78+
await channel.messagePaginator.jumpToMessage(messageId);
79+
};
80+
81+
const viewReplyInThread = async (
82+
replyId = message?.id,
83+
parentId = message?.parent_id,
84+
) => {
85+
if (!replyId || !parentId) return;
86+
let targetThread = client.threads.threadsById[parentId];
87+
88+
if (!targetThread) {
89+
try {
90+
targetThread = await client.getThread(parentId, { watch: true });
91+
} catch (error) {
92+
addThreadNotFoundNotification(error as Error);
93+
return;
94+
}
95+
}
96+
97+
open({ key: targetThread.id ?? undefined, kind: 'thread', source: targetThread });
98+
await targetThread.messagePaginator.jumpToMessage(replyId);
99+
};
100+
101+
const viewReference = async () => {
102+
if (thread) {
103+
await viewReplyInChannel(message?.id);
104+
return;
105+
}
106+
107+
if (!message?.parent_id) return;
108+
await viewReplyInThread(message.id, message.parent_id);
109+
};
110+
111+
return {
112+
isInThread: !!thread,
113+
isShownInChannel: !!message?.show_in_channel,
114+
viewReference,
115+
viewReplyInChannel,
116+
viewReplyInThread,
117+
};
118+
};

0 commit comments

Comments
 (0)