stream-chat-react-native-core
Version:
The official React Native and Expo components for Stream Chat, a service for building chat applications
158 lines (138 loc) • 5.22 kB
text/typescript
import { useCallback, useEffect, useRef } from 'react';
import type { Channel, Event, MessageResponse } from 'stream-chat';
import { useAccessibilityContext } from '../../../contexts/accessibilityContext/AccessibilityContext';
import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext';
import { useAccessibilityAnnouncer } from '../useAccessibilityAnnouncer';
const MESSAGE_ANNOUNCEMENT_THROTTLE_MS = 1000;
const isAnnounceableIncomingMessage = (message: MessageResponse, ownUserId?: string): boolean => {
const messageUserId = message.user?.id;
if (!message.id || !messageUserId || messageUserId === ownUserId) return false;
return (
message.type !== 'deleted' &&
message.type !== 'ephemeral' &&
message.type !== 'error' &&
message.type !== 'system' &&
message.status !== 'failed' &&
message.status !== 'sending'
);
};
const getSenderName = (
message: MessageResponse,
t: ReturnType<typeof useTranslationContext>['t'],
) => message.user?.name?.trim() || message.user?.id || t('Anonymous');
export type UseIncomingMessageAnnouncementsParams = {
activeThreadId?: string;
channel?: Channel;
ownUserId?: string;
threadList?: boolean;
};
/**
* Mirrors stream-chat-react's `useIncomingMessageAnnouncements`:
* - 1 message → "New message from {{user}}"
* - >1 messages within throttle window → "{{count}} new messages"
* - Throttled to one announcement per second
* - Bounded `announcedMessageIds` set so a long-running session does not leak.
*
* Subscribes to `channel.on('message.new')`. When AccessibilityContext.enabled
* is false OR `announceNewMessages` is false, the hook is a no-op (no
* subscription is opened, no listener cost is paid).
*/
export const useIncomingMessageAnnouncements = ({
activeThreadId,
channel,
ownUserId,
threadList = false,
}: UseIncomingMessageAnnouncementsParams) => {
const { announceNewMessages, enabled } = useAccessibilityContext();
const announce = useAccessibilityAnnouncer();
const { t } = useTranslationContext();
const lastAnnouncementTimestampRef = useRef(0);
const flushTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const announcedMessageIdsRef = useRef(new Set<string>());
const pendingAnnouncementBatchRef = useRef<{ count: number; firstSender: string | null }>({
count: 0,
firstSender: null,
});
const flushPendingAnnouncements = useCallback(() => {
const pending = pendingAnnouncementBatchRef.current;
if (pending.count <= 0) return;
if (pending.count === 1) {
announce(
t('a11y/New message from {{user}}', {
user: pending.firstSender || t('Anonymous'),
}),
);
} else {
announce(t('a11y/{{count}} new messages', { count: pending.count }));
}
pending.count = 0;
pending.firstSender = null;
lastAnnouncementTimestampRef.current = Date.now();
}, [announce, t]);
const scheduleFlush = useCallback(() => {
if (flushTimeoutRef.current) return;
const now = Date.now();
const elapsed = now - lastAnnouncementTimestampRef.current;
if (elapsed >= MESSAGE_ANNOUNCEMENT_THROTTLE_MS) {
flushPendingAnnouncements();
return;
}
flushTimeoutRef.current = setTimeout(() => {
flushTimeoutRef.current = undefined;
flushPendingAnnouncements();
}, MESSAGE_ANNOUNCEMENT_THROTTLE_MS - elapsed);
}, [flushPendingAnnouncements]);
useEffect(
() => () => {
if (flushTimeoutRef.current) clearTimeout(flushTimeoutRef.current);
},
[],
);
useEffect(() => {
if (!enabled || !announceNewMessages || !channel) return;
const handleMessageNew = (event: Event) => {
const message = event.message;
if (!message) return;
if (
(event.cid && event.cid !== channel.cid) ||
!isAnnounceableIncomingMessage(message, ownUserId)
) {
return;
}
const isReply = !!message.parent_id;
const belongsToActiveThread = !!activeThreadId && message.parent_id === activeThreadId;
const shouldAnnounceInThreadList = threadList && belongsToActiveThread;
const shouldAnnounceInMainList = !threadList && !isReply;
if (!shouldAnnounceInThreadList && !shouldAnnounceInMainList) return;
if (announcedMessageIdsRef.current.has(message.id || '')) return;
if (message.id) {
if (announcedMessageIdsRef.current.size > 500) {
announcedMessageIdsRef.current.clear();
}
announcedMessageIdsRef.current.add(message.id);
}
pendingAnnouncementBatchRef.current.count += 1;
if (!pendingAnnouncementBatchRef.current.firstSender) {
pendingAnnouncementBatchRef.current.firstSender = getSenderName(message, t);
}
scheduleFlush();
};
const subscription = channel.on('message.new', handleMessageNew);
return () => {
subscription.unsubscribe();
if (flushTimeoutRef.current) {
clearTimeout(flushTimeoutRef.current);
flushTimeoutRef.current = undefined;
}
};
}, [
activeThreadId,
announceNewMessages,
channel,
enabled,
ownUserId,
scheduleFlush,
t,
threadList,
]);
};