From cb1831ff3c4374c2accef2ba42dd2c8305e50caa Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Thu, 5 Mar 2026 16:31:52 +0100 Subject: [PATCH 1/2] Initial commit --- examples/vite/src/stream-imports-layout.scss | 2 +- examples/vite/src/stream-imports-theme.scss | 2 +- .../hooks/useLatestMessagePreview.ts | 202 ++++++----------- src/components/ChannelPreview/utils.tsx | 4 +- .../Threads/ThreadList/ThreadListItemUI.tsx | 203 +++++++++--------- .../ThreadList/styling/ThreadListItem.scss | 102 +++++++++ .../Threads/ThreadList/styling/index.scss | 1 + src/i18n/de.json | 3 + src/i18n/en.json | 3 + src/i18n/es.json | 3 + src/i18n/fr.json | 3 + src/i18n/hi.json | 3 + src/i18n/it.json | 5 +- src/i18n/ja.json | 2 + src/i18n/ko.json | 2 + src/i18n/nl.json | 3 + src/i18n/pt.json | 3 + src/i18n/ru.json | 6 + src/i18n/tr.json | 3 + src/styling/index.scss | 1 + 20 files changed, 313 insertions(+), 243 deletions(-) create mode 100644 src/components/Threads/ThreadList/styling/ThreadListItem.scss create mode 100644 src/components/Threads/ThreadList/styling/index.scss diff --git a/examples/vite/src/stream-imports-layout.scss b/examples/vite/src/stream-imports-layout.scss index c104f19ab..486b8a1b7 100644 --- a/examples/vite/src/stream-imports-layout.scss +++ b/examples/vite/src/stream-imports-layout.scss @@ -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'; diff --git a/examples/vite/src/stream-imports-theme.scss b/examples/vite/src/stream-imports-theme.scss index b594f9c76..19c8e0210 100644 --- a/examples/vite/src/stream-imports-theme.scss +++ b/examples/vite/src/stream-imports-theme.scss @@ -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'; diff --git a/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts b/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts index a78164b29..090556932 100644 --- a/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts +++ b/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts @@ -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. @@ -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 @@ -28,7 +28,7 @@ export type ChannelPreviewMessageType = | 'deleted' | 'error' | 'empty' - | 'photo' + | 'image' | 'video' | 'voice' | 'file' @@ -76,25 +76,13 @@ export type LatestMessagePreviewData = { senderName?: string; }; -const getLatestPollVote = (latestVotesByOption: Record) => { - 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'; @@ -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. @@ -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, @@ -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, - ); - 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, + ]); }; diff --git a/src/components/ChannelPreview/utils.tsx b/src/components/ChannelPreview/utils.tsx index 0c1218220..dc4d8abd5 100644 --- a/src/components/ChannelPreview/utils.tsx +++ b/src/components/ChannelPreview/utils.tsx @@ -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) => diff --git a/src/components/Threads/ThreadList/ThreadListItemUI.tsx b/src/components/Threads/ThreadList/ThreadListItemUI.tsx index e598c86eb..a2b9194c6 100644 --- a/src/components/Threads/ThreadList/ThreadListItemUI.tsx +++ b/src/components/Threads/ThreadList/ThreadListItemUI.tsx @@ -1,70 +1,44 @@ -import React, { useCallback } from 'react'; -import clsx from 'clsx'; +import React, { useCallback, useMemo } from 'react'; -import type { LocalMessage, ThreadState } from 'stream-chat'; +import type { ThreadState } from 'stream-chat'; import type { ComponentPropsWithoutRef } from 'react'; import { Timestamp } from '../../Message/Timestamp'; -import { Avatar, type AvatarProps } from '../../Avatar'; -import { Icon } from '../icons'; -import { UnreadCountBadge } from '../UnreadCountBadge'; - +import { Avatar, type AvatarProps, AvatarStack } from '../../Avatar'; import { useChannelPreviewInfo } from '../../ChannelPreview'; -import { useChatContext } from '../../../context'; +import { useChatContext, useTranslationContext } from '../../../context'; import { useThreadsViewContext } from '../../ChatView'; import { useThreadListItemContext } from './ThreadListItem'; import { useStateStore } from '../../../store'; +import { Badge } from '../../Badge'; +import { + type ChannelPreviewMessageType, + useLatestMessagePreview, +} from '../../ChannelPreview/hooks/useLatestMessagePreview'; +import { + IconCamera1, + IconChainLink, + IconCircleBanSign, + IconExclamationCircle1, + IconFileBend, + IconMapPin, + IconMicrophone, + IconVideo, +} from '../../Icons'; export type ThreadListItemUIProps = ComponentPropsWithoutRef<'button'>; -/** - * TODO: - * - maybe hover state? ask design - */ - -export const attachmentTypeIconMap = { - audio: '🔈', - file: '📄', - image: '📷', - video: '🎥', - voiceRecording: '🎙️', -} as const; - -// TODO: translations -const getTitleFromMessage = ({ - currentUserId, - message, -}: { - currentUserId?: string; - message?: LocalMessage; -}) => { - const attachment = message?.attachments?.at(0); - - let attachmentIcon = ''; - - if (attachment) { - attachmentIcon += - attachmentTypeIconMap[ - (attachment.type as keyof typeof attachmentTypeIconMap) ?? 'file' - ] ?? attachmentTypeIconMap.file; - } - - const messageBelongsToCurrentUser = message?.user?.id === currentUserId; - - if (message?.deleted_at && message.parent_id) - return clsx(messageBelongsToCurrentUser && 'You:', 'This reply was deleted.'); - - if (message?.deleted_at && !message.parent_id) - return clsx(messageBelongsToCurrentUser && 'You:', 'The source message was deleted.'); - - if (attachment?.type === 'voiceRecording') - return clsx(attachmentIcon, messageBelongsToCurrentUser && 'You:', 'Voice message'); - - return clsx( - attachmentIcon, - messageBelongsToCurrentUser && 'You:', - message?.text || attachment?.fallback || 'N/A', - ); +const contentTypeIconMap: Partial< + Record +> = { + deleted: IconCircleBanSign, + error: IconExclamationCircle1, + file: IconFileBend, + image: IconCamera1, + link: IconChainLink, + location: IconMapPin, + video: IconVideo, + voice: IconMicrophone, }; export const ThreadListItemUI = (props: ThreadListItemUIProps) => { @@ -80,70 +54,97 @@ export const ThreadListItemUI = (props: ThreadListItemUIProps) => { ownUnreadMessageCount: (client.userID && nextValue.read[client.userID]?.unreadMessageCount) || 0, parentMessage: nextValue.parentMessage, + participants: nextValue.participants, + replyCount: nextValue.replyCount, }), [client], ); - const { channel, deletedAt, latestReply, ownUnreadMessageCount, parentMessage } = - useStateStore(thread.state, selector); + const { + channel, + deletedAt, + latestReply, + ownUnreadMessageCount, + parentMessage, + participants, + replyCount, + } = useStateStore(thread.state, selector); + + const { senderName, text, type } = useLatestMessagePreview({ + latestMessage: parentMessage, + participantCount: participants?.length, + }); + + const ContentTypeIcon = contentTypeIconMap[type]; const { displayTitle: channelDisplayTitle } = useChannelPreviewInfo({ channel }); + const { t } = useTranslationContext('ThreadListItemUI'); const { activeThread, setActiveThread } = useThreadsViewContext(); - const avatarProps: AvatarProps | undefined = deletedAt + const avatarProps: Partial | undefined = deletedAt ? undefined : ({ imageUrl: latestReply?.user?.image, - size: 'md', userName: latestReply?.user?.name || latestReply?.user?.id, } as const); + const displayInfo = useMemo(() => { + if (!participants) return []; + + return participants.slice(0, 3).map((participant) => ({ + id: participant.user?.id ?? undefined, + imageUrl: participant.user?.image, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + userName: participant.user?.name || participant.user!.id, + })); + }, [participants]); + return ( - + {ownUnreadMessageCount > 0 && ( + + {ownUnreadMessageCount} + + )} + + ); }; diff --git a/src/components/Threads/ThreadList/styling/ThreadListItem.scss b/src/components/Threads/ThreadList/styling/ThreadListItem.scss new file mode 100644 index 000000000..ee631c1eb --- /dev/null +++ b/src/components/Threads/ThreadList/styling/ThreadListItem.scss @@ -0,0 +1,102 @@ +.str-chat__thread-list-item-container { + border-bottom: 1px solid var(--border-core-subtle); + padding: var(--spacing-xxs); +} + +.str-chat__thread-list-item { + display: flex; + gap: var(--spacing-sm); + align-items: flex-start; + padding-inline: var(--spacing-sm); + padding-block: var(--spacing-sm); + padding-block-start: calc(var(--spacing-sm) - 1px); + border: none; + background: none; + cursor: pointer; + text-align: start; + font-family: var(--typography-font-family-sans); + background: var(--background-elevation-elevation-1); + border-radius: var(--radius-lg); + width: 100%; + + &:not(:disabled):hover { + background: var(--background-core-hover); + } + + &:not(:disabled):active { + background: var(--background-core-pressed); + } + + &[aria-selected='true'] { + background: var(--background-core-selected); + } + + .str-chat__avatar { + flex-shrink: 0; + } +} + +.str-chat__thread-list-item__content { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + flex: 1 0 0; + min-width: 0; +} + +.str-chat__thread-list-item__content-leading { + display: flex; + flex-direction: column; + gap: var(--spacing-xxs); + padding-block: var(--spacing-xxxs); +} + +.str-chat__thread-list-item__title { + font-size: var(--typography-font-size-sm); + font-weight: var(--typography-font-weight-regular); + line-height: var(--typography-line-height-normal); + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.str-chat__thread-list-item__message-preview { + font-size: var(--typography-font-size-md); + font-weight: var(--typography-font-weight-regular); + line-height: var(--typography-line-height-normal); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.str-chat__thread-list-item__content-trailing { + display: flex; + gap: var(--spacing-xs); + align-items: center; +} + +.str-chat__thread-list-item__reply-information { + display: flex; + gap: var(--spacing-xs); + align-items: center; +} + +.str-chat__thread-list-item__reply-count { + font-size: var(--typography-font-size-sm); + font-weight: var(--typography-font-weight-semi-bold); + line-height: var(--typography-line-height-normal); + color: var(--text-link); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.str-chat__thread-list-item__timestamp { + font-size: var(--typography-font-size-sm); + font-weight: var(--typography-font-weight-regular); + line-height: var(--typography-line-height-normal); + color: var(--text-tertiary); + white-space: nowrap; +} diff --git a/src/components/Threads/ThreadList/styling/index.scss b/src/components/Threads/ThreadList/styling/index.scss new file mode 100644 index 000000000..1e9f17152 --- /dev/null +++ b/src/components/Threads/ThreadList/styling/index.scss @@ -0,0 +1 @@ +@use 'ThreadListItem'; diff --git a/src/i18n/de.json b/src/i18n/de.json index 3ef4ecb2e..7b58b3ab6 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -164,11 +164,14 @@ "File": "Datei", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Datei ist zu groß: {{ size }}, maximale Upload-Größe beträgt {{ limit }}", "File too large": "Datei ist zu groß", + "fileCount_one": "1 datei", + "fileCount_other": "{{ count }} dateien", "Flag": "Melden", "Generating...": "Generieren...", "giphy-command-args": "[Text]", "giphy-command-description": "Poste ein zufälliges Gif in den Kanal", "Hide who voted": "Verbergen, wer abgestimmt hat", + "Image": "Bild", "Instant commands": "Instant commands", "language/af": "Afrikaans", "language/am": "Amharisch", diff --git a/src/i18n/en.json b/src/i18n/en.json index 3f6d51e23..4601cb628 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -164,11 +164,14 @@ "File": "File", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "File is too large: {{ size }}, maximum upload size is {{ limit }}", "File too large": "File too large", + "fileCount_one": "1 file", + "fileCount_other": "{{ count }} files", "Flag": "Flag", "Generating...": "Generating...", "giphy-command-args": "[text]", "giphy-command-description": "Post a random gif to the channel", "Hide who voted": "Hide who voted", + "Image": "Image", "Instant commands": "Instant commands", "language/af": "Afrikaans", "language/am": "Amharic", diff --git a/src/i18n/es.json b/src/i18n/es.json index 8a7e0020e..4ca1e97c1 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -171,11 +171,14 @@ "File": "Archivo", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "El archivo es demasiado grande: {{ size }}, el tamaño máximo de carga es de {{ limit }}", "File too large": "Archivo demasiado grande", + "fileCount_one": "1 archivo", + "fileCount_other": "{{ count }} archivos", "Flag": "Marcar", "Generating...": "Generando...", "giphy-command-args": "[texto]", "giphy-command-description": "Publicar un gif aleatorio en el canal", "Hide who voted": "Ocultar quién votó", + "Image": "Imagen", "Instant commands": "Comandos instantáneos", "language/af": "Afrikáans", "language/am": "Amárico", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 3197aafb6..442e8f770 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -171,11 +171,14 @@ "File": "Fichier", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Le fichier est trop volumineux : {{ size }}, la taille maximale de téléchargement est de {{ limit }}", "File too large": "Fichier trop volumineux", + "fileCount_one": "1 fichier", + "fileCount_other": "{{ count }} fichiers", "Flag": "Signaler", "Generating...": "Génération...", "giphy-command-args": "[texte]", "giphy-command-description": "Poster un GIF aléatoire dans le canal", "Hide who voted": "Masquer qui a voté", + "Image": "Image", "Instant commands": "Commandes instantanées", "language/af": "Afrikaans", "language/am": "Amharique", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 1f829d948..e5363b956 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -165,11 +165,14 @@ "File": "फ़ाइल", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "फ़ाइल बहुत बड़ी है: {{ size }}, अधिकतम अपलोड साइज़ {{ limit }} है", "File too large": "फ़ाइल बहुत बड़ी है", + "fileCount_one": "1 फ़ाइल", + "fileCount_other": "{{ count }} फ़ाइलें", "Flag": "फ्लैग करे", "Generating...": "बना रहा है...", "giphy-command-args": "[पाठ]", "giphy-command-description": "चैनल पर एक क्रॉफिल जीआइएफ पोस्ट करें", "Hide who voted": "किसने वोट दिया छिपाएं", + "Image": "छवि", "Instant commands": "तत्काल कमांड", "language/af": "अफ्रीकी", "language/am": "अम्हारिक", diff --git a/src/i18n/it.json b/src/i18n/it.json index cc623f2a9..e4c087f52 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -171,11 +171,14 @@ "File": "File", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Il file è troppo grande: {{ size }}, la dimensione massima di caricamento è {{ limit }}", "File too large": "File troppo grande", + "fileCount_one": "1 file", + "fileCount_other": "{{ count }} file", "Flag": "Segnala", "Generating...": "Generando...", "giphy-command-args": "[testo]", "giphy-command-description": "Pubblica un gif casuale sul canale", "Hide who voted": "Nascondi chi ha votato", + "Image": "Immagine", "Instant commands": "Comandi istantanei", "language/af": "Afrikaans", "language/am": "Amarico", @@ -236,7 +239,7 @@ "language/zh-TW": "Cinese (tradizionale)", "Let others add options": "Lascia che altri aggiungano opzioni", "Limit votes per person": "Limita i voti per persona", - "Link": "Link", + "Link": "Collegamento", "live": "live", "Live for {{duration}}": "Live per {{duration}}", "Live location": "Posizione live", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index d879cd56c..ad0bbb421 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -163,11 +163,13 @@ "File": "ファイル", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "ファイルが大きすぎます:{{ size }}、最大アップロードサイズは{{ limit }}です", "File too large": "ファイルが大きすぎます", + "fileCount_other": "{{ count }}件のファイル", "Flag": "フラグ", "Generating...": "生成中...", "giphy-command-args": "[テキスト]", "giphy-command-description": "チャンネルにランダムなGIFを投稿する", "Hide who voted": "誰が投票したかを非表示にする", + "Image": "画像", "Instant commands": "インスタントコマンド", "language/af": "アフリカース語", "language/am": "アムハラ語", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 84a1bf68c..6d81eed1d 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -163,11 +163,13 @@ "File": "파일", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "파일이 너무 큽니다: {{ size }}, 최대 업로드 크기는 {{ limit }}입니다", "File too large": "파일이 너무 큽니다", + "fileCount_other": "파일 {{ count }}개", "Flag": "플래그", "Generating...": "생성 중...", "giphy-command-args": "[텍스트]", "giphy-command-description": "채널에 무작위 GIF 게시", "Hide who voted": "누가 투표했는지 숨기기", + "Image": "이미지", "Instant commands": "즉시 명령어", "language/af": "아프리칸스어", "language/am": "암하라어", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 6ea6c75cc..8f7c01223 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -164,11 +164,14 @@ "File": "Bestand", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Bestand is te groot: {{ size }}, maximale uploadgrootte is {{ limit }}", "File too large": "Bestand is te groot", + "fileCount_one": "1 bestand", + "fileCount_other": "{{ count }} bestanden", "Flag": "Markeer", "Generating...": "Genereren...", "giphy-command-args": "[tekst]", "giphy-command-description": "Plaats een willekeurige gif in het kanaal", "Hide who voted": "Verberg wie heeft gestemd", + "Image": "Afbeelding", "Instant commands": "Snelle opdrachten", "language/af": "Afrikaans", "language/am": "Amhaars", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index b5d7efd97..a0dbe7833 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -171,11 +171,14 @@ "File": "Arquivo", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "O arquivo é muito grande: {{ size }}, o tamanho máximo de upload é {{ limit }}", "File too large": "Arquivo muito grande", + "fileCount_one": "1 arquivo", + "fileCount_other": "{{ count }} arquivos", "Flag": "Reportar", "Generating...": "Gerando...", "giphy-command-args": "[texto]", "giphy-command-description": "Postar um gif aleatório no canal", "Hide who voted": "Ocultar quem votou", + "Image": "Imagem", "Instant commands": "Comandos instantâneos", "language/af": "Africâner", "language/am": "Amárico", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 3e7914f61..3d3326b1b 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -178,11 +178,17 @@ "File": "Файл", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Файл слишком большой: {{ size }}, максимальный размер загрузки составляет {{ limit }}", "File too large": "Файл слишком большой", + "fileCount_one": "{{ count }} файл", + "fileCount_two": "{{ count }} файла", + "fileCount_three": "{{ count }} файла", + "fileCount_four": "{{ count }} файла", + "fileCount_other": "{{ count }} файлов", "Flag": "Пожаловаться", "Generating...": "Генерирую...", "giphy-command-args": "[текст]", "giphy-command-description": "Опубликовать случайную GIF-анимацию в канале", "Hide who voted": "Скрыть, кто голосовал", + "Image": "Изображение", "Instant commands": "Мгновенные команды", "language/af": "Африкаанс", "language/am": "Амхарский", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 986d13f80..a930be85f 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -164,11 +164,14 @@ "File": "Dosya", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Dosya çok büyük: {{ size }}, maksimum yükleme boyutu {{ limit }}", "File too large": "Dosya çok büyük", + "fileCount_one": "1 dosya", + "fileCount_other": "{{ count }} dosya", "Flag": "Bayrak", "Generating...": "Oluşturuluyor...", "giphy-command-args": "[metin]", "giphy-command-description": "Rastgele bir gif'i kanala gönder", "Hide who voted": "Kimin oy verdiğini gizle", + "Image": "Görsel", "Instant commands": "Anlık komutlar", "language/af": "Afrikanca", "language/am": "Amharca", diff --git a/src/styling/index.scss b/src/styling/index.scss index b04392068..f25b1d879 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -44,6 +44,7 @@ @use '../components/Reactions/styling' as Reactions; @use '../components/TextareaComposer/styling' as TextareaComposer; @use '../components/Thread/styling' as Thread; +@use '../components/Threads/ThreadList/styling' as ThreadList; @use '../components/VideoPlayer/styling' as VideoPlayer; // Layers have to be kept the last From 3e445a1a293021366f28c41e152f955c5888b731 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Thu, 5 Mar 2026 16:59:00 +0100 Subject: [PATCH 2/2] Another update --- .../ChannelPreviewMessenger.tsx | 8 +-- .../styling/ChannelPreview.scss | 3 +- .../Threads/ThreadList/ThreadListItem.tsx | 46 --------------- .../Threads/ThreadList/ThreadListItemUI.tsx | 11 +++- .../ThreadList/styling/ThreadListItem.scss | 56 +++++++++++++++++-- src/i18n/es.json | 1 + src/i18n/fr.json | 1 + src/i18n/it.json | 1 + src/i18n/pt.json | 1 + src/i18n/ru.json | 8 ++- 10 files changed, 74 insertions(+), 62 deletions(-) diff --git a/src/components/ChannelPreview/ChannelPreviewMessenger.tsx b/src/components/ChannelPreview/ChannelPreviewMessenger.tsx index 74f8809d4..1a9ecaf14 100644 --- a/src/components/ChannelPreview/ChannelPreviewMessenger.tsx +++ b/src/components/ChannelPreview/ChannelPreviewMessenger.tsx @@ -40,9 +40,9 @@ const contentTypeIconMap: Partial< > = { deleted: IconCircleBanSign, file: IconFileBend, + image: IconCamera1, link: IconChainLink, location: IconMapPin, - photo: IconCamera1, video: IconVideo, voice: IconMicrophone, }; @@ -71,9 +71,9 @@ const UnMemoizedChannelPreviewMessenger = (props: ChannelPreviewUIComponentProps const channelPreviewButton = useRef(null); const { deliveryStatus, senderName, text, type } = useLatestMessagePreview({ - channel, - lastMessage, + latestMessage: lastMessage, messageDeliveryStatus, + participantCount: channel.data?.member_count, }); const DeliveryStatusIcon = deliveryStatus @@ -164,7 +164,7 @@ const UnMemoizedChannelPreviewMessenger = (props: ChannelPreviewUIComponentProps )} {ContentTypeIcon && } - {text} + {text} )} diff --git a/src/components/ChannelPreview/styling/ChannelPreview.scss b/src/components/ChannelPreview/styling/ChannelPreview.scss index 2d2df0f41..6bc9c0620 100644 --- a/src/components/ChannelPreview/styling/ChannelPreview.scss +++ b/src/components/ChannelPreview/styling/ChannelPreview.scss @@ -118,6 +118,7 @@ } } } + .str-chat__channel-preview-data__latest-message { display: flex; align-items: center; @@ -147,7 +148,7 @@ color: var(--text-tertiary); } - p { + .channel-preview-data__latest-message-text { flex: 1; min-width: 0; diff --git a/src/components/Threads/ThreadList/ThreadListItem.tsx b/src/components/Threads/ThreadList/ThreadListItem.tsx index bd2ca7481..c5f4ec611 100644 --- a/src/components/Threads/ThreadList/ThreadListItem.tsx +++ b/src/components/Threads/ThreadList/ThreadListItem.tsx @@ -28,49 +28,3 @@ export const ThreadListItem = ({ ); }; - -// const App = () => { -// const route = useRouter(); - -// return ( -// -// {route === '/channels' && ( -// -// -// -// -// )} -// {route === '/threads' && ( -// -// -// -// -// -// -// )} -// -// ); -// }; - -// pre-built layout - -{ - /* - - - // has default - - - - - - - - <-- activeThread state - <-- uses context for click handler - <-- ThreadProvider + Channel combo - - -; -*/ -} diff --git a/src/components/Threads/ThreadList/ThreadListItemUI.tsx b/src/components/Threads/ThreadList/ThreadListItemUI.tsx index a2b9194c6..72047ee40 100644 --- a/src/components/Threads/ThreadList/ThreadListItemUI.tsx +++ b/src/components/Threads/ThreadList/ThreadListItemUI.tsx @@ -25,6 +25,7 @@ import { IconMicrophone, IconVideo, } from '../../Icons'; +import clsx from 'clsx'; export type ThreadListItemUIProps = ComponentPropsWithoutRef<'button'>; @@ -103,7 +104,7 @@ export const ThreadListItemUI = (props: ThreadListItemUIProps) => { return (