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
2 changes: 1 addition & 1 deletion examples/vite/src/stream-imports-layout.scss
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
@use 'stream-chat-react/dist/scss/v2/Thread/Thread-layout';
@use 'stream-chat-react/dist/scss/v2/Tooltip/Tooltip-layout';
@use 'stream-chat-react/dist/scss/v2/TypingIndicator/TypingIndicator-layout';
@use 'stream-chat-react/dist/scss/v2/ThreadList/ThreadList-layout';
// @use 'stream-chat-react/dist/scss/v2/ThreadList/ThreadList-layout';
//@use 'stream-chat-react/dist/scss/v2/ChatView/ChatView-layout';
@use 'stream-chat-react/dist/scss/v2/UnreadCountBadge/UnreadCountBadge-layout';
@use 'stream-chat-react/dist/scss/v2/AIStateIndicator/AIStateIndicator-layout';
2 changes: 1 addition & 1 deletion examples/vite/src/stream-imports-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
@use 'stream-chat-react/dist/scss/v2/Thread/Thread-theme';
@use 'stream-chat-react/dist/scss/v2/Tooltip/Tooltip-theme';
@use 'stream-chat-react/dist/scss/v2/TypingIndicator/TypingIndicator-theme';
@use 'stream-chat-react/dist/scss/v2/ThreadList/ThreadList-theme';
// @use 'stream-chat-react/dist/scss/v2/ThreadList/ThreadList-theme';
//@use 'stream-chat-react/dist/scss/v2/ChatView/ChatView-theme';
@use 'stream-chat-react/dist/scss/v2/UnreadCountBadge/UnreadCountBadge-theme';
@use 'stream-chat-react/dist/scss/v2/AIStateIndicator/AIStateIndicator-theme';
8 changes: 4 additions & 4 deletions src/components/ChannelPreview/ChannelPreviewMessenger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ const contentTypeIconMap: Partial<
> = {
deleted: IconCircleBanSign,
file: IconFileBend,
image: IconCamera1,
link: IconChainLink,
location: IconMapPin,
photo: IconCamera1,
video: IconVideo,
voice: IconMicrophone,
};
Expand Down Expand Up @@ -71,9 +71,9 @@ const UnMemoizedChannelPreviewMessenger = (props: ChannelPreviewUIComponentProps
const channelPreviewButton = useRef<HTMLButtonElement | null>(null);

const { deliveryStatus, senderName, text, type } = useLatestMessagePreview({
channel,
lastMessage,
latestMessage: lastMessage,
messageDeliveryStatus,
participantCount: channel.data?.member_count,
});

const DeliveryStatusIcon = deliveryStatus
Expand Down Expand Up @@ -164,7 +164,7 @@ const UnMemoizedChannelPreviewMessenger = (props: ChannelPreviewUIComponentProps
</span>
)}
{ContentTypeIcon && <ContentTypeIcon />}
<span>{text}</span>
<span className='channel-preview-data__latest-message-text'>{text}</span>
</>
)}
</div>
Expand Down
202 changes: 64 additions & 138 deletions src/components/ChannelPreview/hooks/useLatestMessagePreview.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useMemo } from 'react';
import type { Attachment, Channel, LocalMessage, PollVote } from 'stream-chat';
import type { Attachment, LocalMessage } from 'stream-chat';

import { useChatContext } from '../../../context/ChatContext';
import { useTranslationContext } from '../../../context/TranslationContext';
import { getTranslatedMessageText } from '../../../context/MessageTranslationViewContext';
import type { TranslationContextValue } from '../../../context/TranslationContext';
import { MessageDeliveryStatus } from './useMessageDeliveryStatus';
import type { MessageDeliveryStatus } from './useMessageDeliveryStatus';

