From 04dfe295e6e3f9845c3175c2f328aa9a81ec1844 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 20 Feb 2026 10:38:57 +0100 Subject: [PATCH 01/16] feat: show username in metadata only for 3+ members --- src/components/Message/MessageSimple.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index 4fcdbeb3d..ded032cfb 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -38,7 +38,11 @@ import { useComponentContext } from '../../context/ComponentContext'; import type { MessageContextValue } from '../../context/MessageContext'; import { useMessageContext } from '../../context/MessageContext'; -import { useChatContext, useTranslationContext } from '../../context'; +import { + useChannelStateContext, + useChatContext, + useTranslationContext, +} from '../../context'; import { MessageEditedTimestamp } from './MessageEditedTimestamp'; import type { MessageUIComponentProps } from './types'; @@ -64,8 +68,10 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { showAvatar = 'incoming', threadList, } = props; - const { client } = useChatContext('MessageSimple'); - const { t } = useTranslationContext('MessageSimple'); + const { channel } = useChannelStateContext(); + const { client } = useChatContext(); + const { t } = useTranslationContext(); + const memberCount = Object.keys(channel?.state?.members ?? {}).length; const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false); const [isEditedTimestampOpen, setEditedTimestampOpen] = useState(false); const reminder = useMessageReminder(message.id); @@ -231,7 +237,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { {showMetadata && (
- {!isMyMessage() && !!message.user && ( + {!isMyMessage() && !!message.user && memberCount > 2 && ( {message.user.name || message.user.id} From 247674559e1d7956df09bf4f0b8393a6f9e4e887 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 20 Feb 2026 10:39:39 +0100 Subject: [PATCH 02/16] feat: show "Pinned by You" for messages pinned by own user --- src/components/Message/PinIndicator.tsx | 10 ++++++++-- src/i18n/de.json | 1 + src/i18n/en.json | 1 + src/i18n/es.json | 1 + src/i18n/fr.json | 1 + src/i18n/hi.json | 1 + src/i18n/it.json | 1 + src/i18n/ja.json | 1 + src/i18n/ko.json | 1 + src/i18n/nl.json | 1 + src/i18n/pt.json | 1 + src/i18n/ru.json | 1 + src/i18n/tr.json | 1 + 13 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/components/Message/PinIndicator.tsx b/src/components/Message/PinIndicator.tsx index cc9249d6f..a8e1b1858 100644 --- a/src/components/Message/PinIndicator.tsx +++ b/src/components/Message/PinIndicator.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { IconPin } from '../Icons'; -import { useTranslationContext } from '../../context'; +import { useChatContext, useTranslationContext } from '../../context'; import type { LocalMessage } from 'stream-chat'; export type PinIndicatorProps = { @@ -14,12 +14,18 @@ export type PinIndicatorProps = { */ export const PinIndicator = ({ message }: PinIndicatorProps) => { const { t } = useTranslationContext(); + const { client } = useChatContext(); if (!message) return null; + const isOwnPin = !!message.pinned_by?.id && message.pinned_by.id === client.user?.id; const name = message.pinned_by?.name ?? message.pinned_by?.id ?? ''; - const label = name ? t('Pinned by {{ name }}', { name }) : t('Message pinned'); + const label = isOwnPin + ? t('Pinned by You') + : name + ? t('Pinned by {{ name }}', { name }) + : t('Message pinned'); return (
diff --git a/src/i18n/de.json b/src/i18n/de.json index 2995a44db..404f391d9 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -209,6 +209,7 @@ "Photo": "Foto", "Pin": "Anheften", "Pinned by {{ name }}": "Angeheftet von {{ name }}", + "Pinned by You": "Von Ihnen angeheftet", "Play video": "Video abspielen", "Poll": "Umfrage", "Poll comments": "Umfragekommentare", diff --git a/src/i18n/en.json b/src/i18n/en.json index 449e320a2..94598b4ba 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -209,6 +209,7 @@ "Photo": "Photo", "Pin": "Pin", "Pinned by {{ name }}": "Pinned by {{ name }}", + "Pinned by You": "Pinned by You", "Play video": "Play video", "Poll": "Poll", "Poll comments": "Poll comments", diff --git a/src/i18n/es.json b/src/i18n/es.json index 742831c68..64e17dc41 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -214,6 +214,7 @@ "Photo": "Foto", "Pin": "Fijar", "Pinned by {{ name }}": "Fijado por {{ name }}", + "Pinned by You": "Anclado por ti", "Play video": "Reproducir video", "Poll": "Encuesta", "Poll comments": "Comentarios de la encuesta", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index f31621c2c..6c30a682f 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -214,6 +214,7 @@ "Photo": "Photo", "Pin": "Épingler", "Pinned by {{ name }}": "Épinglé par {{ name }}", + "Pinned by You": "Épinglé par vous", "Play video": "Lire la vidéo", "Poll": "Sondage", "Poll comments": "Commentaires du sondage", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 70c85f70a..c1dbb6d51 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -210,6 +210,7 @@ "Photo": "फ़ोटो", "Pin": "पिन", "Pinned by {{ name }}": "{{ name }} द्वारा पिन किया गया", + "Pinned by You": "आपके द्वारा पिन किया गया", "Play video": "वीडियो चलाएं", "Poll": "मतदान", "Poll comments": "मतदान टिप्पणियाँ", diff --git a/src/i18n/it.json b/src/i18n/it.json index 3d8659da0..e8012052c 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -214,6 +214,7 @@ "Photo": "Foto", "Pin": "Appunta", "Pinned by {{ name }}": "Appuntato da {{ name }}", + "Pinned by You": "Fissato da te", "Play video": "Riproduci video", "Poll": "Sondaggio", "Poll comments": "Commenti del sondaggio", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 813fa5dbd..6ca9d22ea 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -209,6 +209,7 @@ "Photo": "写真", "Pin": "ピン", "Pinned by {{ name }}": "{{ name }}がピンしました", + "Pinned by You": "あなたがピン留めしました", "Play video": "動画を再生", "Poll": "投票", "Poll comments": "投票コメント", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 929529d10..0ec450017 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -209,6 +209,7 @@ "Photo": "사진", "Pin": "핀", "Pinned by {{ name }}": "{{ name }}님이 핀함", + "Pinned by You": "내가 고정함", "Play video": "동영상 재생", "Poll": "투표", "Poll comments": "투표 댓글", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 689960ea8..0bbd930ab 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -209,6 +209,7 @@ "Photo": "Foto", "Pin": "Vastmaken", "Pinned by {{ name }}": "Vastgemaakt door {{ name }}", + "Pinned by You": "Door jou vastgezet", "Play video": "Video afspelen", "Poll": "Peiling", "Poll comments": "Peiling opmerkingen", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 80ef00bad..d76475bc6 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -214,6 +214,7 @@ "Photo": "Foto", "Pin": "Fixar", "Pinned by {{ name }}": "Fixado por {{ name }}", + "Pinned by You": "Fixado por você", "Play video": "Reproduzir vídeo", "Poll": "Enquete", "Poll comments": "Comentários da pesquisa", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index c27665413..04d02ec51 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -219,6 +219,7 @@ "Photo": "Фото", "Pin": "Закрепить", "Pinned by {{ name }}": "Закреплено: {{ name }}", + "Pinned by You": "Закреплено вами", "Play video": "Воспроизвести видео", "Poll": "Опрос", "Poll comments": "Комментарии к опросу", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 63749a679..fb48b7a6d 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -209,6 +209,7 @@ "Photo": "Fotoğraf", "Pin": "Sabitle", "Pinned by {{ name }}": "{{ name }} sabitledi", + "Pinned by You": "Sizin sabitlediğiniz", "Play video": "Videoyu oynat", "Poll": "Anket", "Poll comments": "Anket yorumları", From 4db82a241c11f050964f4949004fed9be9a0be22 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 20 Feb 2026 10:57:49 +0100 Subject: [PATCH 03/16] feat: set max message width to 400px --- src/components/Message/styling/Message.scss | 22 ++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/components/Message/styling/Message.scss b/src/components/Message/styling/Message.scss index 452d032a1..78e7b8cfb 100644 --- a/src/components/Message/styling/Message.scss +++ b/src/components/Message/styling/Message.scss @@ -81,7 +81,7 @@ --str-chat__message-pinned-background-color: var(--background-core-highlight); /* The maximum allowed width of the message component */ - --str-chat__message-max-width: calc(var(--str-chat__spacing-px) * 480); + --str-chat__message-max-width: calc(var(--str-chat__spacing-px) * 400); /* The maximum allowed width of the message component, if it has attachments */ --str-chat__message-with-attachment-max-width: calc(var(--str-chat__spacing-px) * 300); @@ -93,12 +93,12 @@ padding-block-end: var(--spacing-xxxs); } -.str-chat__li--bottom { +.str-chat__li--bottom { padding-block-start: var(--spacing-xxxs); padding-block-end: var(--spacing-xs); } -.str-chat__li--middle { +.str-chat__li--middle { padding-block: var(--spacing-xxxs); } @@ -286,7 +286,9 @@ } } - &.str-chat__message--me .str-chat__message-pin-indicator .str-chat__message-pin-indicator__content { + &.str-chat__message--me + .str-chat__message-pin-indicator + .str-chat__message-pin-indicator__content { justify-content: flex-end; } @@ -372,7 +374,6 @@ } } } - } &.str-chat__message--deleted { @@ -510,11 +511,14 @@ display: flex; flex-wrap: wrap; align-items: center; - margin-block-start: var(--str-chat__spacing-0_5); + height: var(--size-24); color: var(--chat-text-timestamp); - font-size: var(--typography-font-size-xs,); - font-weight: var(--typography-font-weight-regular); - line-height: var(--typography-line-height-tight); + + * { + font-size: var(--typography-font-size-xs,); + font-weight: var(--typography-font-weight-regular); + line-height: var(--typography-line-height-tight); + } .str-chat__message-simple-name { @include utils.prevent-glitch-text-overflow; From 5260f320dcf082b224b63e238401e0d7d84390b9 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 20 Feb 2026 10:58:05 +0100 Subject: [PATCH 04/16] style: fix linter issues --- src/components/Avatar/styling/Avatar.scss | 2 +- src/components/Dialog/styling/Alert.scss | 3 +-- src/components/Dialog/styling/Prompt.scss | 5 ---- .../MessageBounce/styling/index.scss | 2 +- .../MessageList/styling/MessageList.scss | 25 ++++++++++++++----- .../styling/VirtualizedMessageList.scss | 5 +++- .../Reactions/styling/ReactionsListModal.scss | 2 +- src/components/Reactions/styling/index.scss | 2 +- 8 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/components/Avatar/styling/Avatar.scss b/src/components/Avatar/styling/Avatar.scss index c1bb2cb78..91a3b1920 100644 --- a/src/components/Avatar/styling/Avatar.scss +++ b/src/components/Avatar/styling/Avatar.scss @@ -100,7 +100,7 @@ font-size: var(--typography-font-size-md); } - &.str-chat__avatar--size-md { + &.str-chat__avatar--size-md { --avatar-size: 32px; --avatar-online-badge-size: 12px; --avatar-icon-size: var(--icon-size-md); diff --git a/src/components/Dialog/styling/Alert.scss b/src/components/Dialog/styling/Alert.scss index 55a4ac5e9..7a5539f0c 100644 --- a/src/components/Dialog/styling/Alert.scss +++ b/src/components/Dialog/styling/Alert.scss @@ -1,4 +1,3 @@ - @mixin flex-column { display: flex; flex-direction: column; @@ -45,4 +44,4 @@ width: 100%; } } -} \ No newline at end of file +} diff --git a/src/components/Dialog/styling/Prompt.scss b/src/components/Dialog/styling/Prompt.scss index 26ca0f782..25b94246e 100644 --- a/src/components/Dialog/styling/Prompt.scss +++ b/src/components/Dialog/styling/Prompt.scss @@ -1,10 +1,5 @@ @use '../../../styling/utils'; -// todo: once we have designs for dialogs + context menus create base class instead of a mixin -@mixin dialog-base { - -} - .str-chat__dialog-overlay { inset: 0; position: absolute; diff --git a/src/components/MessageBounce/styling/index.scss b/src/components/MessageBounce/styling/index.scss index b9060626b..f4c0360de 100644 --- a/src/components/MessageBounce/styling/index.scss +++ b/src/components/MessageBounce/styling/index.scss @@ -1 +1 @@ -@use "MessageBouncePrompt"; \ No newline at end of file +@use 'MessageBouncePrompt'; diff --git a/src/components/MessageList/styling/MessageList.scss b/src/components/MessageList/styling/MessageList.scss index 91596687d..b0bafdbdd 100644 --- a/src/components/MessageList/styling/MessageList.scss +++ b/src/components/MessageList/styling/MessageList.scss @@ -15,7 +15,10 @@ overscroll-behavior: none; width: 100%; /* Max container 800px, 16px padding → 768px readable content; matches composer width + padding */ - max-width: calc(var(--str-chat__message-composer-max-width) + var(--str-chat__message-composer-padding)); + max-width: calc( + var(--str-chat__message-composer-max-width) + + var(--str-chat__message-composer-padding) + ); height: 100%; max-height: 100%; @include utils.component-layer-overrides('message-list'); @@ -101,13 +104,17 @@ --str-chat__message-list-border-inline-end: none; /* The border radius used for the borders of the jump to latest message button */ - --str-chat__jump-to-latest-message-border-radius: var(--str-chat__circle-fab-border-radius); + --str-chat__jump-to-latest-message-border-radius: var( + --str-chat__circle-fab-border-radius + ); /* The text/icon color of the jump to latest message button */ --str-chat__jump-to-latest-message-color: var(--str-chat__circle-fab-color); /* The background color of the jump to latest message button */ - --str-chat__jump-to-latest-message-background-color: var(--str-chat__circle-fab-background-color); + --str-chat__jump-to-latest-message-background-color: var( + --str-chat__circle-fab-background-color + ); /* The background color of the jump to latest message button in pressed state */ --str-chat__jump-to-latest-message-pressed-background-color: var( @@ -123,7 +130,9 @@ ); /* Bottom border of the jump to latest message button */ - --str-chat__jump-to-latest-message-border-block-end: var(--str-chat__circle-fab-border-block-end); + --str-chat__jump-to-latest-message-border-block-end: var( + --str-chat__circle-fab-border-block-end + ); /* Left (right in RTL layout) border of the jump to latest message button */ --str-chat__jump-to-latest-message-border-inline-start: var( @@ -153,14 +162,18 @@ } .str-chat__jump-to-latest-message { - --str-chat-icon-color: var(--str-chat__jump-to-latest-message-unread-count-background-color); + --str-chat-icon-color: var( + --str-chat__jump-to-latest-message-unread-count-background-color + ); .str-chat__circle-fab { @include utils.component-layer-overrides('jump-to-latest-message'); @include utils.circle-fab-overrides('jump-to-latest-message'); .str-chat__jump-to-latest-unread-count { - background-color: var(--str-chat__jump-to-latest-message-unread-count-background-color); + background-color: var( + --str-chat__jump-to-latest-message-unread-count-background-color + ); color: var(--str-chat__jump-to-latest-message-unread-count-color); border-radius: var(--str-chat__jump-to-latest-message-border-radius); font: var(--str-chat__caption-text); diff --git a/src/components/MessageList/styling/VirtualizedMessageList.scss b/src/components/MessageList/styling/VirtualizedMessageList.scss index bc50fa0fe..41f0985a9 100644 --- a/src/components/MessageList/styling/VirtualizedMessageList.scss +++ b/src/components/MessageList/styling/VirtualizedMessageList.scss @@ -11,7 +11,10 @@ margin: 0; width: 100%; /* Max container = composer width + padding; matches message list */ - max-width: calc(var(--str-chat__message-composer-max-width) + var(--str-chat__message-composer-padding)); + max-width: calc( + var(--str-chat__message-composer-max-width) + + var(--str-chat__message-composer-padding) + ); height: 100%; .str-chat__message-list-scroll { diff --git a/src/components/Reactions/styling/ReactionsListModal.scss b/src/components/Reactions/styling/ReactionsListModal.scss index ca6311e1f..f657aafe6 100644 --- a/src/components/Reactions/styling/ReactionsListModal.scss +++ b/src/components/Reactions/styling/ReactionsListModal.scss @@ -82,4 +82,4 @@ font: var(--str-chat__subtitle-text); } } -} \ No newline at end of file +} diff --git a/src/components/Reactions/styling/index.scss b/src/components/Reactions/styling/index.scss index f7dbd0be0..2ff58635a 100644 --- a/src/components/Reactions/styling/index.scss +++ b/src/components/Reactions/styling/index.scss @@ -1,3 +1,3 @@ @use 'ReactionList'; @use 'ReactionSelector'; -@use 'ReactionsListModal'; \ No newline at end of file +@use 'ReactionsListModal'; From a51b47f2fc585dff8bf96f57eaa3a6c49d708bb7 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 20 Feb 2026 11:21:14 +0100 Subject: [PATCH 05/16] docs: explain how Attachment prop attachmentActionsDefaultFocus should be used --- src/components/Attachment/Attachment.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/Attachment/Attachment.tsx b/src/components/Attachment/Attachment.tsx index b85e712ae..2f38d3236 100644 --- a/src/components/Attachment/Attachment.tsx +++ b/src/components/Attachment/Attachment.tsx @@ -52,7 +52,13 @@ export type AttachmentProps = { attachments: (StreamAttachment | SharedLocationResponse)[]; /** The handler function to call when an action is performed on an attachment, examples include canceling a \/giphy command or shuffling the results. */ actionHandler?: ActionHandlerReturnType; - /** Which action should be focused on initial render, by attachment type (match by action.value) */ + /** + * Which attachment action button receives focus on initial render, keyed by attachment type. + * Values must match an action's `value` (e.g. `'send'`, `'cancel'`, `'shuffle'` for giphy attachment). + * Default: `{ giphy: 'send' }`. + * To disable auto-focus (e.g. when rendering the Giphy preview above the composer so focus + * stays in the message input), pass an empty object: `attachmentActionsDefaultFocus={{}}`. + */ attachmentActionsDefaultFocus?: AttachmentActionsDefaultFocusByType; /** Custom UI component for displaying attachment actions, defaults to and accepts same props as: [AttachmentActions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/AttachmentActions.tsx) */ AttachmentActions?: React.ComponentType; From f37116c499aa2af6ee4dbd2e5d6519857829f6b3 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 20 Feb 2026 13:46:19 +0100 Subject: [PATCH 06/16] feat: redesign ReminderNotification --- src/components/Icons/icons.tsx | 16 +++- .../Message/ReminderNotification.tsx | 95 +++++++++++++++---- .../__tests__/ReminderNotification.test.js | 4 +- .../ReminderNotification.test.js.snap | 22 +++-- src/components/Message/styling/Message.scss | 19 +--- .../Message/styling/ReminderNotification.scss | 58 +++++++++++ src/components/Message/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 | 3 +- src/i18n/ja.json | 3 +- src/i18n/ko.json | 3 +- src/i18n/nl.json | 3 +- src/i18n/pt.json | 3 +- src/i18n/ru.json | 3 +- src/i18n/tr.json | 3 +- src/styling/_global-theme-variables.scss | 4 + 20 files changed, 193 insertions(+), 62 deletions(-) create mode 100644 src/components/Message/styling/ReminderNotification.scss diff --git a/src/components/Icons/icons.tsx b/src/components/Icons/icons.tsx index 7990dae7a..8b0d1f0b3 100644 --- a/src/components/Icons/icons.tsx +++ b/src/components/Icons/icons.tsx @@ -73,7 +73,13 @@ export const IconAtSolid = createIcon( export const IconBellNotification = createIcon( 'IconBellNotification', - , + , ); export const IconBellOff = createIcon( @@ -90,7 +96,13 @@ export const IconBellOff = createIcon( export const IconBookmark = createIcon( 'IconBookmark', - , + , ); export const IconBookmarkRemove = createIcon( diff --git a/src/components/Message/ReminderNotification.tsx b/src/components/Message/ReminderNotification.tsx index 5fbcf6d92..e58920227 100644 --- a/src/components/Message/ReminderNotification.tsx +++ b/src/components/Message/ReminderNotification.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useTranslationContext } from '../../context'; import { useStateStore } from '../../store'; import type { Reminder, ReminderState } from 'stream-chat'; +import { IconBellNotification, IconBookmark } from '../Icons'; export type ReminderNotificationProps = { reminder?: Reminder; @@ -11,40 +12,92 @@ const reminderStateSelector = (state: ReminderState) => ({ timeLeftMs: state.timeLeftMs, }); -export const ReminderNotification = ({ reminder }: ReminderNotificationProps) => { +function SavedForLaterContent() { + const { t } = useTranslationContext(); + return ( +

+ + {t('Saved for later')} +

+ ); +} + +const THRESHOLD_RELATIVE_MINUTES = 59; + +function RemindMeContent({ reminder }: { reminder: Reminder }) { const { t } = useTranslationContext(); const { timeLeftMs } = useStateStore(reminder?.state, reminderStateSelector) ?? {}; const stopRefreshBoundaryMs = reminder?.timer.stopRefreshBoundaryMs; const stopRefreshTimeStamp = reminder?.remindAt && stopRefreshBoundaryMs - ? reminder?.remindAt.getTime() + stopRefreshBoundaryMs + ? reminder.remindAt.getTime() + stopRefreshBoundaryMs : undefined; const isBehindRefreshBoundary = !!stopRefreshTimeStamp && new Date().getTime() > stopRefreshTimeStamp; + if (timeLeftMs === null || !reminder.remindAt) return null; + + const nowMs = Date.now(); + const remindAtMs = reminder.remindAt.getTime(); + const diffMs = remindAtMs - nowMs; + const diffMinutes = Math.abs(diffMs) / (60 * 1000); + const useAbsoluteFormat = diffMinutes > THRESHOLD_RELATIVE_MINUTES; + + const renderTime = () => { + if (isBehindRefreshBoundary) { + // Past: reminder time has passed + if (useAbsoluteFormat) { + // > 59 min ago: calendar + time (same as DateSeparator + HH:mm) + // e.g. "Due since Today at 15:00", "Due since Yesterday at 09:30" + return t('Due since {{ dueSince }}', { + dueSince: t('timestamp/ReminderNotification', { + timestamp: reminder.remindAt, + }), + }); + } + // Within 59 min ago: relative + // e.g. "Due since 5 minutes ago", "Due since a minute ago" + return t('Due since {{ dueSince }}', { + dueSince: t('duration/Message reminder', { + milliseconds: diffMs, + }), + }); + } + // Future: reminder not yet due + if (useAbsoluteFormat) { + // > 59 min from now: calendar + time (no "Due" prefix) + // e.g. "Today at 15:00", "Tomorrow at 09:30" + return t('timestamp/ReminderNotification', { + timestamp: reminder.remindAt, + }); + } + // Within 59 min from now: relative + // e.g. "Due in 30 minutes", "Due in a minute" + return t('Due {{ timeLeft }}', { + timeLeft: t('duration/Message reminder', { + milliseconds: timeLeftMs, + }), + }); + }; + return (

- {t('Saved for later')} - {reminder?.remindAt && timeLeftMs !== null && ( - <> - | - - {isBehindRefreshBoundary - ? t('Due since {{ dueSince }}', { - dueSince: t('timestamp/ReminderNotification', { - timestamp: reminder.remindAt, - }), - }) - : t('Due {{ timeLeft }}', { - timeLeft: t('duration/Message reminder', { - milliseconds: timeLeftMs, - }), - })} - - - )} + + {t('Reminder set')} + · + {renderTime()}

); +} + +export const ReminderNotification = ({ reminder }: ReminderNotificationProps) => { + if (!reminder) return null; + + if (!reminder.remindAt) { + return ; + } + + return ; }; diff --git a/src/components/Message/__tests__/ReminderNotification.test.js b/src/components/Message/__tests__/ReminderNotification.test.js index efc003735..03e0f6ad5 100644 --- a/src/components/Message/__tests__/ReminderNotification.test.js +++ b/src/components/Message/__tests__/ReminderNotification.test.js @@ -21,12 +21,12 @@ const renderComponent = async ({ reminder }) => { }; describe('ReminderNotification', () => { - it('displays text for bookmark notifications', async () => { + it('displays text for bookmark notifications (saved for later)', async () => { const reminder = new Reminder({ data: generateReminderResponse() }); const { container } = await renderComponent({ reminder }); expect(container).toMatchSnapshot(); }); - it('displays text for time due in case of timed reminders', async () => { + it('displays text for time due in case of timed reminders (remind me)', async () => { const reminder = new Reminder({ data: generateReminderResponse({ scheduleOffsetMs: 60 * 1000, diff --git a/src/components/Message/__tests__/__snapshots__/ReminderNotification.test.js.snap b/src/components/Message/__tests__/__snapshots__/ReminderNotification.test.js.snap index 492cbc1c9..22223330c 100644 --- a/src/components/Message/__tests__/__snapshots__/ReminderNotification.test.js.snap +++ b/src/components/Message/__tests__/__snapshots__/ReminderNotification.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ReminderNotification displays text for bookmark notifications 1`] = ` +exports[`ReminderNotification displays text for bookmark notifications (saved for later) 1`] = `

Saved for later @@ -18,30 +18,34 @@ exports[`ReminderNotification displays text for reminder deadline if trespassed class="str-chat__message-reminder" > - Saved for later + Reminder set - | + · - + Due since 01/01/1970

`; -exports[`ReminderNotification displays text for time due in case of timed reminders 1`] = ` +exports[`ReminderNotification displays text for time due in case of timed reminders (remind me) 1`] = `

- Saved for later + Reminder set - | + · - + Due in a minute

diff --git a/src/components/Message/styling/Message.scss b/src/components/Message/styling/Message.scss index 78e7b8cfb..c3be86077 100644 --- a/src/components/Message/styling/Message.scss +++ b/src/components/Message/styling/Message.scss @@ -67,14 +67,6 @@ --str-chat__tertiary-surface-color ); - --str-chat__message-reminder-color: var(--str-chat__primary-color); - --str-chat__message-reminder-background-color: transparent; - --str-chat__message-reminder-border-block-start: none; - --str-chat__message-reminder-border-block-end: none; - --str-chat__message-reminder-border-inline-start: none; - --str-chat__message-reminder-border-inline-end: none; - --str-chat__message-reminder-box-shadow: none; - --str-chat__message-reminder-border-radius: 0; --str-chat__message-reactions-host-offset-x: calc(var(--spacing-xs) * -1); /* Background color for pinned messages (Figma: background/core/highlight) */ @@ -232,16 +224,9 @@ hyphens: auto; overflow-wrap: break-word; - .str-chat__message-reminder { - grid-area: message-reminder; - padding-block: var(--spacing-xxs); - margin: 0; - @include utils.component-layer-overrides('message-reminder'); - font: var(--str-chat__caption-medium-text); - } - @mixin message-grid-no-avatar { grid-template-areas: + 'message-saved-for-later' 'pin-indicator' 'message-reminder' 'message' @@ -253,6 +238,7 @@ @mixin message-grid-other-with-avatar { grid-template-areas: + '. message-saved-for-later' '. pin-indicator' '. message-reminder' 'avatar message' @@ -264,6 +250,7 @@ @mixin message-grid-me-with-avatar { grid-template-areas: + 'message-saved-for-later .' 'pin-indicator .' 'message-reminder .' 'message avatar' diff --git a/src/components/Message/styling/ReminderNotification.scss b/src/components/Message/styling/ReminderNotification.scss new file mode 100644 index 000000000..74c1ec09b --- /dev/null +++ b/src/components/Message/styling/ReminderNotification.scss @@ -0,0 +1,58 @@ +@use '../../../styling/utils'; + +.str-chat { + --str-chat__message-saved-for-later-color: var(--str-chat__primary-color); + --str-chat__message-saved-for-later-background-color: transparent; + --str-chat__message-saved-for-later-border-block-start: none; + --str-chat__message-saved-for-later-border-block-end: none; + --str-chat__message-saved-for-later-border-inline-start: none; + --str-chat__message-saved-for-later-border-inline-end: none; + --str-chat__message-saved-for-later-box-shadow: none; + --str-chat__message-saved-for-later-border-radius: 0; + + --str-chat__message-reminder-color: var(--text-primary); + --str-chat__message-reminder-background-color: transparent; + --str-chat__message-reminder-border-block-start: none; + --str-chat__message-reminder-border-block-end: none; + --str-chat__message-reminder-border-inline-start: none; + --str-chat__message-reminder-border-inline-end: none; + --str-chat__message-reminder-box-shadow: none; + --str-chat__message-reminder-border-radius: 0; +} + +/* Saved for Later: above pin indicator. Font and spacing aligned with pin indicator. */ +.str-chat__message-saved-for-later { + display: flex; + align-items: center; + gap: var(--spacing-xxs); + grid-area: message-saved-for-later; + padding-block: var(--spacing-xxs); + margin: 0; + @include utils.component-layer-overrides('message-saved-for-later'); + font: var(--str-chat__metadata-emphasis-text); + + svg path { + stroke-width: 1.5px; + stroke: var(--str-chat__message-saved-for-later-color); + } +} + +/* Remind Me: below pin indicator. Font and spacing aligned with pin indicator. */ +.str-chat__message-reminder { + display: flex; + align-items: center; + gap: var(--spacing-xxs); + grid-area: message-reminder; + padding-block: var(--spacing-xxs); + margin: 0; + @include utils.component-layer-overrides('message-reminder'); + font: var(--str-chat__metadata-emphasis-text); + + svg path { + stroke-width: 1.5px; + } + + .str-chat__message-reminder__time-left { + font: var(--str-chat__metadata-default-text); + } +} diff --git a/src/components/Message/styling/index.scss b/src/components/Message/styling/index.scss index b70cfa252..5a21ca7ae 100644 --- a/src/components/Message/styling/index.scss +++ b/src/components/Message/styling/index.scss @@ -3,6 +3,7 @@ @use 'MessageStatus'; @use 'MessageSystem'; @use 'QuotedMessage'; +@use 'ReminderNotification'; @use 'UnreadMessageNotification'; @use 'UnreadMessagesSeparator'; @use 'MessageRepliesCountButton'; diff --git a/src/i18n/de.json b/src/i18n/de.json index 404f391d9..78ab5b724 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -223,6 +223,7 @@ "Recording format is not supported and cannot be reproduced": "Aufnahmeformat wird nicht unterstützt und kann nicht wiedergegeben werden", "Remind me": "Erinnern", "Remind Me": "Erinnern", + "Reminder set": "Erinnerung gesetzt", "Remove reminder": "Erinnerung entfernen", "Remove save for later": "„Später ansehen“ entfernen", "Reply": "Antworten", @@ -285,7 +286,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Um mit der Aufnahme zu beginnen, erlauben Sie den Zugriff auf die Kamera in Ihrem Browser", "To start recording, allow the microphone access in your browser": "Um mit der Aufnahme zu beginnen, erlauben Sie den Zugriff auf das Mikrofon in Ihrem Browser", diff --git a/src/i18n/en.json b/src/i18n/en.json index 94598b4ba..252c13ac7 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -223,6 +223,7 @@ "Recording format is not supported and cannot be reproduced": "Recording format is not supported and cannot be reproduced", "Remind me": "Remind me", "Remind Me": "Remind Me", + "Reminder set": "Reminder set", "Remove reminder": "Remove reminder", "Remove save for later": "Remove save for later", "Reply": "Reply", @@ -285,7 +286,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "To start recording, allow the camera access in your browser", "To start recording, allow the microphone access in your browser": "To start recording, allow the microphone access in your browser", diff --git a/src/i18n/es.json b/src/i18n/es.json index 64e17dc41..debed7510 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -228,6 +228,7 @@ "Recording format is not supported and cannot be reproduced": "El formato de grabación no es compatible y no se puede reproducir", "Remind me": "Recordarme", "Remind Me": "Recordarme", + "Reminder set": "Recordatorio establecido", "Remove reminder": "Eliminar recordatorio", "Remove save for later": "Quitar guardar para después", "Reply": "Responder", @@ -294,7 +295,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Para comenzar a grabar, permita el acceso a la cámara en su navegador", "To start recording, allow the microphone access in your browser": "Para comenzar a grabar, permita el acceso al micrófono en su navegador", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 6c30a682f..728a06acf 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -228,6 +228,7 @@ "Recording format is not supported and cannot be reproduced": "Le format d'enregistrement n'est pas pris en charge et ne peut pas être reproduit", "Remind me": "Me rappeler", "Remind Me": "Me rappeler", + "Reminder set": "Rappel défini", "Remove reminder": "Supprimer le rappel", "Remove save for later": "Supprimer « Enregistrer pour plus tard »", "Reply": "Répondre", @@ -294,7 +295,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Pour commencer l'enregistrement, autorisez l'accès à la caméra dans votre navigateur", "To start recording, allow the microphone access in your browser": "Pour commencer l'enregistrement, autorisez l'accès au microphone dans votre navigateur", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index c1dbb6d51..74d1fa61b 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -224,6 +224,7 @@ "Recording format is not supported and cannot be reproduced": "रेकॉर्डिंग फ़ॉर्मेट समर्थित नहीं है और पुनः उत्पन्न नहीं किया जा सकता", "Remind me": "मुझे याद दिलाएं", "Remind Me": "मुझे याद दिलाएं", + "Reminder set": "अनुस्मारक सेट किया गया", "Remove reminder": "रिमाइंडर हटाएं", "Remove save for later": "बाद में देखें हटाएं", "Reply": "जवाब दे दो", @@ -286,7 +287,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "रिकॉर्डिंग शुरू करने के लिए, अपने ब्राउज़र में कैमरा तक पहुँच दें", "To start recording, allow the microphone access in your browser": "रिकॉर्डिंग शुरू करने के लिए, अपने ब्राउज़र में माइक्रोफ़ोन तक पहुँच दें", diff --git a/src/i18n/it.json b/src/i18n/it.json index e8012052c..cc307783c 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -228,6 +228,7 @@ "Recording format is not supported and cannot be reproduced": "Il formato di registrazione non è supportato e non può essere riprodotto", "Remind me": "Promemoria", "Remind Me": "Ricordami", + "Reminder set": "Promemoria impostato", "Remove reminder": "Rimuovi promemoria", "Remove save for later": "Rimuovi Salva per dopo", "Reply": "Rispondi", @@ -294,7 +295,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Per iniziare a registrare, consenti l'accesso alla fotocamera nel tuo browser", "To start recording, allow the microphone access in your browser": "Per iniziare a registrare, consenti l'accesso al microfono nel tuo browser", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 6ca9d22ea..79711fc16 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -223,6 +223,7 @@ "Recording format is not supported and cannot be reproduced": "録音形式はサポートされておらず、再生できません", "Remind me": "リマインド", "Remind Me": "リマインダー", + "Reminder set": "リマインダーを設定しました", "Remove reminder": "リマインダーを削除", "Remove save for later": "「後で見る」を削除", "Reply": "返事", @@ -285,7 +286,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "録音を開始するには、ブラウザーでカメラへのアクセスを許可してください", "To start recording, allow the microphone access in your browser": "録音を開始するには、ブラウザーでマイクロフォンへのアクセスを許可してください", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 0ec450017..0ffe0951e 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -223,6 +223,7 @@ "Recording format is not supported and cannot be reproduced": "녹음 형식이 지원되지 않으므로 재생할 수 없습니다", "Remind me": "알림", "Remind Me": "알림 설정", + "Reminder set": "알림 설정됨", "Remove reminder": "알림 제거", "Remove save for later": "나중에 보기 제거", "Reply": "답장", @@ -285,7 +286,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "브라우저에서 카메라 액세스를 허용하여 녹음을 시작합니다", "To start recording, allow the microphone access in your browser": "브라우저에서 마이크로폰 액세스를 허용하여 녹음을 시작합니다", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 0bbd930ab..d3f89760a 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -223,6 +223,7 @@ "Recording format is not supported and cannot be reproduced": "Opnameformaat wordt niet ondersteund en kan niet worden gereproduceerd", "Remind me": "Herinner me", "Remind Me": "Herinner mij", + "Reminder set": "Herinnering ingesteld", "Remove reminder": "Herinnering verwijderen", "Remove save for later": "Verwijder 'Bewaren voor later'", "Reply": "Antwoord", @@ -287,7 +288,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Om te beginnen met opnemen, sta toegang tot de camera toe in uw browser", "To start recording, allow the microphone access in your browser": "Om te beginnen met opnemen, sta toegang tot de microfoon toe in uw browser", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index d76475bc6..a4b5423cd 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -228,6 +228,7 @@ "Recording format is not supported and cannot be reproduced": "Formato de gravação não é suportado e não pode ser reproduzido", "Remind me": "Lembrar-me", "Remind Me": "Lembrar-me", + "Reminder set": "Lembrete definido", "Remove reminder": "Remover lembrete", "Remove save for later": "Remover Salvar para depois", "Reply": "Responder", @@ -294,7 +295,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Para começar a gravar, permita o acesso à câmera no seu navegador", "To start recording, allow the microphone access in your browser": "Para começar a gravar, permita o acesso ao microfone no seu navegador", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 04d02ec51..98b040aeb 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -233,6 +233,7 @@ "Recording format is not supported and cannot be reproduced": "Формат записи не поддерживается и не может быть воспроизведен", "Remind me": "Напомнить мне", "Remind Me": "Напомнить мне", + "Reminder set": "Напоминание установлено", "Remove reminder": "Удалить напоминание", "Remove save for later": "Удалить «Сохранить на потом»", "Reply": "Ответить", @@ -303,7 +304,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Для начала записи разрешите доступ к камере в вашем браузере", "To start recording, allow the microphone access in your browser": "Для начала записи разрешите доступ к микрофону в вашем браузере", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index fb48b7a6d..de694481d 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -223,6 +223,7 @@ "Recording format is not supported and cannot be reproduced": "Kayıt formatı desteklenmiyor ve çoğaltılamıyor", "Remind me": "Bana hatırlat", "Remind Me": "Hatırlat", + "Reminder set": "Hatırlatıcı ayarlandı", "Remove reminder": "Hatırlatıcıyı kaldır", "Remove save for later": "Sonraya kaydet'i kaldır", "Reply": "Cevapla", @@ -285,7 +286,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Kayıt yapmaya başlamak için tarayıcınızda kameraya erişime izin verin", "To start recording, allow the microphone access in your browser": "Kayıt yapmaya başlamak için tarayıcınızda mikrofona erişime izin verin", diff --git a/src/styling/_global-theme-variables.scss b/src/styling/_global-theme-variables.scss index bfc6b0998..540d1d3b3 100644 --- a/src/styling/_global-theme-variables.scss +++ b/src/styling/_global-theme-variables.scss @@ -41,6 +41,10 @@ var(--typography-font-size-xs) / var(--typography-line-height-tight) var(--str-chat__font-family); + --str-chat__metadata-emphasis-text: normal var(--typography-font-weight-semi-bold) + var(--typography-font-size-xs) / var(--typography-line-height-tight) + var(--str-chat__font-family); + --str-chat__caption-default-text: normal var(--typography-font-weight-regular) var(--typography-font-size-sm) / var(--typography-line-height-normal) var(--str-chat__font-family); From bddb9e911d03895d362b9e479c88b6a93a01b7e6 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 20 Feb 2026 17:18:26 +0100 Subject: [PATCH 07/16] feat: add MessageTranslationIndicator --- src/components/ChannelPreview/utils.tsx | 9 +- src/components/Icons/icons.tsx | 11 + src/components/Message/FixedHeightMessage.tsx | 6 +- src/components/Message/Message.tsx | 11 + src/components/Message/MessageSimple.tsx | 3 + src/components/Message/MessageText.tsx | 9 +- .../Message/MessageTranslationIndicator.tsx | 83 ++++++++ src/components/Message/index.ts | 1 + src/components/Message/styling/Message.scss | 3 + .../styling/MessageTranslationIndicator.scss | 33 +++ src/components/Message/styling/index.scss | 1 + src/components/MessageList/MessageList.tsx | 109 +++++----- .../MessageList/VirtualizedMessageList.tsx | 189 +++++++++--------- src/context/ComponentContext.tsx | 3 + src/context/MessageContext.tsx | 10 + src/context/MessageTranslationViewContext.tsx | 114 +++++++++++ src/context/index.ts | 1 + src/i18n/de.json | 68 ++++++- src/i18n/en.json | 62 ++++++ src/i18n/es.json | 68 ++++++- src/i18n/fr.json | 68 ++++++- src/i18n/hi.json | 68 ++++++- src/i18n/it.json | 68 ++++++- src/i18n/ja.json | 68 ++++++- src/i18n/ko.json | 68 ++++++- src/i18n/nl.json | 68 ++++++- src/i18n/pt.json | 68 ++++++- src/i18n/ru.json | 68 ++++++- src/i18n/tr.json | 68 ++++++- 29 files changed, 1218 insertions(+), 188 deletions(-) create mode 100644 src/components/Message/MessageTranslationIndicator.tsx create mode 100644 src/components/Message/styling/MessageTranslationIndicator.scss create mode 100644 src/context/MessageTranslationViewContext.tsx diff --git a/src/components/ChannelPreview/utils.tsx b/src/components/ChannelPreview/utils.tsx index 0d318b50b..0c1218220 100644 --- a/src/components/ChannelPreview/utils.tsx +++ b/src/components/ChannelPreview/utils.tsx @@ -1,10 +1,11 @@ +import type { ReactNode } from 'react'; import React from 'react'; import ReactMarkdown from 'react-markdown'; -import type { ReactNode } from 'react'; -import type { Channel, PollVote, TranslationLanguages, UserResponse } from 'stream-chat'; +import type { Channel, PollVote, UserResponse } from 'stream-chat'; -import type { TranslationContextValue } from '../../context/TranslationContext'; import type { ChatContextValue } from '../../context'; +import { getTranslatedMessageText } from '../../context/MessageTranslationViewContext'; +import type { TranslationContextValue } from '../../context/TranslationContext'; import type { PluggableList } from 'unified'; import { htmlToTextPlugin, imageToLink, plusPlusToEmphasis } from '../Message'; import remarkGfm from 'remark-gfm'; @@ -45,7 +46,7 @@ export const getLatestMessagePreview = ( channel.state.latestMessages[channel.state.latestMessages.length - 1]; const previewTextToRender = - latestMessage?.i18n?.[`${userLanguage}_text` as `${TranslationLanguages}_text`] || + getTranslatedMessageText({ language: userLanguage, message: latestMessage }) || latestMessage?.text; const poll = latestMessage?.poll; diff --git a/src/components/Icons/icons.tsx b/src/components/Icons/icons.tsx index 8b0d1f0b3..2c951cb0a 100644 --- a/src/components/Icons/icons.tsx +++ b/src/components/Icons/icons.tsx @@ -896,6 +896,17 @@ export const IconThunder = createIcon( , ); +export const IconTranslate = createIcon( + 'IconTranslate', + , +); + export const IconTrashBin = createIcon( 'IconTrashBin', , diff --git a/src/components/Message/FixedHeightMessage.tsx b/src/components/Message/FixedHeightMessage.tsx index 181b196e4..e12ea9e68 100644 --- a/src/components/Message/FixedHeightMessage.tsx +++ b/src/components/Message/FixedHeightMessage.tsx @@ -14,8 +14,9 @@ import { useMessageContext } from '../../context/MessageContext'; import { useTranslationContext } from '../../context/TranslationContext'; import { renderText } from './renderText'; -import type { LocalMessage, TranslationLanguages } from 'stream-chat'; +import type { LocalMessage } from 'stream-chat'; import { ModalGallery } from '../Attachment'; +import { getTranslatedMessageText } from '../../context/MessageTranslationViewContext'; const selectColor = (number: number, dark: boolean) => { const hue = number * 137.508; // use golden angle approximation @@ -58,8 +59,7 @@ const UnMemoizedFixedHeightMessage = (props: FixedHeightMessageProps) => { const role = useUserRole(message); const messageTextToRender = - message?.i18n?.[`${userLanguage}_text` as `${TranslationLanguages}_text`] || - message?.text; + getTranslatedMessageText({ language: userLanguage, message }) || message?.text; const renderedText = useMemo( () => renderText(messageTextToRender, message.mentioned_users), diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 826f4d026..515d1c030 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -24,6 +24,7 @@ import { useChannelStateContext, useChatContext, useComponentContext, + useMessageTranslationViewContext, } from '../../context'; import { MessageSimple as DefaultMessage } from './MessageSimple'; @@ -74,6 +75,14 @@ const MessageWithContext = (props: MessageWithContextProps) => { const { client, isMessageAIGenerated } = useChatContext('Message'); const { channelConfig, read } = useChannelStateContext('Message'); const { Message: contextMessage } = useComponentContext('Message'); + const { getTranslationView, setTranslationView: setTranslationViewInContext } = + useMessageTranslationViewContext(); + + const translationView = getTranslationView(message.id, !!message.i18n); + const setTranslationView = useCallback( + (view: 'original' | 'translated') => setTranslationViewInContext(message.id, view), + [message.id, setTranslationViewInContext], + ); const actionsEnabled = message.type === 'regular' && message.status === 'received'; const MessageUIComponent = propMessage ?? contextMessage ?? DefaultMessage; @@ -161,6 +170,8 @@ const MessageWithContext = (props: MessageWithContextProps) => { messageIsUnread, onUserClick, onUserHover, + setTranslationView, + translationView, }; return ( diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index ded032cfb..c5229f912 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -14,6 +14,7 @@ import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMes import { isDateSeparatorMessage } from '../MessageList'; import { MessageIsThreadReplyInChannelButtonIndicator as DefaultMessageIsThreadReplyInChannelButtonIndicator } from './MessageIsThreadReplyInChannelButtonIndicator'; import { ReminderNotification as DefaultReminderNotification } from './ReminderNotification'; +import { MessageTranslationIndicator as DefaultMessageTranslationIndicator } from './MessageTranslationIndicator'; import { useMessageReminder } from './hooks'; import { areMessageUIPropsEqual, @@ -87,6 +88,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { MessageRepliesCountButton = DefaultMessageRepliesCountButton, MessageStatus = DefaultMessageStatus, MessageTimestamp = DefaultMessageTimestamp, + MessageTranslationIndicator = DefaultMessageTranslationIndicator, PinIndicator = DefaultPinIndicator, QuotedMessage = DefaultQuotedMessage, ReactionsList = DefaultReactionList, @@ -190,6 +192,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
{message.pinned && } {!!reminder && } + {message.user && ( { onMentionsClickMessage, onMentionsHoverMessage, renderText: contextRenderText, + translationView = 'translated', unsafeHTML, } = useMessageContext('MessageText'); @@ -41,8 +43,9 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => { const hasAttachment = messageHasAttachments(message); const messageTextToRender = - message.i18n?.[`${userLanguage}_text` as `${TranslationLanguages}_text`] || - message.text; + translationView === 'original' + ? message.text + : getTranslatedMessageText({ language: userLanguage, message }) || message.text; const messageText = useMemo( () => renderText(messageTextToRender, message.mentioned_users), diff --git a/src/components/Message/MessageTranslationIndicator.tsx b/src/components/Message/MessageTranslationIndicator.tsx new file mode 100644 index 000000000..1a32cf8a2 --- /dev/null +++ b/src/components/Message/MessageTranslationIndicator.tsx @@ -0,0 +1,83 @@ +import type { LocalMessage } from 'stream-chat'; +import React, { useCallback, useMemo } from 'react'; +import { IconTranslate } from '../Icons'; +import { + getTranslatedMessageText, + useMessageContext, + useTranslationContext, +} from '../../context'; +import { Button } from '../Button'; + +export type TranslationIndicatorProps = { + message?: LocalMessage; +}; + +export const MessageTranslationIndicator = ({ + message: propMessage, +}: TranslationIndicatorProps) => { + const { t, userLanguage } = useTranslationContext(); + const { + message: contextMessage, + setTranslationView, + translationView, + } = useMessageContext('MessageTranslationIndicator'); + const message = propMessage ?? contextMessage; + + const translatedTextForUser = useMemo( + () => getTranslatedMessageText({ language: userLanguage, message }), + [userLanguage, message], + ); + + const hasTranslationForUserLanguage = useMemo( + () => + translatedTextForUser != null && + message?.text !== undefined && + translatedTextForUser !== message.text, + [translatedTextForUser, message?.text], + ); + + const viewingOriginal = useMemo( + () => + translationView === 'original' || + (translationView === undefined && !hasTranslationForUserLanguage), + [translationView, hasTranslationForUserLanguage], + ); + + const handleToggle = useCallback(() => { + setTranslationView?.(viewingOriginal ? 'translated' : 'original'); + }, [setTranslationView, viewingOriginal]); + + const sourceLanguageName = useMemo(() => { + const sourceLanguageCode = message?.i18n?.language; + if (!sourceLanguageCode) return ''; + const languageKey = 'language/' + sourceLanguageCode; + const translatedName = t(languageKey); + return translatedName && translatedName !== languageKey + ? translatedName + : sourceLanguageCode; + }, [message?.i18n?.language, t]); + + if (!message?.i18n || !setTranslationView) return null; + if (!hasTranslationForUserLanguage) return null; + + return ( +
+ + + {viewingOriginal + ? t('Original') + : sourceLanguageName + ? t('Translated from {{ language }}', { language: sourceLanguageName }) + : t('Translated')} + + · + +
+ ); +}; diff --git a/src/components/Message/index.ts b/src/components/Message/index.ts index a9d99d452..e55dfe56b 100644 --- a/src/components/Message/index.ts +++ b/src/components/Message/index.ts @@ -12,6 +12,7 @@ export * from './MessageSimple'; export * from './MessageStatus'; export * from './MessageText'; export * from './MessageTimestamp'; +export * from './MessageTranslationIndicator'; export * from './QuotedMessage'; export * from './ReminderNotification'; export * from './renderText'; diff --git a/src/components/Message/styling/Message.scss b/src/components/Message/styling/Message.scss index c3be86077..d3fb8c709 100644 --- a/src/components/Message/styling/Message.scss +++ b/src/components/Message/styling/Message.scss @@ -229,6 +229,7 @@ 'message-saved-for-later' 'pin-indicator' 'message-reminder' + 'translation-indicator' 'message' 'translation-notice' 'custom-metadata' @@ -241,6 +242,7 @@ '. message-saved-for-later' '. pin-indicator' '. message-reminder' + '. translation-indicator' 'avatar message' 'avatar translation-notice' 'avatar custom-metadata' @@ -253,6 +255,7 @@ 'message-saved-for-later .' 'pin-indicator .' 'message-reminder .' + 'translation-indicator .' 'message avatar' 'translation-notice avatar' 'custom-metadata avatar' diff --git a/src/components/Message/styling/MessageTranslationIndicator.scss b/src/components/Message/styling/MessageTranslationIndicator.scss new file mode 100644 index 000000000..41d271b48 --- /dev/null +++ b/src/components/Message/styling/MessageTranslationIndicator.scss @@ -0,0 +1,33 @@ +@use '../../../styling/utils'; + +.str-chat { + --str-chat__message-translation-indicator-color: var( + --text-primary-text, + var(--text-primary) + ); + --str-chat__message-translation-indicator-background-color: transparent; +} + +/* Translation indicator: below reminder in message grid. */ +.str-chat__message-translation-indicator { + display: flex; + align-items: center; + grid-area: translation-indicator; + padding-block: var(--spacing-xxs); + gap: var(--spacing-xxs); + margin: 0; + color: var(--str-chat__message-translation-indicator-color); + @include utils.component-layer-overrides('message-translation-indicator'); + + &, .str-chat__message-translation-indicator__translation-toggle { + font: var(--str-chat__metadata-default-text); + } + + .str-chat__message-translation-indicator__sign { + font: var(--str-chat__metadata-emphasis-text); + } + + svg path { + stroke-width: 1.5px; + } +} diff --git a/src/components/Message/styling/index.scss b/src/components/Message/styling/index.scss index 5a21ca7ae..cdcccb33e 100644 --- a/src/components/Message/styling/index.scss +++ b/src/components/Message/styling/index.scss @@ -2,6 +2,7 @@ @use 'MessageEditedTimestamp'; @use 'MessageStatus'; @use 'MessageSystem'; +@use 'MessageTranslationIndicator'; @use 'QuotedMessage'; @use 'ReminderNotification'; @use 'UnreadMessageNotification'; diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 121d07391..750eb0b1d 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -20,6 +20,7 @@ import { DialogManagerProvider } from '../../context'; import { useChatContext } from '../../context/ChatContext'; import { useComponentContext } from '../../context/ComponentContext'; import { MessageListContextProvider } from '../../context/MessageListContext'; +import { MessageTranslationViewProvider } from '../../context/MessageTranslationViewContext'; import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator'; import type { InfiniteScrollProps } from '../InfiniteScrollPaginator/InfiniteScroll'; import { InfiniteScroll } from '../InfiniteScrollPaginator/InfiniteScroll'; @@ -237,60 +238,62 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { scrollToBottom, }} > - - - {!threadList && showUnreadMessagesNotification && ( - - )} -
- {showEmptyStateIndicator ? ( - - ) : ( - - {props.loadingMore && } -
- } - loadNextPage={loadMoreNewer} - loadPreviousPage={loadMore} - threshold={loadMoreScrollThreshold} - {...restInternalInfiniteScrollProps} - > - - {elements} - - - -
- + + + + {!threadList && showUnreadMessagesNotification && ( + )} -
-
-
- +
+ {showEmptyStateIndicator ? ( + + ) : ( + + {props.loadingMore && } +
+ } + loadNextPage={loadMoreNewer} + loadPreviousPage={loadMore} + threshold={loadMoreScrollThreshold} + {...restInternalInfiniteScrollProps} + > + + {elements} + + + +
+ + )} +
+ + + + ); }; diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index e85041363..8c63b16ad 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -56,6 +56,7 @@ import type { ChatContextValue } from '../../context/ChatContext'; import { useChatContext } from '../../context/ChatContext'; import type { ComponentContextValue } from '../../context/ComponentContext'; import { useComponentContext } from '../../context/ComponentContext'; +import { MessageTranslationViewProvider } from '../../context/MessageTranslationViewContext'; import { VirtualizedMessageListContextProvider } from '../../context/VirtualizedMessageListContext'; import type { @@ -465,98 +466,102 @@ const VirtualizedMessageListWithContext = ( return ( - - - {!threadList && showUnreadMessagesNotification && ( - - )} -
- - atBottomStateChange={atBottomStateChange} - atBottomThreshold={100} - atTopStateChange={atTopStateChange} - atTopThreshold={100} - className='str-chat__message-list-scroll' - components={{ - EmptyPlaceholder, - Header, - Item, - ...virtuosoComponentsFromProps, - }} - computeItemKey={computeItemKey} - context={{ - additionalMessageInputProps, - closeReactionSelectorOnClick, - customClasses, - customMessageRenderer, - DateSeparator, - firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id, - formatDate, - head, - lastOwnMessage, - lastReadDate: channelUnreadUiState?.last_read, - lastReadMessageId: channelUnreadUiState?.last_read_message_id, - lastReceivedMessageId, - loadingMore, - Message: MessageUIComponent, - messageActions, - messageGroupStyles, - MessageSystem, - numItemsPrepended, - openThread, - ownMessagesDeliveredToOthers, - ownMessagesReadByOthers, - processedMessages, - reactionDetailsSort, - renderText, - returnAllReadData, - shouldGroupByUser, - showAvatar, - sortReactionDetails, - sortReactions, - threadList, - unreadMessageCount: channelUnreadUiState?.unread_messages, - UnreadMessagesSeparator, - virtuosoRef: virtuoso, - }} - firstItemIndex={calculateFirstItemIndex(numItemsPrepended)} - followOutput={followOutput} - increaseViewportBy={{ bottom: 200, top: 0 }} - initialTopMostItemIndex={calculateInitialTopMostItemIndex( - processedMessages, - highlightedMessageId, - )} - itemContent={messageRenderer} - itemSize={fractionalItemSize} - itemsRendered={handleItemsRendered} - key={messageSetKey} - overscan={overscan} - ref={virtuoso} - style={{ overflowX: 'hidden' }} - totalCount={processedMessages.length} - {...overridingVirtuosoProps} - {...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})} - {...(defaultItemHeight ? { defaultItemHeight } : {})} - /> -
-
- {TypingIndicator && } -
- - {giphyPreviewMessage && } + + + + {!threadList && showUnreadMessagesNotification && ( + + )} +
+ + atBottomStateChange={atBottomStateChange} + atBottomThreshold={100} + atTopStateChange={atTopStateChange} + atTopThreshold={100} + className='str-chat__message-list-scroll' + components={{ + EmptyPlaceholder, + Header, + Item, + ...virtuosoComponentsFromProps, + }} + computeItemKey={computeItemKey} + context={{ + additionalMessageInputProps, + closeReactionSelectorOnClick, + customClasses, + customMessageRenderer, + DateSeparator, + firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id, + formatDate, + head, + lastOwnMessage, + lastReadDate: channelUnreadUiState?.last_read, + lastReadMessageId: channelUnreadUiState?.last_read_message_id, + lastReceivedMessageId, + loadingMore, + Message: MessageUIComponent, + messageActions, + messageGroupStyles, + MessageSystem, + numItemsPrepended, + openThread, + ownMessagesDeliveredToOthers, + ownMessagesReadByOthers, + processedMessages, + reactionDetailsSort, + renderText, + returnAllReadData, + shouldGroupByUser, + showAvatar, + sortReactionDetails, + sortReactions, + threadList, + unreadMessageCount: channelUnreadUiState?.unread_messages, + UnreadMessagesSeparator, + virtuosoRef: virtuoso, + }} + firstItemIndex={calculateFirstItemIndex(numItemsPrepended)} + followOutput={followOutput} + increaseViewportBy={{ bottom: 200, top: 0 }} + initialTopMostItemIndex={calculateInitialTopMostItemIndex( + processedMessages, + highlightedMessageId, + )} + itemContent={messageRenderer} + itemSize={fractionalItemSize} + itemsRendered={handleItemsRendered} + key={messageSetKey} + overscan={overscan} + ref={virtuoso} + style={{ overflowX: 'hidden' }} + totalCount={processedMessages.length} + {...overridingVirtuosoProps} + {...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})} + {...(defaultItemHeight ? { defaultItemHeight } : {})} + /> +
+
+ {TypingIndicator && } +
+ + {giphyPreviewMessage && } +
); }; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 5784a73ba..042af6b2e 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -48,6 +48,7 @@ import type { ThreadListItemProps, ThreadListItemUIProps, TimestampProps, + TranslationIndicatorProps, TypingIndicatorProps, UnreadMessagesNotificationProps, UnreadMessagesSeparatorProps, @@ -181,6 +182,8 @@ export type ComponentContextValue = { RecordingPermissionDeniedNotification?: React.ComponentType; /** Custom UI component to display the message reminder information in the Message UI, defaults to and accepts same props as: [ReminderNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/ReminderNotification.tsx) */ ReminderNotification?: React.ComponentType; + /** Custom UI component to display the message translation indicator when message has i18n, defaults to and accepts same props as: [MessageTranslationIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/TranslationIndicator.tsx) */ + MessageTranslationIndicator?: React.ComponentType; /** Custom component to display the search UI, defaults to and accepts same props as: [Search](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Search/Search.tsx) */ Search?: React.ComponentType; /** Custom component to display the UI where the searched string is entered, defaults to and accepts same props as: [SearchBar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Search/SearchBar/SearchBar.tsx) */ diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index 36766699b..edd68f079 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -132,6 +132,16 @@ export type MessageContextValue = { threadList?: boolean; /** render HTML instead of markdown. Posting HTML is only allowed server-side */ unsafeHTML?: boolean; + /** + * User-specific view for translated messages: which text to show. + * - `'original'`: show `message.text` (source language). + * - `'translated'`: show the translation for the **current user language** (from + * `useTranslationContext().userLanguage`), i.e. `message.i18n[userLanguage + '_text']` + * or fallback to `message.text` when missing. Resolved via `getTranslatedMessageText`. + */ + translationView?: 'original' | 'translated'; + /** Set whether this message shows original or translated text (user-specific, does not change message data). */ + setTranslationView?: (view: 'original' | 'translated') => void; }; export const MessageContext = React.createContext( diff --git a/src/context/MessageTranslationViewContext.tsx b/src/context/MessageTranslationViewContext.tsx new file mode 100644 index 000000000..caa17122a --- /dev/null +++ b/src/context/MessageTranslationViewContext.tsx @@ -0,0 +1,114 @@ +/** + * Message translation view context: user-specific state for whether each message + * shows original text or a translation. + * + * ## Spec + * + * - **State**: Per message list (channel vs thread), we store a map + * `messageId → 'original' | 'translated'`. Default for messages with `message.i18n` + * is `'translated'`; otherwise `'original'`. + * + * - **Provider placement**: The provider is tied to the **message list**, not the channel. + * It is rendered inside `MessageList` and `VirtualizedMessageList`. That way the + * main channel list and the thread list each have their own translation view state + * (e.g. Thread.tsx gets correct behavior without sharing channel state). + * + * - **Multiple translations**: `message.i18n` can contain multiple languages, e.g.: + * `{ en_text: "Good morning", fr_text: "Bonjour", it_text: "Buongiorno", language: "en" }`. + * Which translation is shown is determined by the app’s **user language** + * (`useTranslationContext().userLanguage`). We use `message.i18n[userLanguage + '_text']`; + * if missing, we fall back to `message.text`. Only one translation is shown at a time. + * + * - **Source language**: When present, `message.i18n.language` is the original/source + * language of `message.text`. It can be used for the indicator label, e.g. + * "Translated from English · View original". + * + * - **Invariants**: The original message content, layout, grouping, and order stay + * unchanged. Removing or toggling translation only changes the annotation and which + * text is displayed. "View original" / "View translation" switch the displayed + * text and update the annotation (e.g. "Original · View translation" when showing + * original). + * + * - **Translation indicator visibility**: The translation indicator (e.g. "Translated · + * View original") is **not** shown when the currently viewed text already corresponds + * to `userLanguage` — for example when viewing original and the original text is the + * user-language version (i.e. `getTranslatedMessageText({ language: userLanguage, message })` + * equals `message.text`). In that case there is no meaningful original/translated choice, + * so the indicator is hidden. + */ + +import React, { createContext, useCallback, useContext, useState } from 'react'; +import type { LocalMessage, TranslationLanguages } from 'stream-chat'; + +/** + * Returns the translated message text for a given language from `message.i18n`, or + * undefined if not present. Used to resolve which of the multiple translations to show. + */ +export const getTranslatedMessageText = ({ + language, + message, +}: { + language: string; + message?: LocalMessage; +}): string | undefined => + message?.i18n?.[`${language}_text` as `${TranslationLanguages}_text`]; + +/** + * Which message text to show. + * - `'original'`: `message.text` (source language). + * - `'translated'`: translation for the **current user language** (TranslationContext’s + * `userLanguage`), i.e. `getTranslatedMessageText({ language: userLanguage, message })` + * or fallback to `message.text`. + */ +export type TranslationView = 'original' | 'translated'; + +export type MessageTranslationViewContextValue = { + getTranslationView: (messageId: string, hasI18n: boolean) => TranslationView; + setTranslationView: (messageId: string, view: TranslationView) => void; +}; + +const defaultContextValue: MessageTranslationViewContextValue = { + getTranslationView: (_messageId, hasI18n) => (hasI18n ? 'translated' : 'original'), + // eslint-disable-next-line @typescript-eslint/no-empty-function + setTranslationView: () => {}, +}; + +export const MessageTranslationViewContext = + createContext(defaultContextValue); + +export const MessageTranslationViewProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [viewByMessageId, setViewByMessageId] = useState>( + {}, + ); + + const setTranslationView = useCallback((messageId: string, view: TranslationView) => { + setViewByMessageId((prev) => ({ ...prev, [messageId]: view })); + }, []); + + const getTranslationView = useCallback( + (messageId: string, hasI18n: boolean): TranslationView => + viewByMessageId[messageId] ?? (hasI18n ? 'translated' : 'original'), + [viewByMessageId], + ); + + const stableValue = React.useMemo( + () => ({ getTranslationView, setTranslationView }), + [getTranslationView, setTranslationView], + ); + + return ( + + {children} + + ); +}; + +export const useMessageTranslationViewContext = + (): MessageTranslationViewContextValue => { + const context = useContext(MessageTranslationViewContext); + return context ?? defaultContextValue; + }; diff --git a/src/context/index.ts b/src/context/index.ts index d42a0569a..2277cdbab 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -8,6 +8,7 @@ export * from './MessageContext'; export * from './MessageBounceContext'; export * from './MessageInputContext'; export * from './MessageListContext'; +export * from './MessageTranslationViewContext'; export * from './PollContext'; export * from './TranslationContext'; export * from './TypingContext'; diff --git a/src/i18n/de.json b/src/i18n/de.json index 78ab5b724..1a8bdcdf8 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -163,6 +163,63 @@ "giphy-command-description": "Poste ein zufälliges Gif in den Kanal", "Hide who voted": "Verbergen, wer abgestimmt hat", "Instant commands": "Instant commands", + "language/af": "Afrikaans", + "language/am": "Amharisch", + "language/ar": "Arabisch", + "language/az": "Aserbaidschanisch", + "language/bg": "Bulgarisch", + "language/bn": "Bengalisch", + "language/bs": "Bosnisch", + "language/cs": "Tschechisch", + "language/da": "Dänisch", + "language/de": "Deutsch", + "language/el": "Griechisch", + "language/en": "Englisch", + "language/es": "Spanisch", + "language/es-MX": "Spanisch (Mexiko)", + "language/et": "Estnisch", + "language/fa": "Persisch", + "language/fa-AF": "Dari", + "language/fi": "Finnisch", + "language/fr": "Französisch", + "language/fr-CA": "Französisch (Kanada)", + "language/ha": "Hausa", + "language/he": "Hebräisch", + "language/hi": "Hindi", + "language/hr": "Kroatisch", + "language/ht": "Haitianisches Kreolisch", + "language/hu": "Ungarisch", + "language/id": "Indonesisch", + "language/it": "Italienisch", + "language/ja": "Japanisch", + "language/ka": "Georgisch", + "language/ko": "Koreanisch", + "language/lt": "Litauisch", + "language/lv": "Lettisch", + "language/ms": "Malaiisch", + "language/nl": "Niederländisch", + "language/no": "Norwegisch", + "language/pl": "Polnisch", + "language/ps": "Paschtu", + "language/pt": "Portugiesisch", + "language/ro": "Rumänisch", + "language/ru": "Russisch", + "language/sk": "Slowakisch", + "language/sl": "Slowenisch", + "language/so": "Somali", + "language/sq": "Albanisch", + "language/sr": "Serbisch", + "language/sv": "Schwedisch", + "language/sw": "Swahili", + "language/ta": "Tamil", + "language/th": "Thailändisch", + "language/tl": "Tagalog", + "language/tr": "Türkisch", + "language/uk": "Ukrainisch", + "language/ur": "Urdu", + "language/vi": "Vietnamesisch", + "language/zh": "Chinesisch (Vereinfacht)", + "language/zh-TW": "Chinesisch (Traditionell)", "Latest Messages": "Neueste Nachrichten", "Let others add options": "Andere Optionen hinzufügen lassen", "Limit votes per person": "Stimmen pro Person begrenzen", @@ -205,6 +262,7 @@ "Option already exists": "Option existiert bereits", "Option is empty": "Option ist leer", "Options": "Optionen", + "Original": "Original", "People matching": "Passende Personen", "Photo": "Foto", "Pin": "Anheften", @@ -281,15 +339,17 @@ "Thread reply": "Thread-Antwort", "Thread Reply": "Thread-Antwort", "Threads": "Diskussionen", - "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", + "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Heute]\", \"nextDay\": \"[Morgen]\", \"lastDay\": \"[Gestern]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Letzte] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", + "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [um] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Heute] [um] HH:mm\", \"nextDay\": \"[Morgen] [um] HH:mm\", \"lastDay\": \"[Gestern] [um] HH:mm\", \"nextWeek\": \"dddd [um] HH:mm\", \"lastWeek\": \"[Letzte] dddd [um] HH:mm\", \"sameElse\": \"ddd, D MMM [um] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Um mit der Aufnahme zu beginnen, erlauben Sie den Zugriff auf die Kamera in Ihrem Browser", "To start recording, allow the microphone access in your browser": "Um mit der Aufnahme zu beginnen, erlauben Sie den Zugriff auf das Mikrofon in Ihrem Browser", + "Translated": "Übersetzt", + "Translated from {{ language }}": "Übersetzung aus {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", "Type a number from 2 to 10": "Geben Sie eine Zahl von 2 bis 10 ein", "Type your message": "Nachricht eingeben", @@ -316,7 +376,9 @@ "Video": "Video", "View {{count}} comments_one": "{{count}} Kommentar anzeigen", "View {{count}} comments_other": "{{count}} Kommentare anzeigen", + "View original": "Original anzeigen", "View results": "Ergebnisse anzeigen", + "View translation": "Übersetzung anzeigen", "Voice message": "Sprachnachricht", "Voice message {{ duration }}": "Sprachnachricht {{ duration }}", "Vote ended": "Abstimmung beendet", diff --git a/src/i18n/en.json b/src/i18n/en.json index 252c13ac7..6e2286483 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -163,6 +163,63 @@ "giphy-command-description": "Post a random gif to the channel", "Hide who voted": "Hide who voted", "Instant commands": "Instant commands", + "language/af": "Afrikaans", + "language/am": "Amharic", + "language/ar": "Arabic", + "language/az": "Azerbaijani", + "language/bg": "Bulgarian", + "language/bn": "Bengali", + "language/bs": "Bosnian", + "language/cs": "Czech", + "language/da": "Danish", + "language/de": "German", + "language/el": "Greek", + "language/en": "English", + "language/es": "Spanish", + "language/es-MX": "Spanish (Mexico)", + "language/et": "Estonian", + "language/fa": "Persian", + "language/fa-AF": "Dari", + "language/fi": "Finnish", + "language/fr": "French", + "language/fr-CA": "French (Canada)", + "language/ha": "Hausa", + "language/he": "Hebrew", + "language/hi": "Hindi", + "language/hr": "Croatian", + "language/ht": "Haitian Creole", + "language/hu": "Hungarian", + "language/id": "Indonesian", + "language/it": "Italian", + "language/ja": "Japanese", + "language/ka": "Georgian", + "language/ko": "Korean", + "language/lt": "Lithuanian", + "language/lv": "Latvian", + "language/ms": "Malay", + "language/nl": "Dutch", + "language/no": "Norwegian", + "language/pl": "Polish", + "language/ps": "Pashto", + "language/pt": "Portuguese", + "language/ro": "Romanian", + "language/ru": "Russian", + "language/sk": "Slovak", + "language/sl": "Slovenian", + "language/so": "Somali", + "language/sq": "Albanian", + "language/sr": "Serbian", + "language/sv": "Swedish", + "language/sw": "Swahili", + "language/ta": "Tamil", + "language/th": "Thai", + "language/tl": "Tagalog", + "language/tr": "Turkish", + "language/uk": "Ukrainian", + "language/ur": "Urdu", + "language/vi": "Vietnamese", + "language/zh": "Chinese (Simplified)", + "language/zh-TW": "Chinese (Traditional)", "Latest Messages": "Latest Messages", "Let others add options": "Let others add options", "Limit votes per person": "Limit votes per person", @@ -205,6 +262,7 @@ "Option already exists": "Option already exists", "Option is empty": "Option is empty", "Options": "Options", + "Original": "Original", "People matching": "People matching", "Photo": "Photo", "Pin": "Pin", @@ -290,6 +348,8 @@ "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "To start recording, allow the camera access in your browser", "To start recording, allow the microphone access in your browser": "To start recording, allow the microphone access in your browser", + "Translated": "Translated", + "Translated from {{ language }}": "Translated from {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", "Type a number from 2 to 10": "Type a number from 2 to 10", "Type your message": "Type your message", @@ -316,7 +376,9 @@ "Video": "Video", "View {{count}} comments_one": "View {{count}} comment", "View {{count}} comments_other": "View {{count}} comments", + "View original": "View original", "View results": "View results", + "View translation": "View translation", "Voice message": "Voice message", "Voice message {{ duration }}": "Voice message {{ duration }}", "Vote ended": "Vote ended", diff --git a/src/i18n/es.json b/src/i18n/es.json index debed7510..7a00fe014 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -168,6 +168,63 @@ "giphy-command-description": "Publicar un gif aleatorio en el canal", "Hide who voted": "Ocultar quién votó", "Instant commands": "Comandos instantáneos", + "language/af": "Afrikáans", + "language/am": "Amárico", + "language/ar": "Árabe", + "language/az": "Azerbaiyano", + "language/bg": "Búlgaro", + "language/bn": "Bengalí", + "language/bs": "Bosnio", + "language/cs": "Checo", + "language/da": "Danés", + "language/de": "Alemán", + "language/el": "Griego", + "language/en": "Inglés", + "language/es": "Español", + "language/es-MX": "Español (México)", + "language/et": "Estonio", + "language/fa": "Persa", + "language/fa-AF": "Dari", + "language/fi": "Finlandés", + "language/fr": "Francés", + "language/fr-CA": "Francés (Canadá)", + "language/ha": "Hausa", + "language/he": "Hebreo", + "language/hi": "Hindi", + "language/hr": "Croata", + "language/ht": "Criollo haitiano", + "language/hu": "Húngaro", + "language/id": "Indonesio", + "language/it": "Italiano", + "language/ja": "Japonés", + "language/ka": "Georgiano", + "language/ko": "Coreano", + "language/lt": "Lituano", + "language/lv": "Letón", + "language/ms": "Malayo", + "language/nl": "Neerlandés", + "language/no": "Noruego", + "language/pl": "Polaco", + "language/ps": "Pastún", + "language/pt": "Portugués", + "language/ro": "Rumano", + "language/ru": "Ruso", + "language/sk": "Eslovaco", + "language/sl": "Esloveno", + "language/so": "Somalí", + "language/sq": "Albanés", + "language/sr": "Serbio", + "language/sv": "Sueco", + "language/sw": "Suajili", + "language/ta": "Tamil", + "language/th": "Tailandés", + "language/tl": "Tagalo", + "language/tr": "Turco", + "language/uk": "Ucraniano", + "language/ur": "Urdu", + "language/vi": "Vietnamita", + "language/zh": "Chino (simplificado)", + "language/zh-TW": "Chino (tradicional)", "Latest Messages": "Últimos mensajes", "Let others add options": "Permitir que otros añadan opciones", "Limit votes per person": "Limitar votos por persona", @@ -210,6 +267,7 @@ "Option already exists": "La opción ya existe", "Option is empty": "La opción está vacía", "Options": "Opciones", + "Original": "Original", "People matching": "Personas que coinciden", "Photo": "Foto", "Pin": "Fijar", @@ -290,15 +348,17 @@ "Thread reply": "Respuesta en hilo", "Thread Reply": "Respuesta en hilo", "Threads": "Hilos", - "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", + "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoy]\", \"nextDay\": \"[Mañana]\", \"lastDay\": \"[Ayer]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Último] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", + "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [a las] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoy] [a las] HH:mm\", \"nextDay\": \"[Mañana] [a las] HH:mm\", \"lastDay\": \"[Ayer] [a las] HH:mm\", \"nextWeek\": \"dddd [a las] HH:mm\", \"lastWeek\": \"[Último] dddd [a las] HH:mm\", \"sameElse\": \"ddd, D MMM [a las] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Para comenzar a grabar, permita el acceso a la cámara en su navegador", "To start recording, allow the microphone access in your browser": "Para comenzar a grabar, permita el acceso al micrófono en su navegador", + "Translated": "Traducido", + "Translated from {{ language }}": "Traducido de {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", "Type a number from 2 to 10": "Escribe un número del 2 al 10", "Type your message": "Escribe tu mensaje", @@ -327,7 +387,9 @@ "View {{count}} comments_one": "Ver {{count}} comentario", "View {{count}} comments_many": "Ver {{count}} comentarios", "View {{count}} comments_other": "Ver {{count}} comentarios", + "View original": "Ver original", "View results": "Ver resultados", + "View translation": "Ver traducción", "Voice message": "Mensaje de voz", "Voice message {{ duration }}": "Mensaje de voz {{ duration }}", "Vote ended": "Votación finalizada", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 728a06acf..7210917ad 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -168,6 +168,63 @@ "giphy-command-description": "Poster un GIF aléatoire dans le canal", "Hide who voted": "Masquer qui a voté", "Instant commands": "Commandes instantanées", + "language/af": "Afrikaans", + "language/am": "Amharique", + "language/ar": "Arabe", + "language/az": "Azéri", + "language/bg": "Bulgare", + "language/bn": "Bengali", + "language/bs": "Bosnien", + "language/cs": "Tchèque", + "language/da": "Danois", + "language/de": "Allemand", + "language/el": "Grec", + "language/en": "Anglais", + "language/es": "Espagnol", + "language/es-MX": "Espagnol (Mexique)", + "language/et": "Estonien", + "language/fa": "Persan", + "language/fa-AF": "Dari", + "language/fi": "Finnois", + "language/fr": "Français", + "language/fr-CA": "Français (Canada)", + "language/ha": "Haoussa", + "language/he": "Hébreu", + "language/hi": "Hindi", + "language/hr": "Croate", + "language/ht": "Créole haïtien", + "language/hu": "Hongrois", + "language/id": "Indonésien", + "language/it": "Italien", + "language/ja": "Japonais", + "language/ka": "Géorgien", + "language/ko": "Coréen", + "language/lt": "Lituanien", + "language/lv": "Letton", + "language/ms": "Malais", + "language/nl": "Néerlandais", + "language/no": "Norvégien", + "language/pl": "Polonais", + "language/ps": "Pachto", + "language/pt": "Portugais", + "language/ro": "Roumain", + "language/ru": "Russe", + "language/sk": "Slovaque", + "language/sl": "Slovène", + "language/so": "Somali", + "language/sq": "Albanais", + "language/sr": "Serbe", + "language/sv": "Suédois", + "language/sw": "Swahili", + "language/ta": "Tamoul", + "language/th": "Thaï", + "language/tl": "Tagalog", + "language/tr": "Turc", + "language/uk": "Ukrainien", + "language/ur": "Ourdou", + "language/vi": "Vietnamien", + "language/zh": "Chinois (simplifié)", + "language/zh-TW": "Chinois (traditionnel)", "Latest Messages": "Derniers messages", "Let others add options": "Permettre à d'autres d'ajouter des options", "Limit votes per person": "Limiter les votes par personne", @@ -210,6 +267,7 @@ "Option already exists": "L'option existe déjà", "Option is empty": "L'option est vide", "Options": "Options", + "Original": "Original", "People matching": "Correspondance de personnes", "Photo": "Photo", "Pin": "Épingler", @@ -290,15 +348,17 @@ "Thread reply": "Réponse dans le fil", "Thread Reply": "Réponse dans le fil", "Threads": "Fils", - "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", + "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Aujourd'hui]\", \"nextDay\": \"[Demain]\", \"lastDay\": \"[Hier]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Dernier] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", + "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [à] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Aujourd'hui] [à] HH:mm\", \"nextDay\": \"[Demain] [à] HH:mm\", \"lastDay\": \"[Hier] [à] HH:mm\", \"nextWeek\": \"dddd [à] HH:mm\", \"lastWeek\": \"[Dernier] dddd [à] HH:mm\", \"sameElse\": \"ddd, D MMM [à] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Pour commencer l'enregistrement, autorisez l'accès à la caméra dans votre navigateur", "To start recording, allow the microphone access in your browser": "Pour commencer l'enregistrement, autorisez l'accès au microphone dans votre navigateur", + "Translated": "Traduit", + "Translated from {{ language }}": "Traduit du {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", "Type a number from 2 to 10": "Tapez un nombre de 2 à 10", "Type your message": "Tapez votre message", @@ -327,7 +387,9 @@ "View {{count}} comments_one": "Voir {{count}} commentaire", "View {{count}} comments_many": "Voir {{count}} commentaires", "View {{count}} comments_other": "Voir {{count}} commentaires", + "View original": "Voir l'original", "View results": "Voir les résultats", + "View translation": "Voir la traduction", "Voice message": "Message vocal", "Voice message {{ duration }}": "Message vocal {{ duration }}", "Vote ended": "Vote terminé", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 74d1fa61b..7ba152a57 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -164,6 +164,63 @@ "giphy-command-description": "चैनल पर एक क्रॉफिल जीआइएफ पोस्ट करें", "Hide who voted": "किसने वोट दिया छिपाएं", "Instant commands": "तत्काल कमांड", + "language/af": "अफ्रीकी", + "language/am": "अम्हारिक", + "language/ar": "अरबी", + "language/az": "अज़रबैजानी", + "language/bg": "बुल्गारियाई", + "language/bn": "बंगाली", + "language/bs": "बोस्नियाई", + "language/cs": "चेक", + "language/da": "डेनिश", + "language/de": "जर्मन", + "language/el": "यूनानी", + "language/en": "अंग्रेज़ी", + "language/es": "स्पेनिश", + "language/es-MX": "स्पेनिश (मेक्सिको)", + "language/et": "एस्तोनियाई", + "language/fa": "फ़ारसी", + "language/fa-AF": "दरी", + "language/fi": "फ़िनिश", + "language/fr": "फ़्रेंच", + "language/fr-CA": "फ़्रेंच (कनाडा)", + "language/ha": "हौसा", + "language/he": "हिब्रू", + "language/hi": "हिंदी", + "language/hr": "क्रोएशियाई", + "language/ht": "हैतियाई क्रियोल", + "language/hu": "हंगेरी", + "language/id": "इंडोनेशियाई", + "language/it": "इतालवी", + "language/ja": "जापानी", + "language/ka": "जॉर्जियाई", + "language/ko": "कोरियाई", + "language/lt": "लिथुआनियाई", + "language/lv": "लातवियाई", + "language/ms": "मलय", + "language/nl": "डच", + "language/no": "नॉर्वेजियाई", + "language/pl": "पोलिश", + "language/ps": "पश्तो", + "language/pt": "पुर्तगाली", + "language/ro": "रोमानियाई", + "language/ru": "रूसी", + "language/sk": "स्लोवाक", + "language/sl": "स्लोवेनियाई", + "language/so": "सोमाली", + "language/sq": "अल्बानियाई", + "language/sr": "सर्बियाई", + "language/sv": "स्वीडिश", + "language/sw": "स्वाहिली", + "language/ta": "तमिल", + "language/th": "थाई", + "language/tl": "तागालोग", + "language/tr": "तुर्की", + "language/uk": "यूक्रेनियाई", + "language/ur": "उर्दू", + "language/vi": "वियतनामी", + "language/zh": "चीनी (सरलीकृत)", + "language/zh-TW": "चीनी (पारंपरिक)", "Latest Messages": "नवीनतम संदेश", "Let others add options": "दूसरों को विकल्प जोड़ने दें", "Limit votes per person": "प्रति व्यक्ति वोट सीमित करें", @@ -206,6 +263,7 @@ "Option already exists": "विकल्प पहले से मौजूद है", "Option is empty": "विकल्प खाली है", "Options": "विकल्प", + "Original": "मूल", "People matching": "मेल खाते लोग", "Photo": "फ़ोटो", "Pin": "पिन", @@ -282,15 +340,17 @@ "Thread reply": "थ्रेड में उत्तर", "Thread Reply": "थ्रेड में उत्तर", "Threads": "थ्रेड्स", - "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", + "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज]\", \"nextDay\": \"[कल]\", \"lastDay\": \"[बीता कल]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[पिछला] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", + "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [को] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज] [को] HH:mm\", \"nextDay\": \"[कल] [को] HH:mm\", \"lastDay\": \"[बीता कल] [को] HH:mm\", \"nextWeek\": \"dddd [को] HH:mm\", \"lastWeek\": \"[पिछला] dddd [को] HH:mm\", \"sameElse\": \"ddd, D MMM [को] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "रिकॉर्डिंग शुरू करने के लिए, अपने ब्राउज़र में कैमरा तक पहुँच दें", "To start recording, allow the microphone access in your browser": "रिकॉर्डिंग शुरू करने के लिए, अपने ब्राउज़र में माइक्रोफ़ोन तक पहुँच दें", + "Translated": "अनुवादित", + "Translated from {{ language }}": "{{ language }} से अनुवादित", "translationBuilderTopic/notification": "{{value, notification}}", "Type a number from 2 to 10": "2 से 10 तक का एक नंबर टाइप करें", "Type your message": "अपना मैसेज लिखे", @@ -317,7 +377,9 @@ "Video": "वीडियो", "View {{count}} comments_one": "देखें {{count}} टिप्पणी", "View {{count}} comments_other": "देखें {{count}} टिप्पणियाँ", + "View original": "मूल देखें", "View results": "परिणाम देखें", + "View translation": "अनुवाद देखें", "Voice message": "आवाज संदेश", "Voice message {{ duration }}": "वॉइस संदेश {{ duration }}", "Vote ended": "मतदान समाप्त", diff --git a/src/i18n/it.json b/src/i18n/it.json index cc307783c..2c546a484 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -168,6 +168,63 @@ "giphy-command-description": "Pubblica un gif casuale sul canale", "Hide who voted": "Nascondi chi ha votato", "Instant commands": "Comandi istantanei", + "language/af": "Afrikaans", + "language/am": "Amarico", + "language/ar": "Arabo", + "language/az": "Azero", + "language/bg": "Bulgaro", + "language/bn": "Bengalese", + "language/bs": "Bosniaco", + "language/cs": "Ceco", + "language/da": "Danese", + "language/de": "Tedesco", + "language/el": "Greco", + "language/en": "Inglese", + "language/es": "Spagnolo", + "language/es-MX": "Spagnolo (Messico)", + "language/et": "Estone", + "language/fa": "Persiano", + "language/fa-AF": "Dari", + "language/fi": "Finlandese", + "language/fr": "Francese", + "language/fr-CA": "Francese (Canada)", + "language/ha": "Hausa", + "language/he": "Ebraico", + "language/hi": "Hindi", + "language/hr": "Croato", + "language/ht": "Creolo haitiano", + "language/hu": "Ungherese", + "language/id": "Indonesiano", + "language/it": "Italiano", + "language/ja": "Giapponese", + "language/ka": "Georgiano", + "language/ko": "Coreano", + "language/lt": "Lituano", + "language/lv": "Lettone", + "language/ms": "Malese", + "language/nl": "Olandese", + "language/no": "Norvegese", + "language/pl": "Polacco", + "language/ps": "Pashto", + "language/pt": "Portoghese", + "language/ro": "Rumeno", + "language/ru": "Russo", + "language/sk": "Slovacco", + "language/sl": "Sloveno", + "language/so": "Somalo", + "language/sq": "Albanese", + "language/sr": "Serbo", + "language/sv": "Svedese", + "language/sw": "Swahili", + "language/ta": "Tamil", + "language/th": "Thai", + "language/tl": "Tagalog", + "language/tr": "Turco", + "language/uk": "Ucraino", + "language/ur": "Urdu", + "language/vi": "Vietnamita", + "language/zh": "Cinese (semplificato)", + "language/zh-TW": "Cinese (tradizionale)", "Latest Messages": "Ultimi messaggi", "Let others add options": "Lascia che altri aggiungano opzioni", "Limit votes per person": "Limita i voti per persona", @@ -210,6 +267,7 @@ "Option already exists": "L'opzione esiste già", "Option is empty": "L'opzione è vuota", "Options": "Opzioni", + "Original": "Originale", "People matching": "Persone che corrispondono", "Photo": "Foto", "Pin": "Appunta", @@ -290,15 +348,17 @@ "Thread reply": "Risposta nella discussione", "Thread Reply": "Risposta nella discussione", "Threads": "Thread", - "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", + "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Oggi]\", \"nextDay\": \"[Domani]\", \"lastDay\": \"[Ieri]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Scorsa] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", + "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [alle] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Oggi] [alle] HH:mm\", \"nextDay\": \"[Domani] [alle] HH:mm\", \"lastDay\": \"[Ieri] [alle] HH:mm\", \"nextWeek\": \"dddd [alle] HH:mm\", \"lastWeek\": \"[Scorsa] dddd [alle] HH:mm\", \"sameElse\": \"ddd, D MMM [alle] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Per iniziare a registrare, consenti l'accesso alla fotocamera nel tuo browser", "To start recording, allow the microphone access in your browser": "Per iniziare a registrare, consenti l'accesso al microfono nel tuo browser", + "Translated": "Tradotto", + "Translated from {{ language }}": "Tradotto da {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", "Type a number from 2 to 10": "Digita un numero da 2 a 10", "Type your message": "Scrivi il tuo messaggio", @@ -327,7 +387,9 @@ "View {{count}} comments_one": "Visualizza {{count}} commento", "View {{count}} comments_many": "Visualizza {{count}} commenti", "View {{count}} comments_other": "Visualizza {{count}} commenti", + "View original": "Visualizza originale", "View results": "Vedi risultati", + "View translation": "Visualizza traduzione", "Voice message": "Messaggio vocale", "Voice message {{ duration }}": "Messaggio vocale {{ duration }}", "Vote ended": "Voto terminato", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 79711fc16..e81a32529 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -163,6 +163,63 @@ "giphy-command-description": "チャンネルにランダムなGIFを投稿する", "Hide who voted": "誰が投票したかを非表示にする", "Instant commands": "インスタントコマンド", + "language/af": "アフリカース語", + "language/am": "アムハラ語", + "language/ar": "アラビア語", + "language/az": "アゼルバイジャン語", + "language/bg": "ブルガリア語", + "language/bn": "ベンガル語", + "language/bs": "ボスニア語", + "language/cs": "チェコ語", + "language/da": "デンマーク語", + "language/de": "ドイツ語", + "language/el": "ギリシャ語", + "language/en": "英語", + "language/es": "スペイン語", + "language/es-MX": "スペイン語(メキシコ)", + "language/et": "エストニア語", + "language/fa": "ペルシア語", + "language/fa-AF": "ダリー語", + "language/fi": "フィンランド語", + "language/fr": "フランス語", + "language/fr-CA": "フランス語(カナダ)", + "language/ha": "ハウサ語", + "language/he": "ヘブライ語", + "language/hi": "ヒンディー語", + "language/hr": "クロアチア語", + "language/ht": "ハイチ・クレオール語", + "language/hu": "ハンガリー語", + "language/id": "インドネシア語", + "language/it": "イタリア語", + "language/ja": "日本語", + "language/ka": "グルジア語", + "language/ko": "韓国語", + "language/lt": "リトアニア語", + "language/lv": "ラトビア語", + "language/ms": "マレー語", + "language/nl": "オランダ語", + "language/no": "ノルウェー語", + "language/pl": "ポーランド語", + "language/ps": "パシュトー語", + "language/pt": "ポルトガル語", + "language/ro": "ルーマニア語", + "language/ru": "ロシア語", + "language/sk": "スロバキア語", + "language/sl": "スロベニア語", + "language/so": "ソマリ語", + "language/sq": "アルバニア語", + "language/sr": "セルビア語", + "language/sv": "スウェーデン語", + "language/sw": "スワヒリ語", + "language/ta": "タミル語", + "language/th": "タイ語", + "language/tl": "タガログ語", + "language/tr": "トルコ語", + "language/uk": "ウクライナ語", + "language/ur": "ウルドゥー語", + "language/vi": "ベトナム語", + "language/zh": "中国語(簡体字)", + "language/zh-TW": "中国語(繁体字)", "Latest Messages": "最新のメッセージ", "Let others add options": "他の人が選択肢を追加できるようにする", "Limit votes per person": "1人あたりの投票数を制限する", @@ -205,6 +262,7 @@ "Option already exists": "オプションは既に存在します", "Option is empty": "オプションが空です", "Options": "オプション", + "Original": "原文", "People matching": "一致する人", "Photo": "写真", "Pin": "ピン", @@ -281,15 +339,17 @@ "Thread reply": "スレッドの返信", "Thread Reply": "スレッドの返信", "Threads": "スレッド", - "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", + "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日]\", \"nextDay\": \"[明日]\", \"lastDay\": \"[昨日]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[先週の] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", + "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日] HH:mm\", \"nextDay\": \"[明日] HH:mm\", \"lastDay\": \"[昨日] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[先週の] dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "録音を開始するには、ブラウザーでカメラへのアクセスを許可してください", "To start recording, allow the microphone access in your browser": "録音を開始するには、ブラウザーでマイクロフォンへのアクセスを許可してください", + "Translated": "翻訳済み", + "Translated from {{ language }}": "{{ language }}から翻訳", "translationBuilderTopic/notification": "{{value, notification}}", "Type a number from 2 to 10": "2から10までの数字を入力してください", "Type your message": "メッセージを入力してください", @@ -316,7 +376,9 @@ "Video": "動画", "View {{count}} comments_one": "{{count}} コメントを表示", "View {{count}} comments_other": "{{count}} コメントを表示", + "View original": "原文を表示", "View results": "結果を表示", + "View translation": "翻訳を表示", "Voice message": "ボイスメッセージ", "Voice message {{ duration }}": "ボイスメッセージ {{ duration }}", "Vote ended": "投票が終了しました", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 0ffe0951e..84ba83e14 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -163,6 +163,63 @@ "giphy-command-description": "채널에 무작위 GIF 게시", "Hide who voted": "누가 투표했는지 숨기기", "Instant commands": "즉시 명령어", + "language/af": "아프리칸스어", + "language/am": "암하라어", + "language/ar": "아랍어", + "language/az": "아제르바이잔어", + "language/bg": "불가리아어", + "language/bn": "벵골어", + "language/bs": "보스니아어", + "language/cs": "체코어", + "language/da": "덴마크어", + "language/de": "독일어", + "language/el": "그리스어", + "language/en": "영어", + "language/es": "스페인어", + "language/es-MX": "스페인어(멕시코)", + "language/et": "에스토니아어", + "language/fa": "페르시아어", + "language/fa-AF": "다리어", + "language/fi": "핀란드어", + "language/fr": "프랑스어", + "language/fr-CA": "프랑스어(캐나다)", + "language/ha": "하우사어", + "language/he": "히브리어", + "language/hi": "힌디어", + "language/hr": "크로아티아어", + "language/ht": "아이티 크리올어", + "language/hu": "헝가리어", + "language/id": "인도네시아어", + "language/it": "이탈리아어", + "language/ja": "일본어", + "language/ka": "조지아어", + "language/ko": "한국어", + "language/lt": "리투아니아어", + "language/lv": "라트비아어", + "language/ms": "말레이어", + "language/nl": "네덜란드어", + "language/no": "노르웨이어", + "language/pl": "폴란드어", + "language/ps": "파슈토어", + "language/pt": "포르투갈어", + "language/ro": "루마니아어", + "language/ru": "러시아어", + "language/sk": "슬로바키아어", + "language/sl": "슬로베니아어", + "language/so": "소말리아어", + "language/sq": "알바니아어", + "language/sr": "세르비아어", + "language/sv": "스웨덴어", + "language/sw": "스와힐리어", + "language/ta": "타밀어", + "language/th": "태국어", + "language/tl": "타갈로그어", + "language/tr": "터키어", + "language/uk": "우크라이나어", + "language/ur": "우르두어", + "language/vi": "베트남어", + "language/zh": "중국어(간체)", + "language/zh-TW": "중국어(번체)", "Latest Messages": "최신 메시지", "Let others add options": "다른 사람이 선택지를 추가할 수 있도록 허용", "Limit votes per person": "1인당 투표 수 제한", @@ -205,6 +262,7 @@ "Option already exists": "옵션이 이미 존재합니다", "Option is empty": "옵션이 비어 있습니다", "Options": "옵션", + "Original": "원문", "People matching": "일치하는 사람", "Photo": "사진", "Pin": "핀", @@ -281,15 +339,17 @@ "Thread reply": "스레드 답장", "Thread Reply": "스레드 답장", "Threads": "스레드", - "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", + "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘]\", \"nextDay\": \"[내일]\", \"lastDay\": \"[어제]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[지난] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", + "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘] HH:mm\", \"nextDay\": \"[내일] HH:mm\", \"lastDay\": \"[어제] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[지난] dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "브라우저에서 카메라 액세스를 허용하여 녹음을 시작합니다", "To start recording, allow the microphone access in your browser": "브라우저에서 마이크로폰 액세스를 허용하여 녹음을 시작합니다", + "Translated": "번역됨", + "Translated from {{ language }}": "{{ language }}(으)로 번역됨", "translationBuilderTopic/notification": "{{value, notification}}", "Type a number from 2 to 10": "2에서 10 사이의 숫자를 입력하세요", "Type your message": "메시지 입력", @@ -316,7 +376,9 @@ "Video": "동영상", "View {{count}} comments_one": "{{count}}개의 댓글 보기", "View {{count}} comments_other": "{{count}}개의 댓글 보기", + "View original": "원문 보기", "View results": "결과 보기", + "View translation": "번역 보기", "Voice message": "음성 메시지", "Voice message {{ duration }}": "음성 메시지 {{ duration }}", "Vote ended": "투표 종료", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index d3f89760a..3aa0d05f2 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -163,6 +163,63 @@ "giphy-command-description": "Plaats een willekeurige gif in het kanaal", "Hide who voted": "Verberg wie heeft gestemd", "Instant commands": "Snelle opdrachten", + "language/af": "Afrikaans", + "language/am": "Amhaars", + "language/ar": "Arabisch", + "language/az": "Azerbeidzjaans", + "language/bg": "Bulgaars", + "language/bn": "Bengaals", + "language/bs": "Bosnisch", + "language/cs": "Tsjechisch", + "language/da": "Deens", + "language/de": "Duits", + "language/el": "Grieks", + "language/en": "Engels", + "language/es": "Spaans", + "language/es-MX": "Spaans (Mexico)", + "language/et": "Estlands", + "language/fa": "Perzisch", + "language/fa-AF": "Dari", + "language/fi": "Fins", + "language/fr": "Frans", + "language/fr-CA": "Frans (Canada)", + "language/ha": "Hausa", + "language/he": "Hebreeuws", + "language/hi": "Hindi", + "language/hr": "Kroatisch", + "language/ht": "Haïtiaans Creools", + "language/hu": "Hongaars", + "language/id": "Indonesisch", + "language/it": "Italiaans", + "language/ja": "Japans", + "language/ka": "Georgisch", + "language/ko": "Koreaans", + "language/lt": "Litouws", + "language/lv": "Letlands", + "language/ms": "Maleis", + "language/nl": "Nederlands", + "language/no": "Noors", + "language/pl": "Pools", + "language/ps": "Pasjtoe", + "language/pt": "Portugees", + "language/ro": "Roemeens", + "language/ru": "Russisch", + "language/sk": "Slowaaks", + "language/sl": "Sloveens", + "language/so": "Somali", + "language/sq": "Albanees", + "language/sr": "Servisch", + "language/sv": "Zweeds", + "language/sw": "Swahili", + "language/ta": "Tamil", + "language/th": "Thai", + "language/tl": "Tagalog", + "language/tr": "Turks", + "language/uk": "Oekraïens", + "language/ur": "Urdu", + "language/vi": "Vietnamees", + "language/zh": "Chinees (vereenvoudigd)", + "language/zh-TW": "Chinees (traditioneel)", "Latest Messages": "Laatste berichten", "Let others add options": "Laat anderen opties toevoegen", "Limit votes per person": "Stemmen per persoon beperken", @@ -205,6 +262,7 @@ "Option already exists": "Optie bestaat al", "Option is empty": "Optie is leeg", "Options": "Opties", + "Original": "Origineel", "People matching": "Mensen die matchen", "Photo": "Foto", "Pin": "Vastmaken", @@ -283,15 +341,17 @@ "Thread reply": "Draadje antwoord", "Thread Reply": "Draadje antwoord", "Threads": "Discussies", - "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", + "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Vandaag]\", \"nextDay\": \"[Morgen]\", \"lastDay\": \"[Gisteren]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Laatste] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", + "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [om] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Vandaag] [om] HH:mm\", \"nextDay\": \"[Morgen] [om] HH:mm\", \"lastDay\": \"[Gisteren] [om] HH:mm\", \"nextWeek\": \"dddd [om] HH:mm\", \"lastWeek\": \"[Laatste] dddd [om] HH:mm\", \"sameElse\": \"ddd, D MMM [om] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Om te beginnen met opnemen, sta toegang tot de camera toe in uw browser", "To start recording, allow the microphone access in your browser": "Om te beginnen met opnemen, sta toegang tot de microfoon toe in uw browser", + "Translated": "Vertaald", + "Translated from {{ language }}": "Vertaald uit {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", "Type a number from 2 to 10": "Typ een getal van 2 tot 10", "Type your message": "Type je bericht", @@ -318,7 +378,9 @@ "Video": "Video", "View {{count}} comments_one": "Bekijk {{count}} opmerkingen", "View {{count}} comments_other": "Bekijk {{count}} opmerkingen", + "View original": "Origineel bekijken", "View results": "Bekijk resultaten", + "View translation": "Vertaling bekijken", "Voice message": "Spraakbericht", "Voice message {{ duration }}": "Spraakbericht {{ duration }}", "Vote ended": "Stemmen beëindigd", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index a4b5423cd..0e7f7742a 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -168,6 +168,63 @@ "giphy-command-description": "Postar um gif aleatório no canal", "Hide who voted": "Ocultar quem votou", "Instant commands": "Comandos instantâneos", + "language/af": "Africâner", + "language/am": "Amárico", + "language/ar": "Árabe", + "language/az": "Azerbaijano", + "language/bg": "Búlgaro", + "language/bn": "Bengali", + "language/bs": "Bósnio", + "language/cs": "Tcheco", + "language/da": "Dinamarquês", + "language/de": "Alemão", + "language/el": "Grego", + "language/en": "Inglês", + "language/es": "Espanhol", + "language/es-MX": "Espanhol (México)", + "language/et": "Estoniano", + "language/fa": "Persa", + "language/fa-AF": "Dari", + "language/fi": "Finlandês", + "language/fr": "Francês", + "language/fr-CA": "Francês (Canadá)", + "language/ha": "Hauçá", + "language/he": "Hebraico", + "language/hi": "Hindi", + "language/hr": "Croata", + "language/ht": "Crioulo haitiano", + "language/hu": "Húngaro", + "language/id": "Indonésio", + "language/it": "Italiano", + "language/ja": "Japonês", + "language/ka": "Georgiano", + "language/ko": "Coreano", + "language/lt": "Lituano", + "language/lv": "Letão", + "language/ms": "Malaio", + "language/nl": "Holandês", + "language/no": "Norueguês", + "language/pl": "Polonês", + "language/ps": "Pashto", + "language/pt": "Português", + "language/ro": "Romeno", + "language/ru": "Russo", + "language/sk": "Eslovaco", + "language/sl": "Esloveno", + "language/so": "Somali", + "language/sq": "Albanês", + "language/sr": "Sérvio", + "language/sv": "Sueco", + "language/sw": "Suaíli", + "language/ta": "Tâmil", + "language/th": "Tailandês", + "language/tl": "Tagalo", + "language/tr": "Turco", + "language/uk": "Ucraniano", + "language/ur": "Urdu", + "language/vi": "Vietnamita", + "language/zh": "Chinês (simplificado)", + "language/zh-TW": "Chinês (tradicional)", "Latest Messages": "Mensagens mais recentes", "Let others add options": "Permitir que outros adicionem opções", "Limit votes per person": "Limitar votos por pessoa", @@ -210,6 +267,7 @@ "Option already exists": "Opção já existe", "Option is empty": "A opção está vazia", "Options": "Opções", + "Original": "Original", "People matching": "Pessoas correspondentes", "Photo": "Foto", "Pin": "Fixar", @@ -290,15 +348,17 @@ "Thread reply": "Resposta no fio", "Thread Reply": "Resposta no fio", "Threads": "Tópicos", - "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", + "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoje]\", \"nextDay\": \"[Amanhã]\", \"lastDay\": \"[Ontem]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Último] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", + "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [às] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoje] [às] HH:mm\", \"nextDay\": \"[Amanhã] [às] HH:mm\", \"lastDay\": \"[Ontem] [às] HH:mm\", \"nextWeek\": \"dddd [às] HH:mm\", \"lastWeek\": \"[Último] dddd [às] HH:mm\", \"sameElse\": \"ddd, D MMM [às] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Para começar a gravar, permita o acesso à câmera no seu navegador", "To start recording, allow the microphone access in your browser": "Para começar a gravar, permita o acesso ao microfone no seu navegador", + "Translated": "Traduzido", + "Translated from {{ language }}": "Traduzido de {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", "Type a number from 2 to 10": "Digite um número de 2 a 10", "Type your message": "Digite sua mensagem", @@ -327,7 +387,9 @@ "View {{count}} comments_one": "Ver {{count}} comentário", "View {{count}} comments_many": "Ver {{count}} comentários", "View {{count}} comments_other": "Ver {{count}} comentários", + "View original": "Ver original", "View results": "Ver resultados", + "View translation": "Ver tradução", "Voice message": "Mensagem de voz", "Voice message {{ duration }}": "Mensagem de voz {{ duration }}", "Vote ended": "Votação encerrada", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 98b040aeb..17f1f399f 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -173,6 +173,63 @@ "giphy-command-description": "Опубликовать случайную GIF-анимацию в канале", "Hide who voted": "Скрыть, кто голосовал", "Instant commands": "Мгновенные команды", + "language/af": "Африкаанс", + "language/am": "Амхарский", + "language/ar": "Арабский", + "language/az": "Азербайджанский", + "language/bg": "Болгарский", + "language/bn": "Бенгальский", + "language/bs": "Боснийский", + "language/cs": "Чешский", + "language/da": "Датский", + "language/de": "Немецкий", + "language/el": "Греческий", + "language/en": "Английский", + "language/es": "Испанский", + "language/es-MX": "Испанский (Мексика)", + "language/et": "Эстонский", + "language/fa": "Персидский", + "language/fa-AF": "Дари", + "language/fi": "Финский", + "language/fr": "Французский", + "language/fr-CA": "Французский (Канада)", + "language/ha": "Хауса", + "language/he": "Иврит", + "language/hi": "Хинди", + "language/hr": "Хорватский", + "language/ht": "Гаитянский креольский", + "language/hu": "Венгерский", + "language/id": "Индонезийский", + "language/it": "Итальянский", + "language/ja": "Японский", + "language/ka": "Грузинский", + "language/ko": "Корейский", + "language/lt": "Литовский", + "language/lv": "Латышский", + "language/ms": "Малайский", + "language/nl": "Нидерландский", + "language/no": "Норвежский", + "language/pl": "Польский", + "language/ps": "Пушту", + "language/pt": "Португальский", + "language/ro": "Румынский", + "language/ru": "Русский", + "language/sk": "Словацкий", + "language/sl": "Словенский", + "language/so": "Сомали", + "language/sq": "Албанский", + "language/sr": "Сербский", + "language/sv": "Шведский", + "language/sw": "Суахили", + "language/ta": "Тамильский", + "language/th": "Тайский", + "language/tl": "Тагальский", + "language/tr": "Турецкий", + "language/uk": "Украинский", + "language/ur": "Урду", + "language/vi": "Вьетнамский", + "language/zh": "Китайский (упрощённый)", + "language/zh-TW": "Китайский (традиционный)", "Latest Messages": "Последние сообщения", "Let others add options": "Разрешить другим добавлять варианты", "Limit votes per person": "Ограничить голоса на человека", @@ -215,6 +272,7 @@ "Option already exists": "Вариант уже существует", "Option is empty": "Вариант пуст", "Options": "Варианты", + "Original": "Оригинал", "People matching": "Совпадающие люди", "Photo": "Фото", "Pin": "Закрепить", @@ -299,15 +357,17 @@ "Thread reply": "Ответ в ветке", "Thread Reply": "Ответ в ветке", "Threads": "Треды", - "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", + "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня]\", \"nextDay\": \"[Завтра]\", \"lastDay\": \"[Вчера]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[В прошлый] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", + "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [в] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня] [в] HH:mm\", \"nextDay\": \"[Завтра] [в] HH:mm\", \"lastDay\": \"[Вчера] [в] HH:mm\", \"nextWeek\": \"dddd [в] HH:mm\", \"lastWeek\": \"[В прошлый] dddd [в] HH:mm\", \"sameElse\": \"ddd, D MMM [в] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Для начала записи разрешите доступ к камере в вашем браузере", "To start recording, allow the microphone access in your browser": "Для начала записи разрешите доступ к микрофону в вашем браузере", + "Translated": "Переведено", + "Translated from {{ language }}": "Переведено с {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", "Type a number from 2 to 10": "Введите число от 2 до 10", "Type your message": "Ваше сообщение", @@ -338,7 +398,9 @@ "View {{count}} comments_few": "Просмотреть {{count}} комментариев", "View {{count}} comments_many": "Просмотреть {{count}} комментариев", "View {{count}} comments_other": "Просмотреть {{count}} комментариев", + "View original": "Показать оригинал", "View results": "Посмотреть результаты", + "View translation": "Показать перевод", "Voice message": "Голосовое сообщение", "Voice message {{ duration }}": "Голосовое сообщение {{ duration }}", "Vote ended": "Голосование завершено", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index de694481d..b6a4d8f15 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -163,6 +163,63 @@ "giphy-command-description": "Rastgele bir gif'i kanala gönder", "Hide who voted": "Kimin oy verdiğini gizle", "Instant commands": "Anlık komutlar", + "language/af": "Afrikanca", + "language/am": "Amharca", + "language/ar": "Arapça", + "language/az": "Azerice", + "language/bg": "Bulgarca", + "language/bn": "Bengalce", + "language/bs": "Boşnakça", + "language/cs": "Çekçe", + "language/da": "Danca", + "language/de": "Almanca", + "language/el": "Yunanca", + "language/en": "İngilizce", + "language/es": "İspanyolca", + "language/es-MX": "İspanyolca (Meksika)", + "language/et": "Estonyaca", + "language/fa": "Farsça", + "language/fa-AF": "Darice", + "language/fi": "Fince", + "language/fr": "Fransızca", + "language/fr-CA": "Fransızca (Kanada)", + "language/ha": "Hausa", + "language/he": "İbranice", + "language/hi": "Hintçe", + "language/hr": "Hırvatça", + "language/ht": "Haiti Kreolcesi", + "language/hu": "Macarca", + "language/id": "Endonezce", + "language/it": "İtalyanca", + "language/ja": "Japonca", + "language/ka": "Gürcüce", + "language/ko": "Korece", + "language/lt": "Litvanca", + "language/lv": "Letonca", + "language/ms": "Malayca", + "language/nl": "Felemenkçe", + "language/no": "Norveççe", + "language/pl": "Lehçe", + "language/ps": "Peştuca", + "language/pt": "Portekizce", + "language/ro": "Romence", + "language/ru": "Rusça", + "language/sk": "Slovakça", + "language/sl": "Slovence", + "language/so": "Somalice", + "language/sq": "Arnavutça", + "language/sr": "Sırpça", + "language/sv": "İsveççe", + "language/sw": "Svahilice", + "language/ta": "Tamilce", + "language/th": "Tayca", + "language/tl": "Tagalogca", + "language/tr": "Türkçe", + "language/uk": "Ukraynaca", + "language/ur": "Urduca", + "language/vi": "Vietnamca", + "language/zh": "Çince (basitleştirilmiş)", + "language/zh-TW": "Çince (geleneksel)", "Latest Messages": "Son Mesajlar", "Let others add options": "Başkalarının seçenek eklemesine izin ver", "Limit votes per person": "Kişi başına oy sınırı", @@ -205,6 +262,7 @@ "Option already exists": "Seçenek zaten mevcut", "Option is empty": "Seçenek boş", "Options": "Seçenekler", + "Original": "Orijinal", "People matching": "Eşleşen kişiler", "Photo": "Fotoğraf", "Pin": "Sabitle", @@ -281,15 +339,17 @@ "Thread reply": "Konu yanıtı", "Thread Reply": "Konu yanıtı", "Threads": "İleti dizileri", - "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", + "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Bugün]\", \"nextDay\": \"[Yarın]\", \"lastDay\": \"[Dün]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Geçen] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", + "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [saat] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", - "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Bugün] [saat] HH:mm\", \"nextDay\": \"[Yarın] [saat] HH:mm\", \"lastDay\": \"[Dün] [saat] HH:mm\", \"nextWeek\": \"dddd [saat] HH:mm\", \"lastWeek\": \"[Geçen] dddd [saat] HH:mm\", \"sameElse\": \"ddd, D MMM [saat] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Kayıt yapmaya başlamak için tarayıcınızda kameraya erişime izin verin", "To start recording, allow the microphone access in your browser": "Kayıt yapmaya başlamak için tarayıcınızda mikrofona erişime izin verin", + "Translated": "Çevrildi", + "Translated from {{ language }}": "{{ language }} dilinden çevrildi", "translationBuilderTopic/notification": "{{value, notification}}", "Type a number from 2 to 10": "2 ile 10 arasında bir sayı yazın", "Type your message": "Mesajınızı yazın", @@ -316,7 +376,9 @@ "Video": "Video", "View {{count}} comments_one": "{{count}} yorumu görüntüle", "View {{count}} comments_other": "{{count}} yorumu görüntüle", + "View original": "Orijinali görüntüle", "View results": "Sonuçları görüntüle", + "View translation": "Çeviriyi görüntüle", "Voice message": "Sesli mesaj", "Voice message {{ duration }}": "Sesli mesaj {{ duration }}", "Vote ended": "Oylama sona erdi", From f728d1400335e4d2dc23f80d75cf692f71d812bf Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 24 Feb 2026 11:28:36 +0100 Subject: [PATCH 08/16] feat(ChannelHeader): implement Figma design with sidebar collapse and layout updates - Add ChannelHeader styling folder (SCSS) per dev-patterns - Reorder layout: icon | text block | avatar (right) - Add sidebarCollapsed prop with IconLayoutAlignLeft when collapsed - Add IconLayoutAlignLeft to Icons - Add aria/Expand sidebar translations to all locales Co-authored-by: Cursor --- .../ChannelHeader/ChannelHeader.tsx | 37 ++-- src/components/ChannelHeader/plan.md | 169 ++++++++++++++++++ .../ChannelHeader/styling/ChannelHeader.scss | 68 +++++++ .../ChannelHeader/styling/index.scss | 1 + src/components/Icons/icons.tsx | 14 ++ src/i18n/de.json | 1 + src/i18n/en.json | 1 + src/i18n/es.json | 1 + src/i18n/fr.json | 1 + src/i18n/hi.json | 1 + src/i18n/it.json | 1 + src/i18n/ja.json | 1 + src/i18n/ko.json | 1 + src/i18n/nl.json | 1 + src/i18n/pt.json | 1 + src/i18n/ru.json | 1 + src/i18n/tr.json | 1 + src/styling/index.scss | 1 + 18 files changed, 291 insertions(+), 11 deletions(-) create mode 100644 src/components/ChannelHeader/plan.md create mode 100644 src/components/ChannelHeader/styling/ChannelHeader.scss create mode 100644 src/components/ChannelHeader/styling/index.scss diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index 7915d8eb8..e96d91237 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { IconLayoutAlignLeft } from '../Icons/icons'; import { MenuIcon as DefaultMenuIcon } from './icons'; import { Avatar as DefaultAvatar } from '../Avatar'; import { useChannelPreviewInfo } from '../ChannelPreview/hooks/useChannelPreviewInfo'; @@ -17,6 +18,8 @@ export type ChannelHeaderProps = { live?: boolean; /** UI component to display menu icon, defaults to [MenuIcon](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelHeader/ChannelHeader.tsx)*/ MenuIcon?: React.ComponentType; + /** When true, shows IconLayoutAlignLeft instead of MenuIcon for sidebar expansion */ + sidebarCollapsed?: boolean; /** Set title manually */ title?: string; }; @@ -30,6 +33,7 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { image: overrideImage, live, MenuIcon = DefaultMenuIcon, + sidebarCollapsed = false, title: overrideTitle, } = props; @@ -44,22 +48,26 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { const { member_count, subtitle } = channel?.data || {}; + const headerClassName = [ + 'str-chat__channel-header', + sidebarCollapsed && 'str-chat__channel-header--sidebar-collapsed', + ] + .filter(Boolean) + .join(' '); + return ( -
+
-

{displayTitle}{' '} @@ -80,6 +88,13 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { {t('{{ watcherCount }} online', { watcherCount: watcher_count })}

+
); }; diff --git a/src/components/ChannelHeader/plan.md b/src/components/ChannelHeader/plan.md new file mode 100644 index 000000000..a06fe46da --- /dev/null +++ b/src/components/ChannelHeader/plan.md @@ -0,0 +1,169 @@ +# Channel Header Figma Implementation + +## Worktree + +| Field | Value | +| ------------------ | ----------------------------------------------------- | +| **Path** | `../stream-chat-react-worktrees/channel-header-figma` | +| **Branch** | `feat/channel-header-figma` | +| **Base branch** | `feat/add-message-translation-indicator` | +| **Preview branch** | `agent/feat/channel-header-figma` (create when ready) | + +**Agent must `cd` into the worktree before any work:** + +```bash +cd ../stream-chat-react-worktrees/channel-header-figma +yarn install +``` + +--- + +## Task Overview + +Tasks are self-contained; styling and component tasks have a dependency order. All work in `src/components/ChannelHeader/`. + +--- + +## Design Reference + +**Figma:** [Chat SDK Design System – Web / Headers](https://www.figma.com/design/Us73erK1xFNcB5EH3hyq6Y/Chat-SDK-Design-System?node-id=1899-32506&m=dev) + +**Key design elements (channel header section):** + +- Layout: `[hamburger or sidebar icon] | [channelName + Online stacked] | [avatar right]` +- Variant: Sidebar collapsed (shows `IconLayoutAlignLeft`) vs expanded (hamburger / MenuIcon) +- Avatar on the right (current impl has it between hamburger and title) + +--- + +## Task 1: Create ChannelHeader Styling Folder and Base Styles + +**File(s) to create:** `src/components/ChannelHeader/styling/ChannelHeader.scss`, `src/components/ChannelHeader/styling/index.scss` + +**Dependencies:** None + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Create `src/components/ChannelHeader/styling/` folder +- `ChannelHeader.scss`: Base layout using `@include utils.header-layout`, full class names per dev-patterns +- Layout: flex, avatar right-aligned (`margin-left: auto` or `justify-content: space-between`) +- Use design tokens: `--str-chat__channel-header-background-color`, typography vars, spacing +- `index.scss`: `@use './ChannelHeader'` (or forward) +- **Do NOT** register in `src/styling/index.scss` yet (Task 3) + +**Acceptance Criteria:** + +- [ ] `ChannelHeader.scss` exists with `.str-chat__channel-header` and child selectors +- [ ] Uses full class names, no `&__suffix`-only blocks for same selector +- [ ] `index.scss` forwards/uses ChannelHeader.scss + +--- + +## Task 2: Add Sidebar-Collapsed Variant Styles + +**File(s) to create/modify:** `src/components/ChannelHeader/styling/ChannelHeader.scss` + +**Dependencies:** Task 1 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Add modifier: `.str-chat__channel-header--sidebar-collapsed` (compact left icon area if needed) +- Use existing `--str-chat__*` vars from design-system-tokens +- Hamburger (MenuIcon) and sidebar toggle (`IconLayoutAlignLeft`) button styling + +**Acceptance Criteria:** + +- [ ] Sidebar-collapsed modifier applies correctly when class is present + +--- + +## Task 3: Register ChannelHeader Styles and Update Component + +**File(s) to create/modify:** `src/styling/index.scss`, `src/components/ChannelHeader/ChannelHeader.tsx` + +**Dependencies:** Task 1, Task 2 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Add `@use '../components/ChannelHeader/styling' as ChannelHeader` to `src/styling/index.scss` in the appropriate group (alphabetical, chat components) +- Update `ChannelHeader.tsx`: + - Reorder layout: hamburger/sidebar icon | text block (title + Online) | avatar (right) + - Add optional prop: `sidebarCollapsed?: boolean` + - Render `MenuIcon` (hamburger) when expanded, `IconLayoutAlignLeft` when `sidebarCollapsed` — import from `src/components/Icons/icons.tsx` + - Simplify info line to "Online" (or keep watcher_count: "X online") per design + - Apply modifier class: `str-chat__channel-header--sidebar-collapsed` when `sidebarCollapsed` +- Preserve: `live`, `subtitle`, `member_count`, `Avatar`, `MenuIcon`, `title`, `image` — ensure backward compatibility + +**Acceptance Criteria:** + +- [ ] ChannelHeader styles imported in `src/styling/index.scss` +- [ ] Component layout matches Figma: avatar on right, text in middle +- [ ] New prop `sidebarCollapsed` works and applies modifier +- [ ] Existing tests pass; update tests if needed for new structure + +--- + +## Task 4: Integration and Tests + +**File(s) to create/modify:** `src/components/ChannelHeader/__tests__/ChannelHeader.test.js` + +**Dependencies:** Task 3 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Run existing tests; fix any failures from layout/class changes +- Add tests for `sidebarCollapsed` when applicable +- Ensure `yarn test`, `yarn types`, `yarn lint-fix` pass + +**Acceptance Criteria:** + +- [ ] All existing tests pass +- [ ] New props covered if feasible +- [ ] `yarn types` and `yarn lint-fix` pass + +--- + +## Execution Order + +| Phase | Tasks | Can run in parallel? | +| ----- | ------ | ------------------------- | +| 1 | Task 1 | — | +| 2 | Task 2 | No (depends on Task 1) | +| 3 | Task 3 | No (depends on Task 1, 2) | +| 4 | Task 4 | No (depends on Task 3) | + +--- + +## File Ownership Summary + +| Task | Creates | Modifies | +| ---- | ------------------------------------------------------------------------------ | ----------------------------------------------------------- | +| 1 | `ChannelHeader/styling/ChannelHeader.scss`, `ChannelHeader/styling/index.scss` | — | +| 2 | — | `ChannelHeader/styling/ChannelHeader.scss` | +| 3 | — | `src/styling/index.scss`, `ChannelHeader/ChannelHeader.tsx` | +| 4 | — | `ChannelHeader/__tests__/ChannelHeader.test.js` | + +--- + +## Notes + +- ChannelHeader currently has no dedicated styling folder; styles may come from stream-chat-css. This plan introduces ChannelHeader/styling per dev-patterns. +- Loading channel header in `Channel/styling/Channel.scss` uses `--str-chat__channel-header-background-color`; keep variable usage consistent. +- Backward compatibility: `sidebarCollapsed` defaults to `false`; existing usage unchanged. +- Sidebar expansion toggle icon: use `IconLayoutAlignLeft` from `src/components/Icons/icons.tsx` when `sidebarCollapsed=true`. diff --git a/src/components/ChannelHeader/styling/ChannelHeader.scss b/src/components/ChannelHeader/styling/ChannelHeader.scss new file mode 100644 index 000000000..c5270ba7a --- /dev/null +++ b/src/components/ChannelHeader/styling/ChannelHeader.scss @@ -0,0 +1,68 @@ +@use '../../../styling/utils'; + +.str-chat__channel-header { + @include utils.header-layout; + background-color: var(--str-chat__channel-header-background-color); + flex: 1; + min-width: 0; + + .str-chat__header-hamburger, + .str-chat__header-sidebar-toggle { + $icon-size: calc(var(--str-chat__spacing-px) * 24); + @include utils.unset-button; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: calc(var(--str-chat__spacing-px) * 40); + height: calc(var(--str-chat__spacing-px) * 40); + cursor: pointer; + color: var(--str-chat__text-color); + + svg { + width: $icon-size; + height: $icon-size; + } + + &:focus-visible { + @include utils.focusable; + } + } + + .str-chat__channel-header-end { + @include utils.header-text-layout; + min-width: 0; + } + + .str-chat__channel-header-title, + .str-chat__channel-header-subtitle, + .str-chat__channel-header-info { + @include utils.ellipsis-text; + } + + .str-chat__channel-header-title { + font: var(--str-chat__headline-text); + color: var(--str-chat__text-color); + } + + .str-chat__channel-header-subtitle { + font: var(--str-chat__body-text); + color: var(--str-chat__text-low-emphasis-color); + } + + .str-chat__channel-header-info { + font: var(--str-chat__body-text); + color: var(--str-chat__text-low-emphasis-color); + } + + .str-chat__avatar--channel-header { + flex-shrink: 0; + margin-inline-start: auto; + } + + &.str-chat__channel-header--sidebar-collapsed { + .str-chat__header-sidebar-toggle { + // Compact styling when sidebar collapsed + } + } +} diff --git a/src/components/ChannelHeader/styling/index.scss b/src/components/ChannelHeader/styling/index.scss new file mode 100644 index 000000000..1385a7048 --- /dev/null +++ b/src/components/ChannelHeader/styling/index.scss @@ -0,0 +1 @@ +@forward './ChannelHeader'; diff --git a/src/components/Icons/icons.tsx b/src/components/Icons/icons.tsx index ae6902f97..da1ef67c5 100644 --- a/src/components/Icons/icons.tsx +++ b/src/components/Icons/icons.tsx @@ -581,6 +581,20 @@ export const IconLayersBehind = createIcon( , ); +export const IconLayoutAlignLeft = createIcon( + 'IconLayoutAlignLeft', + <> + + + , +); + export const IconLayoutGrid1 = createIcon( 'IconLayoutGrid1', , diff --git a/src/i18n/de.json b/src/i18n/de.json index 4bbdad630..6c6653c5a 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -55,6 +55,7 @@ "aria/Download attachment": "Anhang herunterladen", "aria/Edit Message": "Nachricht bearbeiten", "aria/Emoji picker": "Emoji-Auswahl", + "aria/Expand sidebar": "Seitenleiste einblenden", "aria/File input": "Dateieingabe", "aria/File upload": "Datei hochladen", "aria/Flag Message": "Nachricht melden", diff --git a/src/i18n/en.json b/src/i18n/en.json index 7d589f7d4..0de5bc01a 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -55,6 +55,7 @@ "aria/Download attachment": "Download attachment", "aria/Edit Message": "Edit Message", "aria/Emoji picker": "Emoji picker", + "aria/Expand sidebar": "Expand sidebar", "aria/File input": "File input", "aria/File upload": "File upload", "aria/Flag Message": "Flag Message", diff --git a/src/i18n/es.json b/src/i18n/es.json index 02ae44a03..cb1260a9e 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -60,6 +60,7 @@ "aria/Download attachment": "Descargar adjunto", "aria/Edit Message": "Editar mensaje", "aria/Emoji picker": "Selector de emojis", + "aria/Expand sidebar": "Expandir barra lateral", "aria/File input": "Entrada de archivo", "aria/File upload": "Carga de archivo", "aria/Flag Message": "Marcar mensaje", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index d10627d5b..73a907c45 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -60,6 +60,7 @@ "aria/Download attachment": "Télécharger la pièce jointe", "aria/Edit Message": "Éditer un message", "aria/Emoji picker": "Sélecteur d'émojis", + "aria/Expand sidebar": "Développer la barre latérale", "aria/File input": "Entrée de fichier", "aria/File upload": "Téléchargement de fichier", "aria/Flag Message": "Signaler le message", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 2a45f1146..bf15279da 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -55,6 +55,7 @@ "aria/Download attachment": "अनुलग्नक डाउनलोड करें", "aria/Edit Message": "मैसेज में बदलाव करे", "aria/Emoji picker": "इमोजी चुनने वाला", + "aria/Expand sidebar": "साइडबार विस्तारित करें", "aria/File input": "फ़ाइल इनपुट", "aria/File upload": "फ़ाइल अपलोड", "aria/Flag Message": "संदेश फ्लैग करें", diff --git a/src/i18n/it.json b/src/i18n/it.json index ffd5bc7b5..51dc53182 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -60,6 +60,7 @@ "aria/Download attachment": "Scarica l'allegato", "aria/Edit Message": "Modifica messaggio", "aria/Emoji picker": "Selettore di emoji", + "aria/Expand sidebar": "Espandi barra laterale", "aria/File input": "Input di file", "aria/File upload": "Caricamento di file", "aria/Flag Message": "Segnala messaggio", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index ed3ba58fb..604519812 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -55,6 +55,7 @@ "aria/Download attachment": "添付ファイルをダウンロード", "aria/Edit Message": "メッセージを編集", "aria/Emoji picker": "絵文字ピッカー", + "aria/Expand sidebar": "サイドバーを展開", "aria/File input": "ファイル入力", "aria/File upload": "ファイルアップロード", "aria/Flag Message": "メッセージをフラグ", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 021188122..eac3788bf 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -55,6 +55,7 @@ "aria/Download attachment": "첨부 파일 다운로드", "aria/Edit Message": "메시지 수정", "aria/Emoji picker": "이모지 선택기", + "aria/Expand sidebar": "사이드바 확장", "aria/File input": "파일 입력", "aria/File upload": "파일 업로드", "aria/Flag Message": "메시지 신고", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index a875e5005..936703394 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -55,6 +55,7 @@ "aria/Download attachment": "Bijlage downloaden", "aria/Edit Message": "Bericht bewerken", "aria/Emoji picker": "Emoji kiezer", + "aria/Expand sidebar": "Zijbalken uitvouwen", "aria/File input": "Bestandsinvoer", "aria/File upload": "Bestand uploaden", "aria/Flag Message": "Bericht markeren", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 50b8c5c3f..5e7a68020 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -60,6 +60,7 @@ "aria/Download attachment": "Baixar anexo", "aria/Edit Message": "Editar Mensagem", "aria/Emoji picker": "Seletor de emojis", + "aria/Expand sidebar": "Expandir barra lateral", "aria/File input": "Entrada de arquivo", "aria/File upload": "Carregar arquivo", "aria/Flag Message": "Reportar mensagem", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 78525fac7..7923d89e1 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -65,6 +65,7 @@ "aria/Download attachment": "Скачать вложение", "aria/Edit Message": "Редактировать сообщение", "aria/Emoji picker": "Выбор эмодзи", + "aria/Expand sidebar": "Развернуть боковую панель", "aria/File input": "Ввод файла", "aria/File upload": "Загрузка файла", "aria/Flag Message": "Пожаловаться на сообщение", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 622165dbb..5b9bf0169 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -55,6 +55,7 @@ "aria/Download attachment": "Ek indir", "aria/Edit Message": "Mesajı Düzenle", "aria/Emoji picker": "Emoji seçici", + "aria/Expand sidebar": "Kenar çubuğunu genişlet", "aria/File input": "Dosya girişi", "aria/File upload": "Dosya yükleme", "aria/Flag Message": "Mesajı bayrakla", diff --git a/src/styling/index.scss b/src/styling/index.scss index db4b0db85..cd2c193d3 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -26,6 +26,7 @@ @use '../components/Avatar/styling/AvatarStack' as AvatarStack; @use '../components/Avatar/styling/GroupAvatar' as GroupAvatar; @use '../components/Channel/styling' as Channel; +@use '../components/ChannelHeader/styling' as ChannelHeader; @use '../components/ChatView/styling' as ChatView; @use '../components/DateSeparator/styling' as DateSeparator; @use '../components/DragAndDrop/styling' as DragAndDrop; From 172ed3d5fbdd56a0fcc4826c780f58e846330aa5 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 24 Feb 2026 17:44:27 +0100 Subject: [PATCH 09/16] feat: render - Render FloatingDateSeparator - Add NewMessageNotification as standalone component at bottom of message list - Style NewMessageNotification per Figma (pill, design tokens) - Remove MessageNotification from MessageListNotifications; simplify props to { notifications } - Move isNotAtLatestMessageSet visibility logic into ScrollToLatestMessageButton - Rename ScrollToBottomButton to ScrollToLatestMessageButton - Replace ComponentContext.MessageNotification with NewMessageNotification - Remove "Latest Messages" string and related i18n entries --- examples/vite/src/stream-imports-layout.scss | 4 +- examples/vite/src/stream-imports-theme.scss | 4 +- .../Attachment/styling/Attachment.scss | 1 + src/components/Badge/Badge.tsx | 37 ++++++ src/components/Badge/__tests__/Badge.test.tsx | 35 ++++++ src/components/Badge/index.ts | 1 + src/components/Badge/styling/Badge.scss | 71 +++++++++++ src/components/Badge/styling/index.scss | 1 + src/components/Button/styling/index.scss | 1 + .../DateSeparator/DateSeparator.tsx | 17 ++- .../DateSeparator/styling/DateSeparator.scss | 11 ++ .../styling/UnreadMessageNotification.scss | 13 +- .../styling/UnreadMessagesSeparator.scss | 3 +- .../MessageList/FloatingDateSeparator.tsx | 68 +++++++++++ src/components/MessageList/MessageList.tsx | 32 +++-- .../MessageList/MessageListNotifications.tsx | 30 +---- .../MessageList/MessageNotification.tsx | 36 ------ .../MessageList/NewMessageNotification.tsx | 48 ++++++++ ...on.tsx => ScrollToLatestMessageButton.tsx} | 87 +++++++------ .../MessageList/VirtualizedMessageList.tsx | 55 ++++++--- .../MessageList/__tests__/MessageList.test.js | 34 +++--- ...js => ScrollToLatestMessageButton.test.js} | 49 +++++--- .../MessageList/hooks/MessageList/index.ts | 1 + .../useFloatingDateSeparatorMessageList.ts | 99 +++++++++++++++ .../useFloatingDateSeparator.test.ts | 101 ++++++++++++++++ .../hooks/VirtualizedMessageList/index.ts | 1 + .../useFloatingDateSeparator.ts | 114 ++++++++++++++++++ src/components/MessageList/icons.tsx | 17 --- src/components/MessageList/index.ts | 4 +- .../MessageList/styling/MessageList.scss | 86 +------------ .../styling/NewMessageNotification.scss | 24 ++++ .../styling/ScrollToLatestMessageButton.scss | 29 +++++ src/components/MessageList/styling/index.scss | 2 + src/components/index.ts | 1 + src/context/ComponentContext.tsx | 6 +- src/i18n/de.json | 3 +- src/i18n/en.json | 3 +- src/i18n/es.json | 4 +- src/i18n/fr.json | 4 +- src/i18n/hi.json | 3 +- src/i18n/it.json | 4 +- src/i18n/ja.json | 3 +- src/i18n/ko.json | 3 +- src/i18n/nl.json | 3 +- src/i18n/pt.json | 4 +- src/i18n/ru.json | 5 +- src/i18n/tr.json | 3 +- src/styling/index.scss | 3 +- 48 files changed, 878 insertions(+), 290 deletions(-) create mode 100644 src/components/Badge/Badge.tsx create mode 100644 src/components/Badge/__tests__/Badge.test.tsx create mode 100644 src/components/Badge/index.ts create mode 100644 src/components/Badge/styling/Badge.scss create mode 100644 src/components/Badge/styling/index.scss create mode 100644 src/components/Button/styling/index.scss create mode 100644 src/components/MessageList/FloatingDateSeparator.tsx delete mode 100644 src/components/MessageList/MessageNotification.tsx create mode 100644 src/components/MessageList/NewMessageNotification.tsx rename src/components/MessageList/{ScrollToBottomButton.tsx => ScrollToLatestMessageButton.tsx} (55%) rename src/components/MessageList/__tests__/{ScrollToBottomButton.test.js => ScrollToLatestMessageButton.test.js} (86%) create mode 100644 src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts create mode 100644 src/components/MessageList/hooks/VirtualizedMessageList/__tests__/useFloatingDateSeparator.test.ts create mode 100644 src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts create mode 100644 src/components/MessageList/styling/NewMessageNotification.scss create mode 100644 src/components/MessageList/styling/ScrollToLatestMessageButton.scss diff --git a/examples/vite/src/stream-imports-layout.scss b/examples/vite/src/stream-imports-layout.scss index 7d4b8d446..2550fc765 100644 --- a/examples/vite/src/stream-imports-layout.scss +++ b/examples/vite/src/stream-imports-layout.scss @@ -7,7 +7,7 @@ //@use 'stream-chat-react/dist/scss/v2/AttachmentPreviewList/AttachmentPreviewList-layout'; // X //@use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-layout'; //@use 'stream-chat-react/dist/scss/v2/AudioRecorder/AudioRecorder-layout'; -@use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-layout'; +//@use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-layout'; //@use 'stream-chat-react/dist/scss/v2/Channel/Channel-layout'; @use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-layout'; @use 'stream-chat-react/dist/scss/v2/ChannelList/ChannelList-layout'; @@ -35,7 +35,7 @@ // @use 'stream-chat-react/dist/scss/v2/MessageReactions/MessageReactions-layout'; @use 'stream-chat-react/dist/scss/v2/MessageReactions/MessageReactionsSelector-layout'; //@use 'stream-chat-react/dist/scss/v2/Modal/Modal-layout'; -@use 'stream-chat-react/dist/scss/v2/Notification/MessageNotification-layout'; +//@use 'stream-chat-react/dist/scss/v2/Notification/MessageNotification-layout'; @use 'stream-chat-react/dist/scss/v2/Notification/NotificationList-layout'; @use 'stream-chat-react/dist/scss/v2/Notification/Notification-layout'; //@use 'stream-chat-react/dist/scss/v2/Poll/Poll-layout'; diff --git a/examples/vite/src/stream-imports-theme.scss b/examples/vite/src/stream-imports-theme.scss index 6d284451e..54c3fa97a 100644 --- a/examples/vite/src/stream-imports-theme.scss +++ b/examples/vite/src/stream-imports-theme.scss @@ -7,7 +7,7 @@ //@use 'stream-chat-react/dist/scss/v2/AttachmentList/AttachmentList-theme'; //@use 'stream-chat-react/dist/scss/v2/AudioRecorder/AudioRecorder-theme'; //@use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-theme'; -@use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-theme'; +//@use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-theme'; //@use 'stream-chat-react/dist/scss/v2/Channel/Channel-theme.scss'; @use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-theme'; @use 'stream-chat-react/dist/scss/v2/ChannelList/ChannelList-theme'; @@ -29,7 +29,7 @@ // @use 'stream-chat-react/dist/scss/v2/MessageReactions/MessageReactions-theme'; @use 'stream-chat-react/dist/scss/v2/MessageReactions/MessageReactionsSelector-theme'; //@use 'stream-chat-react/dist/scss/v2/Modal/Modal-theme'; -@use 'stream-chat-react/dist/scss/v2/Notification/MessageNotification-theme'; +//@use 'stream-chat-react/dist/scss/v2/Notification/MessageNotification-theme'; @use 'stream-chat-react/dist/scss/v2/Notification/NotificationList-theme'; @use 'stream-chat-react/dist/scss/v2/Notification/Notification-theme'; //@use 'stream-chat-react/dist/scss/v2/Poll/Poll-theme'; diff --git a/src/components/Attachment/styling/Attachment.scss b/src/components/Attachment/styling/Attachment.scss index be3109034..7f55d9d79 100644 --- a/src/components/Attachment/styling/Attachment.scss +++ b/src/components/Attachment/styling/Attachment.scss @@ -269,6 +269,7 @@ /* Right (left in RTL layout) border of audio widget's play / pause button */ --str-chat__audio-attachment-controls-button-border-inline-end: none; + // todo: we need to solve whether we want to keep the CSS variables. E.g. --str-chat__circle-fab-box-shadow is not declared. /* Box shadow applied to audio widget's play / pause button */ --str-chat__audio-attachment-controls-button-box-shadow: var( --str-chat__circle-fab-box-shadow diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx new file mode 100644 index 000000000..1b1376272 --- /dev/null +++ b/src/components/Badge/Badge.tsx @@ -0,0 +1,37 @@ +import clsx from 'clsx'; +import React, { type ComponentProps } from 'react'; + +export type BadgeVariant = 'default' | 'primary' | 'error' | 'neutral' | 'inverse'; + +export type BadgeSize = 'sm' | 'md' | 'lg'; + +export type BadgeProps = ComponentProps<'span'> & { + /** Visual variant mapping to design tokens */ + variant?: BadgeVariant; + /** Size preset (typography and padding) */ + size?: BadgeSize; +}; + +/** + * Compact pill/circle badge for counts and labels. + * Uses design tokens: --badge-bg-*, --badge-text-*, --badge-border. + */ +export const Badge = ({ + children, + className, + size = 'md', + variant = 'primary', + ...spanProps +}: BadgeProps) => ( + + {children} + +); diff --git a/src/components/Badge/__tests__/Badge.test.tsx b/src/components/Badge/__tests__/Badge.test.tsx new file mode 100644 index 000000000..0df4dd938 --- /dev/null +++ b/src/components/Badge/__tests__/Badge.test.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { Badge } from '../Badge'; + +describe('Badge', () => { + it('renders children', () => { + render(17); + expect(screen.getByText('17')).toBeInTheDocument(); + }); + + it('applies variant class', () => { + const { container } = render(1); + expect(container.firstChild).toHaveClass('str-chat__badge--variant-primary'); + }); + + it('applies size class', () => { + const { container } = render(1); + expect(container.firstChild).toHaveClass('str-chat__badge--size-md'); + }); + + it('passes data-testid', () => { + render(99); + expect(screen.getByTestId('custom-badge')).toHaveTextContent('99'); + }); + + it('merges className', () => { + const { container } = render( + 5, + ); + expect(container.firstChild).toHaveClass('str-chat__badge'); + expect(container.firstChild).toHaveClass('str-chat__jump-to-latest__unread-count'); + }); +}); diff --git a/src/components/Badge/index.ts b/src/components/Badge/index.ts new file mode 100644 index 000000000..9c8edca28 --- /dev/null +++ b/src/components/Badge/index.ts @@ -0,0 +1 @@ +export * from './Badge'; diff --git a/src/components/Badge/styling/Badge.scss b/src/components/Badge/styling/Badge.scss new file mode 100644 index 000000000..974073910 --- /dev/null +++ b/src/components/Badge/styling/Badge.scss @@ -0,0 +1,71 @@ +// Badge: compact pill for counts/labels. Design tokens: badge-bg-*, badge-text-*, badge-border. +// Figma: Badge Notification (scroll-to-bottom unread count) + +.str-chat__badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: var(--typography-font-weight-bold); + border-radius: var(--radius-max); + line-height: 1; + border-style: solid; +} + +// Variants: map to design tokens +.str-chat__badge--variant-default { + background: var(--badge-bg-default); + color: var(--badge-text); + border-color: var(--badge-border); +} + +.str-chat__badge--variant-primary { + background: var(--badge-bg-primary); + color: var(--badge-text-on-accent); + border-color: var(--badge-border); +} + +.str-chat__badge--variant-error { + background: var(--badge-bg-error); + color: var(--badge-text-on-accent); + border-color: var(--badge-border); +} + +.str-chat__badge--variant-neutral { + background: var(--badge-bg-neutral); + color: var(--badge-text-on-accent); + border-color: var(--badge-border); +} + +.str-chat__badge--variant-inverse { + background: var(--badge-bg-inverse); + color: var(--badge-text-inverse); + border-color: var(--badge-border); +} + +// Sizes: caption-numeric equivalents (sm/12px, md/14px, lg/16px) +.str-chat__badge--size-sm { + font-size: var(--typography-font-size-xxs); + min-width: 16px; + min-height: 16px; + padding-inline: var(--spacing-xxxs); + border-width: 1px; + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.14); +} + +.str-chat__badge--size-md { + font-size: var(--typography-font-size-xs); + min-width: 20px; + min-height: 20px; + padding-inline: var(--spacing-xxs); + border-width: 2px; + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.14); +} + +.str-chat__badge--size-lg { + font-size: var(--typography-font-size-sm); + min-width: 24px; + min-height: 24px; + padding-inline: var(--spacing-xs); + border-width: 2px; + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.14); +} diff --git a/src/components/Badge/styling/index.scss b/src/components/Badge/styling/index.scss new file mode 100644 index 000000000..529a29758 --- /dev/null +++ b/src/components/Badge/styling/index.scss @@ -0,0 +1 @@ +@use 'Badge'; diff --git a/src/components/Button/styling/index.scss b/src/components/Button/styling/index.scss new file mode 100644 index 000000000..7514bf355 --- /dev/null +++ b/src/components/Button/styling/index.scss @@ -0,0 +1 @@ +@use 'Button'; diff --git a/src/components/DateSeparator/DateSeparator.tsx b/src/components/DateSeparator/DateSeparator.tsx index 148426bd5..f7bf166c5 100644 --- a/src/components/DateSeparator/DateSeparator.tsx +++ b/src/components/DateSeparator/DateSeparator.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import React from 'react'; import { useTranslationContext } from '../../context/TranslationContext'; @@ -6,8 +7,12 @@ import { getDateString } from '../../i18n/utils'; import type { TimestampFormatterOptions } from '../../i18n/types'; export type DateSeparatorProps = TimestampFormatterOptions & { + /** Optional className for the root element */ + className?: string; /** The date to format */ date: Date; + /** When true, applies floating positioning (fixed at top when scrolling) */ + floating?: boolean; /** Override the default formatting of the date. This is a function that has access to the original date object. */ formatDate?: (date: Date) => string; // todo: position and unread are not necessary anymore @@ -20,7 +25,9 @@ export type DateSeparatorProps = TimestampFormatterOptions & { const UnMemoizedDateSeparator = (props: DateSeparatorProps) => { const { calendar, + className, date: messageCreatedAt, + floating, formatDate, ...restTimestampFormatterOptions } = props; @@ -38,7 +45,15 @@ const UnMemoizedDateSeparator = (props: DateSeparatorProps) => { }); return ( -
+
{formattedDate}
); diff --git a/src/components/DateSeparator/styling/DateSeparator.scss b/src/components/DateSeparator/styling/DateSeparator.scss index 2b83e822f..ed29b9ef5 100644 --- a/src/components/DateSeparator/styling/DateSeparator.scss +++ b/src/components/DateSeparator/styling/DateSeparator.scss @@ -12,6 +12,17 @@ --str-chat__date-separator-box-shadow: none; } +.str-chat__date-separator--floating { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 1; + display: flex; + justify-content: center; + pointer-events: none; +} + .str-chat__date-separator { @include utils.component-layer-overrides('date-separator'); display: flex; diff --git a/src/components/Message/styling/UnreadMessageNotification.scss b/src/components/Message/styling/UnreadMessageNotification.scss index 8104752f3..763071b06 100644 --- a/src/components/Message/styling/UnreadMessageNotification.scss +++ b/src/components/Message/styling/UnreadMessageNotification.scss @@ -1,9 +1,10 @@ .str-chat__unread-messages-notification { - --str-chat-icon-color: var(--str-chat__grey50); + --str-chat-icon-color: var(--str-chat__on-primary-color); background-color: var(--str-chat__text-low-emphasis-color); - border-radius: 1.125rem; + border-radius: var(--str-chat__border-radius-2xl); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); position: absolute; - top: 0.75rem; + top: var(--str-chat__spacing-3); z-index: 2; display: flex; align-items: center; @@ -15,18 +16,18 @@ width: 100%; white-space: nowrap; cursor: pointer; - color: var(--str-chat__grey50); + color: var(--str-chat__on-primary-color); border: none; background-color: transparent; } button:first-of-type { - padding-inline: 0.75rem 0.375rem; + padding-inline: var(--str-chat__spacing-3) var(--str-chat__spacing-2); font: var(--str-chat__caption-text); } button:last-of-type { - padding-inline: 0.375rem 0.75rem; + padding-inline: var(--str-chat__spacing-2) var(--str-chat__spacing-3); svg { width: 0.875rem; diff --git a/src/components/Message/styling/UnreadMessagesSeparator.scss b/src/components/Message/styling/UnreadMessagesSeparator.scss index 356e1cebf..db822b1de 100644 --- a/src/components/Message/styling/UnreadMessagesSeparator.scss +++ b/src/components/Message/styling/UnreadMessagesSeparator.scss @@ -6,10 +6,11 @@ align-items: center; justify-content: center; width: 100%; - padding: var(--str-chat__spacing-2); + padding: var(--str-chat__spacing-2) var(--str-chat__spacing-3); background-color: var(--str-chat__secondary-surface-color); color: var(--str-chat__text-low-emphasis-color); text-transform: uppercase; font: var(--str-chat__caption-strong-text); + letter-spacing: 0.02em; } } diff --git a/src/components/MessageList/FloatingDateSeparator.tsx b/src/components/MessageList/FloatingDateSeparator.tsx new file mode 100644 index 000000000..cb8ff2145 --- /dev/null +++ b/src/components/MessageList/FloatingDateSeparator.tsx @@ -0,0 +1,68 @@ +import React, { useLayoutEffect } from 'react'; +import type { RefObject } from 'react'; + +import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; +import { useComponentContext } from '../../context/ComponentContext'; +import { useFloatingDateSeparatorMessageList } from './hooks/MessageList'; +import { useFloatingDateSeparator } from './hooks/VirtualizedMessageList'; +import type { RenderedMessage } from './utils'; + +type BaseProps = { + disableDateSeparator: boolean; + processedMessages: RenderedMessage[]; +}; + +export type FloatingDateSeparatorProps = BaseProps & + ( + | { + /** For VirtualizedMessageList: ref the parent calls with visible items */ + itemsRenderedRef: RefObject<((rendered: RenderedMessage[]) => void) | null>; + } + | { + /** For MessageList: scroll container to query DOM for date separators */ + listElement: HTMLDivElement | null; + } + ); + +/** + * Renders a floating date separator when the user has scrolled past the in-flow date. + * State is internal so MessageList/VirtualizedMessageList do not re-render when it changes. + * Use itemsRenderedRef for Virtuoso, listElement for non-virtualized MessageList. + */ +export const FloatingDateSeparator = (props: FloatingDateSeparatorProps) => { + const { DateSeparator = DefaultDateSeparator } = useComponentContext( + 'FloatingDateSeparator', + ); + const { disableDateSeparator, processedMessages } = props; + + const listElement = 'listElement' in props ? props.listElement : null; + const useDomMode = listElement != null; + + const virtuosoResult = useFloatingDateSeparator({ + disableDateSeparator, + processedMessages, + }); + const domResult = useFloatingDateSeparatorMessageList({ + disableDateSeparator, + listElement, + processedMessages, + }); + + const floatingDate = useDomMode ? domResult.floatingDate : virtuosoResult.floatingDate; + const showFloatingDate = useDomMode + ? domResult.showFloatingDate + : virtuosoResult.showFloatingDate; + + const itemsRenderedRef = 'itemsRenderedRef' in props ? props.itemsRenderedRef : null; + useLayoutEffect(() => { + if (!itemsRenderedRef) return; + itemsRenderedRef.current = virtuosoResult.onItemsRendered; + return () => { + itemsRenderedRef.current = null; + }; + }, [itemsRenderedRef, virtuosoResult.onItemsRendered]); + + if (!showFloatingDate || !floatingDate || !DateSeparator) return null; + + return ; +}; diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 750eb0b1d..099db38dc 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -9,8 +9,8 @@ import { } from './hooks/MessageList'; import { useMarkRead } from './hooks/useMarkRead'; -import { MessageNotification as DefaultMessageNotification } from './MessageNotification'; import { MessageListNotifications as DefaultMessageListNotifications } from './MessageListNotifications'; +import { NewMessageNotification as DefaultNewMessageNotification } from './NewMessageNotification'; import { UnreadMessagesNotification as DefaultUnreadMessagesNotification } from './UnreadMessagesNotification'; import type { ChannelActionContextValue } from '../../context/ChannelActionContext'; @@ -29,6 +29,7 @@ import { defaultPinPermissions, MESSAGE_ACTIONS } from '../Message/utils'; import { TypingIndicator as DefaultTypingIndicator } from '../TypingIndicator'; import { MessageListMainPanel as DefaultMessageListMainPanel } from './MessageListMainPanel'; +import { FloatingDateSeparator } from './FloatingDateSeparator'; import { defaultRenderMessages } from './renderMessages'; import { useStableId } from '../UtilityComponents/useStableId'; @@ -43,6 +44,7 @@ import { DEFAULT_NEXT_CHANNEL_PAGE_SIZE, } from '../../constants/limits'; import { useLastOwnMessage } from './hooks/useLastOwnMessage'; +import { ScrollToLatestMessageButton } from './ScrollToLatestMessageButton'; type MessageListWithContextProps = Omit< ChannelStateContextValue, @@ -97,7 +99,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { MessageListMainPanel = DefaultMessageListMainPanel, MessageListNotifications = DefaultMessageListNotifications, MessageListWrapper = 'ul', - MessageNotification = DefaultMessageNotification, + NewMessageNotification = DefaultNewMessageNotification, TypingIndicator = DefaultTypingIndicator, UnreadMessagesNotification = DefaultUnreadMessagesNotification, } = useComponentContext('MessageList'); @@ -246,6 +248,11 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { unreadCount={channelUnreadUiState?.unread_messages} /> )} +
{
)} + +
- + ); diff --git a/src/components/MessageList/MessageListNotifications.tsx b/src/components/MessageList/MessageListNotifications.tsx index 2abc0106c..1ade8f98c 100644 --- a/src/components/MessageList/MessageListNotifications.tsx +++ b/src/components/MessageList/MessageListNotifications.tsx @@ -5,7 +5,6 @@ import { CustomNotification } from './CustomNotification'; import { useTranslationContext } from '../../context/TranslationContext'; import { useNotifications } from '../Notifications/hooks/useNotifications'; -import type { MessageNotificationProps } from './MessageNotification'; import type { ChannelNotifications } from '../../context/ChannelStateContext'; const ClientNotifications = () => { @@ -28,29 +27,11 @@ const ClientNotifications = () => { }; export type MessageListNotificationsProps = { - hasNewMessages: boolean; - isMessageListScrolledToBottom: boolean; - isNotAtLatestMessageSet: boolean; - MessageNotification: React.ComponentType; notifications: ChannelNotifications; - scrollToBottom: () => void; - threadList?: boolean; - unreadCount?: number; }; export const MessageListNotifications = (props: MessageListNotificationsProps) => { - const { - hasNewMessages, - isMessageListScrolledToBottom, - isNotAtLatestMessageSet, - MessageNotification, - notifications, - scrollToBottom, - threadList, - unreadCount, - } = props; - - const { t } = useTranslationContext('MessageListNotifications'); + const { notifications } = props; return (
@@ -61,15 +42,6 @@ export const MessageListNotifications = (props: MessageListNotificationsProps) = ))} - - {isNotAtLatestMessageSet ? t('Latest Messages') : t('New Messages!')} -
); }; diff --git a/src/components/MessageList/MessageNotification.tsx b/src/components/MessageList/MessageNotification.tsx deleted file mode 100644 index a777a7ae3..000000000 --- a/src/components/MessageList/MessageNotification.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { PropsWithChildren } from 'react'; -import React from 'react'; - -export type MessageNotificationProps = PropsWithChildren<{ - /** button click event handler */ - onClick: React.MouseEventHandler; - /** signals whether the message list is considered (below a threshold) to be scrolled to the bottom. Prop used only by [ScrollToBottomButton](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/ScrollToBottomButton.tsx) */ - isMessageListScrolledToBottom?: boolean; - /** Whether or not to show notification. Prop used only by [MessageNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/MessageNotification.tsx) */ - showNotification?: boolean; - /** informs the component whether it is rendered inside a thread message list */ - threadList?: boolean; - /** */ - unreadCount?: number; -}>; - -const UnMemoizedMessageNotification = (props: MessageNotificationProps) => { - const { children, onClick, showNotification = true } = props; - - if (!showNotification) return null; - - return ( - - ); -}; - -export const MessageNotification = React.memo( - UnMemoizedMessageNotification, -) as typeof UnMemoizedMessageNotification; diff --git a/src/components/MessageList/NewMessageNotification.tsx b/src/components/MessageList/NewMessageNotification.tsx new file mode 100644 index 000000000..1169e00b8 --- /dev/null +++ b/src/components/MessageList/NewMessageNotification.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import { useComponentContext } from '../../context/ComponentContext'; +import { useTranslationContext } from '../../context/TranslationContext'; + +export type NewMessageNotificationProps = { + /** Pre-computed label text (passed to custom override) */ + label?: string; + /** Controls the notification display */ + showNotification?: boolean; + /** Number of new messages since user scrolled up (optional; when provided shows "{{count}} new messages") */ + newMessageCount?: number; +}; + +const UnMemoizedNewMessageNotification = (props: NewMessageNotificationProps) => { + const { newMessageCount = 0, showNotification } = props; + + const { t } = useTranslationContext(); + + const { NewMessageNotification: CustomNewMessageNotification } = useComponentContext(); + + if (!showNotification) return null; + + const label = + newMessageCount > 0 + ? t('{{count}} new messages', { count: newMessageCount }) + : t('New Messages!'); + + if (CustomNewMessageNotification) { + return ; + } + + return ( +
+
+ {label} +
+
+ ); +}; + +export const NewMessageNotification = React.memo( + UnMemoizedNewMessageNotification, +) as typeof UnMemoizedNewMessageNotification; diff --git a/src/components/MessageList/ScrollToBottomButton.tsx b/src/components/MessageList/ScrollToLatestMessageButton.tsx similarity index 55% rename from src/components/MessageList/ScrollToBottomButton.tsx rename to src/components/MessageList/ScrollToLatestMessageButton.tsx index d8508867c..25cb47687 100644 --- a/src/components/MessageList/ScrollToBottomButton.tsx +++ b/src/components/MessageList/ScrollToLatestMessageButton.tsx @@ -1,20 +1,30 @@ import React, { useEffect, useState } from 'react'; import clsx from 'clsx'; -import { ArrowDown } from './icons'; - import { useChannelStateContext, useChatContext } from '../../context'; import type { Event } from 'stream-chat'; -import type { MessageNotificationProps } from './MessageNotification'; +import { Badge } from '../Badge'; +import { Button } from '../Button'; +import { IconArrowDown } from '../Icons'; + +type ScrollToLatestMessageButtonProps = { + /** When true, user has jumped to an older message set and newer messages can be loaded */ + isNotAtLatestMessageSet?: boolean; + isMessageListScrolledToBottom?: boolean; + onClick: React.MouseEventHandler; + threadList?: boolean; +}; -const UnMemoizedScrollToBottomButton = ( - props: Pick< - MessageNotificationProps, - 'isMessageListScrolledToBottom' | 'onClick' | 'threadList' - >, +const UnMemoizedScrollToLatestMessageButton = ( + props: ScrollToLatestMessageButtonProps, ) => { - const { isMessageListScrolledToBottom, onClick, threadList } = props; + const { + isMessageListScrolledToBottom, + isNotAtLatestMessageSet = false, + onClick, + threadList, + } = props; const { channel: activeChannel, client } = useChatContext(); const { thread } = useChannelStateContext(); @@ -54,8 +64,15 @@ const UnMemoizedScrollToBottomButton = ( return () => { client.off(observedEvent, handleEvent); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeChannel, isMessageListScrolledToBottom, observedEvent, replyCount, thread]); + }, [ + activeChannel, + client, + isMessageListScrolledToBottom, + observedEvent, + replyCount, + thread, + threadList, + ]); useEffect(() => { if (isMessageListScrolledToBottom) { @@ -64,36 +81,38 @@ const UnMemoizedScrollToBottomButton = ( } }, [isMessageListScrolledToBottom, thread]); - if (isMessageListScrolledToBottom) return null; + if (isMessageListScrolledToBottom && !isNotAtLatestMessageSet) return null; return (
- + + + {countUnread > 0 && ( + + {countUnread} + + )}
); }; -export const ScrollToBottomButton = React.memo( - UnMemoizedScrollToBottomButton, -) as typeof UnMemoizedScrollToBottomButton; +export const ScrollToLatestMessageButton = React.memo( + UnMemoizedScrollToLatestMessageButton, +) as typeof UnMemoizedScrollToLatestMessageButton; diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 8c63b16ad..201b13c16 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -1,5 +1,7 @@ import type { RefObject } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { FloatingDateSeparator } from './FloatingDateSeparator'; import type { ComputeItemKey, ScrollSeekConfiguration, @@ -22,8 +24,8 @@ import { } from './hooks/VirtualizedMessageList'; import { useMarkRead } from './hooks/useMarkRead'; -import { MessageNotification as DefaultMessageNotification } from './MessageNotification'; import { MessageListNotifications as DefaultMessageListNotifications } from './MessageListNotifications'; +import { NewMessageNotification as DefaultNewMessageNotification } from './NewMessageNotification'; import { MessageListMainPanel as DefaultMessageListMainPanel } from './MessageListMainPanel'; import type { GroupStyle, ProcessMessagesParams, RenderedMessage } from './utils'; import { getGroupStyles, getLastReceived, processMessages } from './utils'; @@ -40,7 +42,10 @@ import { messageRenderer, } from './VirtualizedMessageListComponents'; -import { UnreadMessagesSeparator as DefaultUnreadMessagesSeparator } from '../MessageList'; +import { + UnreadMessagesSeparator as DefaultUnreadMessagesSeparator, + ScrollToLatestMessageButton, +} from '../MessageList'; import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; import { EventComponent as DefaultMessageSystem } from '../EventComponent'; @@ -243,8 +248,8 @@ const VirtualizedMessageListWithContext = ( GiphyPreviewMessage = DefaultGiphyPreviewMessage, MessageListMainPanel = DefaultMessageListMainPanel, MessageListNotifications = DefaultMessageListNotifications, - MessageNotification = DefaultMessageNotification, MessageSystem = DefaultMessageSystem, + NewMessageNotification = DefaultNewMessageNotification, TypingIndicator, UnreadMessagesNotification = DefaultUnreadMessagesNotification, UnreadMessagesSeparator = DefaultUnreadMessagesSeparator, @@ -303,6 +308,16 @@ const VirtualizedMessageListWithContext = ( client.userID, ]); + /** + * This indirection via a ref is needed because Virtuoso’s itemsRendered is set at render time, + * while FloatingDateSeparator lives in a child component that may run its hooks in a different order. + * Using a ref lets the parent provide the callback to Virtuoso while the child keeps ownership + * of the actual handler implementation. + */ + const floatingDateItemsRenderedRef = useRef< + ((rendered: RenderedMessage[]) => void) | null + >(null); + const lastOwnMessage = useLastOwnMessage({ messages, ownUserId: client.user?.id }); // get the mapping of own messages to array of users who read them @@ -405,7 +420,13 @@ const VirtualizedMessageListWithContext = ( const handleItemsRendered = useMemo( () => - makeItemsRenderedHandler([toggleShowUnreadMessagesNotification], processedMessages), + makeItemsRenderedHandler( + [ + toggleShowUnreadMessagesNotification, + (rendered) => floatingDateItemsRenderedRef.current?.(rendered), + ], + processedMessages, + ), [processedMessages, toggleShowUnreadMessagesNotification], ); const followOutput = (isAtBottom: boolean) => { @@ -479,6 +500,11 @@ const VirtualizedMessageListWithContext = ( customClasses?.virtualizedMessageList || 'str-chat__virtual-list' } > + atBottomStateChange={atBottomStateChange} atBottomThreshold={100} @@ -546,20 +572,21 @@ const VirtualizedMessageListWithContext = ( {...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})} {...(defaultItemHeight ? { defaultItemHeight } : {})} /> + +
{TypingIndicator && } - + {giphyPreviewMessage && } diff --git a/src/components/MessageList/__tests__/MessageList.test.js b/src/components/MessageList/__tests__/MessageList.test.js index d4dba3293..bc3a81b75 100644 --- a/src/components/MessageList/__tests__/MessageList.test.js +++ b/src/components/MessageList/__tests__/MessageList.test.js @@ -26,8 +26,6 @@ import { WithComponents, } from '../../../context'; import { EmptyStateIndicator as EmptyStateIndicatorMock } from '../../EmptyStateIndicator'; -import { ScrollToBottomButton } from '../ScrollToBottomButton'; -import { MessageListNotifications } from '../MessageListNotifications'; import { mockedApiResponse } from '../../../mock-builders/api/utils'; import { nanoid } from 'nanoid'; @@ -795,14 +793,12 @@ describe('MessageList', () => { }); }); - describe('ScrollToBottomButton', () => { - const BUTTON_TEST_ID = 'message-notification'; + describe('ScrollToLatestMessageButton and NewMessageNotification', () => { + const NEW_MESSAGE_NOTIFICATION_TEST_ID = 'message-notification'; + const SCROLL_TO_LATEST_MESSAGE_TEST_ID = 'scroll-to-latest-message-button'; const NEW_MESSAGE_COUNTER_TEST_ID = 'unread-message-notification-counter'; - const MockMessageListNotifications = (props) => ( - - ); - it('does not reflect the channel unread UI state', async () => { + it('ScrollToLatestMessageButton does not reflect the channel unread UI state', async () => { const { channels: [channel], client, @@ -814,15 +810,15 @@ describe('MessageList', () => { channel, }, chatClient: client, - components: { - MessageListNotifications: MockMessageListNotifications, - MessageNotification: ScrollToBottomButton, - }, msgListProps: { messages }, }); }); - expect(screen.queryByTestId(BUTTON_TEST_ID)).toBeInTheDocument(); + const scrollButton = screen.queryByTestId(SCROLL_TO_LATEST_MESSAGE_TEST_ID); + const newMessageNotification = screen.queryByTestId( + NEW_MESSAGE_NOTIFICATION_TEST_ID, + ); + expect(scrollButton || newMessageNotification).toBeTruthy(); expect(screen.queryByTestId(NEW_MESSAGE_COUNTER_TEST_ID)).not.toBeInTheDocument(); await act(() => { @@ -832,7 +828,7 @@ describe('MessageList', () => { expect(screen.queryByTestId(NEW_MESSAGE_COUNTER_TEST_ID)).not.toBeInTheDocument(); }); - it('does not reflect the channel unread state in a thread', async () => { + it('ScrollToLatestMessageButton does not reflect the channel unread state in a thread', async () => { const { channels: [channel], client, @@ -844,15 +840,15 @@ describe('MessageList', () => { channel, }, chatClient: client, - components: { - MessageListNotifications: MockMessageListNotifications, - MessageNotification: ScrollToBottomButton, - }, msgListProps: { messages, threadList: true }, }); }); - expect(screen.queryByTestId(BUTTON_TEST_ID)).toBeInTheDocument(); + const scrollButton = screen.queryByTestId(SCROLL_TO_LATEST_MESSAGE_TEST_ID); + const newMessageNotification = screen.queryByTestId( + NEW_MESSAGE_NOTIFICATION_TEST_ID, + ); + expect(scrollButton || newMessageNotification).toBeTruthy(); expect(screen.queryByTestId(NEW_MESSAGE_COUNTER_TEST_ID)).not.toBeInTheDocument(); await act(() => { diff --git a/src/components/MessageList/__tests__/ScrollToBottomButton.test.js b/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.js similarity index 86% rename from src/components/MessageList/__tests__/ScrollToBottomButton.test.js rename to src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.js index 10b18bb07..bbf0005b6 100644 --- a/src/components/MessageList/__tests__/ScrollToBottomButton.test.js +++ b/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.js @@ -2,7 +2,7 @@ import React from 'react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { ScrollToBottomButton } from '../ScrollToBottomButton'; +import { ScrollToLatestMessageButton } from '../ScrollToLatestMessageButton'; import { ChannelStateProvider, ChatProvider } from '../../../context'; import { createClientWithChannel, @@ -11,7 +11,7 @@ import { generateMessage, } from '../../../mock-builders'; -const BUTTON_TEST_ID = 'message-notification'; +const BUTTON_TEST_ID = 'scroll-to-latest-message-button'; const NEW_MESSAGE_COUNTER_TEST_ID = 'unread-message-notification-counter'; const mainList = 'the main message list'; @@ -42,7 +42,7 @@ const dispatchMessageEvents = ({ channel, client, newMessage, parentMsg, user }) describe.each([ [mainList, threadList], [threadList, mainList], -])('ScrollToBottomButton in %s', (containerMsgList, otherMsgList) => { +])('ScrollToLatestMessageButton in %s', (containerMsgList, otherMsgList) => { beforeEach(async () => { const result = await createClientWithChannel(); client = result.client; @@ -64,7 +64,7 @@ describe.each([ const { container } = render( - + , ); @@ -75,7 +75,10 @@ describe.each([ render( - + , ); @@ -86,7 +89,10 @@ describe.each([ render( - + , ); @@ -105,7 +111,7 @@ describe.each([ render( - + , ); @@ -131,7 +137,10 @@ describe.each([ render( - + , ); @@ -158,7 +167,10 @@ describe.each([ render( - + , ); @@ -187,7 +199,10 @@ describe.each([ render( - + , ); @@ -217,7 +232,10 @@ describe.each([ render( - + , ); @@ -241,7 +259,10 @@ describe.each([ render( - + , ); @@ -280,13 +301,13 @@ describe.each([
-
- { + const [state, setState] = useState<{ date: Date | null; visible: boolean }>({ + date: null, + visible: false, + }); + + const update = useCallback(() => { + if (disableDateSeparator || !listElement || processedMessages.length === 0) { + setState({ date: null, visible: false }); + return; + } + + const separators = listElement.querySelectorAll(DATE_SEPARATOR_SELECTOR); + if (separators.length === 0) { + setState({ date: null, visible: false }); + return; + } + + const containerRect = listElement.getBoundingClientRect(); + let bestDate: Date | null = null; + let bestBottom = -Infinity; + let anyVisible = false; + + for (const el of separators) { + const rect = el.getBoundingClientRect(); + const dataDate = el.getAttribute('data-date'); + if (!dataDate) continue; + + const isAboveViewport = rect.bottom < containerRect.top; + const isVisible = + rect.top < containerRect.bottom && rect.bottom > containerRect.top; + + if (isVisible) { + anyVisible = true; + } + + if (isAboveViewport && rect.bottom > bestBottom) { + bestBottom = rect.bottom; + const d = new Date(dataDate); + if (!isNaN(d.getTime())) bestDate = d; + } + } + + setState({ + date: anyVisible ? null : bestDate, + visible: !anyVisible && bestDate !== null, + }); + }, [disableDateSeparator, listElement, processedMessages]); + + useEffect(() => { + if (!listElement) return; + + const throttled = throttle(update, THROTTLE_MS); + + throttled(); + listElement.addEventListener('scroll', throttled); + const resizeObserver = new ResizeObserver(throttled); + resizeObserver.observe(listElement); + + return () => { + listElement.removeEventListener('scroll', throttled); + resizeObserver.disconnect(); + throttled.cancel(); + }; + }, [listElement, update]); + + return { + floatingDate: state.date, + showFloatingDate: state.visible, + }; +}; diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/__tests__/useFloatingDateSeparator.test.ts b/src/components/MessageList/hooks/VirtualizedMessageList/__tests__/useFloatingDateSeparator.test.ts new file mode 100644 index 000000000..049d07e18 --- /dev/null +++ b/src/components/MessageList/hooks/VirtualizedMessageList/__tests__/useFloatingDateSeparator.test.ts @@ -0,0 +1,101 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useFloatingDateSeparator } from '../useFloatingDateSeparator'; +import type { RenderedMessage } from '../../../utils'; +import { CUSTOM_MESSAGE_TYPE } from '../../../../../constants/messageTypes'; + +const makeDateSeparator = (date: Date): RenderedMessage => + ({ + customType: CUSTOM_MESSAGE_TYPE.date, + date, + id: `date-${date.toISOString()}`, + type: 'date', + unread: false, + }) as unknown as RenderedMessage; + +const makeMessage = (id: string, createdAt: Date): RenderedMessage => + ({ + created_at: createdAt, + id, + type: 'regular', + user: { id: 'user' }, + }) as unknown as RenderedMessage; + +describe('useFloatingDateSeparator', () => { + const jan1 = new Date('2025-01-01T12:00:00Z'); + const jan2 = new Date('2025-01-02T12:00:00Z'); + const processedMessages: RenderedMessage[] = [ + makeDateSeparator(jan1), + makeMessage('m1', jan1), + makeMessage('m2', jan1), + makeDateSeparator(jan2), + makeMessage('m3', jan2), + ]; + + it('returns visible false when date separators are disabled', () => { + const { result } = renderHook(() => + useFloatingDateSeparator({ + disableDateSeparator: true, + processedMessages, + }), + ); + + act(() => { + result.current.onItemsRendered([makeMessage('m2', jan1)]); + }); + + expect(result.current.showFloatingDate).toBe(false); + expect(result.current.floatingDate).toBeNull(); + }); + + it('hides floating when first visible item is a date separator', () => { + const { result } = renderHook(() => + useFloatingDateSeparator({ + disableDateSeparator: false, + processedMessages, + }), + ); + + act(() => { + result.current.onItemsRendered([makeDateSeparator(jan1), makeMessage('m1', jan1)]); + }); + + expect(result.current.showFloatingDate).toBe(false); + expect(result.current.floatingDate).toBeNull(); + }); + + it('shows floating with correct date when first visible is a message', () => { + const { result } = renderHook(() => + useFloatingDateSeparator({ + disableDateSeparator: false, + processedMessages, + }), + ); + + act(() => { + result.current.onItemsRendered([makeMessage('m2', jan1), makeMessage('m3', jan2)]); + }); + + expect(result.current.showFloatingDate).toBe(true); + expect(result.current.floatingDate).toEqual(jan1); + }); + + it('hides when any date separator is in visible set', () => { + const { result } = renderHook(() => + useFloatingDateSeparator({ + disableDateSeparator: false, + processedMessages, + }), + ); + + act(() => { + result.current.onItemsRendered([ + makeMessage('m2', jan1), + makeDateSeparator(jan2), + makeMessage('m3', jan2), + ]); + }); + + expect(result.current.showFloatingDate).toBe(false); + }); +}); diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/index.ts b/src/components/MessageList/hooks/VirtualizedMessageList/index.ts index 2574aa4c0..99d4112e1 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/index.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/index.ts @@ -1,3 +1,4 @@ +export * from './useFloatingDateSeparator'; export * from './useGiphyPreview'; export * from './useMessageSetKey'; export * from './useNewMessageNotification'; diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts new file mode 100644 index 000000000..2730a8db8 --- /dev/null +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts @@ -0,0 +1,114 @@ +import { useCallback, useState } from 'react'; + +import type { RenderedMessage } from '../../utils'; +import { isDateSeparatorMessage, isIntroMessage } from '../../utils'; +import type { LocalMessage } from 'stream-chat'; + +export type UseFloatingDateSeparatorParams = { + disableDateSeparator: boolean; + processedMessages: RenderedMessage[]; +}; + +export type UseFloatingDateSeparatorResult = { + floatingDate: Date | null; + onItemsRendered: (rendered: RenderedMessage[]) => void; + showFloatingDate: boolean; +}; + +/** + * Returns the date to show in the floating date separator based on currently visible messages. + * When the first visible item is a message (not a date separator), we've scrolled past its + * date separator — find that separator's date. + */ +function getFloatingDateForFirstMessage( + firstMessage: RenderedMessage, + processedMessages: RenderedMessage[], + firstMessageIndex: number, +): Date | null { + if (isIntroMessage(firstMessage)) return null; + + // Walk backwards to find the last date separator before this message + for (let i = firstMessageIndex - 1; i >= 0; i -= 1) { + const item = processedMessages[i]; + if (isDateSeparatorMessage(item)) { + return item.date; + } + } + + // No preceding date separator; use message's created_at + const msg = firstMessage as LocalMessage; + const created = msg.created_at; + if (created) { + const d = new Date(created); + return isNaN(d.getTime()) ? null : d; + } + return null; +} + +/** + * Controls when to show the floating date separator (Slack-like: fixed at top when scrolling). + * Show when no in-flow date separator is visible and we've scrolled past one. + */ +const HIDDEN_STATE = { date: null, visible: false } as const; + +export const useFloatingDateSeparator = ({ + disableDateSeparator, + processedMessages, +}: UseFloatingDateSeparatorParams): UseFloatingDateSeparatorResult => { + const [state, setState] = useState<{ + date: Date | null; + visible: boolean; + }>(HIDDEN_STATE); + + const onItemsRendered = useCallback( + (rendered: RenderedMessage[]) => { + if (disableDateSeparator || processedMessages.length === 0) { + setState(HIDDEN_STATE); + return; + } + + const valid = rendered.filter((m): m is RenderedMessage => m != null); + if (valid.length === 0) { + setState(HIDDEN_STATE); + return; + } + + const first = valid[0]; + + // If first visible item is a date separator, it's in view — hide floating + if (isDateSeparatorMessage(first)) { + setState(HIDDEN_STATE); + return; + } + + // Check if any date separator is visible — if so, hide floating + const hasVisibleDateSeparator = valid.some(isDateSeparatorMessage); + if (hasVisibleDateSeparator) { + setState(HIDDEN_STATE); + return; + } + + // First visible is a message; find its date + const firstIndex = processedMessages.findIndex((m) => m.id === first.id); + const date = + firstIndex >= 0 + ? getFloatingDateForFirstMessage(first, processedMessages, firstIndex) + : null; + + const visible = date !== null; + setState((prev) => { + const prevTime = prev.date?.getTime() ?? null; + const nextTime = date?.getTime() ?? null; + if (prev.visible === visible && prevTime === nextTime) return prev; + return { date, visible }; + }); + }, + [disableDateSeparator, processedMessages], + ); + + return { + floatingDate: state.date, + onItemsRendered, + showFloatingDate: !!state.date && state.visible, + }; +}; diff --git a/src/components/MessageList/icons.tsx b/src/components/MessageList/icons.tsx index 39f56c048..68b52c3d6 100644 --- a/src/components/MessageList/icons.tsx +++ b/src/components/MessageList/icons.tsx @@ -22,23 +22,6 @@ export const ArrowUp = ({ className, color }: ArrowProps) => ( ); -export const ArrowDown = ({ className, color }: ArrowProps) => ( - - - -); - export const CloseIcon = () => ( diff --git a/src/components/MessageList/index.ts b/src/components/MessageList/index.ts index 745c2d593..985ddbec2 100644 --- a/src/components/MessageList/index.ts +++ b/src/components/MessageList/index.ts @@ -2,8 +2,8 @@ export * from './ConnectionStatus'; // TODO: export this under its own folder export * from './GiphyPreviewMessage'; export * from './MessageList'; export * from './MessageListNotifications'; -export * from './MessageNotification'; -export * from './ScrollToBottomButton'; +export * from './NewMessageNotification'; +export * from './ScrollToLatestMessageButton'; export * from './UnreadMessagesNotification'; export * from './UnreadMessagesSeparator'; export * from './VirtualizedMessageList'; diff --git a/src/components/MessageList/styling/MessageList.scss b/src/components/MessageList/styling/MessageList.scss index b0bafdbdd..42f0417e9 100644 --- a/src/components/MessageList/styling/MessageList.scss +++ b/src/components/MessageList/styling/MessageList.scss @@ -12,6 +12,7 @@ .str-chat__message-list { @include utils.scrollable; + position: relative; overscroll-behavior: none; width: 100%; /* Max container 800px, 16px padding → 768px readable content; matches composer width + padding */ @@ -51,20 +52,6 @@ } } -.str-chat__jump-to-latest-message { - position: absolute; - inset-block-end: var(--str-chat__spacing-4); - inset-inline-end: var(--str-chat__spacing-2); - z-index: 2; - - .str-chat__jump-to-latest-unread-count { - position: absolute; - padding: var(--str-chat__spacing-0_5) var(--str-chat__spacing-2); - left: 50%; - transform: translateX(-50%) translateY(-100%); - } -} - .str-chat__main-panel { .str-chat__ul { .str-chat__li:first-of-type { @@ -103,80 +90,9 @@ /* Right (left in RTL layout) border of the component */ --str-chat__message-list-border-inline-end: none; - /* The border radius used for the borders of the jump to latest message button */ - --str-chat__jump-to-latest-message-border-radius: var( - --str-chat__circle-fab-border-radius - ); - - /* The text/icon color of the jump to latest message button */ - --str-chat__jump-to-latest-message-color: var(--str-chat__circle-fab-color); - - /* The background color of the jump to latest message button */ - --str-chat__jump-to-latest-message-background-color: var( - --str-chat__circle-fab-background-color - ); - - /* The background color of the jump to latest message button in pressed state */ - --str-chat__jump-to-latest-message-pressed-background-color: var( - --str-chat__circle-fab-pressed-background-color - ); - - /* Box shadow applied to the jump to latest message button */ - --str-chat__jump-to-latest-message-box-shadow: var(--str-chat__circle-fab-box-shadow); - - /* Top border of the jump to latest message button */ - --str-chat__jump-to-latest-message-border-block-start: var( - --str-chat__circle-fab-border-block-start - ); - - /* Bottom border of the jump to latest message button */ - --str-chat__jump-to-latest-message-border-block-end: var( - --str-chat__circle-fab-border-block-end - ); - - /* Left (right in RTL layout) border of the jump to latest message button */ - --str-chat__jump-to-latest-message-border-inline-start: var( - --str-chat__circle-fab-border-inline-start - ); - - /* Right (left in RTL layout) border of the jump to latest message button */ - --str-chat__jump-to-latest-message-border-inline-end: var( - --str-chat__circle-fab-border-inline-end - ); - - /* The background color of the unread messages count on the jump to latest message button */ - --str-chat__jump-to-latest-message-unread-count-background-color: var( - --str-chat__jump-to-latest-message-color - ); - - /* The text/icon color of the unread messages count on the jump to latest message button */ - --str-chat__jump-to-latest-message-unread-count-color: var( - --str-chat__jump-to-latest-message-background-color - ); - /* The color used for displaying thread replies and thread separator at the start of a thread */ --str-chat__thread-head-start-color: var(--str-chat__text-low-emphasis-color); /* The color used for the separator below the first message in a thread */ --str-chat__thread-head-start-border-block-end-color: var(--str-chat__surface-color); } - -.str-chat__jump-to-latest-message { - --str-chat-icon-color: var( - --str-chat__jump-to-latest-message-unread-count-background-color - ); - - .str-chat__circle-fab { - @include utils.component-layer-overrides('jump-to-latest-message'); - @include utils.circle-fab-overrides('jump-to-latest-message'); - - .str-chat__jump-to-latest-unread-count { - background-color: var( - --str-chat__jump-to-latest-message-unread-count-background-color - ); - color: var(--str-chat__jump-to-latest-message-unread-count-color); - border-radius: var(--str-chat__jump-to-latest-message-border-radius); - font: var(--str-chat__caption-text); - } - } -} diff --git a/src/components/MessageList/styling/NewMessageNotification.scss b/src/components/MessageList/styling/NewMessageNotification.scss new file mode 100644 index 000000000..75e3331a0 --- /dev/null +++ b/src/components/MessageList/styling/NewMessageNotification.scss @@ -0,0 +1,24 @@ +@use '../../../styling/utils'; + +.str-chat__new-message-notification { + position: absolute; + inset-block-end: 16px; + inset-inline-start: 0; + inset-inline-end: 0; + z-index: 2; + display: flex; + justify-content: center; + + .str-chat__message-notification__label { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-xxs) var(--spacing-sm); + background-color: var(--background-core-surface-subtle); + border-radius: var(--radius-max); + font: var(--str-chat__metadata-emphasis-text); + color: var(--chat-text-system); + } +} + + diff --git a/src/components/MessageList/styling/ScrollToLatestMessageButton.scss b/src/components/MessageList/styling/ScrollToLatestMessageButton.scss new file mode 100644 index 000000000..6b251c6e5 --- /dev/null +++ b/src/components/MessageList/styling/ScrollToLatestMessageButton.scss @@ -0,0 +1,29 @@ +.str-chat__jump-to-latest-message { + height: 40px; + width: 40px; + position: absolute; + inset-block-end: var(--spacing-md); + inset-inline-end: var(--spacing-md); + z-index: 2; + border-radius: var(--radius-max); + background-color: var(--background-elevation-elevation-1); + // todo - we ned to have the shadows in variables that are supported in light and dark mode too + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.05), + 0 2px 4px 0 rgba(0, 0, 0, 0.12), + 0 6px 16px 0 rgba(0, 0, 0, 0.06); + + .str-chat__jump-to-latest-message__button { + height: 40px; + width: 40px; + position: static; + } + + .str-chat__jump-to-latest__unread-count { + position: absolute; + inset-block-end: 100%; + margin-block-end: var(--str-chat__spacing-1); + right: -15%; + top: -15%; + } +} diff --git a/src/components/MessageList/styling/index.scss b/src/components/MessageList/styling/index.scss index 71982d251..8b516031a 100644 --- a/src/components/MessageList/styling/index.scss +++ b/src/components/MessageList/styling/index.scss @@ -1,2 +1,4 @@ @use 'MessageList'; +@use 'NewMessageNotification'; +@use 'ScrollToLatestMessageButton'; @use 'VirtualizedMessageList'; diff --git a/src/components/index.ts b/src/components/index.ts index e8605f942..59e71bd5f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,6 +2,7 @@ export * from './AIStateIndicator'; export * from './Attachment'; export * from './AudioPlayback'; export * from './Avatar'; +export * from './Badge'; export * from './Button'; export * from './Channel'; export * from './ChannelHeader'; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index c6d1c4498..c649b62ab 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -22,7 +22,6 @@ import { type MessageDeletedProps, type MessageInputProps, type MessageListNotificationsProps, - type MessageNotificationProps, type MessageProps, type MessageRepliesCountButtonProps, type MessageStatusProps, @@ -30,6 +29,7 @@ import { type MessageUIComponentProps, type ModalGalleryProps, type ModalProps, + type NewMessageNotificationProps, type PinIndicatorProps, type PollCreationDialogProps, type PollOptionSelectorProps, @@ -148,8 +148,8 @@ export type ComponentContextValue = { MessageListMainPanel?: React.ComponentType; /** Custom UI component that displays message and connection status notifications in the `MessageList`, defaults to and accepts same props as [DefaultMessageListNotifications](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/MessageListNotifications.tsx) */ MessageListNotifications?: React.ComponentType; - /** Custom UI component to display a notification when scrolled up the list and new messages arrive, defaults to and accepts same props as [MessageNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/MessageNotification.tsx) */ - MessageNotification?: React.ComponentType; + /** Custom UI component to display a notification when scrolled up the list and new messages arrive, defaults to and accepts same props as [NewMessageNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/NewMessageNotification.tsx) */ + NewMessageNotification?: React.ComponentType; /** Custom UI component to display message replies, defaults to and accepts same props as: [MessageRepliesCountButton](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageRepliesCountButton.tsx) */ MessageRepliesCountButton?: React.ComponentType; /** Custom UI component to display message delivery status, defaults to and accepts same props as: [MessageStatus](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageStatus.tsx) */ diff --git a/src/i18n/de.json b/src/i18n/de.json index cc83acede..b69212775 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -16,6 +16,8 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} und {{ user }} tippen...", "{{ users }} and more are typing...": "{{ users }} und mehr tippen...", "{{ watcherCount }} online": "{{ watcherCount }} online", + "{{count}} new messages_one": "{{count}} neue Nachricht", + "{{count}} new messages_other": "{{count}} neue Nachrichten", "{{count}} unread_one": "{{count}} ungelesen", "{{count}} unread_other": "{{count}} ungelesen", "{{count}} votes_one": "{{count}} Stimme", @@ -221,7 +223,6 @@ "language/vi": "Vietnamesisch", "language/zh": "Chinesisch (Vereinfacht)", "language/zh-TW": "Chinesisch (Traditionell)", - "Latest Messages": "Neueste Nachrichten", "Let others add options": "Andere Optionen hinzufügen lassen", "Limit votes per person": "Stimmen pro Person begrenzen", "live": "live", diff --git a/src/i18n/en.json b/src/i18n/en.json index 140266656..b691697f1 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -16,6 +16,8 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} and {{ user }} are typing...", "{{ users }} and more are typing...": "{{ users }} and more are typing...", "{{ watcherCount }} online": "{{ watcherCount }} online", + "{{count}} new messages_one": "{{count}} new message", + "{{count}} new messages_other": "{{count}} new messages", "{{count}} unread_one": "{{count}} unread", "{{count}} unread_other": "{{count}} unread", "{{count}} votes_one": "{{count}} vote", @@ -221,7 +223,6 @@ "language/vi": "Vietnamese", "language/zh": "Chinese (Simplified)", "language/zh-TW": "Chinese (Traditional)", - "Latest Messages": "Latest Messages", "Let others add options": "Let others add options", "Limit votes per person": "Limit votes per person", "live": "live", diff --git a/src/i18n/es.json b/src/i18n/es.json index de808da57..2b73bf53e 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -19,6 +19,9 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} y {{ user }} están escribiendo...", "{{ users }} and more are typing...": "{{ users }} y más están escribiendo...", "{{ watcherCount }} online": "{{ watcherCount }} en línea", + "{{count}} new messages_one": "{{count}} nuevo mensaje", + "{{count}} new messages_many": "{{count}} nuevos mensajes", + "{{count}} new messages_other": "{{count}} nuevos mensajes", "{{count}} unread_one": "{{count}} no leído", "{{count}} unread_many": "{{count}} no leídos", "{{count}} unread_other": "{{count}} no leídos", @@ -226,7 +229,6 @@ "language/vi": "Vietnamita", "language/zh": "Chino (simplificado)", "language/zh-TW": "Chino (tradicional)", - "Latest Messages": "Últimos mensajes", "Let others add options": "Permitir que otros añadan opciones", "Limit votes per person": "Limitar votos por persona", "live": "En vivo", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 86e5293a0..1135025b4 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -19,6 +19,9 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} et {{ user }} sont en train d'écrire...", "{{ users }} and more are typing...": "{{ users }} et plus sont en train d'écrire...", "{{ watcherCount }} online": "{{ watcherCount }} en ligne", + "{{count}} new messages_one": "{{count}} nouveau message", + "{{count}} new messages_many": "{{count}} nouveaux messages", + "{{count}} new messages_other": "{{count}} nouveaux messages", "{{count}} unread_one": "{{count}} non lu", "{{count}} unread_many": "{{count}} non lus", "{{count}} unread_other": "{{count}} non lus", @@ -226,7 +229,6 @@ "language/vi": "Vietnamien", "language/zh": "Chinois (simplifié)", "language/zh-TW": "Chinois (traditionnel)", - "Latest Messages": "Derniers messages", "Let others add options": "Permettre à d'autres d'ajouter des options", "Limit votes per person": "Limiter les votes par personne", "live": "en direct", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index b76e5642f..4778c5559 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -16,6 +16,8 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} और {{ user }} टाइप कर रहे हैं...", "{{ users }} and more are typing...": "{{ users }} और अधिक टाइप कर रहे हैं...", "{{ watcherCount }} online": "{{ watcherCount }} ऑनलाइन", + "{{count}} new messages_one": "{{count}} नया संदेश", + "{{count}} new messages_other": "{{count}} नए संदेश", "{{count}} unread_one": "{{count}} अपठित", "{{count}} unread_other": "{{count}} अपठित", "{{count}} votes_one": "{{count}} वोट", @@ -222,7 +224,6 @@ "language/vi": "वियतनामी", "language/zh": "चीनी (सरलीकृत)", "language/zh-TW": "चीनी (पारंपरिक)", - "Latest Messages": "नवीनतम संदेश", "Let others add options": "दूसरों को विकल्प जोड़ने दें", "Limit votes per person": "प्रति व्यक्ति वोट सीमित करें", "live": "लाइव", diff --git a/src/i18n/it.json b/src/i18n/it.json index a13d07d2e..4c41c15b7 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -19,6 +19,9 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} e {{ user }} stanno digitando...", "{{ users }} and more are typing...": "{{ users }} e altri stanno digitando...", "{{ watcherCount }} online": "{{ watcherCount }} online", + "{{count}} new messages_one": "{{count}} nuovo messaggio", + "{{count}} new messages_many": "{{count}} nuovi messaggi", + "{{count}} new messages_other": "{{count}} nuovi messaggi", "{{count}} unread_one": "{{count}} non letto", "{{count}} unread_many": "{{count}} non letti", "{{count}} unread_other": "{{count}} non letti", @@ -226,7 +229,6 @@ "language/vi": "Vietnamita", "language/zh": "Cinese (semplificato)", "language/zh-TW": "Cinese (tradizionale)", - "Latest Messages": "Ultimi messaggi", "Let others add options": "Lascia che altri aggiungano opzioni", "Limit votes per person": "Limita i voti per persona", "live": "live", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index e27e0352a..718d7d675 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -16,6 +16,8 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} と {{ user }} が入力中...", "{{ users }} and more are typing...": "{{ users }} とその他が入力中...", "{{ watcherCount }} online": "{{ watcherCount }} オンライン", + "{{count}} new messages_one": "{{count}}件の新しいメッセージ", + "{{count}} new messages_other": "{{count}}件の新しいメッセージ", "{{count}} unread_one": "{{count}} 未読", "{{count}} unread_other": "{{count}} 未読", "{{count}} votes_one": "{{count}} 票", @@ -221,7 +223,6 @@ "language/vi": "ベトナム語", "language/zh": "中国語(簡体字)", "language/zh-TW": "中国語(繁体字)", - "Latest Messages": "最新のメッセージ", "Let others add options": "他の人が選択肢を追加できるようにする", "Limit votes per person": "1人あたりの投票数を制限する", "live": "ライブ", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index b06e8e487..144e35538 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -16,6 +16,8 @@ "{{ users }} and {{ user }} are typing...": "{{ users }}와(과) {{ user }}이(가) 입력 중입니다...", "{{ users }} and more are typing...": "{{ users }}와(과) 더 많은 사람들이 입력 중입니다...", "{{ watcherCount }} online": "{{ watcherCount }} 온라인", + "{{count}} new messages_one": "{{count}}개의 새 메시지", + "{{count}} new messages_other": "{{count}}개의 새 메시지", "{{count}} unread_one": "{{count}} 읽지 않음", "{{count}} unread_other": "{{count}} 읽지 않음", "{{count}} votes_one": "{{count}} 투표", @@ -221,7 +223,6 @@ "language/vi": "베트남어", "language/zh": "중국어(간체)", "language/zh-TW": "중국어(번체)", - "Latest Messages": "최신 메시지", "Let others add options": "다른 사람이 선택지를 추가할 수 있도록 허용", "Limit votes per person": "1인당 투표 수 제한", "live": "라이브", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 91937effc..2d6b13e92 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -16,6 +16,8 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} en {{ user }} zijn aan het typen...", "{{ users }} and more are typing...": "{{ users }} en meer zijn aan het typen...", "{{ watcherCount }} online": "{{ watcherCount }} online", + "{{count}} new messages_one": "{{count}} nieuw bericht", + "{{count}} new messages_other": "{{count}} nieuwe berichten", "{{count}} unread_one": "{{count}} ongelezen", "{{count}} unread_other": "{{count}} ongelezen", "{{count}} votes_one": "{{count}} stem", @@ -221,7 +223,6 @@ "language/vi": "Vietnamees", "language/zh": "Chinees (vereenvoudigd)", "language/zh-TW": "Chinees (traditioneel)", - "Latest Messages": "Laatste berichten", "Let others add options": "Laat anderen opties toevoegen", "Limit votes per person": "Stemmen per persoon beperken", "live": "live", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 169f0557b..e357840fd 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -19,6 +19,9 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} e {{ user }} estão digitando...", "{{ users }} and more are typing...": "{{ users }} e mais estão digitando...", "{{ watcherCount }} online": "{{ watcherCount }} online", + "{{count}} new messages_one": "{{count}} nova mensagem", + "{{count}} new messages_many": "{{count}} novas mensagens", + "{{count}} new messages_other": "{{count}} novas mensagens", "{{count}} unread_one": "{{count}} não lido", "{{count}} unread_many": "{{count}} não lidos", "{{count}} unread_other": "{{count}} não lidos", @@ -226,7 +229,6 @@ "language/vi": "Vietnamita", "language/zh": "Chinês (simplificado)", "language/zh-TW": "Chinês (tradicional)", - "Latest Messages": "Mensagens mais recentes", "Let others add options": "Permitir que outros adicionem opções", "Limit votes per person": "Limitar votos por pessoa", "live": "ao vivo", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 45d91a152..9a99574f2 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -22,6 +22,10 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} и {{ user }} печатают...", "{{ users }} and more are typing...": "{{ users }} и другие печатают...", "{{ watcherCount }} online": "{{ watcherCount }} в сети", + "{{count}} new messages_one": "{{count}} новое сообщение", + "{{count}} new messages_few": "{{count}} новых сообщения", + "{{count}} new messages_many": "{{count}} новых сообщений", + "{{count}} new messages_other": "{{count}} новых сообщений", "{{count}} unread_one": "{{count}} непрочитанное", "{{count}} unread_few": "{{count}} непрочитанных", "{{count}} unread_many": "{{count}} непрочитанных", @@ -231,7 +235,6 @@ "language/vi": "Вьетнамский", "language/zh": "Китайский (упрощённый)", "language/zh-TW": "Китайский (традиционный)", - "Latest Messages": "Последние сообщения", "Let others add options": "Разрешить другим добавлять варианты", "Limit votes per person": "Ограничить голоса на человека", "live": "В прямом эфире", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index ad73d5a64..b0f449233 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -16,6 +16,8 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} ve {{ user }} yazıyor...", "{{ users }} and more are typing...": "{{ users }} ve diğerleri yazıyor...", "{{ watcherCount }} online": "{{ watcherCount }} çevrimiçi", + "{{count}} new messages_one": "{{count}} yeni mesaj", + "{{count}} new messages_other": "{{count}} yeni mesaj", "{{count}} unread_one": "{{count}} okunmamış", "{{count}} unread_other": "{{count}} okunmamış", "{{count}} votes_one": "{{count}} oy", @@ -221,7 +223,6 @@ "language/vi": "Vietnamca", "language/zh": "Çince (basitleştirilmiş)", "language/zh-TW": "Çince (geleneksel)", - "Latest Messages": "Son Mesajlar", "Let others add options": "Başkalarının seçenek eklemesine izin ver", "Limit votes per person": "Kişi başına oy sınırı", "live": "canlı", diff --git a/src/styling/index.scss b/src/styling/index.scss index db4b0db85..d754e5b0d 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -14,7 +14,8 @@ @use '../components/FileIcon/styling/FileIcon'; // Base components -@use '../components/Button/styling/Button'; +@use '../components/Badge/styling' as Badge; +@use '../components/Button/styling' as Button; @use '../components/Form/styling' as Form; @use '../components/Dialog/styling' as Dialog; @use '../components/Modal/styling' as Modal; From 67827f817ac8e8fd52eb6f8ce4021fce1ac23cd6 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 24 Feb 2026 17:45:11 +0100 Subject: [PATCH 10/16] chore: update dev_patterns skill --- .cursor/skills/dev-patterns/SKILL.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.cursor/skills/dev-patterns/SKILL.md b/.cursor/skills/dev-patterns/SKILL.md index 38b1e52d5..0cd293092 100644 --- a/.cursor/skills/dev-patterns/SKILL.md +++ b/.cursor/skills/dev-patterns/SKILL.md @@ -15,6 +15,7 @@ Apply when generating or modifying UI code in this repo. - **Location:** `src/components//styling/`. - **Required:** Each component styling folder has an `index.scss`. - **Registration:** Each `src/components//styling/index.scss` is imported in `src/styling/index.scss` with an alias. +- **Specificity:** Each component has own `.scss` file in the `src/components//styling` folder **Import order in `src/styling/index.scss`:** @@ -35,6 +36,17 @@ Apply when generating or modifying UI code in this repo. Source: `.ai/DEV_PATTERNS.md`. +## Translating quantities (plurals) + +- **Use plural suffixes only:** `_one`, `_other`, and `_few`, `_many` where the locale requires them. +- **Do not** add a standalone key (e.g. `"{{count}} new messages"`). Only add quantified variants: `"{{count}} new messages_one"`, `"{{count}} new messages_other"`, etc. +- Follow existing patterns in `src/i18n/` (e.g. `{{count}} unread_one`, `unreadMessagesSeparatorText_other`). +- Locale plural rules (CLDR): `en`, `de`, `nl`, `tr`, `hi`, `ko`, `ja` use `_one` + `_other`; `es`, `fr`, `it`, `pt` add `_many`; `ru` uses `_one`, `_few`, `_many`, `_other`. + ## Imports When importing from 'stream-chat' library, always import by library name (from 'stream-chat'), not relative path (from '..path/to/from 'stream-chat-js/src'). + +## React components + +Try to avoid inline `style` attribute and prefer adding styles to `.scss` files. From ed3a06bec50826e6e0efd7cf012f9d5ff09a93b2 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 25 Feb 2026 10:56:39 +0100 Subject: [PATCH 11/16] feat: redesign UnreadMessagesNotification and UnreadMessagesSeparator --- src/components/Button/styling/Button.scss | 2 +- .../styling/UnreadMessageNotification.scss | 36 ---------------- .../styling/UnreadMessagesSeparator.scss | 16 ------- src/components/Message/styling/index.scss | 2 - .../UnreadMessagesNotification.tsx | 35 ++++++++++------ .../MessageList/UnreadMessagesSeparator.tsx | 29 ++++++++++--- src/components/MessageList/icons.tsx | 29 ------------- .../styling/NewMessageNotification.scss | 2 - .../styling/UnreadMessageNotification.scss | 42 +++++++++++++++++++ .../styling/UnreadMessagesSeparator.scss | 34 +++++++++++++++ src/components/MessageList/styling/index.scss | 2 + src/i18n/de.json | 2 - src/i18n/en.json | 2 - src/i18n/es.json | 3 -- src/i18n/fr.json | 3 -- src/i18n/hi.json | 2 - src/i18n/it.json | 3 -- src/i18n/ja.json | 2 - src/i18n/ko.json | 2 - src/i18n/nl.json | 2 - src/i18n/pt.json | 3 -- src/i18n/ru.json | 4 -- src/i18n/tr.json | 2 - 23 files changed, 124 insertions(+), 135 deletions(-) delete mode 100644 src/components/Message/styling/UnreadMessageNotification.scss delete mode 100644 src/components/Message/styling/UnreadMessagesSeparator.scss delete mode 100644 src/components/MessageList/icons.tsx create mode 100644 src/components/MessageList/styling/UnreadMessageNotification.scss create mode 100644 src/components/MessageList/styling/UnreadMessagesSeparator.scss diff --git a/src/components/Button/styling/Button.scss b/src/components/Button/styling/Button.scss index 68082c5a4..4d331e677 100644 --- a/src/components/Button/styling/Button.scss +++ b/src/components/Button/styling/Button.scss @@ -5,7 +5,7 @@ @include utils.button-reset; position: relative; /* creates positioning context for pseudo ::after overlay */ overflow: hidden; - + white-space: nowrap; cursor: pointer; display: flex; diff --git a/src/components/Message/styling/UnreadMessageNotification.scss b/src/components/Message/styling/UnreadMessageNotification.scss deleted file mode 100644 index 763071b06..000000000 --- a/src/components/Message/styling/UnreadMessageNotification.scss +++ /dev/null @@ -1,36 +0,0 @@ -.str-chat__unread-messages-notification { - --str-chat-icon-color: var(--str-chat__on-primary-color); - background-color: var(--str-chat__text-low-emphasis-color); - border-radius: var(--str-chat__border-radius-2xl); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - position: absolute; - top: var(--str-chat__spacing-3); - z-index: 2; - display: flex; - align-items: center; - overflow: clip; - - button { - padding-block: var(--str-chat__spacing-2); - height: 100%; - width: 100%; - white-space: nowrap; - cursor: pointer; - color: var(--str-chat__on-primary-color); - border: none; - background-color: transparent; - } - - button:first-of-type { - padding-inline: var(--str-chat__spacing-3) var(--str-chat__spacing-2); - font: var(--str-chat__caption-text); - } - - button:last-of-type { - padding-inline: var(--str-chat__spacing-2) var(--str-chat__spacing-3); - - svg { - width: 0.875rem; - } - } -} diff --git a/src/components/Message/styling/UnreadMessagesSeparator.scss b/src/components/Message/styling/UnreadMessagesSeparator.scss deleted file mode 100644 index db822b1de..000000000 --- a/src/components/Message/styling/UnreadMessagesSeparator.scss +++ /dev/null @@ -1,16 +0,0 @@ -.str-chat__unread-messages-separator-wrapper { - padding-block: var(--str-chat__spacing-2); - - .str-chat__unread-messages-separator { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - padding: var(--str-chat__spacing-2) var(--str-chat__spacing-3); - background-color: var(--str-chat__secondary-surface-color); - color: var(--str-chat__text-low-emphasis-color); - text-transform: uppercase; - font: var(--str-chat__caption-strong-text); - letter-spacing: 0.02em; - } -} diff --git a/src/components/Message/styling/index.scss b/src/components/Message/styling/index.scss index dcf4ae3d8..026268bbd 100644 --- a/src/components/Message/styling/index.scss +++ b/src/components/Message/styling/index.scss @@ -7,6 +7,4 @@ @use 'MessageTranslationIndicator'; @use 'QuotedMessage'; @use 'ReminderNotification'; -@use 'UnreadMessageNotification'; -@use 'UnreadMessagesSeparator'; @use 'MessageRepliesCountButton'; diff --git a/src/components/MessageList/UnreadMessagesNotification.tsx b/src/components/MessageList/UnreadMessagesNotification.tsx index 0e4536820..c2e675dab 100644 --- a/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/src/components/MessageList/UnreadMessagesNotification.tsx @@ -1,6 +1,8 @@ import React from 'react'; -import { CloseIcon } from './icons'; import { useChannelActionContext, useTranslationContext } from '../../context'; +import { Button } from '../Button'; +import { IconArrowUp, IconCrossMedium } from '../Icons'; +import clsx from 'clsx'; export type UnreadMessagesNotificationProps = { /** @@ -8,7 +10,7 @@ export type UnreadMessagesNotificationProps = { */ queryMessageLimit?: number; /** - * Configuration parameter to determine, whether the unread count is to be shown on the component. Disabled by default. + * Configuration parameter to determine, whether the unread count is to be shown on the component. Enabled by default. */ showCount?: boolean; /** @@ -19,27 +21,34 @@ export type UnreadMessagesNotificationProps = { export const UnreadMessagesNotification = ({ queryMessageLimit, - showCount, + showCount = true, unreadCount, }: UnreadMessagesNotificationProps) => { - const { jumpToFirstUnreadMessage, markRead } = useChannelActionContext( - 'UnreadMessagesNotification', - ); + const { jumpToFirstUnreadMessage, markRead } = useChannelActionContext(); const { t } = useTranslationContext('UnreadMessagesNotification'); return (
- - + +
); }; diff --git a/src/components/MessageList/UnreadMessagesSeparator.tsx b/src/components/MessageList/UnreadMessagesSeparator.tsx index 0d13e6c54..245481ed3 100644 --- a/src/components/MessageList/UnreadMessagesSeparator.tsx +++ b/src/components/MessageList/UnreadMessagesSeparator.tsx @@ -1,11 +1,14 @@ import React from 'react'; -import { useTranslationContext } from '../../context'; +import { useChannelActionContext, useTranslationContext } from '../../context'; +import { Button } from '../Button'; +import clsx from 'clsx'; +import { IconCrossMedium } from '../Icons'; export const UNREAD_MESSAGE_SEPARATOR_CLASS = 'str-chat__unread-messages-separator'; export type UnreadMessagesSeparatorProps = { /** - * Configuration parameter to determine, whether the unread count is to be shown on the component. Disabled by default. + * Configuration parameter to determine, whether the unread count is to be shown on the component. Enabled by default. */ showCount?: boolean; /** @@ -15,18 +18,32 @@ export type UnreadMessagesSeparatorProps = { }; export const UnreadMessagesSeparator = ({ - showCount, + showCount = true, unreadCount, }: UnreadMessagesSeparatorProps) => { const { t } = useTranslationContext('UnreadMessagesSeparator'); + const { markRead } = useChannelActionContext(); return (
- {unreadCount && showCount - ? t('unreadMessagesSeparatorText', { count: unreadCount }) - : t('Unread messages')} +
+ {unreadCount && showCount + ? t('{{count}} unread', { count: unreadCount }) + : t('Unread messages')} +
+
); }; diff --git a/src/components/MessageList/icons.tsx b/src/components/MessageList/icons.tsx deleted file mode 100644 index 68b52c3d6..000000000 --- a/src/components/MessageList/icons.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; - -interface ArrowProps { - className?: string; - color?: string; -} - -export const ArrowUp = ({ className, color }: ArrowProps) => ( - - - -); - -export const CloseIcon = () => ( - - - -); diff --git a/src/components/MessageList/styling/NewMessageNotification.scss b/src/components/MessageList/styling/NewMessageNotification.scss index 75e3331a0..459878fa1 100644 --- a/src/components/MessageList/styling/NewMessageNotification.scss +++ b/src/components/MessageList/styling/NewMessageNotification.scss @@ -20,5 +20,3 @@ color: var(--chat-text-system); } } - - diff --git a/src/components/MessageList/styling/UnreadMessageNotification.scss b/src/components/MessageList/styling/UnreadMessageNotification.scss new file mode 100644 index 000000000..9e572b80c --- /dev/null +++ b/src/components/MessageList/styling/UnreadMessageNotification.scss @@ -0,0 +1,42 @@ +.str-chat { + .str-chat__unread-messages-notification { + display: flex; + align-items: center; + background: var(--background-elevation-elevation-1); + border-radius: var(--button-radius-lg); + border: 1px solid var(--button-secondary-border); + /* shadow/web/light/elevation-2 */ + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 2px 4px 0 rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.06); + position: absolute; + top: 40px; + z-index: 2; + overflow: clip; + + &.str-chat__unread-messages-notification--with-count button { + text-transform: lowercase; + } + + button.str-chat__button--secondary, button.button.str-chat__button--outline { + border: none; + } + + button:first-of-type { + display: flex; + align-items: center; + gap: var(--spacing-xs); + border-radius: var(--button-radius-lg) 0 0 var(--button-radius-lg); + padding: var(--button-padding-y-md) var(--spacing-xxs) var(--button-padding-y-md) var(--button-padding-x-with-label-md); + font: var(--str-chat__caption-emphasis-text); + } + + button:last-of-type { + border-radius: 0 var(--button-radius-lg) var(--button-radius-lg) 0; + padding: var(--button-padding-y-md) var(--button-padding-x-with-label-md) var(--button-padding-y-md) var(--spacing-xxs); + + svg { + height: 16px; + width: 16px; + } + } + } +} diff --git a/src/components/MessageList/styling/UnreadMessagesSeparator.scss b/src/components/MessageList/styling/UnreadMessagesSeparator.scss new file mode 100644 index 000000000..9a1aeb041 --- /dev/null +++ b/src/components/MessageList/styling/UnreadMessagesSeparator.scss @@ -0,0 +1,34 @@ +.str-chat__unread-messages-separator-wrapper { + padding-block: var(--spacing-xs); + display: flex; + justify-content: center; + + .str-chat__unread-messages-separator { + display: flex; + align-items: center; + width: fit-content; + padding: var(--spacing-xxs) var(--spacing-xs); + background: var(--background-elevation-elevation-1); + border-radius: var(--button-radius-lg); + border: 1px solid var(--button-secondary-border); + overflow: clip; + + .str-chat__unread-messages-separator__text { + padding-inline: var(--spacing-xs) var(--spacing-xxs); + border-radius: var(--button-radius-lg) 0 0 var(--button-radius-lg); + font: var(--str-chat__caption-emphasis-text); + text-transform: lowercase; + } + + button.str-chat__button--secondary, button.button.str-chat__button--outline { + border: none; + } + + button { + svg { + height: 16px; + width: 16px; + } + } + } +} diff --git a/src/components/MessageList/styling/index.scss b/src/components/MessageList/styling/index.scss index 8b516031a..927c8d203 100644 --- a/src/components/MessageList/styling/index.scss +++ b/src/components/MessageList/styling/index.scss @@ -1,4 +1,6 @@ @use 'MessageList'; @use 'NewMessageNotification'; @use 'ScrollToLatestMessageButton'; +@use 'UnreadMessageNotification'; +@use 'UnreadMessagesSeparator'; @use 'VirtualizedMessageList'; diff --git a/src/i18n/de.json b/src/i18n/de.json index b69212775..59c4ad73e 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -365,8 +365,6 @@ "unmute-command-description": "Stummschaltung eines Benutzers aufheben", "Unpin": "Anheftung aufheben", "Unread messages": "Ungelesene Nachrichten", - "unreadMessagesSeparatorText_one": "1 ungelesene Nachricht", - "unreadMessagesSeparatorText_other": "{{count}} ungelesene Nachrichten", "Unsupported attachment": "Nicht unterstützter Anhang", "unsupported file type": "Nicht unterstützter Dateityp", "Update your comment": "Ihren Kommentar aktualisieren", diff --git a/src/i18n/en.json b/src/i18n/en.json index b691697f1..5cd958a7a 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -365,8 +365,6 @@ "unmute-command-description": "Unmute a user", "Unpin": "Unpin", "Unread messages": "Unread messages", - "unreadMessagesSeparatorText_one": "1 unread message", - "unreadMessagesSeparatorText_other": "{{count}} unread messages", "Unsupported attachment": "Unsupported attachment", "unsupported file type": "unsupported file type", "Update your comment": "Update your comment", diff --git a/src/i18n/es.json b/src/i18n/es.json index 2b73bf53e..ea26aae3d 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -375,9 +375,6 @@ "unmute-command-description": "Desactivar el silencio de un usuario", "Unpin": "Desfijar", "Unread messages": "Mensajes no leídos", - "unreadMessagesSeparatorText_one": "1 mensaje no leído", - "unreadMessagesSeparatorText_many": "{{count}} mensajes no leídos", - "unreadMessagesSeparatorText_other": "{{count}} mensajes no leídos", "Unsupported attachment": "Adjunto no compatible", "unsupported file type": "tipo de archivo no compatible", "Update your comment": "Actualizar tu comentario", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 1135025b4..8acf3f718 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -375,9 +375,6 @@ "unmute-command-description": "Démuter un utilisateur", "Unpin": "Détacher", "Unread messages": "Messages non lus", - "unreadMessagesSeparatorText_one": "1 message non lu", - "unreadMessagesSeparatorText_many": "{{count}} messages non lus", - "unreadMessagesSeparatorText_other": "{{count}} messages non lus", "Unsupported attachment": "Pièce jointe non prise en charge", "unsupported file type": "type de fichier non pris en charge", "Update your comment": "Mettre à jour votre commentaire", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 4778c5559..97968505c 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -366,8 +366,6 @@ "unmute-command-description": "एक उपयोगकर्ता को अनम्यूट करें", "Unpin": "अनपिन", "Unread messages": "अपठित संदेश", - "unreadMessagesSeparatorText_one": "1 अपठित संदेश", - "unreadMessagesSeparatorText_other": "{{count}} अपठित संदेश", "Unsupported attachment": "असमर्थित अटैचमेंट", "unsupported file type": "असमर्थित फ़ाइल प्रकार", "Update your comment": "अपने टिप्पणी को अपडेट करें", diff --git a/src/i18n/it.json b/src/i18n/it.json index 4c41c15b7..f3cc7a0a6 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -375,9 +375,6 @@ "unmute-command-description": "Togliere il silenzio a un utente", "Unpin": "Sblocca", "Unread messages": "Messaggi non letti", - "unreadMessagesSeparatorText_one": "1 messaggio non letto", - "unreadMessagesSeparatorText_many": "{{count}} messaggi non letti", - "unreadMessagesSeparatorText_other": "{{count}} messaggi non letti", "Unsupported attachment": "Allegato non supportato", "unsupported file type": "tipo di file non supportato", "Update your comment": "Aggiorna il tuo commento", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 718d7d675..34653fc63 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -365,8 +365,6 @@ "unmute-command-description": "ユーザーのミュートを解除する", "Unpin": "ピンを解除する", "Unread messages": "未読メッセージ", - "unreadMessagesSeparatorText_one": "未読メッセージ 1 件", - "unreadMessagesSeparatorText_other": "未読メッセージ {{count}} 件", "Unsupported attachment": "サポートされていない添付ファイル", "unsupported file type": "サポートされていないファイル形式", "Update your comment": "コメントを更新", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 144e35538..8ae0294b8 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -365,8 +365,6 @@ "unmute-command-description": "사용자 음소거 해제", "Unpin": "핀 해제", "Unread messages": "읽지 않은 메시지", - "unreadMessagesSeparatorText_one": "읽지 않은 메시지 1개", - "unreadMessagesSeparatorText_other": "읽지 않은 메시지 {{count}}개", "Unsupported attachment": "지원되지 않는 첨부 파일", "unsupported file type": "지원되지 않는 파일 형식", "Update your comment": "댓글 업데이트", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 2d6b13e92..2d90092c1 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -367,8 +367,6 @@ "unmute-command-description": "Een gebruiker niet meer dempen", "Unpin": "Losmaken", "Unread messages": "Ongelezen berichten", - "unreadMessagesSeparatorText_one": "1 ongelezen bericht", - "unreadMessagesSeparatorText_other": "{{count}} ongelezen berichten", "Unsupported attachment": "Niet-ondersteunde bijlage", "unsupported file type": "niet-ondersteund bestandstype", "Update your comment": "Werk je opmerking bij", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index e357840fd..eb72ab646 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -375,9 +375,6 @@ "unmute-command-description": "Retirar o silenciamento de um usuário", "Unpin": "Desfixar", "Unread messages": "Mensagens não lidas", - "unreadMessagesSeparatorText_one": "1 mensagem não lida", - "unreadMessagesSeparatorText_many": "{{count}} mensagens não lidas", - "unreadMessagesSeparatorText_other": "{{count}} mensagens não lidas", "Unsupported attachment": "Anexo não suportado", "unsupported file type": "tipo de arquivo não suportado", "Update your comment": "Atualizar seu comentário", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 9a99574f2..052bf8444 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -385,10 +385,6 @@ "unmute-command-description": "Включить микрофон у пользователя", "Unpin": "Открепить", "Unread messages": "Непрочитанные сообщения", - "unreadMessagesSeparatorText_one": "1 непрочитанное сообщение", - "unreadMessagesSeparatorText_few": "1 непрочитанное сообщения", - "unreadMessagesSeparatorText_many": "{{count}} непрочитанных сообщений", - "unreadMessagesSeparatorText_other": "{{count}} непрочитанных сообщений", "Unsupported attachment": "Неподдерживаемое вложение", "unsupported file type": "неподдерживаемый тип файла", "Update your comment": "Обновите ваш комментарий", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index b0f449233..546b9a9ff 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -365,8 +365,6 @@ "unmute-command-description": "Bir kullanıcının sesini aç", "Unpin": "Sabitlemeyi kaldır", "Unread messages": "Okunmamış mesajlar", - "unreadMessagesSeparatorText_one": "1 okunmamış mesaj", - "unreadMessagesSeparatorText_other": "{{count}} okunmamış mesaj", "Unsupported attachment": "Desteklenmeyen ek", "unsupported file type": "desteklenmeyen dosya türü", "Update your comment": "Yorumunuzu güncelleyin", From 034186944c75d4fe047899347641fca04b4f89d4 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 25 Feb 2026 11:46:15 +0100 Subject: [PATCH 12/16] fix: display the UnreadMessageNotification reliably in MessageList --- src/components/MessageList/MessageList.tsx | 1 + .../useUnreadMessagesNotification.ts | 46 ++++++++++++++----- .../styling/UnreadMessageNotification.scss | 14 ++++-- .../styling/UnreadMessagesSeparator.scss | 5 +- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 099db38dc..240364bae 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -121,6 +121,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { const { show: showUnreadMessagesNotification } = useUnreadMessagesNotification({ isMessageListScrolledToBottom, + listElement, showAlways: !!showUnreadNotificationAlways, unreadCount: channelUnreadUiState?.unread_messages, }); diff --git a/src/components/MessageList/hooks/MessageList/useUnreadMessagesNotification.ts b/src/components/MessageList/hooks/MessageList/useUnreadMessagesNotification.ts index 466e36099..17dafb7b1 100644 --- a/src/components/MessageList/hooks/MessageList/useUnreadMessagesNotification.ts +++ b/src/components/MessageList/hooks/MessageList/useUnreadMessagesNotification.ts @@ -3,9 +3,13 @@ import { useEffect, useRef, useState } from 'react'; import { MESSAGE_LIST_MAIN_PANEL_CLASS } from '../../MessageListMainPanel'; import { UNREAD_MESSAGE_SEPARATOR_CLASS } from '../../UnreadMessagesSeparator'; -const targetScrolledAboveVisibleContainerArea = (element: Element) => { +const targetScrolledAboveVisibleContainerArea = ( + element: Element, + container?: Element, +) => { const { bottom: targetBottom } = element.getBoundingClientRect(); - return targetBottom < 0; + const containerTop = container?.getBoundingClientRect().top ?? 0; + return targetBottom < containerTop; }; const targetScrolledBelowVisibleContainerArea = ( @@ -13,11 +17,13 @@ const targetScrolledBelowVisibleContainerArea = ( container: Element, ) => { const { top: targetTop } = element.getBoundingClientRect(); - const { top: containerBottom } = container.getBoundingClientRect(); + const { bottom: containerBottom } = container.getBoundingClientRect(); return targetTop > containerBottom; }; export type UseUnreadMessagesNotificationParams = { + /** Scroll container (the element with overflow that actually scrolls). When provided, used as IntersectionObserver root and for initial visibility. */ + listElement: HTMLDivElement | null; isMessageListScrolledToBottom: boolean; showAlways: boolean; unreadCount?: number; @@ -25,6 +31,7 @@ export type UseUnreadMessagesNotificationParams = { export const useUnreadMessagesNotification = ({ isMessageListScrolledToBottom, + listElement, showAlways, unreadCount, }: UseUnreadMessagesNotificationParams) => { @@ -39,8 +46,20 @@ export const useUnreadMessagesNotification = ({ return; } - const [msgListPanel] = document.getElementsByClassName(MESSAGE_LIST_MAIN_PANEL_CLASS); - if (!msgListPanel) return; + const scrollRoot = listElement ?? null; + if (!scrollRoot) { + const [msgListPanel] = document.getElementsByClassName( + MESSAGE_LIST_MAIN_PANEL_CLASS, + ); + if (!msgListPanel) return; + const [observedTarget] = document.getElementsByClassName( + UNREAD_MESSAGE_SEPARATOR_CLASS, + ); + if (!observedTarget) { + setShow(true); + } + return; + } const [observedTarget] = document.getElementsByClassName( UNREAD_MESSAGE_SEPARATOR_CLASS, @@ -50,11 +69,13 @@ export const useUnreadMessagesNotification = ({ return; } - const scrolledBelowSeparator = - targetScrolledAboveVisibleContainerArea(observedTarget); + const scrolledBelowSeparator = targetScrolledAboveVisibleContainerArea( + observedTarget, + scrollRoot, + ); const scrolledAboveSeparator = targetScrolledBelowVisibleContainerArea( observedTarget, - msgListPanel, + scrollRoot, ); setShow( @@ -66,16 +87,18 @@ export const useUnreadMessagesNotification = ({ const observer = new IntersectionObserver( (elements) => { if (!elements.length) return; - const { boundingClientRect, isIntersecting } = elements[0]; + const entry = elements[0]; + const { boundingClientRect, isIntersecting, rootBounds } = entry; if (isIntersecting) { setShow(false); return; } - const separatorIsAboveContainerTop = boundingClientRect.bottom < 0; + const rootTop = rootBounds?.top ?? 0; + const separatorIsAboveContainerTop = boundingClientRect.bottom < rootTop; setShow(showAlways || separatorIsAboveContainerTop); isScrolledAboveTargetTop.current = separatorIsAboveContainerTop; }, - { root: msgListPanel }, + { root: scrollRoot }, ); observer.observe(observedTarget); @@ -84,6 +107,7 @@ export const useUnreadMessagesNotification = ({ }; }, [ intersectionObserverIsSupported, + listElement, isMessageListScrolledToBottom, messages, showAlways, diff --git a/src/components/MessageList/styling/UnreadMessageNotification.scss b/src/components/MessageList/styling/UnreadMessageNotification.scss index 9e572b80c..d8c4c4992 100644 --- a/src/components/MessageList/styling/UnreadMessageNotification.scss +++ b/src/components/MessageList/styling/UnreadMessageNotification.scss @@ -6,7 +6,10 @@ border-radius: var(--button-radius-lg); border: 1px solid var(--button-secondary-border); /* shadow/web/light/elevation-2 */ - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 2px 4px 0 rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.06); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.05), + 0 2px 4px 0 rgba(0, 0, 0, 0.12), + 0 6px 16px 0 rgba(0, 0, 0, 0.06); position: absolute; top: 40px; z-index: 2; @@ -16,7 +19,8 @@ text-transform: lowercase; } - button.str-chat__button--secondary, button.button.str-chat__button--outline { + button.str-chat__button--secondary, + button.button.str-chat__button--outline { border: none; } @@ -25,13 +29,15 @@ align-items: center; gap: var(--spacing-xs); border-radius: var(--button-radius-lg) 0 0 var(--button-radius-lg); - padding: var(--button-padding-y-md) var(--spacing-xxs) var(--button-padding-y-md) var(--button-padding-x-with-label-md); + padding: var(--button-padding-y-md) var(--spacing-xxs) var(--button-padding-y-md) + var(--button-padding-x-with-label-md); font: var(--str-chat__caption-emphasis-text); } button:last-of-type { border-radius: 0 var(--button-radius-lg) var(--button-radius-lg) 0; - padding: var(--button-padding-y-md) var(--button-padding-x-with-label-md) var(--button-padding-y-md) var(--spacing-xxs); + padding: var(--button-padding-y-md) var(--button-padding-x-with-label-md) + var(--button-padding-y-md) var(--spacing-xxs); svg { height: 16px; diff --git a/src/components/MessageList/styling/UnreadMessagesSeparator.scss b/src/components/MessageList/styling/UnreadMessagesSeparator.scss index 9a1aeb041..f1306fde7 100644 --- a/src/components/MessageList/styling/UnreadMessagesSeparator.scss +++ b/src/components/MessageList/styling/UnreadMessagesSeparator.scss @@ -7,7 +7,7 @@ display: flex; align-items: center; width: fit-content; - padding: var(--spacing-xxs) var(--spacing-xs); + padding: var(--spacing-xxs) var(--spacing-xs); background: var(--background-elevation-elevation-1); border-radius: var(--button-radius-lg); border: 1px solid var(--button-secondary-border); @@ -20,7 +20,8 @@ text-transform: lowercase; } - button.str-chat__button--secondary, button.button.str-chat__button--outline { + button.str-chat__button--secondary, + button.button.str-chat__button--outline { border: none; } From 80b6989ed26fd59bb834d2dbec1ff903cadf9677 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 25 Feb 2026 14:39:05 +0100 Subject: [PATCH 13/16] feat(Button): add variant, appearance, circular, and size props Co-authored-by: Cursor --- src/components/Button/Button.tsx | 44 +++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 8637f1d20..f213cf260 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -2,10 +2,41 @@ import type { ComponentProps } from 'react'; import { forwardRef } from 'react'; import clsx from 'clsx'; -export type ButtonProps = ComponentProps<'button'>; +export type ButtonVariant = 'primary' | 'secondary' | 'danger'; +export type ButtonAppearance = 'solid' | 'outline' | 'ghost'; +export type ButtonSize = 'lg' | 'md' | 'sm'; + +export type ButtonProps = ComponentProps<'button'> & { + /** Semantic variant: primary, secondary, or danger (maps to destructive in styles). */ + variant?: ButtonVariant; + /** Visual style: solid, outline, or ghost. */ + appearance?: ButtonAppearance; + /** When true, uses full border-radius for icon-only/pill shape. */ + circular?: boolean; + /** Size: lg, md, or sm. */ + size?: ButtonSize; +}; + +const variantToClass: Record = { + danger: 'str-chat__button--destructive', + primary: 'str-chat__button--primary', + secondary: 'str-chat__button--secondary', +}; + +const appearanceToClass: Record = { + ghost: 'str-chat__button--ghost', + outline: 'str-chat__button--outline', + solid: 'str-chat__button--solid', +}; + +const sizeToClass: Record = { + lg: 'str-chat__button--size-lg', + md: 'str-chat__button--size-md', + sm: 'str-chat__button--size-sm', +}; export const Button = forwardRef(function Button( - { className, ...props }, + { appearance, circular, className, size, variant, ...props }, ref, ) { return ( @@ -13,7 +44,14 @@ export const Button = forwardRef(function Button ref={ref} type='button' {...props} - className={clsx('str-chat__button', className)} + className={clsx( + 'str-chat__button', + variant != null && variantToClass[variant], + appearance != null && appearanceToClass[appearance], + circular && 'str-chat__button--circular', + size != null && sizeToClass[size], + className, + )} /> ); }); From 6c0c0eaeeb2d4b8edb8c1ff5003c9d85287c5419 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 25 Feb 2026 16:25:48 +0100 Subject: [PATCH 14/16] refactor(Button): use variant, appearance, size, circular props in consumers Co-authored-by: Cursor --- src/components/MessageInput/SendButton.tsx | 13 ++++------- .../Modal/CloseButtonOnModalOverlay.tsx | 9 +++----- .../Poll/PollActions/PollAction.tsx | 13 +++++------ .../PollCreationDialogControls.tsx | 1 - src/components/Reactions/ReactionSelector.tsx | 11 +++++++-- src/plugins/Emojis/EmojiPicker.tsx | 23 ++++++++++--------- 6 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/components/MessageInput/SendButton.tsx b/src/components/MessageInput/SendButton.tsx index b71fbb209..4e750fe43 100644 --- a/src/components/MessageInput/SendButton.tsx +++ b/src/components/MessageInput/SendButton.tsx @@ -3,7 +3,6 @@ import { useMessageComposerHasSendableData } from './hooks'; import { useTranslationContext } from '../../context'; import { IconPaperPlane } from '../Icons'; import { Button } from '../Button'; -import clsx from 'clsx'; export type SendButtonProps = { sendMessage: (event: React.BaseSyntheticEvent) => void; @@ -14,18 +13,16 @@ export const SendButton = ({ children, sendMessage, ...rest }: SendButtonProps) const hasSendableData = useMessageComposerHasSendableData(); return ( diff --git a/src/components/Poll/PollActions/PollAction.tsx b/src/components/Poll/PollActions/PollAction.tsx index e84a5096e..34d5341eb 100644 --- a/src/components/Poll/PollActions/PollAction.tsx +++ b/src/components/Poll/PollActions/PollAction.tsx @@ -31,14 +31,13 @@ export const PollAction = ({ return ( <> diff --git a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx index 1daa055d6..5385be895 100644 --- a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx +++ b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx @@ -43,7 +43,6 @@ export const PollCreationDialogControls = ({ }) .catch(console.error); }} - type='submit' > {t('Send poll')} diff --git a/src/components/Reactions/ReactionSelector.tsx b/src/components/Reactions/ReactionSelector.tsx index d66e1fe30..1ce9d519c 100644 --- a/src/components/Reactions/ReactionSelector.tsx +++ b/src/components/Reactions/ReactionSelector.tsx @@ -5,6 +5,7 @@ import { useDialog } from '../Dialog'; import { defaultReactionOptions } from './reactionOptions'; import { useComponentContext } from '../../context/ComponentContext'; import { useMessageContext } from '../../context/MessageContext'; +import { Button } from '../Button'; import { IconPlusLarge } from '../Icons'; import type { ReactionResponse } from 'stream-chat'; @@ -73,9 +74,15 @@ const UnMemoizedReactionSelector = (props: ReactionSelectorProps) => { ))} - +
); }; diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index 3af1815c4..974e74b9b 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -9,7 +9,6 @@ import { useMessageComposer, } from '../../components'; import { usePopoverPosition } from '../../components/Dialog/hooks/usePopoverPosition'; -import clsx from 'clsx'; import { useIsCooldownActive } from '../../components/MessageInput/hooks/useIsCooldownActive'; const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; @@ -35,14 +34,12 @@ export type EmojiPickerProps = { popperOptions?: Partial<{ placement: PopperLikePlacement }>; }; -const classNames: EmojiPickerProps = { - buttonClassName: clsx( - 'str-chat__emoji-picker-button', - 'str-chat__button--ghost', - 'str-chat__button--secondary', - 'str-chat__button--size-sm', - 'str-chat__button--circular', - ), +const defaultButtonClassName = 'str-chat__emoji-picker-button'; + +const classNames: Pick< + EmojiPickerProps, + 'pickerContainerClassName' | 'wrapperClassName' +> = { pickerContainerClassName: 'str-chat__message-textarea-emoji-picker-container', wrapperClassName: 'str-chat__message-textarea-emoji-picker', }; @@ -68,7 +65,7 @@ export const EmojiPicker = (props: EmojiPickerProps) => { refs.setFloating(popperElement); }, [popperElement, refs]); - const { buttonClassName, pickerContainerClassName, wrapperClassName } = classNames; + const { pickerContainerClassName, wrapperClassName } = classNames; const { ButtonIconComponent = IconEmojiSmile } = props; @@ -118,13 +115,17 @@ export const EmojiPicker = (props: EmojiPickerProps) => {
)} From 3f8c19b3d9b72d819c8d85aa84ce496818fa956e Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 25 Feb 2026 16:59:24 +0100 Subject: [PATCH 15/16] feat: introduce button variant props --- .../Attachment/AttachmentActions.tsx | 4 +- .../components/PlaybackRateButton.tsx | 1 - src/components/Button/PlayButton.tsx | 15 +++--- src/components/ChatView/ChatView.tsx | 11 ++--- src/components/Dialog/components/Callout.tsx | 13 ++---- src/components/Dialog/components/Prompt.tsx | 46 ++++++++----------- src/components/Form/NumericInput.tsx | 14 +++--- .../AudioRecorderRecordingControls.tsx | 37 ++++++--------- .../AudioRecordingButtonWithNotification.tsx | 13 ++---- .../AudioRecorder/AudioRecordingPlayback.tsx | 12 ++--- .../MessageActions/DeleteMessageAlert.tsx | 21 ++++----- .../MessageActions/MessageActions.tsx | 10 ++-- .../QuickMessageActionButton.tsx | 11 ++--- .../MessageBounce/MessageBouncePrompt.tsx | 31 +++++-------- .../MediaAttachmentPreview.tsx | 12 ++--- .../AttachmentSelector/AttachmentSelector.tsx | 13 ++---- .../RemoveAttachmentPreviewButton.tsx | 12 ++--- src/components/MessageInput/SendButton.tsx | 1 - .../ScrollToLatestMessageButton.tsx | 13 ++---- .../UnreadMessagesNotification.tsx | 8 ++-- .../MessageList/UnreadMessagesSeparator.tsx | 11 ++--- .../PollCreationDialog/OptionFieldSet.tsx | 12 ++--- .../PollCreationDialogControls.tsx | 1 + src/components/VideoPlayer/VideoThumbnail.tsx | 8 ++-- 24 files changed, 132 insertions(+), 198 deletions(-) diff --git a/src/components/Attachment/AttachmentActions.tsx b/src/components/Attachment/AttachmentActions.tsx index 938cd031e..ced98d4cd 100644 --- a/src/components/Attachment/AttachmentActions.tsx +++ b/src/components/Attachment/AttachmentActions.tsx @@ -70,10 +70,9 @@ const UnMemoizedAttachmentActions = (props: AttachmentActionsProps) => { {text} {actions.map((action, index) => ( diff --git a/src/components/Attachment/components/PlaybackRateButton.tsx b/src/components/Attachment/components/PlaybackRateButton.tsx index 008037cb6..503b5aee3 100644 --- a/src/components/Attachment/components/PlaybackRateButton.tsx +++ b/src/components/Attachment/components/PlaybackRateButton.tsx @@ -9,7 +9,6 @@ export const PlaybackRateButton = ({ children, onClick }: PlaybackRateButtonProp className={clsx('str-chat__message_attachment__playback-rate-button')} data-testid='playback-rate-button' onClick={onClick} - type='button' > {children} diff --git a/src/components/Button/PlayButton.tsx b/src/components/Button/PlayButton.tsx index 459f79014..3e467a34f 100644 --- a/src/components/Button/PlayButton.tsx +++ b/src/components/Button/PlayButton.tsx @@ -9,16 +9,13 @@ export type PlayButtonProps = ComponentProps<'button'> & { export const PlayButton = ({ className, isPlaying, ...props }: PlayButtonProps) => ( diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index 51053d712..7e46393cc 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -142,14 +142,11 @@ export const ChatViewSelectorButton = ({ ...props }: ButtonProps & { Icon?: ComponentType; text?: string }) => ( diff --git a/src/components/Dialog/components/Prompt.tsx b/src/components/Dialog/components/Prompt.tsx index 42ff6870f..96de6b8b3 100644 --- a/src/components/Dialog/components/Prompt.tsx +++ b/src/components/Dialog/components/Prompt.tsx @@ -27,14 +27,12 @@ const PromptHeader = ({
{goBack && ( @@ -47,14 +45,12 @@ const PromptHeader = ({
{close && ( @@ -88,27 +84,21 @@ const PromptFooterControls = ({ children, className }: PromptFooterControlsProps const PromptFooterControlsButtonSecondary = ({ className, ...props }: ButtonProps) => ( diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx index c2ccc4d71..42f07c4ef 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx @@ -4,7 +4,6 @@ import React from 'react'; import { useMessageInputContext } from '../../../context'; import { isRecording } from './recordingStateIdentity'; import { Button } from '../../Button'; -import clsx from 'clsx'; const ToggleRecordingButton = () => { const { @@ -13,16 +12,14 @@ const ToggleRecordingButton = () => { return ( @@ -41,31 +38,27 @@ export const AudioRecorderRecordingControls = () => {
{!isRecording(recordingState) && ( )} diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx index 47581d3fd..e7ba6596a 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx @@ -5,7 +5,6 @@ import { useAttachmentManagerState } from '../../MessageInput'; import { useComponentContext, useMessageInputContext } from '../../../context'; import { Callout, useDialogOnNearestManager } from '../../Dialog'; import { Button } from '../../Button'; -import clsx from 'clsx'; import { IconMicrophone } from '../../Icons'; const dialogId = 'recording-permission-denied-notification'; @@ -66,15 +65,13 @@ export const DefaultStartRecordingAudioButton = forwardRef< >(function StartRecordingAudioButton(props, ref) { return ( diff --git a/src/components/MessageActions/DeleteMessageAlert.tsx b/src/components/MessageActions/DeleteMessageAlert.tsx index 254a72ef8..deebb4dea 100644 --- a/src/components/MessageActions/DeleteMessageAlert.tsx +++ b/src/components/MessageActions/DeleteMessageAlert.tsx @@ -1,6 +1,5 @@ import { Alert } from '../Dialog'; import { Button } from '../Button'; -import clsx from 'clsx'; import React from 'react'; import { useTranslationContext } from '../../context'; import type { ModalProps } from '../Modal'; @@ -22,26 +21,22 @@ export const DeleteMessageAlert = ({ onClose, onDelete }: DeleteMessageAlertProp /> diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx index dab95c2ff..7e767d812 100644 --- a/src/components/MessageActions/MessageActions.tsx +++ b/src/components/MessageActions/MessageActions.tsx @@ -98,20 +98,18 @@ export const MessageActions = ({ {dropdownActionSet.length > 0 && ( <> diff --git a/src/components/MessageActions/QuickMessageActionButton.tsx b/src/components/MessageActions/QuickMessageActionButton.tsx index 1cfe3d819..249da7034 100644 --- a/src/components/MessageActions/QuickMessageActionButton.tsx +++ b/src/components/MessageActions/QuickMessageActionButton.tsx @@ -4,13 +4,10 @@ import React from 'react'; export const QuickMessageActionsButton = ({ className, ...props }: ButtonProps) => ( diff --git a/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx index 4ace7b6f4..8cead490e 100644 --- a/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx @@ -109,16 +109,14 @@ export const MediaAttachmentPreview = ({ {hasRetriableError && ( diff --git a/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx b/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx index f0f71b218..287972e60 100644 --- a/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx +++ b/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx @@ -65,15 +65,12 @@ export const AttachmentSelectorButton = forwardRef diff --git a/src/components/MessageInput/RemoveAttachmentPreviewButton.tsx b/src/components/MessageInput/RemoveAttachmentPreviewButton.tsx index aeee2f4d4..ae3964bf7 100644 --- a/src/components/MessageInput/RemoveAttachmentPreviewButton.tsx +++ b/src/components/MessageInput/RemoveAttachmentPreviewButton.tsx @@ -15,17 +15,15 @@ export const RemoveAttachmentPreviewButton = ({ const { t } = useTranslationContext(); return ( diff --git a/src/components/MessageInput/SendButton.tsx b/src/components/MessageInput/SendButton.tsx index 4e750fe43..9c60655b8 100644 --- a/src/components/MessageInput/SendButton.tsx +++ b/src/components/MessageInput/SendButton.tsx @@ -21,7 +21,6 @@ export const SendButton = ({ children, sendMessage, ...rest }: SendButtonProps) disabled={!hasSendableData} onClick={sendMessage} size='sm' - type='button' variant='primary' {...rest} > diff --git a/src/components/MessageList/ScrollToLatestMessageButton.tsx b/src/components/MessageList/ScrollToLatestMessageButton.tsx index 25cb47687..6d4b4cc9b 100644 --- a/src/components/MessageList/ScrollToLatestMessageButton.tsx +++ b/src/components/MessageList/ScrollToLatestMessageButton.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState } from 'react'; -import clsx from 'clsx'; import { useChannelStateContext, useChatContext } from '../../context'; @@ -86,16 +85,14 @@ const UnMemoizedScrollToLatestMessageButton = ( return (
diff --git a/src/components/MessageList/UnreadMessagesNotification.tsx b/src/components/MessageList/UnreadMessagesNotification.tsx index c2e675dab..c49dcb7d2 100644 --- a/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/src/components/MessageList/UnreadMessagesNotification.tsx @@ -35,18 +35,16 @@ export const UnreadMessagesNotification = ({ data-testid='unread-messages-notification' > -
diff --git a/src/components/MessageList/UnreadMessagesSeparator.tsx b/src/components/MessageList/UnreadMessagesSeparator.tsx index 245481ed3..5dcc158d4 100644 --- a/src/components/MessageList/UnreadMessagesSeparator.tsx +++ b/src/components/MessageList/UnreadMessagesSeparator.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { useChannelActionContext, useTranslationContext } from '../../context'; import { Button } from '../Button'; -import clsx from 'clsx'; import { IconCrossMedium } from '../Icons'; export const UNREAD_MESSAGE_SEPARATOR_CLASS = 'str-chat__unread-messages-separator'; @@ -34,13 +33,11 @@ export const UnreadMessagesSeparator = ({ : t('Unread messages')}
diff --git a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx index afc0b56da..712ffacb4 100644 --- a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx +++ b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx @@ -114,13 +114,11 @@ export const OptionFieldSet = () => { const RemoveOptionButton = ({ className, ...props }: ButtonProps) => ( From ea0800edb8f310ca80679e0610bf524e5d7f00fe Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 26 Feb 2026 07:49:39 +0100 Subject: [PATCH 16/16] feat: redesign ChannelHeader --- examples/vite/src/stream-imports-layout.scss | 2 +- examples/vite/src/stream-imports-theme.scss | 2 +- .../ChannelHeader/ChannelHeader.tsx | 71 ++++++--------- .../hooks/useChannelHeaderOnlineStatus.ts | 50 +++++++++++ src/components/ChannelHeader/icons.tsx | 17 ---- .../ChannelHeader/styling/ChannelHeader.scss | 87 +++++++++---------- src/i18n/de.json | 2 + src/i18n/en.json | 2 + src/i18n/es.json | 2 + src/i18n/fr.json | 2 + src/i18n/hi.json | 2 + src/i18n/it.json | 2 + src/i18n/ja.json | 2 + src/i18n/ko.json | 2 + src/i18n/nl.json | 2 + src/i18n/pt.json | 2 + src/i18n/ru.json | 2 + src/i18n/tr.json | 2 + src/styling/_utils.scss | 1 + 19 files changed, 146 insertions(+), 108 deletions(-) create mode 100644 src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts delete mode 100644 src/components/ChannelHeader/icons.tsx diff --git a/examples/vite/src/stream-imports-layout.scss b/examples/vite/src/stream-imports-layout.scss index 2550fc765..c59905c39 100644 --- a/examples/vite/src/stream-imports-layout.scss +++ b/examples/vite/src/stream-imports-layout.scss @@ -9,7 +9,7 @@ //@use 'stream-chat-react/dist/scss/v2/AudioRecorder/AudioRecorder-layout'; //@use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-layout'; //@use 'stream-chat-react/dist/scss/v2/Channel/Channel-layout'; -@use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-layout'; +//@use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-layout'; @use 'stream-chat-react/dist/scss/v2/ChannelList/ChannelList-layout'; @use 'stream-chat-react/dist/scss/v2/ChannelPreview/ChannelPreview-layout'; @use 'stream-chat-react/dist/scss/v2/ChannelSearch/ChannelSearch-layout'; diff --git a/examples/vite/src/stream-imports-theme.scss b/examples/vite/src/stream-imports-theme.scss index 54c3fa97a..60fd040f5 100644 --- a/examples/vite/src/stream-imports-theme.scss +++ b/examples/vite/src/stream-imports-theme.scss @@ -9,7 +9,7 @@ //@use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-theme'; //@use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-theme'; //@use 'stream-chat-react/dist/scss/v2/Channel/Channel-theme.scss'; -@use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-theme'; +//@use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-theme'; @use 'stream-chat-react/dist/scss/v2/ChannelList/ChannelList-theme'; @use 'stream-chat-react/dist/scss/v2/ChannelPreview/ChannelPreview-theme'; @use 'stream-chat-react/dist/scss/v2/ChannelSearch/ChannelSearch-theme'; diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index e96d91237..4bfb2e321 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -1,21 +1,21 @@ import React from 'react'; import { IconLayoutAlignLeft } from '../Icons/icons'; -import { MenuIcon as DefaultMenuIcon } from './icons'; import { Avatar as DefaultAvatar } from '../Avatar'; +import { useChannelHeaderOnlineStatus } from './hooks/useChannelHeaderOnlineStatus'; import { useChannelPreviewInfo } from '../ChannelPreview/hooks/useChannelPreviewInfo'; import { useChannelStateContext } from '../../context/ChannelStateContext'; import { useChatContext } from '../../context/ChatContext'; import { useTranslationContext } from '../../context/TranslationContext'; import type { ChannelAvatarProps } from '../Avatar'; +import { Button } from '../Button'; +import clsx from 'clsx'; export type ChannelHeaderProps = { /** UI component to display an avatar, defaults to [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) component and accepts the same props as: [ChannelAvatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/ChannelAvatar.tsx) */ Avatar?: React.ComponentType; /** Manually set the image to render, defaults to the Channel image */ image?: string; - /** Show a little indicator that the Channel is live right now */ - live?: boolean; /** UI component to display menu icon, defaults to [MenuIcon](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelHeader/ChannelHeader.tsx)*/ MenuIcon?: React.ComponentType; /** When true, shows IconLayoutAlignLeft instead of MenuIcon for sidebar expansion */ @@ -31,13 +31,12 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { const { Avatar = DefaultAvatar, image: overrideImage, - live, - MenuIcon = DefaultMenuIcon, - sidebarCollapsed = false, + MenuIcon = IconLayoutAlignLeft, + sidebarCollapsed = true, title: overrideTitle, } = props; - const { channel, watcher_count } = useChannelStateContext('ChannelHeader'); + const { channel } = useChannelStateContext(); const { openMobileNav } = useChatContext('ChannelHeader'); const { t } = useTranslationContext('ChannelHeader'); const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ @@ -45,48 +44,32 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { overrideImage, overrideTitle, }); - - const { member_count, subtitle } = channel?.data || {}; - - const headerClassName = [ - 'str-chat__channel-header', - sidebarCollapsed && 'str-chat__channel-header--sidebar-collapsed', - ] - .filter(Boolean) - .join(' '); + const onlineStatusText = useChannelHeaderOnlineStatus(); return ( -
- -
-

- {displayTitle}{' '} - {live && ( - {t('live')} - )} -

- {subtitle &&

{subtitle}

} -

- {!live && !!member_count && member_count > 0 && ( - <> - {t('{{ memberCount }} members', { - memberCount: member_count, - })} - ,{' '} - - )} - {t('{{ watcherCount }} online', { watcherCount: watcher_count })} -

+ {sidebarCollapsed && } + +
+
{displayTitle}
+ {onlineStatusText != null && ( +
+ {onlineStatusText} +
+ )}
(() => + Object.assign({}, channel?.state?.watchers ?? {}), + ); + + useEffect(() => { + if (!channel) return; + const subscription = channel.on('user.watching.start', (event) => { + setWatchers((prev) => { + if (!event.user?.id) return prev; + if (prev[event.user.id]) return prev; + return Object.assign({ [event.user.id]: event.user }, prev); + }); + }); + return () => subscription.unsubscribe(); + }, [channel]); + + if (!memberCount) return null; + + const isDmChannel = + memberCount === 1 || + (memberCount === 2 && + Object.values(channel?.state?.members ?? {}).some( + ({ user }) => user?.id === client.user?.id, + )); + + if (isDmChannel) { + const hasWatchers = Object.keys(watchers).length > 0; + return hasWatchers ? t('Online') : t('Offline'); + } + + return `${t('{{ memberCount }} members', { memberCount })}, ${t('{{ watcherCount }} online', { watcherCount })}`; +} diff --git a/src/components/ChannelHeader/icons.tsx b/src/components/ChannelHeader/icons.tsx deleted file mode 100644 index 3c8621033..000000000 --- a/src/components/ChannelHeader/icons.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -import { useTranslationContext } from '../../context'; - -export const MenuIcon = ({ title }: { title?: string }) => { - const { t } = useTranslationContext('MenuIcon'); - - return ( - - {title ?? t('Menu')} - - - ); -}; diff --git a/src/components/ChannelHeader/styling/ChannelHeader.scss b/src/components/ChannelHeader/styling/ChannelHeader.scss index c5270ba7a..217e2c0b9 100644 --- a/src/components/ChannelHeader/styling/ChannelHeader.scss +++ b/src/components/ChannelHeader/styling/ChannelHeader.scss @@ -1,63 +1,60 @@ @use '../../../styling/utils'; -.str-chat__channel-header { - @include utils.header-layout; - background-color: var(--str-chat__channel-header-background-color); - flex: 1; - min-width: 0; +.str-chat { + /* The border radius used for the borders of the component */ + --str-chat__channel-header-border-radius: 0; - .str-chat__header-hamburger, - .str-chat__header-sidebar-toggle { - $icon-size: calc(var(--str-chat__spacing-px) * 24); - @include utils.unset-button; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: calc(var(--str-chat__spacing-px) * 40); - height: calc(var(--str-chat__spacing-px) * 40); - cursor: pointer; - color: var(--str-chat__text-color); + /* The text/icon color of the component */ + --str-chat__channel-header-color: 0; - svg { - width: $icon-size; - height: $icon-size; - } + /* The background color of the component */ + --str-chat__channel-header-background-color: var(--background-elevation-elevation-1); - &:focus-visible { - @include utils.focusable; - } - } + /* Top border of the component */ + --str-chat__channel-header-border-block-start: none; + + /* Bottom border of the component */ + --str-chat__channel-header-border-block-end: 1px solid var(--border-core-default); + + /* Left (right in RTL layout) border of the component */ + --str-chat__channel-header-border-inline-start: none; + + /* Right (left in RTL layout) border of the component */ + --str-chat__channel-header-border-inline-end: none; + + /* Box shadow applied to the component */ + --str-chat__channel-header-box-shadow: none; - .str-chat__channel-header-end { + /* The text/icon color used to display member information about the channel */ + --str-chat__channel-header__data__subtitle-color: var(--text-secondary); +} + +.str-chat__channel-header { + @include utils.component-layer-overrides('channel-header'); + display: flex; + padding: var(--spacing-md); + column-gap: var(--spacing-sm); + align-items: center; + flex: 1; + min-width: 0; + + .str-chat__channel-header__data { @include utils.header-text-layout; min-width: 0; } - .str-chat__channel-header-title, - .str-chat__channel-header-subtitle, - .str-chat__channel-header-info { + .str-chat__channel-header__data__title, + .str-chat__channel-header__data__subtitle { @include utils.ellipsis-text; } - .str-chat__channel-header-title { - font: var(--str-chat__headline-text); - color: var(--str-chat__text-color); - } - - .str-chat__channel-header-subtitle { - font: var(--str-chat__body-text); - color: var(--str-chat__text-low-emphasis-color); - } - - .str-chat__channel-header-info { - font: var(--str-chat__body-text); - color: var(--str-chat__text-low-emphasis-color); + .str-chat__channel-header__data__title { + font: var(--str-chat__heading-sm-text); } - .str-chat__avatar--channel-header { - flex-shrink: 0; - margin-inline-start: auto; + .str-chat__channel-header__data__subtitle { + font: var(--str-chat__caption-default-text); + color: var(--str-chat__channel-header__data__subtitle-color); } &.str-chat__channel-header--sidebar-collapsed { diff --git a/src/i18n/de.json b/src/i18n/de.json index e1390c63d..ff8021140 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -258,7 +258,9 @@ "No results found": "Keine Ergebnisse gefunden", "Nobody will be able to vote in this poll anymore.": "Niemand kann mehr in dieser Umfrage abstimmen.", "Nothing yet...": "Noch nichts...", + "Offline": "Offline", "Ok": "OK", + "Online": "Online", "Only numbers are allowed": "Nur Zahlen sind erlaubt", "Only visible to you": "Nur für dich sichtbar", "Open emoji picker": "Emoji-Auswahl öffnen", diff --git a/src/i18n/en.json b/src/i18n/en.json index 35914cb72..13225a710 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -258,7 +258,9 @@ "No results found": "No results found", "Nobody will be able to vote in this poll anymore.": "Nobody will be able to vote in this poll anymore.", "Nothing yet...": "Nothing yet...", + "Offline": "Offline", "Ok": "Ok", + "Online": "Online", "Only numbers are allowed": "Only numbers are allowed", "Only visible to you": "Only visible to you", "Open emoji picker": "Open emoji picker", diff --git a/src/i18n/es.json b/src/i18n/es.json index be1013ab2..462e97321 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -265,7 +265,9 @@ "No results found": "No se han encontrado resultados", "Nobody will be able to vote in this poll anymore.": "Nadie podrá votar en esta encuesta.", "Nothing yet...": "Nada aún...", + "Offline": "Desconectado", "Ok": "Aceptar", + "Online": "En línea", "Only numbers are allowed": "Solo se permiten números", "Only visible to you": "Solo visible para ti", "Open emoji picker": "Abrir el selector de emojis", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 8ccbc0716..1f31bc9ed 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -265,7 +265,9 @@ "No results found": "Aucun résultat trouvé", "Nobody will be able to vote in this poll anymore.": "Personne ne pourra plus voter dans ce sondage.", "Nothing yet...": "Rien pour l'instant...", + "Offline": "Hors ligne", "Ok": "D'accord", + "Online": "En ligne", "Only numbers are allowed": "Seuls les chiffres sont autorisés", "Only visible to you": "Visible uniquement pour vous", "Open emoji picker": "Ouvrir le sélecteur d'émojis", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 87e325ba2..f94b9130a 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -259,7 +259,9 @@ "No results found": "कोई परिणाम नहीं मिला", "Nobody will be able to vote in this poll anymore.": "अब कोई भी इस मतदान में मतदान नहीं कर सकेगा।", "Nothing yet...": "कोई मैसेज नहीं है", + "Offline": "ऑफलाइन", "Ok": "ठीक है", + "Online": "ऑनलाइन", "Only numbers are allowed": "केवल संख्याएँ अनुमत हैं", "Only visible to you": "केवल आपको दिखाई देता है", "Open emoji picker": "इमोजी पिकर खोलिये", diff --git a/src/i18n/it.json b/src/i18n/it.json index 02881d579..115da6826 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -265,7 +265,9 @@ "No results found": "Nessun risultato trovato", "Nobody will be able to vote in this poll anymore.": "Nessuno potrà più votare in questo sondaggio.", "Nothing yet...": "Ancora niente...", + "Offline": "Offline", "Ok": "OK", + "Online": "Online", "Only numbers are allowed": "Sono consentiti solo numeri", "Only visible to you": "Visibile solo per te", "Open emoji picker": "Apri il selettore di emoji", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index f22ab9681..16afa582c 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -257,7 +257,9 @@ "No results found": "結果が見つかりません", "Nobody will be able to vote in this poll anymore.": "この投票では、誰も投票できなくなります。", "Nothing yet...": "まだ何もありません...", + "Offline": "オフライン", "Ok": "OK", + "Online": "オンライン", "Only numbers are allowed": "数字のみ許可されています", "Only visible to you": "あなただけに表示", "Open emoji picker": "絵文字ピッカーを開く", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index dd6950aa6..5ff3cf554 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -257,7 +257,9 @@ "No results found": "검색 결과가 없습니다", "Nobody will be able to vote in this poll anymore.": "이 투표에 더 이상 아무도 투표할 수 없습니다.", "Nothing yet...": "아직 아무것도...", + "Offline": "오프라인", "Ok": "확인", + "Online": "온라인", "Only numbers are allowed": "숫자만 입력 가능합니다", "Only visible to you": "당신에게만 표시됨", "Open emoji picker": "이모지 선택기 열기", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index b07e9202e..d3f41074d 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -258,7 +258,9 @@ "No results found": "Geen resultaten gevonden", "Nobody will be able to vote in this poll anymore.": "Niemand kan meer stemmen in deze peiling.", "Nothing yet...": "Nog niets ...", + "Offline": "Offline", "Ok": "Oké", + "Online": "Online", "Only numbers are allowed": "Alleen nummers zijn toegestaan", "Only visible to you": "Alleen zichtbaar voor jou", "Open emoji picker": "Emoji-kiezer openen", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index a6b71bb28..8daa14c49 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -265,7 +265,9 @@ "No results found": "Nenhum resultado encontrado", "Nobody will be able to vote in this poll anymore.": "Ninguém mais poderá votar nesta pesquisa.", "Nothing yet...": "Nada ainda...", + "Offline": "Offline", "Ok": "OK", + "Online": "Online", "Only numbers are allowed": "Apenas números são permitidos", "Only visible to you": "Visível apenas para você", "Open emoji picker": "Abrir seletor de emoji", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 7a3bcfd32..743cea85a 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -272,7 +272,9 @@ "No results found": "Результаты не найдены", "Nobody will be able to vote in this poll anymore.": "Никто больше не сможет голосовать в этом опросе.", "Nothing yet...": "Пока ничего нет...", + "Offline": "Не в сети", "Ok": "Ок", + "Online": "В сети", "Only numbers are allowed": "Разрешены только цифры", "Only visible to you": "Видно только вам", "Open emoji picker": "Открыть выбор смайлов", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 00e63f673..bfa0f166f 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -258,7 +258,9 @@ "No results found": "Sonuç bulunamadı", "Nobody will be able to vote in this poll anymore.": "Artık bu ankette kimse oy kullanamayacak.", "Nothing yet...": "Şimdilik hiçbir şey...", + "Offline": "Çevrimdışı", "Ok": "Tamam", + "Online": "Çevrimiçi", "Only numbers are allowed": "Sadece sayılar kullanılabilir", "Only visible to you": "Sadece sana görünür", "Open emoji picker": "Emoji klavyesini aç", diff --git a/src/styling/_utils.scss b/src/styling/_utils.scss index 298868634..34c72b717 100644 --- a/src/styling/_utils.scss +++ b/src/styling/_utils.scss @@ -99,6 +99,7 @@ @mixin header-text-layout { display: flex; flex-direction: column; + align-items: center; overflow-y: hidden; // for Edge overflow-x: hidden; // for ellipsis text flex: 1;