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
171 changes: 9 additions & 162 deletions package/src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, { PropsWithChildren, useEffect, useMemo } from 'react';
import React, { PropsWithChildren, useMemo } from 'react';
import { StyleSheet, Text } from 'react-native';

import { ChannelConfigProps, ChannelShell } from './ChannelShell';

import { useChannelLifecycle } from './hooks/useChannelLifecycle';
import { useSupersededChannelSwap } from './hooks/useSupersededChannelSwap';

import { ChannelContextValue } from '../../contexts/channelContext/ChannelContext';
Expand All @@ -13,13 +14,9 @@ import {
TranslationContextValue,
useTranslationContext,
} from '../../contexts/translationContext/TranslationContext';
import { useStableCallback } from '../../hooks';
import { useStateStore } from '../../hooks/useStateStore';
import { primitives } from '../../theme';

import { useMarkRead } from '../MessageList/hooks/useMarkRead';
import { DEFAULT_HIGHLIGHT_DURATION } from '../MessageList/hooks/useMessageListFocus';
import { useNotificationApi } from '../Notifications';
import { getChannelNotificationHostId } from '../Notifications/notificationTarget';
import { NotificationTargetProvider } from '../Notifications/NotificationTargetContext';

Expand All @@ -34,15 +31,6 @@ export type MarkReadFunctionOptions = {
updateChannelUnreadState?: boolean;
};

/**
* Initial message-list page size. stream-chat's `MessagePaginator` defaults to 100
* (`DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE`). On native that makes the initial load — and therefore
* every subsequent message-list commit, whose cost scales with the number of loaded messages — several
* times heavier than necessary. We keep it to a screenful-plus-buffer; older messages load on demand
* via pagination. Mirrors the SDK's historical initial-load size.
*/
const DEFAULT_MESSAGE_LIST_PAGE_SIZE = 25;

export type ChannelPropsWithContext = Pick<ChannelContextValue, 'channel'> &
Pick<ChatContextValue, 'client'> &
Pick<TranslationContextValue, 't'> &
Expand Down Expand Up @@ -91,156 +79,15 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
const { blockingError } =
useStateStore(channel.messagePaginator.state, channelQuerySelector) ?? {};

const { addNotification } = useNotificationApi();

const notifyJumpToFirstUnreadError = useStableCallback((error: unknown) => {
addNotification({
message: t(
'channel.jumpToFirstUnreadFailed.error',
'Failed to jump to the first unread message',
),
options: {
...(error instanceof Error ? { originalError: error } : {}),
severity: 'error',
type: 'channel:jumpToFirstUnread:failed',
},
origin: { context: { feature: 'jumpToFirstUnread' }, emitter: 'Channel' },
});
});

/**
* Whether this list is already aimed at a message.
*
* There is no `messageId` prop any more: jumping is the paginator's job, so an integrator targets a
* message by calling `paginator.jumpToMessage(...)` — from a `useLayoutEffect`, or before this
* mounts at all — and the focus signal is what that leaves behind. `<Channel>` reads the signal so
* it can tell a jump already happened and not overwrite it with its own jump to the first unread.
*
* A `useLayoutEffect` is early enough: the one consumer that races this
* (`MessageFlashList`'s initial anchor) asks inside a passive `useEffect`, which React runs after
* every layout effect.
*
* Read imperatively, not through `useStateStore`: every caller is an effect or a one-shot
* decision, and subscribing would re-render `Channel` on every jump for no benefit.
*/
const hasFocusTarget = useStableCallback(
() => !!channel.messagePaginator.messageFocusSignal.getLatestValue().signal,
);

const shouldLoadInitialChannelAtFirstUnreadMessage = useStableCallback((unreadCount?: number) => {
if (hasFocusTarget() || !initialScrollToFirstUnreadMessage || !client.user) {
return false;
}

return (unreadCount ?? channel.countUnread()) > 0;
});