/**
* The type of content being previewed in the channel list item.
Expand All @@ -15,7 +15,7 @@ import { MessageDeliveryStatus } from './useMessageDeliveryStatus';
* - `deleted` — Deleted message, renders ban/circle icon
* - `error` — Failed message, renders exclamation circle icon
* - `empty` — No messages yet, no icon
* - `photo` — Photo attachment, renders camera icon
* - `image` — Image attachment, renders camera icon
* - `video` — Video attachment, renders video icon
* - `voice` — Voice message, renders microphone icon
* - `file` — File attachment, renders file icon
Expand All @@ -28,7 +28,7 @@ export type ChannelPreviewMessageType =
| 'deleted'
| 'error'
| 'empty'
| 'photo'
| 'image'
| 'video'
| 'voice'
| 'file'
Expand Down Expand Up @@ -76,25 +76,13 @@ export type LatestMessagePreviewData = {
senderName?: string;
};

const getLatestPollVote = (latestVotesByOption: Record<string, PollVote[]>) => {
let latestVote: PollVote | undefined;
for (const optionVotes of Object.values(latestVotesByOption)) {
optionVotes.forEach((vote) => {
if (latestVote && new Date(latestVote.updated_at) >= new Date(vote.created_at))
return;
latestVote = vote;
});
}
return latestVote;
};

function getAttachmentContentType(attachments: Attachment[]): ChannelPreviewMessageType {
const [first] = attachments;
if (!first) return 'text';

if (first.type === 'image' || first.type === 'giphy') return 'photo';
if (first.type === 'image' || first.type === 'giphy') return 'image';
if (first.type === 'video') return 'video';
if (first.type === 'voicenote' || first.type === 'audio') return 'voice';
if (first.type === 'voiceRecording') return 'voice';
if (first.type === 'file') return 'file';
if (first.og_scrape_url || first.title_link) return 'link';

Expand All @@ -106,28 +94,25 @@ function getAttachmentFallbackText(
t: TranslationContextValue['t'],
): string {
switch (type) {
case 'photo':
return t('Photo');
case 'image':
return t('Image');
case 'video':
return t('Video');
case 'voice':
return t('Voice message');
case 'file':
return t('File');
case 'link':
return t('Link');
case 'location':
return t('Shared location');
case 'file':
default:
return t('Attachment');
return t('File');
}
}

export type UseLatestMessagePreviewParams = {
/** The channel to generate preview for */
channel: Channel;
/** The last message in the channel */
lastMessage?: LocalMessage;
participantCount?: number;
/** The latest message in the channel */
latestMessage?: LocalMessage;
/**
* Delivery status from the `useMessageDeliveryStatus` hook.
* When provided, used to determine the delivery status icon.
Expand All @@ -136,71 +121,38 @@ export type UseLatestMessagePreviewParams = {
messageDeliveryStatus?: MessageDeliveryStatus;
};

/**
* Hook that produces structured preview data for the channel list item's message row.
*
* Given a channel and its last message, returns the preview type, text, delivery status,
* and sender name that can be used to render appropriate icons and formatted text.
*
* @example
* ```tsx
* const { type, text, deliveryStatus, senderName } = useLatestMessagePreview({
* channel,
* lastMessage,
* messageDeliveryStatus,
* });
* ```
*/
export const useLatestMessagePreview = ({
channel,
lastMessage,
latestMessage,
messageDeliveryStatus,
participantCount = Infinity,
}: UseLatestMessagePreviewParams): LatestMessagePreviewData => {
const { client } = useChatContext('useLatestMessagePreview');
const { t, userLanguage } = useTranslationContext('useLatestMessagePreview');

return useMemo(() => {
// Empty channel — no messages yet
if (!lastMessage) {
if (!latestMessage) {
return { text: t('Nothing yet...'), type: 'empty' as const };
}

// Failed message — error state overrides everything
if (lastMessage.status === 'failed' || lastMessage.type === 'error') {
if (latestMessage.status === 'failed' || latestMessage.type === 'error') {
return { text: t('Message failed to send'), type: 'error' as const };
}

// Determine ownership
const isOwnMessage = lastMessage.user?.id === client.user?.id;
const isOwnMessage = latestMessage.user?.id === client.user?.id;

// Compute delivery status for own messages
let deliveryStatus: ChannelPreviewDeliveryStatus | undefined;
if (isOwnMessage) {
if (lastMessage.status === 'sending') {
deliveryStatus = 'sending';
} else if (messageDeliveryStatus === MessageDeliveryStatus.READ) {
deliveryStatus = 'read';
} else if (messageDeliveryStatus === MessageDeliveryStatus.DELIVERED) {
deliveryStatus = 'delivered';
} else if (messageDeliveryStatus === MessageDeliveryStatus.SENT) {
deliveryStatus = 'sent';
}
deliveryStatus = messageDeliveryStatus ?? (latestMessage.status as 'sending');
}

// Compute sender name prefix
let senderName: string | undefined;
if (isOwnMessage) {
senderName = t('You');
} else {
console.log(lastMessage, channel);
const memberCount = channel.data?.member_count ?? Infinity;
if (memberCount > 2) {
senderName = lastMessage.user?.name || lastMessage.user?.id;
}
} else if (!isOwnMessage && participantCount > 2) {
senderName = latestMessage.user?.name || latestMessage.user?.id;
}

// Deleted message
if (lastMessage.deleted_at) {
if (latestMessage.deleted_at) {
return {
deliveryStatus,
senderName,
Expand All @@ -209,92 +161,66 @@ export const useLatestMessagePreview = ({
};
}

// Poll message
if (lastMessage.poll) {
const poll = lastMessage.poll;
let text: string;

if (!poll.vote_count) {
const createdBy =
poll.created_by?.id === client.user?.id
? t('You')
: (poll.created_by?.name ?? t('Poll'));
text = t('📊 {{createdBy}} created: {{ pollName}}', {
createdBy,
pollName: poll.name,
});
} else {
const latestVote = getLatestPollVote(
poll.latest_votes_by_option as Record<string, PollVote[]>,
);
const option =
latestVote && poll.options.find((opt) => opt.id === latestVote.option_id);

if (option && latestVote) {
text = t('📊 {{votedBy}} voted: {{pollOptionText}}', {
pollOptionText: option.text,
votedBy:
latestVote?.user?.id === client.user?.id
? t('You')
: (latestVote.user?.name ?? t('Poll')),
});
} else {
text = `📊 ${poll.name}`;
}
}

return { deliveryStatus, senderName, text, type: 'poll' as const };
if (latestMessage.poll) {
return {
deliveryStatus,
senderName,
text: t('Poll'),
type: 'poll' as const,
};
}

// Get text content (with translation support)
const textContent =
getTranslatedMessageText({ language: userLanguage, message: lastMessage }) ||
lastMessage.text;
getTranslatedMessageText({ language: userLanguage, message: latestMessage }) ||
latestMessage.text;

const attachments = lastMessage.attachments;
const hasAttachments = !!attachments?.length;
const hasLocation = !!lastMessage.shared_location;
if (latestMessage.shared_location) {
return {
deliveryStatus,
senderName,
text: textContent || t('Location'),
type: 'location' as const,
};
}

// Message with text content (may also have attachments — attachment icon + text)
if (textContent) {
const type =
hasAttachments && attachments
? getAttachmentContentType(attachments)
: hasLocation
? ('location' as const)
: ('text' as const);
if (latestMessage.attachments && latestMessage.attachments.length) {
const attachments = latestMessage.attachments;

return { deliveryStatus, senderName, text: textContent, type };
}
let contentType: ChannelPreviewMessageType;
let text: string;

if (attachments.length === 1) {
contentType = getAttachmentContentType(attachments);
text = textContent || getAttachmentFallbackText(contentType, t);
} else {
contentType = 'file';
text = textContent || t('fileCount', { count: attachments.length });
}

// Command
if (lastMessage.command) {
return {
deliveryStatus,
senderName,
text: `/${lastMessage.command}`,
type: 'text' as const,
text,
type: contentType,
};
}

// Attachment-only message (no text content)
if (hasAttachments && attachments) {
const contentType = getAttachmentContentType(attachments);
const text = getAttachmentFallbackText(contentType, t);
return { deliveryStatus, senderName, text, type: contentType };
}

// Location-only message
if (hasLocation) {
if (textContent) {
return {
deliveryStatus,
senderName,
text: t('Shared location'),
type: 'location' as const,
text: textContent,
type: 'text' as const,
};
}

// Fallback
return { text: t('Empty message...'), type: 'empty' as const };
}, [channel, client, lastMessage, messageDeliveryStatus, t, userLanguage]);
}, [
client.user?.id,
latestMessage,
messageDeliveryStatus,
participantCount,
t,
userLanguage,
]);
};
3 changes: 2 additions & 1 deletion src/components/ChannelPreview/styling/ChannelPreview.scss
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
}
}
}

.str-chat__channel-preview-data__latest-message {
display: flex;
align-items: center;
Expand Down Expand Up @@ -147,7 +148,7 @@
color: var(--text-tertiary);
}

p {
.channel-preview-data__latest-message-text {
flex: 1;
min-width: 0;

Expand Down
4 changes: 3 additions & 1 deletion src/components/ChannelPreview/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ const getChannelDisplayInfo = (
const members = Object.values(channel.state.members);
if (members.length !== 2) return;
const otherMember = members.find((member) => member.user?.id !== currentUser?.id);
return otherMember?.user?.[info];
return (
otherMember?.user?.[info] || (info === 'name' ? otherMember?.user?.id : undefined)
);
};

export const getDisplayTitle = (channel: Channel, currentUser?: UserResponse) =>
Expand Down
Loading
Loading