const hasPendingInitialTargetLoad = useStableCallback(() => {
return hasFocusTarget() || shouldLoadInitialChannelAtFirstUnreadMessage();
const { hasPendingInitialTargetLoad } = useChannelLifecycle({
channel,
client,
initialScrollToFirstUnreadMessage,
initializeOnMount,
markReadOnMount,
t,
});

useEffect(() => {
const initChannel = async () => {
const unreadCount = channel.countUnread();
const shouldLoadAtFirstUnread = shouldLoadInitialChannelAtFirstUnreadMessage(unreadCount);
if (!channel) {
return;
}

// a draft has no id and is marked initialized and querying it would create it on the server
if (channel.isProvisional && channel.initialized) {
return;
}

// Keep the message-list page light: the list's per-update commit cost scales with the number
// of loaded messages, and the paginator otherwise defaults to a 100-message page.
channel.messagePaginator.pageSize = DEFAULT_MESSAGE_LIST_PAGE_SIZE;

if (
(!channel.initialized || !channel.messagePaginator.isActiveIntervalAtHead) &&
initializeOnMount
) {
try {
await channel?.watch();
} catch (err) {
console.warn('Channel watch request failed with error:', err);
channel.offlineMode = true;
}
}

// Seed the paginator for a cold open (deep link / push). Channels reached via the channel
// list are already seeded by client.hydrateChannels, so guard on an empty paginator
// to avoid a redundant fetch.
if (!channel.messagePaginator.state.getLatestValue().items?.length) {
await channel.messagePaginator.reload();
}

// Re-seed the unread snapshot from the CURRENT read state on every open. The paginator is
// usually reused from cache (the reload above is skipped), and hydrateChannels merges
// rather than re-seeds, so without this the snapshot's boundary/count/first-unread stay frozen
// at the very first open — making the separator, the "N new" banner and the jump-to-first-unread
// target all go stale on reopen. Mirrors stream-chat-react, which re-seeds by re-querying on open.
channel.messagePaginator.seedUnreadSnapshot();

if (shouldLoadAtFirstUnread) {
try {
// jumpToTheFirstUnreadMessage resolves the first-unread id from the paginator's snapshot,
// and emits messageFocusSignal for the highlight + scroll.
await channel.messagePaginator.jumpToTheFirstUnreadMessage({
focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
});
} catch (error) {
notifyJumpToFirstUnreadError(error);
}
}

if (unreadCount > 0 && markReadOnMount) {
// Keep the original unread UI (separator frozen at the boundary, "N new" banner) when
// opening a channel with unreads — don't reset the snapshot here. It clears once the user
// catches up (a subsequent markRead with the default updateChannelUnreadState: true).
await markRead({ updateChannelUnreadState: false });
}
};

initChannel();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channel]);

// Mark the channel active while this <Channel> is mounted. The LLC refcounts `active`, so a
// Channel instance shared with the channel-list preview stays active until the last
// mount unmounts. Being active enables auto-mark-read-on-focus and suppresses destructive
// channel-list re-seeding of the open channel's message list on reconnect. `activate()` returns
// the release for this exact activation, so swapping the `channel` prop releases the previous
// instance before activating the new one.
useEffect(() => channel?.activate(), [channel]);

/**
* CHANNEL METHODS
*/
// markRead is no longer placed on the ChannelContext; the message lists own their own throttled
// instance via useMarkRead(channel). Channel still needs it internally (mark-read-on-mount + resync).
const markRead = useMarkRead(channel);

// Mark-read after the LLC's reconnect reload. `connection.recovered` is dispatched by
// `client.connectionRecovery` once that reload has landed, so `hasMoreHead` read here reflects the
// refreshed window — which is why this cannot hang off the socket's status store, which moves the
// moment the socket does. Only the reload moved into the LLC; whether a caught-up channel is
// marked read stays a UI decision (see `useMarkRead`).
useEffect(() => {
// Mark read has to wait for `connection.recovered`, as it is dispatched once the reloads have
// landed, so `hasMoreHead` read here reflects the refreshed window. Channel view only, and only
// when that window is at the newest, only if the user has paginated up into older history so leave
// their read state alone.
const { unsubscribe } = client.on('connection.recovered', () => {
if (channel.messagePaginator.hasMoreHead) {
return;
}
markRead();
});

return unsubscribe;
}, [channel, client, markRead]);

if (!channel || blockingError) {
// Retry re-runs the query that failed. A new failure lands in the paginator's `lastQueryError`,
// which is the `error` this indicator renders off — so the warn is all the handling needed.
Expand Down
157 changes: 157 additions & 0 deletions package/src/components/Channel/hooks/useChannelLifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { useEffect } from 'react';

import type { Channel, StreamChat } from 'stream-chat';

import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext';
import { useStableCallback } from '../../../hooks';
import { useMarkRead } from '../../MessageList/hooks/useMarkRead';
import { DEFAULT_HIGHLIGHT_DURATION } from '../../MessageList/hooks/useMessageListFocus';
import { useNotificationApi } from '../../Notifications';

const DEFAULT_MESSAGE_LIST_PAGE_SIZE = 25;

export type UseChannelLifecycleParams = Pick<TranslationContextValue, 't'> & {
channel: Channel;
client: StreamChat;
initialScrollToFirstUnreadMessage: boolean;
initializeOnMount: boolean;
markReadOnMount: boolean;
};

/**
* A hook encapsulating everything `<Channel>` does to a channel while it is mounted. The first load
* (watch, a cold open's first page, the unread snapshot, the jump to the first unread, mark read on
* open etc.), activation and mark read after a reconnect.
*
* Returns whether the list is already aimed at a message, which the list reads to keep its initial
* anchor from overriding that jump.
*/
export const useChannelLifecycle = ({
channel,
client,
initialScrollToFirstUnreadMessage,
initializeOnMount,
markReadOnMount,
t,
}: UseChannelLifecycleParams) => {
const { addNotification } = useNotificationApi();

const notifyJumpToFirstUnreadError = useStableCallback((error: unknown) => {
addNotification({
message: t(
'channel.jumpToFirstUnreadFailed.error',
'Failed to jump to the first unread message',
),
options: {
...(error instanceof Error ? { originalError: error } : {}),
severity: 'error',
type: 'channel:jumpToFirstUnread:failed',
},
origin: { context: { feature: 'jumpToFirstUnread' }, emitter: 'Channel' },
});
});

/**
* Whether something already jumped the list to a message, i.e an integrator calling
* `channel.messagePaginator.jumpToMessage()` before or while the channel mounts. A jump leaves a
* focus signal on the paginator, and while it is set the channel does not jump to the first unread.
*
* Read on demand rather than subscribed to, since only effects ask.
*/
const hasFocusTarget = useStableCallback(
() => !!channel.messagePaginator.messageFocusSignal.getLatestValue().signal,
);

const shouldLoadInitialChannelAtFirstUnreadMessage = useStableCallback((unreadCount?: number) => {
if (hasFocusTarget() || !initialScrollToFirstUnreadMessage || !client.user) {
return false;
}

return (unreadCount ?? channel.countUnread()) > 0;
});

const hasPendingInitialTargetLoad = useStableCallback(() => {
return hasFocusTarget() || shouldLoadInitialChannelAtFirstUnreadMessage();
});

// markRead is not placed on the ChannelContext; the message lists own their own throttled
// instance via useMarkRead(channel). The lifecycle needs its own for mark-read on open and after a
// reconnect.
const markRead = useMarkRead(channel);

useEffect(() => {
const initChannel = async () => {
const unreadCount = channel.countUnread();
const shouldLoadAtFirstUnread = shouldLoadInitialChannelAtFirstUnreadMessage(unreadCount);
if (!channel) {
return;
}

// a draft has no id and is marked initialized and querying it would create it on the server
if (channel.isProvisional && channel.initialized) {
return;
}

channel.messagePaginator.pageSize = DEFAULT_MESSAGE_LIST_PAGE_SIZE;

if (
(!channel.initialized || !channel.messagePaginator.isActiveIntervalAtHead) &&
initializeOnMount
) {
try {
await channel?.watch();
} catch (err) {
console.warn('Channel watch request failed with error:', err);
channel.offlineMode = true;
}
}

// we seed the paginator on cold open (push/deep link for example)
if (!channel.messagePaginator.state.getLatestValue().items?.length) {
await channel.messagePaginator.reload();
}

// A reopened channel keeps its paginator and with it the unread snapshot from the first open.
// Seed it again from the current read state so the unread separator, the unread banner and
// the jump to the first unread match what is unread now.
channel.messagePaginator.seedUnreadSnapshot();

if (shouldLoadAtFirstUnread) {
try {
await channel.messagePaginator.jumpToTheFirstUnreadMessage({
focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
});
} catch (error) {
notifyJumpToFirstUnreadError(error);
}
}

if (unreadCount > 0 && markReadOnMount) {
// needed to keep the unread banner stable
await markRead({ updateChannelUnreadState: false });
}
};

initChannel();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channel]);

useEffect(() => channel?.activate(), [channel]);

useEffect(() => {
// Mark read has to wait for `connection.recovered`, as it is dispatched once the reloads have
// landed, so `hasMoreHead` read here reflects the refreshed window. Channel view only, and only
// when that window is at the newest, only if the user has paginated up into older history so leave
// their read state alone.
const { unsubscribe } = client.on('connection.recovered', () => {
if (channel.messagePaginator.hasMoreHead) {
return;
}
markRead();
});

return unsubscribe;
}, [channel, client, markRead]);

return { hasPendingInitialTargetLoad };
};
Loading