diff --git a/examples/vite/package.json b/examples/vite/package.json index 3a0f881e80..7db65c915e 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite --port 5175", + "dev": "vite --host --port 5173", "build": "tsc && vite build", "preview": "vite preview" }, diff --git a/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx b/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx index 50aff629f1..69dcd44e0e 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx +++ b/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx @@ -12,6 +12,7 @@ import { IconClock, IconExclamationMark, IconExclamationTriangleFill, + IconInfo, IconMinus, IconPlusSmall, IconRefresh, @@ -47,7 +48,7 @@ const severityIcons: Partial< Record> > = { error: IconExclamationMark, - info: IconExclamationMark, + info: IconInfo, loading: IconRefresh, success: IconCheckmark, warning: IconExclamationTriangleFill, diff --git a/examples/vite/src/AppSettings/AppSettings.scss b/examples/vite/src/AppSettings/AppSettings.scss index 80965713a5..2af6d8e961 100644 --- a/examples/vite/src/AppSettings/AppSettings.scss +++ b/examples/vite/src/AppSettings/AppSettings.scss @@ -730,6 +730,7 @@ .app__notification-dialog__duration-controls { display: flex; align-items: center; + flex-wrap: wrap; gap: 8px; .str-chat__form-input-field { @@ -968,37 +969,25 @@ height: min(80vh, 760px); background: var(--str-chat__background-core-elevation-2); color: var(--str-chat__text-primary); - border: 1px solid var(--str-chat__border-core-default); border-radius: 14px; - } - - .app__settings-modal__header { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 16px 20px; - font-size: 1.5rem; - font-weight: 700; - border-bottom: 1px solid var(--str-chat__border-core-default); - - svg.str-chat__icon--cog { - height: 1.75rem; - width: 1.75rem; - } + overflow: hidden; } .app__settings-modal__body { - display: grid; - grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); min-height: 0; height: 100%; } - .app__settings-modal__tabs { + .app__settings-modal__body .str-chat__section-navigator__navigation { overflow-y: auto; overscroll-behavior: contain; border-inline-end: 1px solid var(--str-chat__border-core-default); padding: 10px; + width: auto; + } + + .app__settings-modal__body .str-chat__section-navigator__navigation-item { + padding: 0; } .app__settings-modal__tab { @@ -1018,22 +1007,51 @@ font-weight: 600; } - .app__settings-modal__content { - overflow-y: auto; - overscroll-behavior: contain; - padding: 20px 24px; + .app__settings-modal__content-stack { + display: flex; + flex-direction: column; } - .app__settings-modal__content-stack { + .app__settings-modal__tab-header .str-chat__prompt__header__title { + color: var(--str-chat__text-primary); + font: var(--str-chat__font-heading-sm); + margin: 0; + } + + .app__settings-modal__tab-header .str-chat__prompt__header__description { + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); + margin: 0; + } + + .app__settings-modal__tab-header .str-chat__prompt__header__trailing-content { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xs); + } + + .app__settings-modal__tab-header .str-chat__prompt__header__close-button { + flex-shrink: 0; + color: var(--str-chat__text-primary); + } + + .app__settings-modal__tab-header + .str-chat__prompt__header__close-button + .str-chat__icon { + height: var(--str-chat__icon-size-sm); + width: var(--str-chat__icon-size-sm); + } + + .app__settings-modal__tab-body { display: flex; flex-direction: column; - gap: 20px; + gap: var(--str-chat__spacing-xl); } .app__settings-modal__field { display: flex; flex-direction: column; - gap: 10px; + gap: var(--str-chat__spacing-xs); } .app__settings-modal__field-label { @@ -1059,6 +1077,22 @@ flex-wrap: wrap; } + .app__settings-modal__action-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .app__settings-modal__action-row { + display: grid; + grid-template-columns: minmax(180px, 1fr) auto; + gap: 12px; + align-items: center; + border: 1px solid var(--str-chat__border-core-default); + border-radius: 10px; + padding: 10px 12px; + } + .app__settings-modal__option-button[aria-pressed='true'] { border-color: var(--str-chat__border-utility-selected); background: var(--str-chat__background-utility-selected); @@ -1117,16 +1151,7 @@ flex-direction: column; } - .app__settings-modal { - width: min(92vw, 680px); - } - - .app__settings-modal__body { - grid-template-columns: minmax(140px, 180px) minmax(0, 1fr); - } - - .app__settings-modal__tabs { - border-inline-end: 1px solid var(--str-chat__border-core-default); + .app__settings-modal__body .str-chat__section-navigator__navigation { border-bottom: 0; display: block; gap: 0; @@ -1135,4 +1160,20 @@ overflow-x: hidden; } } + + .app__settings-modal--inline { + width: 100dvw; + height: 100dvh; + max-width: none; + border-radius: 0; + box-shadow: none; + + .app__settings-modal__tab-header { + padding: var(--str-chat__spacing-xs); + } + + .app__settings-modal__tab-body { + padding: var(--str-chat__spacing-lg); + } + } } diff --git a/examples/vite/src/AppSettings/AppSettings.tsx b/examples/vite/src/AppSettings/AppSettings.tsx index 1b0fca31eb..a8a6f45b21 100644 --- a/examples/vite/src/AppSettings/AppSettings.tsx +++ b/examples/vite/src/AppSettings/AppSettings.tsx @@ -1,4 +1,5 @@ -import React, { type ComponentType, useState } from 'react'; +import { type ComponentType, useCallback, useMemo, useState } from 'react'; +import type { SectionNavigatorLayout } from 'stream-chat-react'; import { Button, ChatViewSelectorButton, @@ -6,9 +7,15 @@ import { IconBell, IconEmoji, IconMessageBubble, + IconMessageBubbles, + SECTION_NAVIGATOR_LAYOUT, + SectionNavigator, + type SectionNavigatorNavButtonProps, + type SectionNavigatorSection, } from 'stream-chat-react'; import { ActionsMenu } from './ActionsMenu'; +import { ChannelDetailTab } from './tabs/ChannelDetail'; import { GeneralTab } from './tabs/General'; import { MessageActionsTab } from './tabs/MessageActions'; import { NotificationsTab } from './tabs/Notifications'; @@ -22,17 +29,94 @@ import { IconSun, IconTextDirection, } from '../icons.tsx'; +import clsx from 'clsx'; -type TabId = 'general' | 'messageActions' | 'notifications' | 'reactions' | 'sidebar'; +type TabId = + | 'channelDetail' + | 'general' + | 'messageActions' + | 'notifications' + | 'reactions' + | 'sidebar'; -const tabConfig: { Icon: ComponentType; id: TabId; title: string }[] = [ - { Icon: IconGear, id: 'general', title: 'General' }, - { Icon: IconMessageBubble, id: 'messageActions', title: 'Message Actions' }, - { Icon: IconBell, id: 'notifications', title: 'Notifications' }, - { Icon: IconSidebar, id: 'sidebar', title: 'Sidebar' }, - { Icon: IconEmoji, id: 'reactions', title: 'Reactions' }, +type SettingsSectionConfig = { + Content: ComponentType; + Icon: ComponentType; + id: TabId; + title: string; +}; + +type SettingsTabContentProps = { + close: () => void; +}; + +const settingsSectionConfig: SettingsSectionConfig[] = [ + { Content: GeneralTab, Icon: IconGear, id: 'general', title: 'General' }, + { + Content: ChannelDetailTab, + Icon: IconMessageBubbles, + id: 'channelDetail', + title: 'Channel Detail', + }, + { + Content: MessageActionsTab, + Icon: IconMessageBubble, + id: 'messageActions', + title: 'Message Actions', + }, + { + Content: NotificationsTab, + Icon: IconBell, + id: 'notifications', + title: 'Notifications', + }, + { Content: SidebarTab, Icon: IconSidebar, id: 'sidebar', title: 'Sidebar' }, + { Content: ReactionsTab, Icon: IconEmoji, id: 'reactions', title: 'Reactions' }, ]; +const createSettingsNavButton = ({ + Icon, + id, + title, +}: Pick) => { + const SettingsNavButton = ({ select, selected }: SectionNavigatorNavButtonProps) => ( + + ); + SettingsNavButton.displayName = `${id}SettingsNavButton`; + + return SettingsNavButton; +}; + +const createSettingsSectionContent = ({ + close, + Content, + id, +}: Pick & { + close: () => void; +}) => { + const SettingsSectionContent = () => ; + SettingsSectionContent.displayName = `${id}SettingsSectionContent`; + + return SettingsSectionContent; +}; + +const createSettingsSections = (close: () => void): SectionNavigatorSection[] => + settingsSectionConfig.map(({ Content, Icon, id, title }) => ({ + id, + NavButton: createSettingsNavButton({ Icon, id, title }), + SectionContent: createSettingsSectionContent({ close, Content, id }), + })); + const SidebarThemeToggle = ({ iconOnly = true }: { iconOnly?: boolean }) => { const { theme, @@ -88,8 +172,13 @@ const SidebarRtlToggle = ({ iconOnly = true }: { iconOnly?: boolean }) => { }; export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => { - const [activeTab, setActiveTab] = useState('general'); const [open, setOpen] = useState(false); + const closeSettingsModal = useCallback(() => setOpen(false), []); + const settingsSections = useMemo( + () => createSettingsSections(closeSettingsModal), + [closeSettingsModal], + ); + const [layout, setLayout] = useState(); return (
@@ -103,46 +192,17 @@ export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => { onClick={() => setOpen(true)} text='Settings' /> - setOpen(false)} open={open}> -
-
- - Settings -
-
- -
- {activeTab === 'general' && } - {activeTab === 'messageActions' && } - {activeTab === 'notifications' && } - {activeTab === 'sidebar' && } - {activeTab === 'reactions' && } -
-
+ +
+
diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index 1a69a002b3..9cd77495d4 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -30,6 +30,23 @@ export type MessageActionsSettingsState = { }; }; +export type ChannelMembersHeaderActionForm = 'menu' | 'quick'; +export type ChannelMembersHeaderActionId = 'addMembers' | 'removeMembers'; + +export type ChannelDetailSettingsState = { + modal: { + channelMembersView: { + headerActions: Record< + ChannelMembersHeaderActionId, + { + enabled: boolean; + form: ChannelMembersHeaderActionForm; + } + >; + }; + }; +}; + export const LEFT_PANEL_MIN_WIDTH = 260; export const THREAD_PANEL_MIN_WIDTH = 260; @@ -53,6 +70,7 @@ export type MessageListSettingsState = { }; export type AppSettingsState = { + channelDetail: ChannelDetailSettingsState; chatView: ChatViewSettingsState; messageActions: MessageActionsSettingsState; messageList: MessageListSettingsState; @@ -79,6 +97,22 @@ const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; const defaultAppSettingsState: AppSettingsState = { + channelDetail: { + modal: { + channelMembersView: { + headerActions: { + addMembers: { + enabled: true, + form: 'quick', + }, + removeMembers: { + enabled: false, + form: 'menu', + }, + }, + }, + }, + }, chatView: { iconOnly: true, }, diff --git a/examples/vite/src/AppSettings/tabs/ChannelDetail/ChannelDetailTab.tsx b/examples/vite/src/AppSettings/tabs/ChannelDetail/ChannelDetailTab.tsx new file mode 100644 index 0000000000..3b181d8957 --- /dev/null +++ b/examples/vite/src/AppSettings/tabs/ChannelDetail/ChannelDetailTab.tsx @@ -0,0 +1,121 @@ +import { Button, SwitchField } from 'stream-chat-react'; + +import { + appSettingsStore, + type ChannelMembersHeaderActionForm, + type ChannelMembersHeaderActionId, + useAppSettingsState, +} from '../../state'; +import { + SettingsTabBody, + SettingsTabLayoutHeader, +} from '../SettingsTabLayoutComponents.tsx'; +import { channelMembersHeaderActionLabels } from './channelDetailSettings'; + +const channelMembersHeaderActionIds: ChannelMembersHeaderActionId[] = [ + 'addMembers', + 'removeMembers', +]; + +const channelMembersHeaderActionForms: ChannelMembersHeaderActionForm[] = [ + 'quick', + 'menu', +]; + +const getChannelMembersHeaderActionFormLabel = (form: ChannelMembersHeaderActionForm) => + form === 'quick' ? 'Quick' : 'Menu'; + +type ChannelDetailTabProps = { + close: () => void; +}; + +export const ChannelDetailTab = ({ close }: ChannelDetailTabProps) => { + const { + channelDetail, + channelDetail: { + modal: { + channelMembersView: { headerActions }, + }, + }, + } = useAppSettingsState(); + + const updateChannelMembersHeaderAction = ( + type: ChannelMembersHeaderActionId, + update: Partial<(typeof headerActions)[ChannelMembersHeaderActionId]>, + ) => { + appSettingsStore.partialNext({ + channelDetail: { + ...channelDetail, + modal: { + ...channelDetail.modal, + channelMembersView: { + ...channelDetail.modal.channelMembersView, + headerActions: { + ...headerActions, + [type]: { + ...headerActions[type], + ...update, + }, + }, + }, + }, + }, + }); + }; + + return ( +
+ + +
+
+ Channel members view actions +
+ +
+ {channelMembersHeaderActionIds.map((type) => { + const action = headerActions[type]; + + return ( +
+ + updateChannelMembersHeaderAction(type, { + enabled: event.target.checked, + }) + } + title={channelMembersHeaderActionLabels[type]} + > + +
+ {channelMembersHeaderActionForms.map((form) => ( + + ))} +
+
+ ); + })} +
+
+
+
+ ); +}; diff --git a/examples/vite/src/AppSettings/tabs/ChannelDetail/channelDetailSettings.ts b/examples/vite/src/AppSettings/tabs/ChannelDetail/channelDetailSettings.ts new file mode 100644 index 0000000000..48518a4ab9 --- /dev/null +++ b/examples/vite/src/AppSettings/tabs/ChannelDetail/channelDetailSettings.ts @@ -0,0 +1,63 @@ +import { + type ChannelMembersHeaderActionItem, + DefaultChannelMembersHeaderActions, +} from 'stream-chat-react/channel-detail'; + +import type { + ChannelDetailSettingsState, + ChannelMembersHeaderActionId, +} from '../../state'; + +export const channelMembersHeaderActionLabels: Record< + ChannelMembersHeaderActionId, + string +> = { + addMembers: 'Add members', + removeMembers: 'Remove members', +}; + +export const getChannelMembersHeaderActionSet = ( + channelDetail: ChannelDetailSettingsState, +): ChannelMembersHeaderActionItem[] => { + const { headerActions } = channelDetail.modal.channelMembersView; + const actionSet: ChannelMembersHeaderActionItem[] = []; + + (Object.keys(headerActions) as ChannelMembersHeaderActionId[]).forEach((type) => { + const action = headerActions[type]; + + if (!action.enabled) return; + + switch (type) { + case 'addMembers': + actionSet.push( + action.form === 'quick' + ? { + quick: DefaultChannelMembersHeaderActions.AddMembers, + type, + } + : { + menu: DefaultChannelMembersHeaderActions.AddMembersMenu, + type, + }, + ); + break; + case 'removeMembers': + actionSet.push( + action.form === 'quick' + ? { + quick: DefaultChannelMembersHeaderActions.RemoveMembers, + type, + } + : { + menu: DefaultChannelMembersHeaderActions.RemoveMembersMenu, + type, + }, + ); + break; + default: + break; + } + }); + + return actionSet; +}; diff --git a/examples/vite/src/AppSettings/tabs/ChannelDetail/index.ts b/examples/vite/src/AppSettings/tabs/ChannelDetail/index.ts new file mode 100644 index 0000000000..611b003782 --- /dev/null +++ b/examples/vite/src/AppSettings/tabs/ChannelDetail/index.ts @@ -0,0 +1,2 @@ +export * from './ChannelDetailTab'; +export * from './channelDetailSettings'; diff --git a/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx b/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx index e32ceeb928..7b91cc709e 100644 --- a/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx +++ b/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx @@ -1,7 +1,15 @@ import { Button } from 'stream-chat-react'; import { appSettingsStore, useAppSettingsState } from '../../state'; +import { + SettingsTabBody, + SettingsTabLayoutHeader, +} from '../SettingsTabLayoutComponents.tsx'; -export const GeneralTab = () => { +type GeneralTabProps = { + close: () => void; +}; + +export const GeneralTab = ({ close }: GeneralTabProps) => { const { messageList, theme, @@ -10,60 +18,68 @@ export const GeneralTab = () => { return (
-
-
Text direction
-
- - + + + +
+
Text direction
+
+ + +
-
-
-
Message list
-
- - +
+
Message list
+
+ + +
-
+
); }; diff --git a/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx b/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx index 76c21d5060..8f76d0876e 100644 --- a/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx +++ b/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx @@ -1,7 +1,15 @@ import { SwitchField } from 'stream-chat-react'; import { appSettingsStore, useAppSettingsState } from '../../state'; +import { + SettingsTabBody, + SettingsTabLayoutHeader, +} from '../SettingsTabLayoutComponents.tsx'; -export const MessageActionsTab = () => { +type MessageActionsTabProps = { + close: () => void; +}; + +export const MessageActionsTab = ({ close }: MessageActionsTabProps) => { const { messageActions, messageActions: { customMessageActions }, @@ -9,75 +17,80 @@ export const MessageActionsTab = () => { return (
-
-
Delete message
- - appSettingsStore.partialNext({ - messageActions: { - ...messageActions, - customMessageActions: { - ...customMessageActions, - delete: { - enableOptionConfiguration: event.target.checked, + + + +
+
Delete message
+ + appSettingsStore.partialNext({ + messageActions: { + ...messageActions, + customMessageActions: { + ...customMessageActions, + delete: { + enableOptionConfiguration: event.target.checked, + }, }, }, - }, - }) - } - > - Enabled option configuration - -
- It enables to configure delete request params in the Delete Message Alert like - “Delete only for me”,{' '} - “Hard delete”. + }) + } + title='Enabled option configuration' + /> +
+ It enables to configure delete request params in the Delete Message Alert like + “Delete only for me”,{' '} + “Hard delete”. +
-
-
-
Mark as unread
- - appSettingsStore.partialNext({ - messageActions: { - ...messageActions, - customMessageActions: { - ...customMessageActions, - markOwnUnread: event.target.checked, +
+
Mark as unread
+ + appSettingsStore.partialNext({ + messageActions: { + ...messageActions, + customMessageActions: { + ...customMessageActions, + markOwnUnread: event.target.checked, + }, }, - }, - }) - } - > - Mark own messages as unread too - -
+ }) + } + title='Mark own messages as unread too' + /> +
-
-
View message info
- - appSettingsStore.partialNext({ - messageActions: { - ...messageActions, - customMessageActions: { - ...customMessageActions, - viewMessageInfo: event.target.checked, +
+
View message info
+ + appSettingsStore.partialNext({ + messageActions: { + ...messageActions, + customMessageActions: { + ...customMessageActions, + viewMessageInfo: event.target.checked, + }, }, - }, - }) - } - > - Show JSON viewer action in the message actions menu - -
+ }) + } + title='Show JSON viewer action in the message actions menu' + /> +
+
); }; diff --git a/examples/vite/src/AppSettings/tabs/Notifications/NotificationsTab.tsx b/examples/vite/src/AppSettings/tabs/Notifications/NotificationsTab.tsx index 468b5a1426..d9f919f120 100644 --- a/examples/vite/src/AppSettings/tabs/Notifications/NotificationsTab.tsx +++ b/examples/vite/src/AppSettings/tabs/Notifications/NotificationsTab.tsx @@ -1,7 +1,15 @@ import { Button } from 'stream-chat-react'; import { appSettingsStore, useAppSettingsState } from '../../state'; +import { + SettingsTabBody, + SettingsTabLayoutHeader, +} from '../SettingsTabLayoutComponents.tsx'; -export const NotificationsTab = () => { +type NotificationsTabProps = { + close: () => void; +}; + +export const NotificationsTab = ({ close }: NotificationsTabProps) => { const { notifications, notifications: { verticalAlignment }, @@ -9,33 +17,41 @@ export const NotificationsTab = () => { return (
-
-
Vertical alignment
-
- - + + + +
+
Vertical alignment
+
+ + +
-
+
); }; diff --git a/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx b/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx index 7a2ad54cd6..f71a10ef0d 100644 --- a/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx +++ b/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx @@ -7,6 +7,10 @@ import { useComponentContext, } from 'stream-chat-react'; import { appSettingsStore, useAppSettingsState } from '../../state'; +import { + SettingsTabBody, + SettingsTabLayoutHeader, +} from '../SettingsTabLayoutComponents.tsx'; import { reactionsPreviewChannelActions, reactionsPreviewChannelState, @@ -14,120 +18,132 @@ import { reactionsPreviewOptions, } from './reactionsExampleData'; -export const ReactionsTab = () => { +type ReactionsTabProps = { + close: () => void; +}; + +export const ReactionsTab = ({ close }: ReactionsTabProps) => { const state = useAppSettingsState(); const { reactions } = state; const componentContext = useComponentContext(); return (
-
-
Visual style
-
- - + + + +
+
Visual style
+
+ + +
-
-
-
Vertical position
-
- - +
+
Vertical position
+
+ + +
-
-
-
Horizontal alignment
-
- - +
+
Horizontal alignment
+
+ + +
-
-
-
Preview
-
- - - -
  • - -
  • -
    -
    -
    +
    +
    Preview
    +
    + + + +
  • + +
  • +
    +
    +
    +
    -
    +
    ); }; diff --git a/examples/vite/src/AppSettings/tabs/SettingsTabLayoutComponents.tsx b/examples/vite/src/AppSettings/tabs/SettingsTabLayoutComponents.tsx new file mode 100644 index 0000000000..a12ab19521 --- /dev/null +++ b/examples/vite/src/AppSettings/tabs/SettingsTabLayoutComponents.tsx @@ -0,0 +1,26 @@ +import { Prompt, SectionNavigatorHeader } from 'stream-chat-react'; +import { type ComponentProps } from 'react'; +import clsx from 'clsx'; + +type SettingsTabHeaderProps = { + close: () => void; + description: string; + title: string; +}; + +export const SettingsTabLayoutHeader = ({ + close, + description, + title, +}: SettingsTabHeaderProps) => ( + +); + +export const SettingsTabBody = ({ className, ...props }: ComponentProps<'div'>) => ( + +); diff --git a/examples/vite/src/AppSettings/tabs/Sidebar/SidebarTab.tsx b/examples/vite/src/AppSettings/tabs/Sidebar/SidebarTab.tsx index 62b793a3c2..b83fe7238b 100644 --- a/examples/vite/src/AppSettings/tabs/Sidebar/SidebarTab.tsx +++ b/examples/vite/src/AppSettings/tabs/Sidebar/SidebarTab.tsx @@ -1,7 +1,15 @@ import { Button } from 'stream-chat-react'; import { appSettingsStore, useAppSettingsState } from '../../state'; +import { + SettingsTabBody, + SettingsTabLayoutHeader, +} from '../SettingsTabLayoutComponents.tsx'; -export const SidebarTab = () => { +type SidebarTabProps = { + close: () => void; +}; + +export const SidebarTab = ({ close }: SidebarTabProps) => { const { chatView, chatView: { iconOnly }, @@ -9,33 +17,40 @@ export const SidebarTab = () => { return (
    -
    -
    Label visibility
    -
    - - + + +
    +
    Label visibility
    +
    + + +
    -
    +
    ); }; diff --git a/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx b/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx new file mode 100644 index 0000000000..baa0c7e95a --- /dev/null +++ b/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx @@ -0,0 +1,45 @@ +import { useMemo } from 'react'; +import { + type SectionNavigatorSection, + type SectionNavigatorSectionContentProps, +} from 'stream-chat-react'; +import { + AvatarWithChannelDetail, + type AvatarWithChannelDetailProps, + ChannelDetail, + type ChannelDetailProps, + ChannelMembersView, + defaultChannelDetailSections, +} from 'stream-chat-react/channel-detail'; + +import { useAppSettingsSelector } from '../AppSettings/state'; +import { getChannelMembersHeaderActionSet } from '../AppSettings/tabs/ChannelDetail'; + +const ConfiguredChannelDetail = (props: ChannelDetailProps) => { + const channelDetail = useAppSettingsSelector((state) => state.channelDetail); + const headerActionSet = useMemo( + () => getChannelMembersHeaderActionSet(channelDetail), + [channelDetail], + ); + const sections = useMemo( + () => [ + ...defaultChannelDetailSections.map((section) => + section.id !== 'channel-members' + ? section + : { + ...section, + SectionContent: (sectionProps: SectionNavigatorSectionContentProps) => ( + + ), + }, + ), + ], + [headerActionSet], + ); + + return ; +}; + +export const ConfiguredAvatarWithChannelDetail = ( + props: AvatarWithChannelDetailProps, +) => ; diff --git a/examples/vite/src/ChatLayout/Panels.tsx b/examples/vite/src/ChatLayout/Panels.tsx index 74e091abff..f1f479d679 100644 --- a/examples/vite/src/ChatLayout/Panels.tsx +++ b/examples/vite/src/ChatLayout/Panels.tsx @@ -24,6 +24,7 @@ import { } from 'stream-chat-react'; import { useAppSettingsSelector } from '../AppSettings/state'; +import { ConfiguredAvatarWithChannelDetail } from './ConfiguredChannelDetail.tsx'; import { DESKTOP_LAYOUT_BREAKPOINT } from './constants.ts'; import { SidebarResizeHandle, ThreadResizeHandle } from './Resize.tsx'; import { ReturnToSkipNavigation } from '../AccessibilityNavigation/ReturnToSkipNavigation.tsx'; @@ -75,7 +76,7 @@ const ResponsiveChannelPanels = () => { > - +
    {messageListType === 'virtualized' ? ( diff --git a/examples/vite/src/SystemNotification/SystemNotification.tsx b/examples/vite/src/SystemNotification/SystemNotification.tsx index ad2c1463e6..578ac0a6d0 100644 --- a/examples/vite/src/SystemNotification/SystemNotification.tsx +++ b/examples/vite/src/SystemNotification/SystemNotification.tsx @@ -6,13 +6,13 @@ import { IconCheckmark, IconExclamationCircleFill, IconExclamationTriangleFill, + IconInfo, IconLoading, useSystemNotifications, } from 'stream-chat-react'; -import { IconInfoCircle } from '../icons.tsx'; const IconsBySeverity: Record = { error: IconExclamationCircleFill, - info: IconInfoCircle, + info: IconInfo, loading: IconLoading, success: IconCheckmark, warning: IconExclamationTriangleFill, diff --git a/examples/vite/src/icons.tsx b/examples/vite/src/icons.tsx index 6de5b326bc..e9c497610d 100644 --- a/examples/vite/src/icons.tsx +++ b/examples/vite/src/icons.tsx @@ -46,15 +46,3 @@ export const IconTextDirection = createIcon( strokeLinejoin='round' />, ); - -export const IconInfoCircle = createIcon( - 'IconInfoCircle', - , -); diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index a7ec39b74d..922a276e3d 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -13,6 +13,7 @@ layer(stream-app-overrides); @import url('./AccessibilityNavigation/ReturnToSkipNavigation.scss') layer(stream-app-overrides); @import url('stream-chat-react/dist/css/emoji-picker.css') layer(stream-new-plugins); +@import url('stream-chat-react/dist/css/channel-detail.css') layer(stream-new-plugins); :root { font-synthesis: none; diff --git a/package.json b/package.json index e5d1c58f03..5b83038155 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,12 @@ "require": "./dist/cjs/index.js", "default": "./dist/cjs/index.js" }, + "./channel-detail": { + "types": "./dist/types/plugins/ChannelDetail/index.d.ts", + "import": "./dist/es/channel-detail.mjs", + "require": "./dist/cjs/channel-detail.js", + "default": "./dist/cjs/channel-detail.js" + }, "./emojis": { "types": "./dist/types/plugins/Emojis/index.d.ts", "import": "./dist/es/emojis.mjs", @@ -43,6 +49,9 @@ }, "typesVersions": { "*": { + "channel-detail": [ + "./dist/types/plugins/ChannelDetail/index.d.ts" + ], "emojis": [ "./dist/types/plugins/Emojis/index.d.ts" ], @@ -182,7 +191,7 @@ "scripts": { "clean": "rm -rf dist", "build": "yarn clean && concurrently 'yarn build-translations' 'vite build' 'tsc --project tsconfig.lib.json' 'yarn build-styling'", - "build-styling": "sass src/styling/index.scss:dist/css/index.css src/styling/_emoji-replacement.scss:dist/css/emoji-replacement.css src/plugins/Emojis/styling/index.scss:dist/css/emoji-picker.css; cp -r src/styling/assets dist/css/assets", + "build-styling": "sass src/styling/index.scss:dist/css/index.css src/styling/_emoji-replacement.scss:dist/css/emoji-replacement.css src/plugins/Emojis/styling/index.scss:dist/css/emoji-picker.css src/plugins/ChannelDetail/styling/index.scss:dist/css/channel-detail.css; cp -r src/styling/assets dist/css/assets", "build-translations": "i18next-cli extract", "coverage": "vitest run --coverage", "lint": "yarn prettier --list-different && yarn eslint && yarn validate-translations", diff --git a/scripts/watch-styling.mjs b/scripts/watch-styling.mjs index 033a606db2..3d9b211446 100644 --- a/scripts/watch-styling.mjs +++ b/scripts/watch-styling.mjs @@ -19,6 +19,10 @@ const STYLE_ENTRYPOINTS = [ entryFile: path.join(SRC_DIR, 'plugins/Emojis/styling/index.scss'), outputFile: path.resolve('dist/css/emoji-picker.css'), }, + { + entryFile: path.join(SRC_DIR, 'plugins/ChannelDetail/styling/index.scss'), + outputFile: path.resolve('dist/css/channel-detail.css'), + }, ]; const SCSS_EXTENSION = '.scss'; const BUILD_DELAY_MS = 150; diff --git a/src/components/AudioPlayback/components/index.ts b/src/components/AudioPlayback/components/index.ts index ab375753b8..2d159bcfff 100644 --- a/src/components/AudioPlayback/components/index.ts +++ b/src/components/AudioPlayback/components/index.ts @@ -1,4 +1,5 @@ export * from './DurationDisplay'; +export * from './formatTime'; export * from './PlaybackRateButton'; export * from './ProgressBar'; export * from './WaveProgressBar'; diff --git a/src/components/Avatar/ChannelAvatar.tsx b/src/components/Avatar/ChannelAvatar.tsx index fcda9999b0..404b1d035d 100644 --- a/src/components/Avatar/ChannelAvatar.tsx +++ b/src/components/Avatar/ChannelAvatar.tsx @@ -15,7 +15,9 @@ export const ChannelAvatar = ({ ...sharedProps }: ChannelAvatarProps) => { const displayInfo = useMemo(() => { - if (displayMembers && displayMembers.length > 0) { + // Prefer the channel's own image; only derive the avatar from members when + // there is no channel imageUrl to display. + if (!imageUrl && displayMembers && displayMembers.length > 0) { return displayMembers; } diff --git a/src/components/Avatar/__tests__/GroupAvatar.test.tsx b/src/components/Avatar/__tests__/GroupAvatar.test.tsx index 97a3db0ad5..95d9d907fc 100644 --- a/src/components/Avatar/__tests__/GroupAvatar.test.tsx +++ b/src/components/Avatar/__tests__/GroupAvatar.test.tsx @@ -192,6 +192,21 @@ describe('ChannelAvatar', () => { expect(getByTestId('group-avatar')).toBeInTheDocument(); }); + it('should prefer channel imageUrl over displayMembers', () => { + const { getByTestId, getByTitle, queryByTestId } = render( + , + ); + expect(getByTestId('avatar')).toBeInTheDocument(); + expect(getByTestId('avatar-img')).toHaveAttribute('src', 'channel.png'); + expect(getByTitle('General')).toBeInTheDocument(); + expect(queryByTestId('group-avatar')).not.toBeInTheDocument(); + }); + it('should pass overflowCount to GroupAvatar', () => { const { getByTestId } = render( { + const { channel: contextChannel } = useChannelStateContext(); + const channel = channelOverride ?? contextChannel; + const [watchers, setWatchers] = useState(() => + Object.assign({}, channel?.state?.watchers ?? {}), + ); + + useEffect(() => { + setWatchers(Object.assign({}, channel?.state?.watchers ?? {})); + }, [channel]); + + useEffect(() => { + if (!enabled || !channel) return; + + const startSubscription = 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); + }); + }); + const stopSubscription = channel.on('user.watching.stop', (event) => { + setWatchers((prev) => { + if (!event.user?.id || !prev[event.user.id]) return prev; + + const next = Object.assign({}, prev); + delete next[event.user.id]; + return next; + }); + }); + + return () => { + startSubscription.unsubscribe(); + stopSubscription.unsubscribe(); + }; + }, [channel, enabled]); + + if (!enabled) return false; + + return Object.keys(watchers).length > 0; +}; diff --git a/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts b/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts index fb9cf67d2e..b6fa4fafa4 100644 --- a/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts +++ b/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts @@ -1,50 +1,44 @@ -import { useEffect, useState } from 'react'; -import type { ChannelState } from 'stream-chat'; - import { useChannelStateContext } from '../../../context/ChannelStateContext'; import { useChatContext } from '../../../context/ChatContext'; import { useTranslationContext } from '../../../context/TranslationContext'; +import { isDmChannel } from '../../../utils'; +import { useChannelHasMembersOnline } from './useChannelHasMembersOnline'; +import type { Channel } from 'stream-chat'; + +export type UseChannelHeaderOnlineStatusParams = { + channel?: Channel; + watcherCount?: number; +}; /** * Returns the channel header online status text (e.g. "Online", "Offline", or "X members, Y online"). * Returns null when the channel has no members (nothing to show). */ -export function useChannelHeaderOnlineStatus(): string | null { +export function useChannelHeaderOnlineStatus({ + channel: channelOverride, + watcherCount: watcherCountOverride, +}: UseChannelHeaderOnlineStatusParams = {}): string | null { const { t } = useTranslationContext(); const { client } = useChatContext(); - const { channel, watcherCount = 0 } = useChannelStateContext(); + const { channel: contextChannel, watcherCount: contextWatcherCount = 0 } = + useChannelStateContext(); + const channel = channelOverride ?? contextChannel; + const watcherCount = watcherCountOverride ?? contextWatcherCount; const { member_count: memberCount = 0 } = channel?.data || {}; - - // todo: we need reactive state for watchers in LLC - const [watchers, setWatchers] = useState(() => - 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]); + const isDirectMessagingChannel = isDmChannel({ + channel, + ownUserId: client.user?.id, + }); + const hasMembersOnline = useChannelHasMembersOnline({ + channel, + enabled: isDirectMessagingChannel, + }); 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'); + if (isDirectMessagingChannel) { + return hasMembersOnline ? t('Online') : t('Offline'); } - return `${t('{{ memberCount }} members', { memberCount })}, ${t('{{ watcherCount }} online', { watcherCount })}`; + return `${t('{{ memberCount }} members', { memberCount })} · ${t('{{ watcherCount }} online', { watcherCount })}`; } diff --git a/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx index 1afb3dbc2b..992b348c96 100644 --- a/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx +++ b/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx @@ -288,7 +288,7 @@ describe('ChannelListItemActionButtons defaults', () => { openDropdownMenu(); const unblockButton = screen.getByTestId('dropdown-action-ban'); - expect(unblockButton).toHaveTextContent('Unblock User'); + expect(unblockButton).toHaveTextContent('Unblock'); act(() => { fireEvent.click(unblockButton); diff --git a/src/components/ChannelListItem/hooks/index.ts b/src/components/ChannelListItem/hooks/index.ts index c7a445d5d3..c6f9e62c58 100644 --- a/src/components/ChannelListItem/hooks/index.ts +++ b/src/components/ChannelListItem/hooks/index.ts @@ -1,3 +1,4 @@ export { useChannelDisplayName } from './useChannelDisplayName'; export { useChannelPreviewInfo } from './useChannelPreviewInfo'; +export { useIsUserMuted } from './useIsUserMuted'; export { MessageDeliveryStatus } from './useMessageDeliveryStatus'; diff --git a/src/components/ChannelListItem/hooks/useChannelPreviewInfo.ts b/src/components/ChannelListItem/hooks/useChannelPreviewInfo.ts index 0183697b69..33e3234a5d 100644 --- a/src/components/ChannelListItem/hooks/useChannelPreviewInfo.ts +++ b/src/components/ChannelListItem/hooks/useChannelPreviewInfo.ts @@ -50,9 +50,14 @@ export const useChannelPreviewInfo = (props: ChannelPreviewInfoParams) => { }; updateInfo(); - client.on('user.updated', updateInfo); + const { unsubscribe: unsubscribeChannelUpdated } = channel.on( + 'channel.updated', + updateInfo, + ); + const { unsubscribe: unsubscribeUserUpdated } = client.on('user.updated', updateInfo); return () => { - client.off('user.updated', updateInfo); + unsubscribeChannelUpdated(); + unsubscribeUserUpdated(); }; }, [channel, channel?.data, client, overrideImage]); diff --git a/src/components/ChannelListItem/hooks/useIsUserMuted.ts b/src/components/ChannelListItem/hooks/useIsUserMuted.ts new file mode 100644 index 0000000000..2753ecbb94 --- /dev/null +++ b/src/components/ChannelListItem/hooks/useIsUserMuted.ts @@ -0,0 +1,12 @@ +import { useMemo } from 'react'; + +import { useChatContext } from '../../../context/ChatContext'; + +export const useIsUserMuted = (targetUserId?: string) => { + const { mutes } = useChatContext(); + + return useMemo( + () => !!targetUserId && mutes.some((mute) => mute.target.id === targetUserId), + [mutes, targetUserId], + ); +}; diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index e178b9c5cc..e73e22f025 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -9,7 +9,7 @@ import { } from 'stream-chat'; import { NotificationAnnouncer as DefaultNotificationAnnouncer } from '../Accessibility'; -import { useModalDialogIsOpen } from '../Dialog'; +import { useOpenedDialogCount } from '../Dialog'; import { getNotificationTargetPanels, NotificationConfigurationProvider, @@ -23,7 +23,11 @@ import type { CustomClasses } from '../../context/ChatContext'; import { ChatProvider } from '../../context/ChatContext'; import { useComponentContext } from '../../context/ComponentContext'; import { TranslationProvider } from '../../context/TranslationContext'; -import { type MessageContextValue, ModalDialogManagerProvider } from '../../context'; +import { + type MessageContextValue, + modalDialogManagerId, + ModalDialogManagerProvider, +} from '../../context'; import type { SupportedTranslations } from '../../i18n/types'; import type { Streami18n } from '../../i18n/Streami18n'; @@ -33,7 +37,7 @@ const NetworkConnectionNotificationReporter = () => { }; const createDefaultNotificationDisplayFilter = - (modalIsOpen: boolean): NotificationDisplayFilter => + (modalManagerHasOpenDialog: boolean): NotificationDisplayFilter => ({ notification, panel }) => { const targetPanels = getNotificationTargetPanels(notification); @@ -41,7 +45,7 @@ const createDefaultNotificationDisplayFilter = return panel === 'modal'; } - if (!modalIsOpen) return true; + if (!modalManagerHasOpenDialog) return true; return panel === 'modal'; }; @@ -52,11 +56,15 @@ const ModalNotificationConfiguration = ({ }: PropsWithChildren<{ notificationDisplayFilter?: NotificationDisplayFilter; }>) => { - const modalIsOpen = useModalDialogIsOpen(); + const openedModalDialogCount = useOpenedDialogCount({ + dialogManagerId: modalDialogManagerId, + }); + const modalManagerHasOpenDialog = openedModalDialogCount > 0; const displayFilter = useMemo( () => - notificationDisplayFilter ?? createDefaultNotificationDisplayFilter(modalIsOpen), - [modalIsOpen, notificationDisplayFilter], + notificationDisplayFilter ?? + createDefaultNotificationDisplayFilter(modalManagerHasOpenDialog), + [modalManagerHasOpenDialog, notificationDisplayFilter], ); return ( diff --git a/src/components/Chat/__tests__/Chat.test.tsx b/src/components/Chat/__tests__/Chat.test.tsx index 61927170dc..8f8aba14d7 100644 --- a/src/components/Chat/__tests__/Chat.test.tsx +++ b/src/components/Chat/__tests__/Chat.test.tsx @@ -8,6 +8,7 @@ import { Chat } from '..'; import { ChatContext, ComponentProvider, TranslationContext } from '../../../context'; import type { ChatContextValue } from '../../../context'; import { useNotificationConfigurationContext } from '../../Notifications'; +import { GlobalModal } from '../../Modal'; import { Streami18n } from '../../../i18n'; import type { Notification } from 'stream-chat'; import type { Mute } from 'stream-chat'; @@ -89,6 +90,40 @@ describe('Chat', () => { }); }); + it('routes notifications to the modal panel when a generated-id GlobalModal is open', async () => { + let displayFilter: ReturnType< + typeof useNotificationConfigurationContext + >['displayFilter']; + + await act(() => { + render( + + null }}> + + Modal content + + + { + displayFilter = filter; + }} + /> + , + ); + }); + + await waitFor(() => { + const channelNotification = notification(['target:channel']); + + expect(displayFilter({ notification: channelNotification, panel: 'channel' })).toBe( + false, + ); + expect(displayFilter({ notification: channelNotification, panel: 'modal' })).toBe( + true, + ); + }); + }); + it('should expose the context', async () => { let context: ChatContextValue; await act(() => { diff --git a/src/components/Dialog/components/Prompt.tsx b/src/components/Dialog/components/Prompt.tsx index 902949d023..eefd92616c 100644 --- a/src/components/Dialog/components/Prompt.tsx +++ b/src/components/Dialog/components/Prompt.tsx @@ -1,7 +1,7 @@ import React, { type ComponentProps, type PropsWithChildren } from 'react'; import clsx from 'clsx'; import { Button, type ButtonProps } from '../../Button'; -import { IconXmark } from '../../Icons'; +import { IconArrowLeft, IconXmark } from '../../Icons'; import { useModalContext, useTranslationContext } from '../../../context'; import { useAriaIdentifiers } from '../../../a11y/hooks/useAriaIdentifiers'; @@ -13,11 +13,14 @@ const PromptRoot = ({ children, className, ...props }: ComponentProps<'div'>) => export type PromptHeaderProps = { title: string; - description?: string; className?: string; close?: () => void; + description?: string; descriptionId?: string; + goBack?: () => void; + LeadingContent?: React.ComponentType; titleId?: string; + TrailingContent?: React.ComponentType; }; const PromptHeader = ({ @@ -25,8 +28,11 @@ const PromptHeader = ({ close, description, descriptionId, + goBack, + LeadingContent, title, titleId, + TrailingContent, }: PromptHeaderProps) => { const { t } = useTranslationContext(); const { dialogId } = useModalContext(); @@ -34,34 +40,61 @@ const PromptHeader = ({ useAriaIdentifiers(dialogId); const resolvedTitleId = titleId ?? derivedTitleId; const resolvedDescriptionId = descriptionId ?? derivedDescriptionId; + const hasDescription = description != null && description !== ''; return ( -
    -
    +
    + {LeadingContent && ( +
    + +
    + )} +
    + {goBack && ( + + )}

    {title}

    - {description != null && description !== '' && ( + {hasDescription && (

    {description}

    )}
    - {close && ( - + {(close || TrailingContent) && ( +
    + {TrailingContent && } + {close && ( + + )} +
    )}
    ); diff --git a/src/components/Dialog/service/DialogPortal.tsx b/src/components/Dialog/service/DialogPortal.tsx index fb381a412a..685a16d889 100644 --- a/src/components/Dialog/service/DialogPortal.tsx +++ b/src/components/Dialog/service/DialogPortal.tsx @@ -1,8 +1,13 @@ +import clsx from 'clsx'; import type { PropsWithChildren } from 'react'; import React, { useCallback, useEffect, useState } from 'react'; import { useDialogIsOpen, useOpenedDialogCount } from '../hooks'; import { Portal } from '../../Portal/Portal'; -import { useDialogManager, useNearestDialogManagerContext } from '../../../context'; +import { + modalDialogManagerId, + useDialogManager, + useNearestDialogManagerContext, +} from '../../../context'; const shouldCloseOnOutsideClick = ({ dialog, @@ -12,7 +17,15 @@ const shouldCloseOnOutsideClick = ({ managerCloseOnClickOutside: boolean; }) => dialog.closeOnClickOutside ?? managerCloseOnClickOutside; -export const DialogPortalDestination = () => { +export type DialogPortalDestinationProps = { + captureOutsideClicks?: boolean; + className?: string; +}; + +export const DialogPortalDestination = ({ + captureOutsideClicks, + className, +}: DialogPortalDestinationProps) => { const { dialogManager } = useNearestDialogManagerContext() ?? {}; const openedDialogCount = useOpenedDialogCount({ dialogManagerId: dialogManager?.id }); const [destinationRoot, setDestinationRoot] = useState(null); @@ -57,9 +70,18 @@ export const DialogPortalDestination = () => { if (!openedDialogCount) return null; + const isModalDialogManager = dialogManager?.id === modalDialogManagerId; + return (
    ({ function generateGeneralTypeToIconMap({ FileAltIcon, FileAudioIcon, + FileFallbackIcon, FileVideoIcon, -}: Pick, GeneralContentTypeComponent>) { +}: Pick, GeneralContentTypeComponent | 'FileFallbackIcon'>) { return { 'audio/': FileAudioIcon, + 'image/': FileFallbackIcon, 'text/': FileAltIcon, 'video/': FileVideoIcon, }; @@ -103,6 +105,7 @@ export const iconMap: IconMap = { ...generateGeneralTypeToIconMap({ FileAltIcon: fileIconSet.FileFallbackIcon, FileAudioIcon: fileIconSet.FileAudioIcon, + FileFallbackIcon: fileIconSet.FileFallbackIcon, FileVideoIcon: fileIconSet.FileVideoIcon, }), fallback: fileIconSet.FileFallbackIcon, diff --git a/src/components/Form/Checkbox.tsx b/src/components/Form/Checkbox.tsx new file mode 100644 index 0000000000..4fbbf70eb1 --- /dev/null +++ b/src/components/Form/Checkbox.tsx @@ -0,0 +1,19 @@ +import clsx from 'clsx'; +import type { HTMLAttributes } from 'react'; + +export type CheckboxProps = HTMLAttributes & { checked?: boolean }; + +export const Checkbox = ({ checked, ...props }: CheckboxProps) => ( +
    +); + +// fixme: remove str-chat__checkmark class with next major release v15 +export type CheckmarkProps = CheckboxProps; +// fixme: remove str-chat__checkmark class with next major release v15 +export const Checkmark = Checkbox; diff --git a/src/components/Form/SwitchField.tsx b/src/components/Form/SwitchField.tsx index 522091e774..9594242c7f 100644 --- a/src/components/Form/SwitchField.tsx +++ b/src/components/Form/SwitchField.tsx @@ -120,27 +120,41 @@ export const SwitchField = ({
    ); }; - export type SwitchProps = Omit, 'type'> & { on?: boolean; onSwitchClick?: MouseEventHandler; + /** + * Renders the switch as a visual-only indicator when another element owns interaction. + * Example: a button row with a trailing switch indicator must not render an input inside the button. + */ + presentation?: boolean; switchRef?: React.RefObject; }; -const Switch = ({ className, on, onSwitchClick, switchRef, ...props }: SwitchProps) => ( +export const Switch = ({ + className, + on, + onSwitchClick, + presentation, + switchRef, + ...props +}: SwitchProps) => (
    - + {!presentation && ( + + )}
    ); diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts index a5e7590345..1b4ba1b9ce 100644 --- a/src/components/Form/index.ts +++ b/src/components/Form/index.ts @@ -1,3 +1,4 @@ +export * from './Checkbox'; export * from './Dropdown'; export * from './FieldError'; export * from './NumericInput'; diff --git a/src/components/Form/styling/Checkbox.scss b/src/components/Form/styling/Checkbox.scss new file mode 100644 index 0000000000..bb5971a0ca --- /dev/null +++ b/src/components/Form/styling/Checkbox.scss @@ -0,0 +1,21 @@ +.str-chat__checkbox, +.str-chat__checkmark { + $poll-checkmark-size: var(--str-chat__size-24); + grid-column: 1; + grid-row: span 2; + height: $poll-checkmark-size; + width: $poll-checkmark-size; + border-radius: var(--str-chat__radius-max); + border: 1px solid + var(--chat-border-on-chat, var(--str-chat__chat-border-on-chat-incoming)); +} + +.str-chat__checkbox--checked, +.str-chat__checkmark--checked { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Im00IDkuNC00LTRMMS40IDQgNCA2LjYgMTAuNiAwIDEyIDEuNGwtOCA4WiIvPjwvc3ZnPg=='); + background-repeat: no-repeat; + background-position: center; + background-size: 11px 10px; + background-color: var(--str-chat__control-radio-check-bg-selected); + border: none; +} diff --git a/src/components/Form/styling/index.scss b/src/components/Form/styling/index.scss index f63bfc8a72..2978431d85 100644 --- a/src/components/Form/styling/index.scss +++ b/src/components/Form/styling/index.scss @@ -1,3 +1,4 @@ +@use 'Checkbox'; @use 'DropDown'; @use 'FieldError'; @use 'NumericInput'; diff --git a/src/components/Icons/icons.tsx b/src/components/Icons/icons.tsx index 2c6cc5267a..f24b3ab2c7 100644 --- a/src/components/Icons/icons.tsx +++ b/src/components/Icons/icons.tsx @@ -450,6 +450,18 @@ export const IconMore = createIcon( , ); +export const IconMenu = createIcon( + 'IconMenu', + , +); + // was: IconDotGrid2x3 export const IconReorder = createIcon( 'IconReorder', @@ -611,6 +623,14 @@ export const IconFlag = createIcon( />, ); +export const IconFolder = createIcon( + 'IconFolder', + , +); + export const IconImage = createIcon( 'IconImage', , ); +export const IconInfo = createIcon( + 'IconInfo', + <> + + + + , +); + // was: IconMagnifyingGlassSearch export const IconSearch = createIcon( 'IconSearch', diff --git a/src/components/ListItemLayout/ListItemLayout.tsx b/src/components/ListItemLayout/ListItemLayout.tsx new file mode 100644 index 0000000000..7acecf4a95 --- /dev/null +++ b/src/components/ListItemLayout/ListItemLayout.tsx @@ -0,0 +1,148 @@ +import clsx from 'clsx'; +import type { + ComponentProps, + ComponentType, + ElementType, + HTMLAttributes, + ReactNode, +} from 'react'; +import React from 'react'; + +export type ListItemLayoutRootElement = Extract< + keyof React.JSX.IntrinsicElements, + keyof HTMLElementTagNameMap +>; + +export type ListItemLayoutBaseProps = { + ContentSlot?: ComponentType; + contentClassName?: string; + description?: ReactNode; + descriptionClassName?: string; + destructive?: boolean; + LeadingIcon?: ComponentType; + LeadingSlot?: ComponentType; + selected?: boolean; + subtitle?: ReactNode; + subtitleClassName?: string; + title: ReactNode; + titleClassName?: string; + TrailingIcon?: ComponentType; + TrailingSlot?: ComponentType; +}; + +export type ListItemLayoutProps = + ListItemLayoutBaseProps & { + RootElement?: RootElement; + rootProps?: Omit, 'children'>; + }; + +export const ListItemLayout = ({ + ContentSlot = ListItemLayoutContent, + contentClassName, + description, + descriptionClassName, + destructive, + LeadingIcon, + LeadingSlot, + RootElement, + rootProps, + selected, + subtitle, + subtitleClassName, + title, + titleClassName, + TrailingIcon, + TrailingSlot, +}: ListItemLayoutProps) => { + // The inner container is the interactive element (button/anchor). The outer + // wrapper is a plain spacing buffer so the container's focus ring (2px offset) + // never overlaps adjacent items or gets clipped by an overflow ancestor. + const ContainerComponent = (RootElement ?? 'div') as ElementType< + HTMLAttributes + >; + const containerProps = { + ...(ContainerComponent === 'button' ? { type: 'button' } : undefined), + ...rootProps, + className: clsx( + 'str-chat__list-item-layout__container', + rootProps?.className, + destructive && 'str-chat__list-item-layout__container--destructive', + selected && 'str-chat__list-item-layout__container--selected', + ), + } as HTMLAttributes; + + return ( +
    + + {LeadingIcon && ( +
    + +
    + )} + {LeadingSlot && } + + {TrailingIcon && ( +
    + +
    + )} + {TrailingSlot && } +
    +
    + ); +}; + +export type ListItemLayoutContentProps = Omit, 'title'> & { + description?: ReactNode; + descriptionClassName?: string; + subtitle?: ReactNode; + subtitleClassName?: string; + title: ReactNode; + titleClassName?: string; +}; + +export const ListItemLayoutContent = ({ + className, + description, + descriptionClassName, + subtitle, + subtitleClassName, + title, + titleClassName, + ...props +}: ListItemLayoutContentProps) => ( +
    + {title && ( +
    + {title} +
    + )} + {subtitle && ( +
    + {subtitle} +
    + )} + {description && ( +
    + {description} +
    + )} +
    +); diff --git a/src/components/ListItemLayout/index.ts b/src/components/ListItemLayout/index.ts new file mode 100644 index 0000000000..496ff4dea0 --- /dev/null +++ b/src/components/ListItemLayout/index.ts @@ -0,0 +1 @@ +export * from './ListItemLayout'; diff --git a/src/components/ListItemLayout/styling/ListItemLayout.scss b/src/components/ListItemLayout/styling/ListItemLayout.scss new file mode 100644 index 0000000000..c80be714d8 --- /dev/null +++ b/src/components/ListItemLayout/styling/ListItemLayout.scss @@ -0,0 +1,135 @@ +@use '../../../styling/utils'; + +.str-chat__list-item-layout { + // Outer wrapper: a plain spacing buffer around the interactive container so + // the container's focus ring (2px offset) clears neighbouring items and any + // overflow/scroll ancestor. Carries the row's minimum height. + display: flex; + align-items: stretch; + width: 100%; + min-width: 0; + padding-inline: var(--str-chat__spacing-xxs); + + .str-chat__list-item-layout__container { + --list-item-padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm); + display: flex; + flex: 1; + align-items: center; + gap: var(--str-chat__spacing-sm); + min-width: 0; + text-align: start; + padding: var(--list-item-padding); + border-radius: var(--str-chat__radius-md); + } + + // Interactive baseline. `:where(button)` keeps this reset at the same low + // specificity as the bare container class so the state rules below (selected, + // destructive, disabled) reliably win — `:is(button)` would add element + // specificity and let `button-reset`'s `background: none` clobber them. + .str-chat__list-item-layout__container:where(button) { + @include utils.button-reset; + padding: var(--list-item-padding); + cursor: pointer; + + &:hover:not(:disabled) { + background: var(--str-chat__background-utility-hover); + } + + &:active:not(:disabled) { + background-color: var(--str-chat__background-utility-pressed); + } + + &:focus:not(:disabled) { + @include utils.focusable; + } + } + + .str-chat__list-item-layout__container.str-chat__form__switch-field { + padding: var(--list-item-padding); + } + + .str-chat__list-item-layout__container--selected { + background-color: var(--str-chat__background-utility-selected); + } + + .str-chat__list-item-layout__container--destructive { + color: var(--str-chat__accent-error); + + .str-chat__icon, + .str-chat__list-item-layout__title, + .str-chat__list-item-layout__subtitle, + .str-chat__list-item-layout__description { + color: var(--str-chat__accent-error); + } + } + + .str-chat__list-item-layout__container:disabled { + color: var(--str-chat__text-disabled); + + .str-chat__list-item-layout__title, + .str-chat__list-item-layout__subtitle, + .str-chat__list-item-layout__description { + color: var(--str-chat__text-disabled); + } + } + + .str-chat__list-item-layout__content { + flex: 1; + display: grid; + align-items: start; + grid-template-areas: + 'title' + 'description'; + grid-template-columns: minmax(0, 1fr); + justify-items: start; + min-width: 0; + } + + .str-chat__list-item-layout__content--withSubtitle { + grid-template-areas: + 'title description' + 'subtitle description'; + grid-template-columns: minmax(0, 1fr) auto; + column-gap: var(--str-chat__spacing-sm); + } + + .str-chat__list-item-layout__leading-icon, + .str-chat__list-item-layout__trailing-icon { + display: flex; + flex-shrink: 0; + } + + .str-chat__list-item-layout__description, + .str-chat__list-item-layout__title { + font: var(--str-chat__font-caption-default); + } + + .str-chat__list-item-layout__subtitle { + font: var(--str-chat__font-metadata-default); + } + + .str-chat__list-item-layout__title { + color: var(--str-chat__text-primary); + grid-area: title; + } + + .str-chat__list-item-layout__subtitle, + .str-chat__list-item-layout__description { + color: var(--str-chat__text-tertiary); + } + + .str-chat__list-item-layout__subtitle { + grid-area: subtitle; + } + + .str-chat__list-item-layout__description { + grid-area: description; + } + + .str-chat__list-item-layout__subtitle, + .str-chat__list-item-layout__description, + .str-chat__list-item-layout__title { + @include utils.ellipsis-text; + max-width: 100%; + } +} diff --git a/src/components/ListItemLayout/styling/index.scss b/src/components/ListItemLayout/styling/index.scss new file mode 100644 index 0000000000..a172d1df64 --- /dev/null +++ b/src/components/ListItemLayout/styling/index.scss @@ -0,0 +1 @@ +@use 'ListItemLayout'; diff --git a/src/components/MessageActions/MessageActions.defaults.tsx b/src/components/MessageActions/MessageActions.defaults.tsx index b0a6851e33..22daec1697 100644 --- a/src/components/MessageActions/MessageActions.defaults.tsx +++ b/src/components/MessageActions/MessageActions.defaults.tsx @@ -665,7 +665,7 @@ const DefaultMessageActionComponents = { return ( { @@ -677,7 +677,7 @@ const DefaultMessageActionComponents = { closeMenu(); }} > - {isBlocked ? t('Unblock User') : t('Block User')} + {isBlocked ? t('Unblock') : t('Block User')} ); }, diff --git a/src/components/MessageComposer/__tests__/MessageInput.test.tsx b/src/components/MessageComposer/__tests__/MessageInput.test.tsx index ae95552102..e550522fb6 100644 --- a/src/components/MessageComposer/__tests__/MessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageInput.test.tsx @@ -118,9 +118,9 @@ const cooldown = 30; const filename = 'some.txt'; const fileUploadUrl = 'http://www.getstream.io'; // real url, because ImageAttachmentPreview will try to load the image -const getImage = () => new File(['content'], filename, { type: 'image/png' }); +const getImage = () => new File(['SectionContent'], filename, { type: 'image/png' }); const getFile = (name = filename): File => - new File(['content'], name, { type: 'text/plain' }); + new File(['SectionContent'], name, { type: 'text/plain' }); // Polyfill DOMRect for jsdom if (typeof globalThis.DOMRect === 'undefined') { diff --git a/src/components/Modal/GlobalModal.tsx b/src/components/Modal/GlobalModal.tsx index b7bfff03d4..1e7025adf3 100644 --- a/src/components/Modal/GlobalModal.tsx +++ b/src/components/Modal/GlobalModal.tsx @@ -12,6 +12,7 @@ import { FocusScope } from '@react-aria/focus'; import { NotificationList as DefaultNotificationList } from '../Notifications'; import { + DialogManagerProvider, ModalContextProvider, modalDialogManagerId, useChatContext, @@ -41,6 +42,11 @@ export type ModalProps = { className?: string; /** Optional stable id for this modal instance. Generated automatically when omitted. */ dialogId?: string; + /** Properties forwarded to the root div within which the dialog content is rendered */ + dialogRootProps?: Omit< + ComponentProps<'div'>, + 'aria-label' | 'aria-labelledby' | 'aria-describedby' | 'role' + >; /** Accessible label for the modal dialog. Ignored when aria-labelledby is provided. */ 'aria-label'?: string; /** ID of the element that labels the modal dialog. */ @@ -65,6 +71,7 @@ export const GlobalModal = ({ className, CloseButtonOnOverlay, dialogId, + dialogRootProps, onClose, onCloseAttempt, open, @@ -80,6 +87,11 @@ export const GlobalModal = ({ const closingRef = useRef(false); const { theme } = useChatContext(); const { NotificationList = DefaultNotificationList } = useComponentContext(); + const { + className: dialogRootClassName, + onKeyDown: dialogRootOnKeyDown, + ...dialogRootPropsRest + } = dialogRootProps ?? {}; const dialogLabelingBaseId = dialog.id; const resolvedModalAriaProps = useResolvedModalAriaProps({ ariaDescribedby, @@ -122,6 +134,7 @@ export const GlobalModal = ({ }; const handleDialogKeyDown = (event: React.KeyboardEvent) => { + dialogRootOnKeyDown?.(event); if (event.defaultPrevented || event.key !== 'Escape' || !isTopmost) return; maybeClose('escape', event); }; @@ -158,17 +171,26 @@ export const GlobalModal = ({ >
    - {children} + + {children} +
    ); +const ModalContextMenu = () => { + const [referenceElement, setReferenceElement] = + React.useState(null); + const { dialog, dialogManager } = useDialogOnNearestManager({ + id: 'modal-context-menu', + }); + + return ( + <> + + + Menu action + + + ); +}; + const renderStackedModals = ({ childOnClose = vi.fn(), parentOnClose = vi.fn(), @@ -355,6 +384,50 @@ describe('GlobalModal', () => { expect(dialog).toHaveAttribute('aria-describedby', 'modal-description'); }); + it('forwards dialogRootProps to the dialog surface', () => { + renderComponent({ + props: { + 'aria-label': 'Modal label', + children: , + dialogRootProps: { + className: 'custom-dialog', + 'data-testid': 'dialog-root', + }, + open: true, + }, + }); + + const dialog = screen.getByRole('dialog', { name: 'Modal label' }); + + expect(dialog).toBe(screen.getByTestId('dialog-root')); + expect(dialog).toHaveClass('str-chat__modal__dialog'); + expect(dialog).toHaveClass('custom-dialog'); + }); + + it('lets dialogRootProps onKeyDown prevent the internal escape close', () => { + const onClose = vi.fn(); + const onKeyDown = vi.fn((event: React.KeyboardEvent) => { + event.preventDefault(); + }); + + renderComponent({ + props: { + 'aria-label': 'Modal label', + children: , + dialogRootProps: { onKeyDown }, + onClose, + open: true, + }, + }); + + fireEvent.keyDown(screen.getByRole('dialog', { name: 'Modal label' }), { + key: 'Escape', + }); + + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + it('falls back to aria-label when aria-labelledby is not provided', () => { renderComponent({ props: { @@ -531,6 +604,48 @@ describe('GlobalModal', () => { expect(dialog).toHaveAttribute('aria-modal', 'true'); }); + it('closes a context menu rendered above the modal without closing or demoting the modal', async () => { + renderComponent({ + props: { + 'aria-label': 'Modal with context menu', + children: ( + + + + ), + open: true, + }, + }); + + const modal = screen.getByRole('dialog', { name: 'Modal with context menu' }); + expect(modal).toHaveAttribute('aria-modal', 'true'); + + fireEvent.click(screen.getByRole('button', { name: 'Open context menu' })); + + expect( + await screen.findByRole('menu', { name: 'Modal context menu' }), + ).toBeInTheDocument(); + expect(modal).toHaveAttribute('aria-modal', 'true'); + expect(modal).not.toHaveAttribute('inert'); + + const floatingOverlay = document.querySelector( + '.str-chat__modal__floating-dialog-overlay', + ); + expect(floatingOverlay).toBeInTheDocument(); + + fireEvent.click(floatingOverlay as Element); + + await waitFor(() => { + expect( + screen.queryByRole('menu', { name: 'Modal context menu' }), + ).not.toBeInTheDocument(); + }); + + expect( + screen.getByRole('dialog', { name: 'Modal with context menu' }), + ).toHaveAttribute('aria-modal', 'true'); + }); + it('has no accessibility violations for modal semantics', async () => { renderComponent({ props: { diff --git a/src/components/Modal/styling/Modal.scss b/src/components/Modal/styling/Modal.scss index b0795b7ce7..f34f0c0998 100644 --- a/src/components/Modal/styling/Modal.scss +++ b/src/components/Modal/styling/Modal.scss @@ -46,6 +46,10 @@ background-color: var(--str-chat__modal-overlay-color); backdrop-filter: var(--str-chat__modal-overlay-backdrop-filter); + .str-chat__modal__floating-dialog-overlay { + z-index: 1; + } + .str-chat__modal__overlay__close-button { position: absolute; inset-inline-end: 10px; diff --git a/src/components/Notifications/hooks/__tests__/useNotificationApi.test.tsx b/src/components/Notifications/hooks/__tests__/useNotificationApi.test.tsx new file mode 100644 index 0000000000..b137c40b6f --- /dev/null +++ b/src/components/Notifications/hooks/__tests__/useNotificationApi.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { StateStore } from 'stream-chat'; + +import { GlobalModal } from '../../../Modal'; +import { useNotificationApi } from '../useNotificationApi'; +import { + ChatProvider, + ComponentProvider, + ModalDialogManagerProvider, +} from '../../../../context'; +import { mockChatContext } from '../../../../mock-builders'; + +import type { NotificationManagerState } from 'stream-chat'; + +describe('useNotificationApi', () => { + it('targets the modal panel when a generated-id GlobalModal is open', async () => { + const add = vi.fn(); + const store = new StateStore({ notifications: [] }); + const client = { + notifications: { + add, + store, + }, + }; + + const wrapper = ({ children }: React.PropsWithChildren) => ( + + null }}> + + + Modal content + + {children} + + + + ); + + const { result } = renderHook(() => useNotificationApi(), { wrapper }); + + await waitFor(() => { + result.current.addNotification({ + emitter: 'test', + message: 'Failed', + severity: 'error', + }); + + expect(add).toHaveBeenLastCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + tags: ['target:modal'], + }), + }), + ); + }); + }); +}); diff --git a/src/components/Notifications/hooks/useNotificationApi.ts b/src/components/Notifications/hooks/useNotificationApi.ts index 286ab979ea..849afcbf99 100644 --- a/src/components/Notifications/hooks/useNotificationApi.ts +++ b/src/components/Notifications/hooks/useNotificationApi.ts @@ -2,7 +2,6 @@ import { useCallback } from 'react'; import type { Notification, NotificationAction, NotificationSeverity } from 'stream-chat'; -import { modalDialogId } from '../../Dialog'; import { useChatContext, useModalDialogManager } from '../../../context'; import { useStateStore } from '../../../store'; import { @@ -14,8 +13,13 @@ import { useNotificationTarget } from './useNotificationTarget'; import type { DialogManagerState } from '../../Dialog/service/DialogManager'; -const modalDialogIsOpenSelector = ({ dialogsById }: DialogManagerState) => ({ - isOpen: !!dialogsById[modalDialogId]?.isOpen, +const modalDialogManagerStateSelector = ({ + dialogsById, + openedDialogIds, +}: DialogManagerState) => ({ + hasOpenDialog: openedDialogIds + ? openedDialogIds.length > 0 + : Object.values(dialogsById).some((dialog) => dialog.isOpen), }); /** Tag used for full-width system banners (e.g. connection status). Excluded from `NotificationList` by default. */ @@ -89,11 +93,11 @@ const getTargetTags = ( targetPanels: NotificationTargetPanel[] | undefined, inferredPanel: NotificationTargetPanel | undefined, tags: string[] | undefined, - modalIsOpen: boolean, + modalManagerHasOpenDialog: boolean, ) => { if (targetPanels) { const effectiveTargetPanels = - modalIsOpen && targetPanels.length > 0 + modalManagerHasOpenDialog && targetPanels.length > 0 ? [...targetPanels, 'modal' as const] : targetPanels; @@ -102,7 +106,7 @@ const getTargetTags = ( ); } - if (modalIsOpen) { + if (modalManagerHasOpenDialog) { return Array.from( new Set([ ...(inferredPanel ? [getNotificationTargetTag(inferredPanel)] : []), @@ -136,8 +140,9 @@ export const useNotificationApi = (): NotificationApi => { const { client } = useChatContext(); const inferredPanel = useNotificationTarget(); const modalDialogManager = useModalDialogManager(); - const modalIsOpen = - useStateStore(modalDialogManager?.state, modalDialogIsOpenSelector)?.isOpen ?? false; + const modalManagerHasOpenDialog = + useStateStore(modalDialogManager?.state, modalDialogManagerStateSelector) + ?.hasOpenDialog ?? false; const addNotification: AddNotification = useCallback( ({ @@ -157,7 +162,7 @@ export const useNotificationApi = (): NotificationApi => { targetPanels, inferredPanel, tags, - modalIsOpen, + modalManagerHasOpenDialog, ); const resolvedType = getTypeFromIncident({ incident, severity, type }); const origin = context ? { context, emitter } : { emitter }; @@ -177,7 +182,7 @@ export const useNotificationApi = (): NotificationApi => { origin, }); }, - [client, inferredPanel, modalIsOpen], + [client, inferredPanel, modalManagerHasOpenDialog], ); const addSystemNotification: AddSystemNotification = useCallback( diff --git a/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx b/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx index 4c0d81534f..53be789d2a 100644 --- a/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx +++ b/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx @@ -1,4 +1,3 @@ -import clsx from 'clsx'; import React, { useMemo, useRef, useState } from 'react'; import { NumericInput } from '../../Form/NumericInput'; import { SwitchField, SwitchFieldLabel } from '../../Form/SwitchField'; @@ -37,7 +36,7 @@ export const MultipleAnswersField = () => { const voteLimitSwitchLabelId = `${voteLimitSwitchId}-label`; return ( -
    +
    ( /> ); -export type CheckmarkProps = { checked?: boolean }; - -export const Checkmark = ({ checked }: CheckmarkProps) => ( -
    -); - type PollStateSelectorReturnValue = { is_closed: boolean | undefined; latest_votes_by_option: Record; @@ -134,7 +127,7 @@ export const PollOptionSelector = ({ role={isInteractive ? 'button' : undefined} tabIndex={isInteractive ? 0 : undefined} > - {canCastVote && } + {canCastVote && }

    {option.text}

    diff --git a/src/components/Poll/styling/PollCreationDialog.scss b/src/components/Poll/styling/PollCreationDialog.scss index 3b7c26cb64..a7ac19068a 100644 --- a/src/components/Poll/styling/PollCreationDialog.scss +++ b/src/components/Poll/styling/PollCreationDialog.scss @@ -21,6 +21,10 @@ align-items: center; } + .str-chat__form__switch-field { + padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-md); + } + .str-chat__form__input-field__value input, .str-chat__form__input-field__value.str-chat__form-text-input .str-chat__form-text-input__wrapper { diff --git a/src/components/Poll/styling/PollOptionList.scss b/src/components/Poll/styling/PollOptionList.scss index 7af07bc91f..95d8ba9fce 100644 --- a/src/components/Poll/styling/PollOptionList.scss +++ b/src/components/Poll/styling/PollOptionList.scss @@ -1,5 +1,3 @@ -$poll-checkmark-size: var(--str-chat__size-24); - .str-chat__poll-option-list { display: flex; flex-direction: column; @@ -20,25 +18,6 @@ $poll-checkmark-size: var(--str-chat__size-24); } } - .str-chat__checkmark { - grid-column: 1; - grid-row: span 2; - height: $poll-checkmark-size; - width: $poll-checkmark-size; - border-radius: var(--str-chat__radius-max); - border: 1px solid - var(--chat-border-on-chat, var(--str-chat__chat-border-on-chat-incoming)); - } - - .str-chat__checkmark--checked { - background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Im00IDkuNC00LTRMMS40IDQgNCA2LjYgMTAuNiAwIDEyIDEuNGwtOCA4WiIvPjwvc3ZnPg=='); - background-repeat: no-repeat; - background-position: center; - background-size: 11px 10px; - background-color: var(--str-chat__control-radio-check-bg-selected); - border: none; - } - .str-chat__poll-option-data { flex: 1; display: flex; diff --git a/src/components/SectionNavigator/SectionNavigator.tsx b/src/components/SectionNavigator/SectionNavigator.tsx new file mode 100644 index 0000000000..5d1a6c86d1 --- /dev/null +++ b/src/components/SectionNavigator/SectionNavigator.tsx @@ -0,0 +1,299 @@ +import clsx from 'clsx'; +import React, { + type ComponentProps, + type ComponentType, + createContext, + type HTMLAttributes, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +export type SectionNavigatorLayout = string; + +export const SECTION_NAVIGATOR_LAYOUT = { + inline: 'inline', + tabs: 'tabs', +} as const; + +export type SectionNavigatorRoute = { + id: string; +}; + +export type SectionNavigatorSectionContentProps = { + layout: SectionNavigatorLayout; +}; + +export type SectionNavigatorNavButtonProps = ComponentProps<'button'> & { + sectionId: string; + selected: boolean; + select: () => void; +}; + +export type SectionNavigatorSection = { + id: string; + NavButton: ComponentType; + SectionContent: ComponentType; +}; + +export type SectionNavigatorLayoutObserverFactory = ({ + element, + setLayout, + tabsLayoutMinWidth, +}: { + element: HTMLElement; + setLayout: (layout: SectionNavigatorLayout) => void; + tabsLayoutMinWidth: number; +}) => (() => void) | void; + +export type SectionNavigatorContextValue = { + layout: SectionNavigatorLayout; + history: SectionNavigatorRoute[]; + historyPop: () => void; + historyPush: (route: SectionNavigatorRoute) => void; + /** Whether the navigation drawer overlay is currently open (inline layout only). */ + isNavigationOpen: boolean; + /** Opens the navigation drawer overlay (inline layout). */ + openNavigation: () => void; + /** Closes the navigation drawer overlay (inline layout). */ + closeNavigation: () => void; +}; + +export type SectionNavigatorProps = HTMLAttributes & { + sections: SectionNavigatorSection[]; + createLayoutObserver?: SectionNavigatorLayoutObserverFactory; + defaultLayout?: SectionNavigatorLayout; + initialHistory?: SectionNavigatorRoute[]; + layout?: SectionNavigatorLayout; + /** Called whenever the resolved layout changes (and once on mount). */ + onLayoutChange?: (layout: SectionNavigatorLayout) => void; + tabsLayoutMinWidth?: number; +}; + +const DEFAULT_TABS_LAYOUT_MIN_WIDTH = 640; + +const defaultCreateLayoutObserver: SectionNavigatorLayoutObserverFactory = ({ + element, + setLayout, + tabsLayoutMinWidth, +}) => { + if (typeof ResizeObserver === 'undefined') return; + + const observedElement = element.parentElement ?? element; + const updateLayout = (width: number) => { + if (width <= 0) return; + + setLayout( + width < tabsLayoutMinWidth + ? SECTION_NAVIGATOR_LAYOUT.inline + : SECTION_NAVIGATOR_LAYOUT.tabs, + ); + }; + const observer = new ResizeObserver(([entry]) => { + updateLayout(entry.contentRect.width); + }); + + updateLayout(observedElement.getBoundingClientRect().width); + observer.observe(observedElement); + + return () => observer.disconnect(); +}; + +const defaultSectionNavigatorContextValue: SectionNavigatorContextValue = { + closeNavigation: () => undefined, + history: [], + historyPop: () => undefined, + historyPush: () => undefined, + isNavigationOpen: false, + layout: SECTION_NAVIGATOR_LAYOUT.tabs, + openNavigation: () => undefined, +}; + +const SectionNavigatorContext = createContext( + defaultSectionNavigatorContextValue, +); + +export const useSectionNavigatorContext = () => useContext(SectionNavigatorContext); + +const getCurrentRoute = (history: SectionNavigatorRoute[]) => history[history.length - 1]; + +export const SectionNavigator = ({ + className, + createLayoutObserver = defaultCreateLayoutObserver, + defaultLayout = SECTION_NAVIGATOR_LAYOUT.tabs, + initialHistory, + layout: controlledLayout, + onLayoutChange, + sections, + tabsLayoutMinWidth = DEFAULT_TABS_LAYOUT_MIN_WIDTH, + ...props +}: SectionNavigatorProps) => { + const rootRef = useRef(null); + const [internalLayout, setInternalLayout] = + useState(defaultLayout); + const [history, setHistory] = useState( + () => initialHistory ?? (sections[0] ? [{ id: sections[0].id }] : []), + ); + const [isNavigationOpen, setIsNavigationOpen] = useState(false); + const layout = controlledLayout ?? internalLayout; + const currentRoute = getCurrentRoute(history); + const currentSection = sections.find((section) => section.id === currentRoute?.id); + const activeSection = currentSection ?? sections[0]; + const isInlineLayout = layout === SECTION_NAVIGATOR_LAYOUT.inline; + const showDockedNavigation = !isInlineLayout || !currentSection; + + const openNavigation = useCallback(() => setIsNavigationOpen(true), []); + const closeNavigation = useCallback(() => setIsNavigationOpen(false), []); + + const historyPush = useCallback( + (route: SectionNavigatorRoute) => { + setHistory((history) => { + const currentRoute = getCurrentRoute(history); + + if (currentRoute?.id === route.id) return history; + if (layout === SECTION_NAVIGATOR_LAYOUT.tabs) return [route]; + + return [...history, route]; + }); + }, + [layout], + ); + + const historyPop = useCallback(() => { + setHistory((history) => (history.length > 1 ? history.slice(0, -1) : history)); + }, []); + + // The drawer overlay only exists in inline layout; close it whenever we leave + // inline so it cannot linger after a resize to a wider layout. + useEffect(() => { + if (!isInlineLayout) setIsNavigationOpen(false); + }, [isInlineLayout]); + + useEffect(() => { + if (!isNavigationOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') closeNavigation(); + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [closeNavigation, isNavigationOpen]); + + useEffect(() => { + onLayoutChange?.(layout); + }, [layout, onLayoutChange]); + + useEffect(() => { + if (controlledLayout) return; + if (!rootRef.current) return; + + return createLayoutObserver({ + element: rootRef.current, + setLayout: setInternalLayout, + tabsLayoutMinWidth, + }); + }, [controlledLayout, createLayoutObserver, tabsLayoutMinWidth]); + + useEffect(() => { + setHistory((history) => { + const currentRoute = getCurrentRoute(history); + const currentRouteHasSection = sections.some( + (section) => section.id === currentRoute?.id, + ); + + if (!currentRoute) return sections[0] ? [{ id: sections[0].id }] : []; + + if (currentRouteHasSection) return history; + + return sections[0] ? [{ id: sections[0].id }] : []; + }); + }, [sections]); + + const contextValue = useMemo( + () => ({ + closeNavigation, + history, + historyPop, + historyPush, + isNavigationOpen, + layout, + openNavigation, + }), + [ + closeNavigation, + history, + historyPop, + historyPush, + isNavigationOpen, + layout, + openNavigation, + ], + ); + + const Content = activeSection?.SectionContent; + + const navigation = ( +
    + {sections.map((section) => { + const NavButton = section.NavButton; + const selected = activeSection?.id === section.id; + + return ( +
    + { + historyPush({ id: section.id }); + closeNavigation(); + }} + selected={selected} + /> +
    + ); + })} +
    + ); + + return ( + +
    + {showDockedNavigation && navigation} + {Content && ( +
    + +
    + )} + {isInlineLayout && ( +
    +
    + )} +
    +
    + ); +}; diff --git a/src/components/SectionNavigator/SectionNavigatorHeader.tsx b/src/components/SectionNavigator/SectionNavigatorHeader.tsx new file mode 100644 index 0000000000..5f1bd52c9c --- /dev/null +++ b/src/components/SectionNavigator/SectionNavigatorHeader.tsx @@ -0,0 +1,44 @@ +import React, { useMemo } from 'react'; + +import { SECTION_NAVIGATOR_LAYOUT, useSectionNavigatorContext } from './SectionNavigator'; +import { useTranslationContext } from '../../context'; +import { Button } from '../Button'; +import { Prompt, type PromptHeaderProps } from '../Dialog'; +import { IconMenu } from '../Icons'; + +export type SectionNavigatorHeaderProps = Omit; + +/** + * Generic header for content rendered inside a `SectionNavigator`. It renders a + * `Prompt.Header` and, in the inline layout (where the navigation sidebar is not + * shown), prepends a hamburger button that opens the navigation drawer. The + * hamburger is omitted on nested views that already show a back button + * (`goBack`), where it would compete with the back affordance. + */ +export const SectionNavigatorHeader = (props: SectionNavigatorHeaderProps) => { + const { t } = useTranslationContext('SectionNavigatorHeader'); + const { layout, openNavigation } = useSectionNavigatorContext(); + + const MenuButton = useMemo(() => { + if (layout !== SECTION_NAVIGATOR_LAYOUT.inline) return undefined; + if (props.goBack) return undefined; + + return function SectionNavigatorHeaderMenuButton() { + return ( + + ); + }; + }, [layout, openNavigation, props.goBack, t]); + + return ; +}; diff --git a/src/components/SectionNavigator/__tests__/SectionNavigator.test.tsx b/src/components/SectionNavigator/__tests__/SectionNavigator.test.tsx new file mode 100644 index 0000000000..926491b403 --- /dev/null +++ b/src/components/SectionNavigator/__tests__/SectionNavigator.test.tsx @@ -0,0 +1,341 @@ +import React from 'react'; +import { afterEach, beforeEach, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { + SectionNavigator, + type SectionNavigatorNavButtonProps, + type SectionNavigatorSection, + useSectionNavigatorContext, +} from '../SectionNavigator'; + +const createNavButton = (label: string) => { + const NavButton = ({ select, selected }: SectionNavigatorNavButtonProps) => ( + + ); + + return NavButton; +}; + +const createContent = (label: string) => { + const Content = ({ layout }: { layout: string }) => { + const { history, historyPop } = useSectionNavigatorContext(); + + return ( +
    + {`${label} content ${layout}`} + {`history length ${history.length}`} + +
    + ); + }; + + return Content; +}; + +const createDrawerContent = (label: string) => { + const Content = () => { + const { closeNavigation, isNavigationOpen, layout, openNavigation } = + useSectionNavigatorContext(); + + return ( +
    + {`${label} content`} + {`layout ${layout}`} + {`open ${isNavigationOpen}`} + + +
    + ); + }; + + return Content; +}; + +const sections: SectionNavigatorSection[] = [ + { + id: 'media', + NavButton: createNavButton('Media nav'), + SectionContent: createContent('Media'), + }, + { + id: 'files', + NavButton: createNavButton('Files nav'), + SectionContent: createContent('Files'), + }, +]; + +const drawerSections: SectionNavigatorSection[] = [ + { + id: 'media', + NavButton: createNavButton('Media nav'), + SectionContent: createDrawerContent('Media'), + }, + { + id: 'files', + NavButton: createNavButton('Files nav'), + SectionContent: createDrawerContent('Files'), + }, +]; + +describe('SectionNavigator', () => { + const OriginalResizeObserver = globalThis.ResizeObserver; + let observedElements: Element[] = []; + let resizeObserverCallback: ResizeObserverCallback | undefined; + + beforeEach(() => { + observedElements = []; + resizeObserverCallback = undefined; + + globalThis.ResizeObserver = class MockResizeObserver implements ResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeObserverCallback = callback; + } + + disconnect = vi.fn(); + observe = vi.fn((target: Element) => { + observedElements.push(target); + }); + unobserve = vi.fn(); + }; + }); + + afterEach(() => { + globalThis.ResizeObserver = OriginalResizeObserver; + }); + + it('renders navigation and active section content in tabs layout', () => { + render(); + + expect(screen.getByText('Media nav')).toBeInTheDocument(); + expect(screen.getByText('Files nav')).toBeInTheDocument(); + expect(screen.getByText('Media content tabs')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Files nav')); + + expect(screen.getByText('Files content tabs')).toBeInTheDocument(); + expect(screen.queryByText('Media content tabs')).not.toBeInTheDocument(); + expect(screen.getByText('history length 1')).toBeInTheDocument(); + }); + + it('renders the first section content by default in inline layout', () => { + const { container } = render( + , + ); + + expect(container.querySelector('.str-chat__section-navigator')).toHaveAttribute( + 'data-layout', + 'inline', + ); + expect(screen.getByText('Media content inline')).toBeInTheDocument(); + expect(screen.getByText('history length 1')).toBeInTheDocument(); + }); + + it('pops back to the previous content in inline layout', () => { + const { container, rerender } = render( + , + ); + + fireEvent.click(screen.getByText('Back')); + + expect(screen.getByText('Media content inline')).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText('Files content inline')).toBeInTheDocument(); + expect(screen.getByText('history length 2')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Back')); + + expect(container.querySelector('.str-chat__section-navigator')).toHaveAttribute( + 'data-layout', + 'inline', + ); + expect(screen.queryByText('Files content inline')).not.toBeInTheDocument(); + expect(screen.getByText('Media content inline')).toBeInTheDocument(); + }); + + it('exposes a docked navigation in tabs layout and never opens the drawer overlay', () => { + const { container } = render( + , + ); + + expect(screen.getByText('layout tabs')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Open menu')); + + expect( + container.querySelector('.str-chat__section-navigator__navigation-overlay'), + ).not.toBeInTheDocument(); + }); + + const OVERLAY_SELECTOR = '.str-chat__section-navigator__navigation-overlay'; + const OVERLAY_OPEN_CLASS = 'str-chat__section-navigator__navigation-overlay--open'; + + it('opens a navigation drawer overlay in inline layout and closes it on selection', () => { + const { container } = render( + , + ); + const overlay = () => container.querySelector(OVERLAY_SELECTOR); + + expect(screen.getByText('layout inline')).toBeInTheDocument(); + // The overlay stays mounted in inline layout so it can animate; closed + // state is signalled by the absence of the `--open` modifier. + expect(overlay()).toBeInTheDocument(); + expect(overlay()).not.toHaveClass(OVERLAY_OPEN_CLASS); + + fireEvent.click(screen.getByText('Open menu')); + + expect(overlay()).toHaveClass(OVERLAY_OPEN_CLASS); + expect(screen.getByText('Media nav')).toBeInTheDocument(); + expect(screen.getByText('Files nav')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Files nav')); + + expect(screen.getByText('Files content')).toBeInTheDocument(); + expect(overlay()).not.toHaveClass(OVERLAY_OPEN_CLASS); + }); + + it('closes the navigation drawer when the scrim is clicked', () => { + const { container } = render( + , + ); + const overlay = () => container.querySelector(OVERLAY_SELECTOR); + + fireEvent.click(screen.getByText('Open menu')); + expect(overlay()).toHaveClass(OVERLAY_OPEN_CLASS); + + const scrim = container.querySelector( + '.str-chat__section-navigator__navigation-scrim', + ); + fireEvent.click(scrim as Element); + + expect(overlay()).not.toHaveClass(OVERLAY_OPEN_CLASS); + }); + + it('closes the navigation drawer on Escape', () => { + const { container } = render( + , + ); + const overlay = () => container.querySelector(OVERLAY_SELECTOR); + + fireEvent.click(screen.getByText('Open menu')); + expect(overlay()).toHaveClass(OVERLAY_OPEN_CLASS); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(overlay()).not.toHaveClass(OVERLAY_OPEN_CLASS); + }); + + it('lets a custom layout observer set the layout', () => { + const createLayoutObserver = vi.fn(({ setLayout }) => { + setLayout('inline'); + }); + + const { container } = render( + , + ); + + expect(createLayoutObserver).toHaveBeenCalledWith( + expect.objectContaining({ tabsLayoutMinWidth: 720 }), + ); + expect(container.querySelector('.str-chat__section-navigator')).toHaveAttribute( + 'data-layout', + 'inline', + ); + expect(screen.getByText('Media content inline')).toBeInTheDocument(); + }); + + it('observes parent width by default to avoid self-measurement feedback loops', () => { + render( +
    + +
    , + ); + + expect(observedElements[0]).toBe(screen.getByTestId('observer-parent')); + }); + + it('ignores zero-width observer entries before applying the resolved layout', () => { + const { container } = render( +
    + +
    , + ); + const root = () => container.querySelector('.str-chat__section-navigator'); + + expect(root()).toHaveAttribute('data-layout', 'tabs'); + + act(() => { + resizeObserverCallback?.( + [{ contentRect: { width: 0 } as DOMRectReadOnly } as ResizeObserverEntry], + {} as ResizeObserver, + ); + }); + + expect(root()).toHaveAttribute('data-layout', 'tabs'); + + act(() => { + resizeObserverCallback?.( + [{ contentRect: { width: 320 } as DOMRectReadOnly } as ResizeObserverEntry], + {} as ResizeObserver, + ); + }); + + expect(root()).toHaveAttribute('data-layout', 'inline'); + + act(() => { + resizeObserverCallback?.( + [{ contentRect: { width: 800 } as DOMRectReadOnly } as ResizeObserverEntry], + {} as ResizeObserver, + ); + }); + + expect(root()).toHaveAttribute('data-layout', 'tabs'); + }); + + it('uses tabsLayoutMinWidth to resolve the default observer layout', () => { + const { container } = render( +
    + +
    , + ); + const root = () => container.querySelector('.str-chat__section-navigator'); + + act(() => { + resizeObserverCallback?.( + [{ contentRect: { width: 640 } as DOMRectReadOnly } as ResizeObserverEntry], + {} as ResizeObserver, + ); + }); + + expect(root()).toHaveAttribute('data-layout', 'inline'); + + act(() => { + resizeObserverCallback?.( + [{ contentRect: { width: 960 } as DOMRectReadOnly } as ResizeObserverEntry], + {} as ResizeObserver, + ); + }); + + expect(root()).toHaveAttribute('data-layout', 'tabs'); + }); +}); diff --git a/src/components/SectionNavigator/__tests__/SectionNavigatorHeader.test.tsx b/src/components/SectionNavigator/__tests__/SectionNavigatorHeader.test.tsx new file mode 100644 index 0000000000..899c00d8e0 --- /dev/null +++ b/src/components/SectionNavigator/__tests__/SectionNavigatorHeader.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { SectionNavigatorHeader } from '../SectionNavigatorHeader'; + +const mocks = vi.hoisted(() => ({ + closeNavigation: vi.fn(), + layout: 'tabs', + openNavigation: vi.fn(), +})); + +vi.mock('../../../context', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useModalContext: () => ({ close: vi.fn(), dialogId: 'dialog-1' }), + useTranslationContext: () => ({ t: (key: string) => key }), + }; +}); + +vi.mock('../SectionNavigator', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useSectionNavigatorContext: () => ({ + closeNavigation: mocks.closeNavigation, + history: [], + historyPop: vi.fn(), + historyPush: vi.fn(), + isNavigationOpen: false, + layout: mocks.layout, + openNavigation: mocks.openNavigation, + }), + }; +}); + +describe('SectionNavigatorHeader', () => { + beforeEach(() => { + mocks.layout = 'tabs'; + mocks.openNavigation.mockClear(); + }); + + it('does not render the menu button in the tabs layout', () => { + render(); + + expect(screen.getByText('Files')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Open menu' })).not.toBeInTheDocument(); + }); + + it('renders a menu button that opens the navigation in the inline layout', () => { + mocks.layout = 'inline'; + + render(); + + const menuButton = screen.getByRole('button', { name: 'Open menu' }); + fireEvent.click(menuButton); + + expect(mocks.openNavigation).toHaveBeenCalledTimes(1); + }); + + it('does not render the menu button on nested views that allow going back', () => { + mocks.layout = 'inline'; + + render(); + + expect(screen.getByText('Member detail')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Open menu' })).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/SectionNavigator/index.ts b/src/components/SectionNavigator/index.ts new file mode 100644 index 0000000000..a3c390a6cf --- /dev/null +++ b/src/components/SectionNavigator/index.ts @@ -0,0 +1,2 @@ +export * from './SectionNavigator'; +export * from './SectionNavigatorHeader'; diff --git a/src/components/SectionNavigator/styling/SectionNavigator.scss b/src/components/SectionNavigator/styling/SectionNavigator.scss new file mode 100644 index 0000000000..b492a3d9f3 --- /dev/null +++ b/src/components/SectionNavigator/styling/SectionNavigator.scss @@ -0,0 +1,131 @@ +@use '../../../styling/utils'; + +.str-chat__section-navigator { + position: relative; + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + width: 100%; + height: 100%; +} + +.str-chat__section-navigator--inline { + .str-chat__prompt__header { + padding: var(--str-chat__spacing-md); + } +} + +.str-chat__section-navigator__navigation { + @include utils.hide-scrollbar(y); + overscroll-behavior: contain; + display: flex; + flex-direction: column; + flex-shrink: 0; + min-height: 0; + padding: var(--str-chat__spacing-xxs); + border-right: 1px solid var(--str-chat__border-core-subtle); + width: 200px; + align-self: stretch; +} + +.str-chat__section-navigator__navigation-item { + min-width: 0; + width: 100%; + padding: var(--str-chat__spacing-xxxs); + + .str-chat__section-navigator__navigation-item__nav-button { + padding-block: var(--str-chat__spacing-xs); + } +} + +.str-chat__section-navigator__content { + flex: 1; + min-height: 0; + min-width: 0; +} + +.str-chat__section-navigator__header-menu-button { + flex-shrink: 0; + align-self: center; +} + +.str-chat__prompt__header--withDescription + .str-chat__section-navigator__header-menu-button { + align-self: flex-start; +} + +.str-chat__section-navigator__navigation-overlay { + --str-chat__navigation-drawer-transition-duration: 280ms; + --str-chat__navigation-drawer-transition-easing: cubic-bezier(0.22, 1, 0.36, 1); + + position: absolute; + inset: 0; + z-index: 2; + display: flex; + + // Closed state: the overlay stays mounted so the drawer can animate out, but + // it is non-interactive and removed from the a11y/tab order. `visibility` is + // transitioned with a delay equal to the slide duration so the drawer remains + // visible while it slides away, then hides once the animation completes. + visibility: hidden; + pointer-events: none; + transition: visibility 0s linear var(--str-chat__navigation-drawer-transition-duration); +} + +.str-chat__section-navigator__navigation-overlay--open { + visibility: visible; + pointer-events: auto; + transition-delay: 0s; +} + +.str-chat__section-navigator__navigation-scrim { + position: absolute; + inset: 0; + border: none; + padding: 0; + cursor: pointer; + background: var(--str-chat__overlay-color, rgb(0 0 0 / 50%)); + backdrop-filter: blur(25px); + opacity: 0; + transition: opacity var(--str-chat__navigation-drawer-transition-duration) + var(--str-chat__navigation-drawer-transition-easing); + + .str-chat__section-navigator__navigation-overlay--open & { + opacity: 1; + } +} + +.str-chat__section-navigator__navigation-drawer { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + min-height: 0; + width: 75%; + max-width: 280px; + height: 100%; + background: var(--str-chat__background-core-elevation-1, #fff); + box-shadow: 0 4px 16px rgb(0 0 0 / 8%); + transform: translateX(-100%); + transition: transform var(--str-chat__navigation-drawer-transition-duration) + var(--str-chat__navigation-drawer-transition-easing); + will-change: transform; + + .str-chat__section-navigator__navigation-overlay--open & { + transform: translateX(0); + } + + .str-chat__section-navigator__navigation { + flex: 1; + width: 100%; + border-right: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .str-chat__section-navigator__navigation-scrim, + .str-chat__section-navigator__navigation-drawer { + transition: none; + } +} diff --git a/src/components/SectionNavigator/styling/index.scss b/src/components/SectionNavigator/styling/index.scss new file mode 100644 index 0000000000..ff22929958 --- /dev/null +++ b/src/components/SectionNavigator/styling/index.scss @@ -0,0 +1 @@ +@use 'SectionNavigator'; diff --git a/src/components/index.ts b/src/components/index.ts index 40b57938f7..781f88fbdb 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -23,6 +23,7 @@ export * from './Form'; export * from './Gallery'; export * from './Icons'; export * from './InfiniteScrollPaginator'; +export * from './ListItemLayout'; export * from './Loading'; export * from './LoadMore'; export * from './Location'; @@ -37,6 +38,7 @@ export * from './Notifications'; export * from './Poll'; export * from './Reactions'; export * from './SafeAnchor'; +export * from './SectionNavigator'; export * from './TextareaComposer'; export * from './Thread'; export * from './Threads'; diff --git a/src/context/DialogManagerContext.tsx b/src/context/DialogManagerContext.tsx index 74a49fabc3..b6350615b9 100644 --- a/src/context/DialogManagerContext.tsx +++ b/src/context/DialogManagerContext.tsx @@ -8,7 +8,10 @@ import React, { import { StateStore } from 'stream-chat'; import { DialogManager } from '../components/Dialog/service/DialogManager'; -import { DialogPortalDestination } from '../components/Dialog/service/DialogPortal'; +import { + DialogPortalDestination, + type DialogPortalDestinationProps, +} from '../components/Dialog/service/DialogPortal'; import type { PropsWithChildrenOnly } from '../types/types'; type DialogManagerId = string; @@ -56,6 +59,7 @@ type DialogManagerProviderProps = PropsWithChildren<{ * in this manager. When `false`, outside clicks do not dismiss dialogs. */ closeOnClickOutside?: boolean; + portalDestinationProps?: DialogPortalDestinationProps; id?: string; }>; @@ -66,6 +70,7 @@ export const DialogManagerProvider = ({ children, closeOnClickOutside, id, + portalDestinationProps, }: DialogManagerProviderProps) => { const [dialogManager, setDialogManager] = useState(() => { if (id) return getDialogManager(id) ?? null; @@ -87,7 +92,7 @@ export const DialogManagerProvider = ({ return ( {children} - + ); }; diff --git a/src/i18n/de.json b/src/i18n/de.json index 6aaad56b8e..a6c19b8759 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -3,6 +3,10 @@ "{{ commaSeparatedUsers }}, and {{ lastUser }}": "{{ commaSeparatedUsers }} und {{ lastUser }}", "{{ count }} files_one": "{{ count }} Datei", "{{ count }} files_other": "{{ count }} Dateien", + "{{ count }} members_one": "{{ count }} Mitglied", + "{{ count }} members_other": "{{ count }} Mitglieder", + "{{ count }} members added_one": "{{ count }} Mitglied hinzugefügt", + "{{ count }} members added_other": "{{ count }} Mitglieder hinzugefügt", "{{ count }} people are typing_one": "{{ count }} Person tippt", "{{ count }} people are typing_many": "{{ count }} Personen tippen", "{{ count }} people are typing_other": "{{ count }} Personen tippen", @@ -14,6 +18,8 @@ "{{ count }} videos_other": "{{ count }} Videos", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} und {{ secondUser }}", "{{ imageCount }} more": "{{ imageCount }} mehr", + "{{ member }} will be able to message you again.": "{{ member }} kann dir wieder Nachrichten senden.", + "{{ member }} won't be able to message you anymore.": "{{ member }} kann dir keine Nachrichten mehr senden.", "{{ memberCount }} members": "{{ memberCount }} Mitglieder", "{{ typing }} are typing": "{{ typing }} tippen", "{{ typing }} is typing": "{{ typing }} tippt", @@ -36,16 +42,24 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} hat erstellt: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} hat abgestimmt: {{pollOptionText}}", "📍Shared location": "📍Geteilter Standort", + "Actions": "Actions", + "Add": "Hinzufügen", + "Add {{ count }} members_one": "{{ count }} Mitglied hinzufügen", + "Add {{ count }} members_other": "{{ count }} Mitglieder hinzufügen", "Add a comment": "Einen Kommentar hinzufügen", "Add a comment to your poll answer": "Füge einen Kommentar zu deiner Umfrageantwort hinzu", "Add an option": "Eine Option hinzufügen", + "Add channel members": "Kanalmitglieder hinzufügen", + "Add members": "Mitglieder hinzufügen", "Add reaction": "Reaktion hinzufügen", + "Admin": "Administrator", "All results loaded": "Alle Ergebnisse geladen", "Allow access to camera": "Zugriff auf Kamera erlauben", "Allow access to microphone": "Zugriff auf Mikrofon erlauben", "Allow comments": "Kommentare erlauben", "Allow option suggestion": "Optionsvorschläge erlauben", "Allow others to add comments": "Anderen das Hinzufügen von Kommentaren erlauben", + "Already a member": "Bereits Mitglied", "Also send as a direct message": "Auch als Direktnachricht senden", "Also send in channel": "Auch im Kanal senden", "Also sent in channel": "Auch im Kanal gesendet", @@ -55,6 +69,7 @@ "Anonymous poll": "Anonyme Umfrage", "Archive": "Archivieren", "Are you sure you want to delete this message?": "Sind Sie sicher, dass Sie diese Nachricht löschen möchten?", + "Are you sure you want to leave this channel?": "Are you sure you want to leave this channel?", "aria/Attachment": "Anhang", "aria/Attachment Actions": "Anhangaktionen", "aria/Audio position {{ elapsed }} of {{ duration }}": "Audioposition {{ elapsed }} von {{ duration }}", @@ -65,6 +80,7 @@ "aria/Cancel Reply": "Antwort abbrechen", "aria/Cancel upload": "Upload abbrechen", "aria/Channel Actions": "Kanalaktionen", + "aria/Channel details": "Kanaldetails", "aria/Channel list": "Kanalliste", "aria/Channel search results": "Kanalsuchergebnisse", "aria/Chat view tabs": "Chat-Ansicht Tabs", @@ -104,10 +120,13 @@ "aria/Notifications": "Benachrichtigungen", "aria/Open Attachment Selector": "Anhang-Auswahl öffnen", "aria/Open Channel Actions Menu": "Kanalaktionsmenü öffnen", + "aria/Open channel details": "Kanaldetails öffnen", + "aria/Open image shared by {{ name }}": "Von {{ name }} geteiltes Bild öffnen", "aria/Open Menu": "Menü öffnen", "aria/Open Message Actions Menu": "Nachrichtenaktionsmenü öffnen", "aria/Open Reaction Selector": "Reaktionsauswahl öffnen", "aria/Open Thread": "Thread öffnen", + "aria/Open video shared by {{ name }}": "Von {{ name }} geteiltes Video öffnen", "aria/Pause": "Pausieren", "aria/Pause recording": "Aufnahme pausieren", "aria/Percent complete": "{{percent}} Prozent abgeschlossen", @@ -151,10 +170,15 @@ "Back": "Zurück", "ban-command-args": "[@Benutzername] [Text]", "ban-command-description": "Einen Benutzer verbannen", + "Block user": "Benutzer blockieren", "Block User": "Benutzer blockieren", + "Browse channel members": "Kanalmitglieder durchsuchen", + "Browse pinned messages": "Angeheftete Nachrichten durchsuchen", "Cancel": "Abbrechen", "Cannot seek in the recording": "In der Aufnahme kann nicht gesucht werden", + "Changes saved": "Änderungen gespeichert", "Channel archived": "Kanal archiviert", + "Channel members": "Kanalmitglieder", "Channel Missing": "Kanal fehlt", "Channel muted": "Kanal stummgeschaltet", "Channel pinned": "Kanal angeheftet", @@ -162,6 +186,7 @@ "Channel unmuted": "Stummschaltung des Kanals aufgehoben", "Channel unpinned": "Kanal nicht mehr angeheftet", "Channels": "Kanäle", + "Chat deleted": "Chat deleted", "Chats": "Chats", "Choose between 2 to 10 options": "Wähle zwischen 2 und 10 Optionen", "Close": "Schließen", @@ -173,12 +198,15 @@ "Commands": "Befehle", "Commands matching": "Übereinstimmende Befehle", "Connection failure, reconnecting now...": "Verbindungsfehler, Wiederherstellung der Verbindung...", + "Contact info": "Kontaktinfo", + "Contact name": "Kontaktname", "Copy Message": "Nachricht kopieren", "Create": "Erstellen", "Create a question, add options, and configure poll settings": "Erstelle eine Frage, füge Optionen hinzu und konfiguriere die Umfrageeinstellungen", "Create poll": "Umfrage erstellen", "Current location": "Aktueller Standort", "Delete": "Löschen", + "Delete chat": "Chat löschen", "Delete for me": "Für mich löschen", "Delete message": "Nachricht löschen", "Delivered": "Zugestellt", @@ -194,6 +222,10 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "Bearbeiten", + "Edit chat data": "Chatdaten bearbeiten", + "Edit contact": "Kontakt bearbeiten", + "Edit group": "Gruppe bearbeiten", "Edit Message": "Nachricht bearbeiten", "Edit message request failed": "Anfrage zum Bearbeiten der Nachricht fehlgeschlagen", "Edited": "Bearbeitet", @@ -206,16 +238,26 @@ "Enforce unique vote is enabled": "Eindeutige Abstimmung ist aktiviert", "Error": "Fehler", "Error adding flag": "Fehler beim Hinzufügen des Flags", + "Error adding members": "Error adding members", + "Error blocking user": "Fehler beim Blockieren des Benutzers", "Error connecting to chat, refresh the page to try again.": "Verbindungsfehler zum Chat, aktualisieren Sie die Seite, um es erneut zu versuchen.", + "Error deleting chat": "Error deleting chat", "Error deleting message": "Fehler beim Löschen der Nachricht", "Error fetching reactions": "Fehler beim Laden von Reaktionen", "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Fehler beim Markieren der Nachricht als ungelesen. Kann keine älteren ungelesenen Nachrichten markieren als die neuesten 100 Kanalnachrichten.", "Error muting a user ...": "Fehler beim Stummschalten eines Nutzers.", + "Error muting channel": "Fehler beim Stummschalten des Kanals", + "Error muting user": "Fehler beim Stummschalten des Benutzers", + "Error opening direct message": "Fehler beim Öffnen der Direktnachricht", "Error pinning message": "Fehler beim Pinnen der Nachricht", "Error removing message pin": "Fehler beim Entfernen der gepinnten Nachricht", + "Error removing user": "Fehler beim Entfernen des Benutzers", "Error reproducing the recording": "Fehler bei der Wiedergabe der Aufnahme", "Error starting recording": "Fehler beim Starten der Aufnahme", + "Error unblocking user": "Fehler beim Entsperren des Benutzers", "Error unmuting a user ...": "Fehler beim Aufheben der Stummschaltung eines Nutzers ...", + "Error unmuting channel": "Fehler beim Aufheben der Kanal-Stummschaltung", + "Error unmuting user": "Fehler beim Aufheben der Benutzer-Stummschaltung", "Error uploading attachment": "Fehler beim Hochladen des Anhangs", "Error uploading file": "Fehler beim Hochladen der Datei", "Error uploading image": "Fehler beim Hochladen des Bildes", @@ -234,6 +276,7 @@ "Failed to mark channel as read": "Fehler beim Markieren des Kanals als gelesen", "Failed to play the recording": "Wiedergabe der Aufnahme fehlgeschlagen", "Failed to retrieve location": "Standort konnte nicht abgerufen werden", + "Failed to save changes": "Änderungen konnten nicht gespeichert werden", "Failed to share location": "Standort konnte nicht geteilt werden", "Failed to update channel archive status": "Archivierungsstatus des Kanals konnte nicht aktualisiert werden", "Failed to update channel mute status": "Stummschaltungsstatus des Kanals konnte nicht aktualisiert werden", @@ -244,10 +287,14 @@ "File too large": "Datei ist zu groß", "fileCount_one": "1 datei", "fileCount_other": "{{ count }} dateien", + "Files": "Dateien", "Flag": "Melden", "Generating...": "Generieren...", "giphy-command-args": "[Text]", "giphy-command-description": "Poste ein zufälliges Gif in den Kanal", + "Go back": "Zurück", + "Group info": "Gruppeninfo", + "Group name": "Gruppenname", "Hide who voted": "Verbergen, wer abgestimmt hat", "Image": "Bild", "imageCount_one": "Bild", @@ -310,7 +357,9 @@ "language/vi": "Vietnamesisch", "language/zh": "Chinesisch (Vereinfacht)", "language/zh-TW": "Chinesisch (Traditionell)", + "Last seen {{ timestamp }}": "Zuletzt gesehen {{ timestamp }}", "Leave Channel": "Kanal verlassen", + "Leave chat": "Kanal verlassen", "Left channel": "Kanal verlassen", "Let others add options": "Andere Optionen hinzufügen lassen", "Limit votes per person": "Stimmen pro Person begrenzen", @@ -326,9 +375,13 @@ "Location": "Standort", "Location sharing ended": "Standortfreigabe beendet", "Location: {{ coordinates }}": "Standort: {{ coordinates }}", + "Manage channel": "Kanal verwalten", + "Manage members": "Mitglieder verwalten", "Mark as unread": "Als ungelesen markieren", "Maximum number of votes (from 2 to 10)": "Maximale Anzahl der Stimmen (von 2 bis 10)", "Maximum votes per person": "Maximale Stimmen pro Person", + "Member detail": "Mitgliederdetails", + "Member not found": "Mitglied nicht gefunden", "Menu": "Menü", "Message deleted": "Nachricht gelöscht", "Message failed to send": "Nachricht konnte nicht gesendet werden", @@ -339,8 +392,11 @@ "Message was blocked by moderation policies": "Nachricht wurde durch moderationsrichtlinien blockiert", "Messages have been marked unread.": "Nachrichten wurden als ungelesen markiert.", "Missing permissions to upload the attachment": "Fehlende Berechtigungen zum Hochladen des Anhangs", + "Moderator": "Moderator", "Multiple votes": "Mehrfachstimmen", "Mute": "Stummschalten", + "Mute chat": "Chat stummschalten", + "Mute user": "Benutzer stummschalten", "mute-command-args": "[@Benutzername]", "mute-command-description": "Stummschalten eines Benutzers", "network error": "Netzwerkfehler", @@ -350,8 +406,14 @@ "Next image": "Nächstes Bild", "No chats here yet…": "Noch keine Chats hier...", "No conversations yet": "Noch keine Unterhaltungen", + "No files": "Keine Dateien", "No items exist": "Keine Elemente vorhanden", + "No member found": "Kein Mitglied gefunden", + "No messages found": "Keine Nachrichten gefunden", + "No photos or videos": "Keine Fotos oder Videos", + "No pinned messages": "Keine angehefteten Nachrichten", "No results found": "Keine Ergebnisse gefunden", + "No user found": "Kein Benutzer gefunden", "Nobody will be able to vote in this poll anymore.": "Niemand kann mehr in dieser Umfrage abstimmen.", "Nothing yet...": "Noch nichts...", "Offline": "Offline", @@ -363,15 +425,22 @@ "Open gallery at image {{ index }}": "Galerie bei Bild {{ index }} öffnen", "Open image in gallery": "Bild in Galerie öffnen", "Open location in a map": "Standort in einer Karte öffnen", + "Open members actions": "Open members actions", + "Open menu": "Menü öffnen", "Option already exists": "Option existiert bereits", "Option is empty": "Option ist leer", "Options": "Optionen", "Original": "Original", + "Owner": "Besitzer", "People matching": "Passende Personen", "Photo": "Foto", + "Photos & videos": "Fotos & Videos", "Pin": "Anheften", + "Pin a message to see it here": "Hefte eine Nachricht an, um sie hier zu sehen", "Pinned by {{ name }}": "Angeheftet von {{ name }}", "Pinned by You": "Von Ihnen angeheftet", + "Pinned message": "Angeheftete Nachricht", + "Pinned messages": "Angeheftete Nachrichten", "placeholder/PollComment": "Ihr Kommentar", "placeholder/PollOptionSuggestion": "Neue Option eingeben", "Play video": "Video abspielen", @@ -391,8 +460,14 @@ "Remind me": "Erinnern", "Remind Me": "Erinnern", "Reminder set": "Erinnerung gesetzt", + "Remove": "Entfernen", + "Remove {{ count }} members_one": "{{ count }} Mitglied entfernen", + "Remove {{ count }} members_other": "{{ count }} Mitglieder entfernen", + "Remove {{ member }} from this channel?": "{{ member }} aus diesem Kanal entfernen?", + "Remove channel members": "Kanalmitglieder entfernen", "Remove reminder": "Erinnerung entfernen", "Remove save for later": "„Später ansehen“ entfernen", + "Remove user": "Benutzer entfernen", "Replied to a thread": "In einem Thread geantwortet", "Reply": "Antworten", "Reply to {{ authorName }}": "Antwort an {{ authorName }}", @@ -407,6 +482,7 @@ "Review poll results and open an option to see detailed votes": "Überprüfe die Umfrageergebnisse und öffne eine Option, um detaillierte Stimmen zu sehen", "Review this message and choose whether to delete it, edit it, or send it anyway": "Überprüfe diese Nachricht und wähle, ob du sie löschen, bearbeiten oder trotzdem senden möchtest", "Review who voted for this option": "Überprüfe, wer für diese Option gestimmt hat", + "Save": "Speichern", "Save for later": "Für später speichern", "Saved for later": "Für später gespeichert", "Search": "Suche", @@ -429,11 +505,14 @@ "Send a message": "Nachricht senden", "Send a message to start the conversation": "Senden Sie eine Nachricht, um die Unterhaltung zu beginnen", "Send Anyway": "Trotzdem senden", + "Send direct message": "Direktnachricht senden", "Send message request failed": "Senden der Nachrichtenanfrage fehlgeschlagen", "Send poll": "Umfrage senden", "Sending...": "Senden...", "Sent": "Gesendet", "Share": "Teilen", + "Share a file to see it here": "Teile eine Datei, um sie hier zu sehen", + "Share a photo or video to see it here": "Teile ein Foto oder Video, um es hier zu sehen", "Share live location for": "Live-Standort teilen für", "Share Location": "Standort teilen", "Shared live location": "Geteilter Live-Standort", @@ -454,6 +533,9 @@ "this content could not be displayed": "Dieser Inhalt konnte nicht angezeigt werden", "This field cannot be empty or contain only spaces": "Dieses Feld darf nicht leer sein oder nur Leerzeichen enthalten", "This message did not meet our content guidelines": "Diese Nachricht entsprach nicht unseren Inhaltsrichtlinien", + "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This user will be able to message you again.": "Dieser Benutzer kann dir wieder Nachrichten senden.", + "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", "Thread": "Thread", "Thread has not been found": "Thread wurde nicht gefunden", "Thread reply": "Thread-Antwort", @@ -462,6 +544,8 @@ "ThreadListUnseenThreadsBanner/unreadThreads_one": "{{ count }} ungelesener Thread", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} ungelesene Threads", "Threads": "Diskussionen", + "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", + "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Gestern]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "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) }}", @@ -485,9 +569,13 @@ "Unarchive": "Archivierung aufheben", "unban-command-args": "[@Benutzername]", "unban-command-description": "Einen Benutzer entbannen", + "Unblock": "Entsperren", + "Unblock user": "Benutzer entsperren", "Unblock User": "Benutzer entsperren", "unknown error": "Unbekannter Fehler", "Unmute": "Stummschaltung aufheben", + "Unmute chat": "Chat-Stummschaltung aufheben", + "Unmute user": "Benutzer-Stummschaltung aufheben", "unmute-command-args": "[@Benutzername]", "unmute-command-description": "Stummschaltung eines Benutzers aufheben", "Unpin": "Anheftung aufheben", @@ -500,9 +588,13 @@ "Upload blocked": "Upload blockiert", "Upload error": "Upload-Fehler", "Upload failed": "Upload fehlgeschlagen", + "Upload Picture": "Bild hochladen", "Upload type: \"{{ type }}\" is not allowed": "Upload-Typ: \"{{ type }}\" ist nicht erlaubt", "User blocked": "Benutzer blockiert", + "User muted": "Benutzer stummgeschaltet", + "User removed": "Benutzer entfernt", "User unblocked": "Blockierung des Benutzers aufgehoben", + "User unmuted": "Benutzer-Stummschaltung aufgehoben", "User uploaded content": "Vom Benutzer hochgeladener Inhalt", "Video": "Video", "videoCount_one": "Video", @@ -511,6 +603,7 @@ "View {{count}} comments_one": "{{count}} Kommentar anzeigen", "View {{count}} comments_other": "{{count}} Kommentare anzeigen", "View all": "Alle anzeigen", + "View member details for {{ member }}": "Mitgliederdetails für {{ member }} anzeigen", "View original": "Original anzeigen", "View results": "Ergebnisse anzeigen", "View translation": "Übersetzung anzeigen", diff --git a/src/i18n/en.json b/src/i18n/en.json index 3d1e3b0d38..b4a20c08c5 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -3,6 +3,10 @@ "{{ commaSeparatedUsers }}, and {{ lastUser }}": "{{ commaSeparatedUsers }}, and {{ lastUser }}", "{{ count }} files_one": "{{ count }} file", "{{ count }} files_other": "{{ count }} files", + "{{ count }} members_one": "{{ count }} member", + "{{ count }} members_other": "{{ count }} members", + "{{ count }} members added_one": "{{ count }} member added", + "{{ count }} members added_other": "{{ count }} members added", "{{ count }} people are typing_one": "{{ count }} person is typing", "{{ count }} people are typing_many": "{{ count }} people are typing", "{{ count }} people are typing_other": "{{ count }} people are typing", @@ -14,6 +18,8 @@ "{{ count }} videos_other": "{{ count }} videos", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} and {{ secondUser }}", "{{ imageCount }} more": "{{ imageCount }} more", + "{{ member }} will be able to message you again.": "{{ member }} will be able to message you again.", + "{{ member }} won't be able to message you anymore.": "{{ member }} won't be able to message you anymore.", "{{ memberCount }} members": "{{ memberCount }} members", "{{ typing }} are typing": "{{ typing }} are typing", "{{ typing }} is typing": "{{ typing }} is typing", @@ -36,16 +42,24 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} created: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} voted: {{pollOptionText}}", "📍Shared location": "📍Shared location", + "Actions": "Actions", + "Add": "Add", + "Add {{ count }} members_one": "Add {{ count }} member", + "Add {{ count }} members_other": "Add {{ count }} members", "Add a comment": "Add a Comment", "Add a comment to your poll answer": "Add a comment to your poll answer", "Add an option": "Add an Option", + "Add channel members": "Add channel members", + "Add members": "Add members", "Add reaction": "Add reaction", + "Admin": "Admin", "All results loaded": "All results loaded", "Allow access to camera": "Allow access to camera", "Allow access to microphone": "Allow access to microphone", "Allow comments": "Allow comments", "Allow option suggestion": "Allow option suggestion", "Allow others to add comments": "Allow Others to Add Comments", + "Already a member": "Already a member", "Also send as a direct message": "Also send as a direct message", "Also send in channel": "Also send in channel", "Also sent in channel": "Also sent in channel", @@ -55,6 +69,7 @@ "Anonymous poll": "Anonymous Poll", "Archive": "Archive", "Are you sure you want to delete this message?": "Are you sure you want to delete this message?", + "Are you sure you want to leave this channel?": "Are you sure you want to leave this channel?", "aria/Attachment": "Attachment", "aria/Attachment Actions": "Attachment Actions", "aria/Audio position {{ elapsed }} of {{ duration }}": "Audio position {{ elapsed }} of {{ duration }}", @@ -65,6 +80,7 @@ "aria/Cancel Reply": "Cancel Reply", "aria/Cancel upload": "Cancel upload", "aria/Channel Actions": "Channel Actions", + "aria/Channel details": "Channel details", "aria/Channel list": "Channel list", "aria/Channel search results": "Channel search results", "aria/Chat view tabs": "Chat view tabs", @@ -104,10 +120,13 @@ "aria/Notifications": "Notifications", "aria/Open Attachment Selector": "Open Attachment Selector", "aria/Open Channel Actions Menu": "Open Channel Actions Menu", + "aria/Open channel details": "Open channel details", + "aria/Open image shared by {{ name }}": "Open image shared by {{ name }}", "aria/Open Menu": "Open Menu", "aria/Open Message Actions Menu": "Open Message Actions Menu", "aria/Open Reaction Selector": "Open Reaction Selector", "aria/Open Thread": "Open Thread", + "aria/Open video shared by {{ name }}": "Open video shared by {{ name }}", "aria/Pause": "Pause", "aria/Pause recording": "Pause recording", "aria/Percent complete": "{{percent}} percent complete", @@ -151,10 +170,15 @@ "Back": "Back", "ban-command-args": "[@username] [text]", "ban-command-description": "Ban a user", + "Block user": "Block user", "Block User": "Block User", + "Browse channel members": "Browse channel members", + "Browse pinned messages": "Browse pinned messages", "Cancel": "Cancel", "Cannot seek in the recording": "Cannot seek in the recording", + "Changes saved": "Changes saved", "Channel archived": "Channel archived", + "Channel members": "Channel members", "Channel Missing": "Channel Missing", "Channel muted": "Channel muted", "Channel pinned": "Channel pinned", @@ -162,6 +186,7 @@ "Channel unmuted": "Channel unmuted", "Channel unpinned": "Channel unpinned", "Channels": "Channels", + "Chat deleted": "Chat deleted", "Chats": "Chats", "Choose between 2 to 10 options": "Choose Between 2 to 10 Options", "Close": "Close", @@ -173,12 +198,15 @@ "Commands": "Commands", "Commands matching": "Commands matching", "Connection failure, reconnecting now...": "Connection failure, reconnecting now...", + "Contact info": "Contact info", + "Contact name": "Contact name", "Copy Message": "Copy Message", "Create": "Create", "Create a question, add options, and configure poll settings": "Create a question, add options, and configure poll settings", "Create poll": "Create Poll", "Current location": "Current location", "Delete": "Delete", + "Delete chat": "Delete chat", "Delete for me": "Delete for me", "Delete message": "Delete message", "Delivered": "Delivered", @@ -194,6 +222,10 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "Edit", + "Edit chat data": "Edit chat data", + "Edit contact": "Edit contact", + "Edit group": "Edit group", "Edit Message": "Edit Message", "Edit message request failed": "Edit message request failed", "Edited": "Edited", @@ -206,16 +238,26 @@ "Enforce unique vote is enabled": "Enforce unique vote is enabled", "Error": "Error", "Error adding flag": "Error adding flag", + "Error adding members": "Error adding members", + "Error blocking user": "Error blocking user", "Error connecting to chat, refresh the page to try again.": "Error connecting to chat, refresh the page to try again.", + "Error deleting chat": "Error deleting chat", "Error deleting message": "Error deleting message", "Error fetching reactions": "Error loading reactions", "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.", "Error muting a user ...": "Error muting a user ...", + "Error muting channel": "Error muting channel", + "Error muting user": "Error muting user", + "Error opening direct message": "Error opening direct message", "Error pinning message": "Error pinning message", "Error removing message pin": "Error removing message pin", + "Error removing user": "Error removing user", "Error reproducing the recording": "Error reproducing the recording", "Error starting recording": "Error starting recording", + "Error unblocking user": "Error unblocking user", "Error unmuting a user ...": "Error unmuting a user ...", + "Error unmuting channel": "Error unmuting channel", + "Error unmuting user": "Error unmuting user", "Error uploading attachment": "Error uploading attachment", "Error uploading file": "Error uploading file", "Error uploading image": "Error uploading image", @@ -234,6 +276,7 @@ "Failed to mark channel as read": "Failed to mark channel as read", "Failed to play the recording": "Failed to play the recording", "Failed to retrieve location": "Failed to retrieve location", + "Failed to save changes": "Failed to save changes", "Failed to share location": "Failed to share location", "Failed to update channel archive status": "Failed to update channel archive status", "Failed to update channel mute status": "Failed to update channel mute status", @@ -244,10 +287,14 @@ "File too large": "File too large", "fileCount_one": "File", "fileCount_other": "{{ count }} files", + "Files": "Files", "Flag": "Flag", "Generating...": "Generating...", "giphy-command-args": "[text]", "giphy-command-description": "Post a random gif to the channel", + "Go back": "Go back", + "Group info": "Group info", + "Group name": "Group name", "Hide who voted": "Hide Who Voted", "Image": "Image", "imageCount_one": "Image", @@ -310,7 +357,9 @@ "language/vi": "Vietnamese", "language/zh": "Chinese (Simplified)", "language/zh-TW": "Chinese (Traditional)", + "Last seen {{ timestamp }}": "Last seen {{ timestamp }}", "Leave Channel": "Leave Channel", + "Leave chat": "Leave chat", "Left channel": "Left channel", "Let others add options": "Let Others Add Options", "Limit votes per person": "Limit Votes per Person", @@ -326,9 +375,13 @@ "Location": "Location", "Location sharing ended": "Location sharing ended", "Location: {{ coordinates }}": "Location: {{ coordinates }}", + "Manage channel": "Manage channel", + "Manage members": "Manage members", "Mark as unread": "Mark as unread", "Maximum number of votes (from 2 to 10)": "Maximum number of votes (from 2 to 10)", "Maximum votes per person": "Maximum votes per person", + "Member detail": "Member detail", + "Member not found": "Member not found", "Menu": "Menu", "Message deleted": "Message deleted", "Message failed to send": "Message failed to send", @@ -339,8 +392,11 @@ "Message was blocked by moderation policies": "Message was blocked by moderation policies", "Messages have been marked unread.": "Messages have been marked unread.", "Missing permissions to upload the attachment": "Missing permissions to upload the attachment", + "Moderator": "Moderator", "Multiple votes": "Multiple Votes", "Mute": "Mute", + "Mute chat": "Mute chat", + "Mute user": "Mute user", "mute-command-args": "[@username]", "mute-command-description": "Mute a user", "network error": "network error", @@ -350,8 +406,14 @@ "Next image": "Next image", "No chats here yet…": "No chats here yet…", "No conversations yet": "No conversations yet", + "No files": "No files", "No items exist": "No items exist", + "No member found": "No member found", + "No messages found": "No messages found", + "No photos or videos": "No photos or videos", + "No pinned messages": "No pinned messages", "No results found": "No results found", + "No user found": "No user 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", @@ -363,15 +425,22 @@ "Open gallery at image {{ index }}": "Open gallery at image {{ index }}", "Open image in gallery": "Open image in gallery", "Open location in a map": "Open location in a map", + "Open members actions": "Open members actions", + "Open menu": "Open menu", "Option already exists": "Option already exists", "Option is empty": "Option is empty", "Options": "Options", "Original": "Original", + "Owner": "Owner", "People matching": "People matching", "Photo": "Photo", + "Photos & videos": "Photos & videos", "Pin": "Pin", + "Pin a message to see it here": "Pin a message to see it here", "Pinned by {{ name }}": "Pinned by {{ name }}", "Pinned by You": "Pinned by You", + "Pinned message": "Pinned message", + "Pinned messages": "Pinned messages", "placeholder/PollComment": "Your comment", "placeholder/PollOptionSuggestion": "Enter a new option", "Play video": "Play video", @@ -391,8 +460,14 @@ "Remind me": "Remind me", "Remind Me": "Remind Me", "Reminder set": "Reminder set", + "Remove": "Remove", + "Remove {{ count }} members_one": "Remove {{ count }} member", + "Remove {{ count }} members_other": "Remove {{ count }} members", + "Remove {{ member }} from this channel?": "Remove {{ member }} from this channel?", + "Remove channel members": "Remove channel members", "Remove reminder": "Remove reminder", "Remove save for later": "Remove save for later", + "Remove user": "Remove user", "Replied to a thread": "Replied to a thread", "Reply": "Reply", "Reply to {{ authorName }}": "Reply to {{ authorName }}", @@ -407,6 +482,7 @@ "Review poll results and open an option to see detailed votes": "Review poll results and open an option to see detailed votes", "Review this message and choose whether to delete it, edit it, or send it anyway": "Review this message and choose whether to delete it, edit it, or send it anyway", "Review who voted for this option": "Review who voted for this option", + "Save": "Save", "Save for later": "Save for later", "Saved for later": "Saved for later", "Search": "Search", @@ -429,11 +505,14 @@ "Send a message": "Send a message", "Send a message to start the conversation": "Send a message to start the conversation", "Send Anyway": "Send Anyway", + "Send direct message": "Send direct message", "Send message request failed": "Send message request failed", "Send poll": "Send Poll", "Sending...": "Sending...", "Sent": "Sent", "Share": "Share", + "Share a file to see it here": "Share a file to see it here", + "Share a photo or video to see it here": "Share a photo or video to see it here", "Share live location for": "Share live location for", "Share Location": "Share Location", "Shared live location": "Shared live location", @@ -454,6 +533,9 @@ "this content could not be displayed": "this content could not be displayed", "This field cannot be empty or contain only spaces": "This field cannot be empty or contain only spaces", "This message did not meet our content guidelines": "This message did not meet our content guidelines", + "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This user will be able to message you again.": "This user will be able to message you again.", + "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", "Thread": "Thread", "Thread has not been found": "Thread has not been found", "Thread reply": "Thread reply", @@ -462,6 +544,8 @@ "ThreadListUnseenThreadsBanner/unreadThreads_one": "{{ count }} unread thread", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} unread threads", "Threads": "Threads", + "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", + "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -485,9 +569,13 @@ "Unarchive": "Unarchive", "unban-command-args": "[@username]", "unban-command-description": "Unban a user", + "Unblock": "Unblock", + "Unblock user": "Unblock user", "Unblock User": "Unblock User", "unknown error": "unknown error", "Unmute": "Unmute", + "Unmute chat": "Unmute chat", + "Unmute user": "Unmute user", "unmute-command-args": "[@username]", "unmute-command-description": "Unmute a user", "Unpin": "Unpin", @@ -500,9 +588,13 @@ "Upload blocked": "Upload blocked", "Upload error": "Upload error", "Upload failed": "Upload failed", + "Upload Picture": "Upload Picture", "Upload type: \"{{ type }}\" is not allowed": "Upload type: \"{{ type }}\" is not allowed", "User blocked": "User blocked", + "User muted": "User muted", + "User removed": "User removed", "User unblocked": "User unblocked", + "User unmuted": "User unmuted", "User uploaded content": "User uploaded content", "Video": "Video", "videoCount_one": "Video", @@ -511,6 +603,7 @@ "View {{count}} comments_one": "View {{count}} Comment", "View {{count}} comments_other": "View {{count}} Comments", "View all": "View all", + "View member details for {{ member }}": "View member details for {{ member }}", "View original": "View original", "View results": "View Results", "View translation": "View translation", diff --git a/src/i18n/es.json b/src/i18n/es.json index 85c08f7408..933a8c20bd 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -4,6 +4,12 @@ "{{ count }} files_one": "{{ count }} archivo", "{{ count }} files_many": "{{ count }} archivos", "{{ count }} files_other": "{{ count }} archivos", + "{{ count }} members_one": "{{ count }} miembro", + "{{ count }} members_many": "{{ count }} miembros", + "{{ count }} members_other": "{{ count }} miembros", + "{{ count }} members added_one": "{{ count }} miembro añadido", + "{{ count }} members added_many": "{{ count }} miembros añadidos", + "{{ count }} members added_other": "{{ count }} miembros añadidos", "{{ count }} people are typing_one": "{{ count }} persona está escribiendo", "{{ count }} people are typing_many": "{{ count }} personas están escribiendo", "{{ count }} people are typing_other": "{{ count }} personas están escribiendo", @@ -18,6 +24,8 @@ "{{ count }} videos_other": "{{ count }} vídeos", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} y {{ secondUser }}", "{{ imageCount }} more": "{{ imageCount }} más", + "{{ member }} will be able to message you again.": "{{ member }} podrá enviarte mensajes de nuevo.", + "{{ member }} won't be able to message you anymore.": "{{ member }} ya no podrá enviarte mensajes.", "{{ memberCount }} members": "{{ memberCount }} miembros", "{{ typing }} are typing": "{{ typing }} están escribiendo", "{{ typing }} is typing": "{{ typing }} está escribiendo", @@ -44,16 +52,25 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} creó: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} votó: {{pollOptionText}}", "📍Shared location": "📍Ubicación compartida", + "Actions": "Actions", + "Add": "Añadir", + "Add {{ count }} members_one": "Añadir {{ count }} miembro", + "Add {{ count }} members_many": "Añadir {{ count }} miembros", + "Add {{ count }} members_other": "Añadir {{ count }} miembros", "Add a comment": "Agregar un comentario", "Add a comment to your poll answer": "Añade un comentario a tu respuesta de la encuesta", "Add an option": "Agregar una opción", + "Add channel members": "Añadir miembros al canal", + "Add members": "Añadir miembros", "Add reaction": "Añadir reacción", + "Admin": "Administrador", "All results loaded": "Todos los resultados cargados", "Allow access to camera": "Permitir acceso a la cámara", "Allow access to microphone": "Permitir acceso al micrófono", "Allow comments": "Permitir comentarios", "Allow option suggestion": "Permitir sugerencia de opciones", "Allow others to add comments": "Permitir que otros añadan comentarios", + "Already a member": "Ya es miembro", "Also send as a direct message": "También enviar como mensaje directo", "Also send in channel": "También enviar en el canal", "Also sent in channel": "También enviado en el canal", @@ -63,6 +80,7 @@ "Anonymous poll": "Encuesta anónima", "Archive": "Archivo", "Are you sure you want to delete this message?": "¿Estás seguro de que quieres eliminar este mensaje?", + "Are you sure you want to leave this channel?": "Are you sure you want to leave this channel?", "aria/Attachment": "Adjunto", "aria/Attachment Actions": "Acciones del adjunto", "aria/Audio position {{ elapsed }} of {{ duration }}": "Posición de audio {{ elapsed }} de {{ duration }}", @@ -73,6 +91,7 @@ "aria/Cancel Reply": "Cancelar respuesta", "aria/Cancel upload": "Cancelar carga", "aria/Channel Actions": "Acciones del canal", + "aria/Channel details": "Detalles del canal", "aria/Channel list": "Lista de canales", "aria/Channel search results": "Resultados de búsqueda de canales", "aria/Chat view tabs": "Pestañas de vista del chat", @@ -112,10 +131,13 @@ "aria/Notifications": "Notificaciones", "aria/Open Attachment Selector": "Abrir selector de adjuntos", "aria/Open Channel Actions Menu": "Abrir menú de acciones del canal", + "aria/Open channel details": "Abrir detalles del canal", + "aria/Open image shared by {{ name }}": "Abrir imagen compartida por {{ name }}", "aria/Open Menu": "Abrir menú", "aria/Open Message Actions Menu": "Abrir menú de acciones de mensaje", "aria/Open Reaction Selector": "Abrir selector de reacciones", "aria/Open Thread": "Abrir hilo", + "aria/Open video shared by {{ name }}": "Abrir video compartido por {{ name }}", "aria/Pause": "Pausar", "aria/Pause recording": "Pausar grabación", "aria/Percent complete": "{{percent}} por ciento completado", @@ -159,10 +181,15 @@ "Back": "Atrás", "ban-command-args": "[@usuario] [texto]", "ban-command-description": "Prohibir a un usuario", + "Block user": "Bloquear usuario", "Block User": "Bloquear usuario", + "Browse channel members": "Explorar miembros del canal", + "Browse pinned messages": "Explorar mensajes fijados", "Cancel": "Cancelar", "Cannot seek in the recording": "No se puede buscar en la grabación", + "Changes saved": "Cambios guardados", "Channel archived": "Canal archivado", + "Channel members": "Miembros del canal", "Channel Missing": "Falta canal", "Channel muted": "Canal silenciado", "Channel pinned": "Canal fijado", @@ -170,6 +197,7 @@ "Channel unmuted": "Silencio del canal desactivado", "Channel unpinned": "Canal desanclado", "Channels": "Canales", + "Chat deleted": "Chat deleted", "Chats": "Chats", "Choose between 2 to 10 options": "Elige entre 2 y 10 opciones", "Close": "Cerrar", @@ -181,12 +209,15 @@ "Commands": "Comandos", "Commands matching": "Coincidencia de comandos", "Connection failure, reconnecting now...": "Fallo de conexión, reconectando ahora...", + "Contact info": "Información de contacto", + "Contact name": "Nombre del contacto", "Copy Message": "Copiar mensaje", "Create": "Crear", "Create a question, add options, and configure poll settings": "Crea una pregunta, añade opciones y configura los ajustes de la encuesta", "Create poll": "Crear encuesta", "Current location": "Ubicación actual", "Delete": "Borrar", + "Delete chat": "Eliminar chat", "Delete for me": "Eliminar para mí", "Delete message": "Eliminar mensaje", "Delivered": "Entregado", @@ -202,6 +233,10 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "Editar", + "Edit chat data": "Editar datos del chat", + "Edit contact": "Editar contacto", + "Edit group": "Editar grupo", "Edit Message": "Editar mensaje", "Edit message request failed": "Error al editar la solicitud de mensaje", "Edited": "Editado", @@ -214,16 +249,26 @@ "Enforce unique vote is enabled": "El voto único está habilitado", "Error": "Error", "Error adding flag": "Error al agregar la bandera", + "Error adding members": "Error adding members", + "Error blocking user": "Error al bloquear al usuario", "Error connecting to chat, refresh the page to try again.": "Error al conectarse al chat, actualice la página para volver a intentarlo.", + "Error deleting chat": "Error deleting chat", "Error deleting message": "Error al eliminar el mensaje", "Error fetching reactions": "Error al cargar las reacciones", "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Error al marcar el mensaje como no leído. No se pueden marcar mensajes no leídos más antiguos que los últimos 100 mensajes del canal.", "Error muting a user ...": "Error al silenciar el usuario...", + "Error muting channel": "Error al silenciar el canal", + "Error muting user": "Error al silenciar al usuario", + "Error opening direct message": "Error al abrir el mensaje directo", "Error pinning message": "Error al fijar el mensaje", "Error removing message pin": "Error al quitar el pin del mensaje", + "Error removing user": "Error al eliminar al usuario", "Error reproducing the recording": "Error al reproducir la grabación", "Error starting recording": "Error al iniciar la grabación", + "Error unblocking user": "Error al desbloquear usuario", "Error unmuting a user ...": "Error al desactivar el silencio del usuario...", + "Error unmuting channel": "Error al desactivar el silencio del canal", + "Error unmuting user": "Error al desactivar el silencio del usuario", "Error uploading attachment": "Error al subir el archivo adjunto", "Error uploading file": "Error al cargar el archivo", "Error uploading image": "Error al subir la imagen", @@ -242,6 +287,7 @@ "Failed to mark channel as read": "Error al marcar el canal como leído", "Failed to play the recording": "No se pudo reproducir la grabación", "Failed to retrieve location": "No se pudo obtener la ubicación", + "Failed to save changes": "No se pudieron guardar los cambios", "Failed to share location": "No se pudo compartir la ubicación", "Failed to update channel archive status": "No se pudo actualizar el estado de archivo del canal", "Failed to update channel mute status": "No se pudo actualizar el estado de silencio del canal", @@ -253,10 +299,14 @@ "fileCount_one": "1 archivo", "fileCount_many": "{{ count }} archivos", "fileCount_other": "{{ count }} archivos", + "Files": "Archivos", "Flag": "Marcar", "Generating...": "Generando...", "giphy-command-args": "[texto]", "giphy-command-description": "Publicar un gif aleatorio en el canal", + "Go back": "Volver", + "Group info": "Información del grupo", + "Group name": "Nombre del grupo", "Hide who voted": "Ocultar quién votó", "Image": "Imagen", "imageCount_one": "Imagen", @@ -320,7 +370,9 @@ "language/vi": "Vietnamita", "language/zh": "Chino (simplificado)", "language/zh-TW": "Chino (tradicional)", + "Last seen {{ timestamp }}": "Visto por última vez {{ timestamp }}", "Leave Channel": "Abandonar canal", + "Leave chat": "Abandonar canal", "Left channel": "Canal abandonado", "Let others add options": "Permitir que otros añadan opciones", "Limit votes per person": "Limitar votos por persona", @@ -337,9 +389,13 @@ "Location": "Ubicación", "Location sharing ended": "Compartir ubicación terminado", "Location: {{ coordinates }}": "Ubicación: {{ coordinates }}", + "Manage channel": "Gestionar canal", + "Manage members": "Gestionar miembros", "Mark as unread": "Marcar como no leído", "Maximum number of votes (from 2 to 10)": "Número máximo de votos (de 2 a 10)", "Maximum votes per person": "Máximo de votos por persona", + "Member detail": "Detalle del miembro", + "Member not found": "Miembro no encontrado", "Menu": "Menú", "Message deleted": "Mensaje eliminado", "Message failed to send": "No se pudo enviar el mensaje", @@ -350,8 +406,11 @@ "Message was blocked by moderation policies": "El mensaje fue bloqueado por las políticas de moderación", "Messages have been marked unread.": "Los mensajes han sido marcados como no leídos.", "Missing permissions to upload the attachment": "Faltan permisos para subir el archivo adjunto", + "Moderator": "Moderador", "Multiple votes": "Votos múltiples", "Mute": "Silenciar", + "Mute chat": "Silenciar chat", + "Mute user": "Silenciar usuario", "mute-command-args": "[@usuario]", "mute-command-description": "Silenciar a un usuario", "network error": "error de red", @@ -361,8 +420,14 @@ "Next image": "Siguiente imagen", "No chats here yet…": "Aún no hay mensajes aquí...", "No conversations yet": "Aún no hay conversaciones", + "No files": "No hay archivos", "No items exist": "No existen elementos", + "No member found": "No se encontró ningún miembro", + "No messages found": "No se encontraron mensajes", + "No photos or videos": "No hay fotos ni videos", + "No pinned messages": "No hay mensajes fijados", "No results found": "No se han encontrado resultados", + "No user found": "No se encontró ningún usuario", "Nobody will be able to vote in this poll anymore.": "Nadie podrá votar en esta encuesta.", "Nothing yet...": "Nada aún...", "Offline": "Desconectado", @@ -374,15 +439,22 @@ "Open gallery at image {{ index }}": "Abrir galería en la imagen {{ index }}", "Open image in gallery": "Abrir imagen en la galería", "Open location in a map": "Abrir ubicación en un mapa", + "Open members actions": "Open members actions", + "Open menu": "Abrir menú", "Option already exists": "La opción ya existe", "Option is empty": "La opción está vacía", "Options": "Opciones", "Original": "Original", + "Owner": "Propietario", "People matching": "Personas que coinciden", "Photo": "Foto", + "Photos & videos": "Fotos y videos", "Pin": "Fijar", + "Pin a message to see it here": "Fija un mensaje para verlo aquí", "Pinned by {{ name }}": "Fijado por {{ name }}", "Pinned by You": "Anclado por ti", + "Pinned message": "Mensaje fijado", + "Pinned messages": "Mensajes fijados", "placeholder/PollComment": "Tu comentario", "placeholder/PollOptionSuggestion": "Introduce una nueva opción", "Play video": "Reproducir video", @@ -402,8 +474,15 @@ "Remind me": "Recordarme", "Remind Me": "Recordarme", "Reminder set": "Recordatorio establecido", + "Remove": "Eliminar", + "Remove {{ count }} members_one": "Eliminar {{ count }} miembro", + "Remove {{ count }} members_many": "Eliminar {{ count }} miembros", + "Remove {{ count }} members_other": "Eliminar {{ count }} miembros", + "Remove {{ member }} from this channel?": "¿Eliminar a {{ member }} de este canal?", + "Remove channel members": "Eliminar miembros del canal", "Remove reminder": "Eliminar recordatorio", "Remove save for later": "Quitar guardar para después", + "Remove user": "Eliminar usuario", "Replied to a thread": "Respondió en un hilo", "Reply": "Responder", "Reply to {{ authorName }}": "Responder a {{ authorName }}", @@ -419,6 +498,7 @@ "Review poll results and open an option to see detailed votes": "Revisa los resultados de la encuesta y abre una opción para ver votos detallados", "Review this message and choose whether to delete it, edit it, or send it anyway": "Revisa este mensaje y elige si eliminarlo, editarlo o enviarlo de todos modos", "Review who voted for this option": "Revisa quién votó por esta opción", + "Save": "Guardar", "Save for later": "Guardar para más tarde", "Saved for later": "Guardado para más tarde", "Search": "Buscar", @@ -443,11 +523,14 @@ "Send a message": "Envía un mensaje", "Send a message to start the conversation": "Envía un mensaje para iniciar la conversación", "Send Anyway": "Enviar de todos modos", + "Send direct message": "Enviar mensaje directo", "Send message request failed": "Error al enviar la solicitud de mensaje", "Send poll": "Enviar encuesta", "Sending...": "Enviando...", "Sent": "Enviado", "Share": "Compartir", + "Share a file to see it here": "Comparte un archivo para verlo aquí", + "Share a photo or video to see it here": "Comparte una foto o un video para verlo aquí", "Share live location for": "Compartir ubicación en vivo durante", "Share Location": "Compartir ubicación", "Shared live location": "Ubicación en vivo compartida", @@ -468,6 +551,9 @@ "this content could not be displayed": "Este contenido no se pudo mostrar", "This field cannot be empty or contain only spaces": "Este campo no puede estar vacío o contener solo espacios", "This message did not meet our content guidelines": "Este mensaje no cumple con nuestras directrices de contenido", + "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This user will be able to message you again.": "Este usuario podrá enviarte mensajes de nuevo.", + "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", "Thread": "Hilo", "Thread has not been found": "No se ha encontrado el hilo", "Thread reply": "Respuesta en hilo", @@ -477,6 +563,8 @@ "ThreadListUnseenThreadsBanner/unreadThreads_many": "{{ count }} hilos no leídos", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} hilos no leídos", "Threads": "Hilos", + "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", + "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Ayer]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "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) }}", @@ -501,9 +589,13 @@ "Unarchive": "Desarchivar", "unban-command-args": "[@usuario]", "unban-command-description": "Quitar la prohibición a un usuario", + "Unblock": "Desbloquear", + "Unblock user": "Desbloquear usuario", "Unblock User": "Desbloquear usuario", "unknown error": "error desconocido", "Unmute": "Activar sonido", + "Unmute chat": "Desactivar silencio del chat", + "Unmute user": "Desactivar silencio del usuario", "unmute-command-args": "[@usuario]", "unmute-command-description": "Desactivar el silencio de un usuario", "Unpin": "Desfijar", @@ -516,9 +608,13 @@ "Upload blocked": "Carga bloqueada", "Upload error": "Error de carga", "Upload failed": "Carga fallida", + "Upload Picture": "Subir imagen", "Upload type: \"{{ type }}\" is not allowed": "Tipo de carga: \"{{ type }}\" no está permitido", "User blocked": "Usuario bloqueado", + "User muted": "Usuario silenciado", + "User removed": "Usuario eliminado", "User unblocked": "Usuario desbloqueado", + "User unmuted": "Usuario con silencio desactivado", "User uploaded content": "Contenido subido por el usuario", "Video": "Vídeo", "videoCount_one": "Video", @@ -529,6 +625,7 @@ "View {{count}} comments_many": "Ver {{count}} comentarios", "View {{count}} comments_other": "Ver {{count}} comentarios", "View all": "Ver todo", + "View member details for {{ member }}": "Ver detalles del miembro {{ member }}", "View original": "Ver original", "View results": "Ver resultados", "View translation": "Ver traducción", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index ab30a98545..3785c0ddf2 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -4,6 +4,12 @@ "{{ count }} files_one": "{{ count }} fichier", "{{ count }} files_many": "{{ count }} fichiers", "{{ count }} files_other": "{{ count }} fichiers", + "{{ count }} members_one": "{{ count }} membre", + "{{ count }} members_many": "{{ count }} membres", + "{{ count }} members_other": "{{ count }} membres", + "{{ count }} members added_one": "{{ count }} membre ajouté", + "{{ count }} members added_many": "{{ count }} membres ajoutés", + "{{ count }} members added_other": "{{ count }} membres ajoutés", "{{ count }} people are typing_one": "{{ count }} personne écrit", "{{ count }} people are typing_many": "{{ count }} personnes écrivent", "{{ count }} people are typing_other": "{{ count }} personnes écrivent", @@ -18,6 +24,8 @@ "{{ count }} videos_other": "{{ count }} vidéos", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} et {{ secondUser }}", "{{ imageCount }} more": "{{ imageCount }} supplémentaires", + "{{ member }} will be able to message you again.": "{{ member }} pourra à nouveau vous envoyer des messages.", + "{{ member }} won't be able to message you anymore.": "{{ member }} ne pourra plus vous envoyer de messages.", "{{ memberCount }} members": "{{ memberCount }} membres", "{{ typing }} are typing": "{{ typing }} écrivent", "{{ typing }} is typing": "{{ typing }} écrit", @@ -44,16 +52,25 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} a créé : {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} a voté : {{pollOptionText}}", "📍Shared location": "📍Emplacement partagé", + "Actions": "Actions", + "Add": "Ajouter", + "Add {{ count }} members_one": "Ajouter {{ count }} membre", + "Add {{ count }} members_many": "Ajouter {{ count }} membres", + "Add {{ count }} members_other": "Ajouter {{ count }} membres", "Add a comment": "Ajouter un commentaire", "Add a comment to your poll answer": "Ajoutez un commentaire à votre réponse au sondage", "Add an option": "Ajouter une option", + "Add channel members": "Ajouter des membres au canal", + "Add members": "Ajouter des membres", "Add reaction": "Ajouter une réaction", + "Admin": "Admin", "All results loaded": "Tous les résultats sont chargés", "Allow access to camera": "Autoriser l'accès à la caméra", "Allow access to microphone": "Autoriser l'accès au microphone", "Allow comments": "Autoriser les commentaires", "Allow option suggestion": "Autoriser la suggestion d'options", "Allow others to add comments": "Permettre à d'autres d'ajouter des commentaires", + "Already a member": "Déjà membre", "Also send as a direct message": "Également envoyer en message direct", "Also send in channel": "Également envoyer dans le canal", "Also sent in channel": "Également envoyé dans le canal", @@ -63,6 +80,7 @@ "Anonymous poll": "Sondage anonyme", "Archive": "Archiver", "Are you sure you want to delete this message?": "Êtes-vous sûr de vouloir supprimer ce message ?", + "Are you sure you want to leave this channel?": "Are you sure you want to leave this channel?", "aria/Attachment": "Pièce jointe", "aria/Attachment Actions": "Actions de la pièce jointe", "aria/Audio position {{ elapsed }} of {{ duration }}": "Position audio {{ elapsed }} sur {{ duration }}", @@ -73,6 +91,7 @@ "aria/Cancel Reply": "Annuler la réponse", "aria/Cancel upload": "Annuler le téléchargement", "aria/Channel Actions": "Actions du canal", + "aria/Channel details": "Détails du canal", "aria/Channel list": "Liste des canaux", "aria/Channel search results": "Résultats de recherche de canaux", "aria/Chat view tabs": "Onglets de la vue de chat", @@ -112,10 +131,13 @@ "aria/Notifications": "Notifications", "aria/Open Attachment Selector": "Ouvrir le sélecteur de pièces jointes", "aria/Open Channel Actions Menu": "Ouvrir le menu des actions du canal", + "aria/Open channel details": "Ouvrir les détails du canal", + "aria/Open image shared by {{ name }}": "Ouvrir l'image partagée par {{ name }}", "aria/Open Menu": "Ouvrir le menu", "aria/Open Message Actions Menu": "Ouvrir le menu des actions du message", "aria/Open Reaction Selector": "Ouvrir le sélecteur de réactions", "aria/Open Thread": "Ouvrir le fil", + "aria/Open video shared by {{ name }}": "Ouvrir la vidéo partagée par {{ name }}", "aria/Pause": "Pause", "aria/Pause recording": "Mettre en pause l'enregistrement", "aria/Percent complete": "{{percent}} pour cent terminé", @@ -159,10 +181,15 @@ "Back": "Retour", "ban-command-args": "[@nomdutilisateur] [texte]", "ban-command-description": "Bannir un utilisateur", + "Block user": "Bloquer l'utilisateur", "Block User": "Bloquer l'utilisateur", + "Browse channel members": "Parcourir les membres du canal", + "Browse pinned messages": "Parcourir les messages épinglés", "Cancel": "Annuler", "Cannot seek in the recording": "Impossible de rechercher dans l'enregistrement", + "Changes saved": "Modifications enregistrées", "Channel archived": "Canal archivé", + "Channel members": "Membres du canal", "Channel Missing": "Canal Manquant", "Channel muted": "Canal mis en sourdine", "Channel pinned": "Canal épinglé", @@ -170,6 +197,7 @@ "Channel unmuted": "Sourdine du canal désactivée", "Channel unpinned": "Canal désépinglé", "Channels": "Canaux", + "Chat deleted": "Chat deleted", "Chats": "Discussions", "Choose between 2 to 10 options": "Choisir entre 2 et 10 options", "Close": "Fermer", @@ -181,12 +209,15 @@ "Commands": "Commandes", "Commands matching": "Correspondance des commandes", "Connection failure, reconnecting now...": "Échec de la connexion, reconnexion en cours...", + "Contact info": "Informations du contact", + "Contact name": "Nom du contact", "Copy Message": "Copier le message", "Create": "Créer", "Create a question, add options, and configure poll settings": "Créez une question, ajoutez des options et configurez les paramètres du sondage", "Create poll": "Créer un sondage", "Current location": "Emplacement actuel", "Delete": "Supprimer", + "Delete chat": "Supprimer le chat", "Delete for me": "Supprimer pour moi", "Delete message": "Supprimer le message", "Delivered": "Publié", @@ -202,6 +233,10 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "Modifier", + "Edit chat data": "Modifier les données du chat", + "Edit contact": "Modifier le contact", + "Edit group": "Modifier le groupe", "Edit Message": "Éditer un message", "Edit message request failed": "Échec de la demande de modification du message", "Edited": "Modifié", @@ -214,16 +249,26 @@ "Enforce unique vote is enabled": "Le vote unique est activé", "Error": "Erreur", "Error adding flag": "Erreur lors de l'ajout du signalement", + "Error adding members": "Error adding members", + "Error blocking user": "Erreur lors du blocage de l'utilisateur", "Error connecting to chat, refresh the page to try again.": "Erreur de connexion au chat, rafraîchissez la page pour réessayer.", + "Error deleting chat": "Error deleting chat", "Error deleting message": "Erreur lors de la suppression du message", "Error fetching reactions": "Erreur lors du chargement des réactions", "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Erreur lors de la marque du message comme non lu. Impossible de marquer des messages non lus plus anciens que les 100 derniers messages du canal.", "Error muting a user ...": "Erreur lors de la mise en sourdine d'un utilisateur...", + "Error muting channel": "Erreur lors de la mise en sourdine du canal", + "Error muting user": "Erreur lors de la mise en sourdine de l'utilisateur", + "Error opening direct message": "Erreur lors de l'ouverture du message direct", "Error pinning message": "Erreur lors de l'épinglage du message", "Error removing message pin": "Erreur lors du retrait de l'épinglage du message", + "Error removing user": "Erreur lors du retrait de l'utilisateur", "Error reproducing the recording": "Erreur lors de la reproduction de l'enregistrement", "Error starting recording": "Erreur lors du démarrage de l'enregistrement", + "Error unblocking user": "Erreur lors du déblocage de l'utilisateur", "Error unmuting a user ...": "Erreur lors du démarrage de la sourdine d'un utilisateur ...", + "Error unmuting channel": "Erreur lors de la désactivation de la sourdine du canal", + "Error unmuting user": "Erreur lors de la désactivation de la sourdine de l'utilisateur", "Error uploading attachment": "Erreur lors du téléchargement de la pièce jointe", "Error uploading file": "Erreur lors du téléchargement du fichier", "Error uploading image": "Erreur lors de l'envoi de l'image", @@ -242,6 +287,7 @@ "Failed to mark channel as read": "Échec du marquage du canal comme lu", "Failed to play the recording": "Impossible de lire l'enregistrement", "Failed to retrieve location": "Impossible de récupérer l'emplacement", + "Failed to save changes": "Échec de l'enregistrement des modifications", "Failed to share location": "Impossible de partager l'emplacement", "Failed to update channel archive status": "Impossible de mettre à jour l'état d'archivage du canal", "Failed to update channel mute status": "Impossible de mettre à jour l'état de la mise en sourdine du canal", @@ -253,10 +299,14 @@ "fileCount_one": "1 fichier", "fileCount_many": "{{ count }} fichiers", "fileCount_other": "{{ count }} fichiers", + "Files": "Fichiers", "Flag": "Signaler", "Generating...": "Génération...", "giphy-command-args": "[texte]", "giphy-command-description": "Poster un GIF aléatoire dans le canal", + "Go back": "Retour", + "Group info": "Informations du groupe", + "Group name": "Nom du groupe", "Hide who voted": "Masquer qui a voté", "Image": "Image", "imageCount_one": "Photo", @@ -320,7 +370,9 @@ "language/vi": "Vietnamien", "language/zh": "Chinois (simplifié)", "language/zh-TW": "Chinois (traditionnel)", + "Last seen {{ timestamp }}": "Vu pour la dernière fois {{ timestamp }}", "Leave Channel": "Quitter le canal", + "Leave chat": "Quitter le canal", "Left channel": "Canal quitté", "Let others add options": "Permettre à d'autres d'ajouter des options", "Limit votes per person": "Limiter les votes par personne", @@ -337,9 +389,13 @@ "Location": "Emplacement", "Location sharing ended": "Partage d'emplacement terminé", "Location: {{ coordinates }}": "Emplacement : {{ coordinates }}", + "Manage channel": "Gérer le canal", + "Manage members": "Gérer les membres", "Mark as unread": "Marquer comme non lu", "Maximum number of votes (from 2 to 10)": "Nombre maximum de votes (de 2 à 10)", "Maximum votes per person": "Nombre maximal de votes par personne", + "Member detail": "Détails du membre", + "Member not found": "Membre introuvable", "Menu": "Menu", "Message deleted": "Message supprimé", "Message failed to send": "Échec de l'envoi du message", @@ -350,8 +406,11 @@ "Message was blocked by moderation policies": "Le message a été bloqué par les politiques de modération", "Messages have been marked unread.": "Les messages ont été marqués comme non lus.", "Missing permissions to upload the attachment": "Autorisations manquantes pour télécharger la pièce jointe", + "Moderator": "Modérateur", "Multiple votes": "Votes multiples", "Mute": "Muet", + "Mute chat": "Mettre le chat en sourdine", + "Mute user": "Mettre l'utilisateur en sourdine", "mute-command-args": "[@nomdutilisateur]", "mute-command-description": "Muter un utilisateur", "network error": "erreur réseau", @@ -361,8 +420,14 @@ "Next image": "Image suivante", "No chats here yet…": "Pas encore de messages ici...", "No conversations yet": "Aucune conversation pour le moment", + "No files": "Aucun fichier", "No items exist": "Aucun élément", + "No member found": "Aucun membre trouvé", + "No messages found": "Aucun message trouvé", + "No photos or videos": "Aucune photo ni vidéo", + "No pinned messages": "Aucun message épinglé", "No results found": "Aucun résultat trouvé", + "No user found": "Aucun utilisateur 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", @@ -374,15 +439,22 @@ "Open gallery at image {{ index }}": "Ouvrir la galerie à l'image {{ index }}", "Open image in gallery": "Ouvrir l'image dans la galerie", "Open location in a map": "Ouvrir l'emplacement dans une carte", + "Open members actions": "Open members actions", + "Open menu": "Ouvrir le menu", "Option already exists": "L'option existe déjà", "Option is empty": "L'option est vide", "Options": "Options", "Original": "Original", + "Owner": "Propriétaire", "People matching": "Correspondance de personnes", "Photo": "Photo", + "Photos & videos": "Photos et vidéos", "Pin": "Épingler", + "Pin a message to see it here": "Épinglez un message pour le voir ici", "Pinned by {{ name }}": "Épinglé par {{ name }}", "Pinned by You": "Épinglé par vous", + "Pinned message": "Message épinglé", + "Pinned messages": "Messages épinglés", "placeholder/PollComment": "Votre commentaire", "placeholder/PollOptionSuggestion": "Saisir une nouvelle option", "Play video": "Lire la vidéo", @@ -402,8 +474,15 @@ "Remind me": "Me rappeler", "Remind Me": "Me rappeler", "Reminder set": "Rappel défini", + "Remove": "Retirer", + "Remove {{ count }} members_one": "Retirer {{ count }} membre", + "Remove {{ count }} members_many": "Retirer {{ count }} membres", + "Remove {{ count }} members_other": "Retirer {{ count }} membres", + "Remove {{ member }} from this channel?": "Retirer {{ member }} de ce canal ?", + "Remove channel members": "Retirer les membres du canal", "Remove reminder": "Supprimer le rappel", "Remove save for later": "Supprimer « Enregistrer pour plus tard »", + "Remove user": "Retirer l'utilisateur", "Replied to a thread": "A répondu à un fil", "Reply": "Répondre", "Reply to {{ authorName }}": "Répondre à {{ authorName }}", @@ -419,6 +498,7 @@ "Review poll results and open an option to see detailed votes": "Consultez les résultats du sondage et ouvrez une option pour voir les votes détaillés", "Review this message and choose whether to delete it, edit it, or send it anyway": "Consultez ce message et choisissez de le supprimer, le modifier ou l'envoyer quand même", "Review who voted for this option": "Consultez qui a voté pour cette option", + "Save": "Enregistrer", "Save for later": "Enregistrer pour plus tard", "Saved for later": "Enregistré pour plus tard", "Search": "Rechercher", @@ -443,11 +523,14 @@ "Send a message": "Envoyez un message", "Send a message to start the conversation": "Envoyez un message pour commencer la conversation", "Send Anyway": "Envoyer quand même", + "Send direct message": "Envoyer un message direct", "Send message request failed": "Échec de la demande d'envoi de message", "Send poll": "Envoyer le sondage", "Sending...": "Envoi en cours...", "Sent": "Envoyé", "Share": "Partager", + "Share a file to see it here": "Partagez un fichier pour le voir ici", + "Share a photo or video to see it here": "Partagez une photo ou une vidéo pour la voir ici", "Share live location for": "Partager l'emplacement en direct pendant", "Share Location": "Partager l'emplacement", "Shared live location": "Emplacement en direct partagé", @@ -468,6 +551,9 @@ "this content could not be displayed": "ce contenu n'a pas pu être affiché", "This field cannot be empty or contain only spaces": "Ce champ ne peut pas être vide ou contenir uniquement des espaces", "This message did not meet our content guidelines": "Ce message ne respecte pas nos directives de contenu", + "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This user will be able to message you again.": "Cet utilisateur pourra à nouveau vous envoyer des messages.", + "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", "Thread": "Fil de discussion", "Thread has not been found": "Le fil de discussion n'a pas été trouvé", "Thread reply": "Réponse dans le fil", @@ -477,6 +563,8 @@ "ThreadListUnseenThreadsBanner/unreadThreads_many": "{{ count }} fils non lus", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} fils non lus", "Threads": "Fils", + "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", + "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Hier]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "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) }}", @@ -501,9 +589,13 @@ "Unarchive": "Désarchiver", "unban-command-args": "[@nomdutilisateur]", "unban-command-description": "Débannir un utilisateur", + "Unblock": "Débloquer", + "Unblock user": "Débloquer l'utilisateur", "Unblock User": "Débloquer l'utilisateur", "unknown error": "erreur inconnue", "Unmute": "Désactiver muet", + "Unmute chat": "Désactiver la sourdine du chat", + "Unmute user": "Désactiver la sourdine de l'utilisateur", "unmute-command-args": "[@nomdutilisateur]", "unmute-command-description": "Démuter un utilisateur", "Unpin": "Détacher", @@ -516,9 +608,13 @@ "Upload blocked": "Téléversement bloqué", "Upload error": "Erreur de téléversement", "Upload failed": "Échec du téléversement", + "Upload Picture": "Importer une image", "Upload type: \"{{ type }}\" is not allowed": "Le type de fichier : \"{{ type }}\" n'est pas autorisé", "User blocked": "Utilisateur bloqué", + "User muted": "Utilisateur mis en sourdine", + "User removed": "Utilisateur retiré", "User unblocked": "Utilisateur débloqué", + "User unmuted": "Sourdine de l'utilisateur désactivée", "User uploaded content": "Contenu téléchargé par l'utilisateur", "Video": "Vidéo", "videoCount_one": "Vidéo", @@ -529,6 +625,7 @@ "View {{count}} comments_many": "Voir {{count}} commentaires", "View {{count}} comments_other": "Voir {{count}} commentaires", "View all": "Tout voir", + "View member details for {{ member }}": "Voir les détails du membre {{ member }}", "View original": "Voir l'original", "View results": "Voir les résultats", "View translation": "Voir la traduction", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 610b1e4290..260f797ccf 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -3,6 +3,10 @@ "{{ commaSeparatedUsers }}, and {{ lastUser }}": "{{ commaSeparatedUsers }} और {{ lastUser }}", "{{ count }} files_one": "{{ count }} फ़ाइल", "{{ count }} files_other": "{{ count }} फ़ाइलें", + "{{ count }} members_one": "{{ count }} सदस्य", + "{{ count }} members_other": "{{ count }} सदस्य", + "{{ count }} members added_one": "{{ count }} सदस्य जोड़ा गया", + "{{ count }} members added_other": "{{ count }} सदस्य जोड़े गए", "{{ count }} people are typing_one": "{{ count }} व्यक्ति टाइप कर रहा है", "{{ count }} people are typing_many": "{{ count }} लोग टाइप कर रहे हैं", "{{ count }} people are typing_other": "{{ count }} लोग टाइप कर रहे हैं", @@ -14,6 +18,8 @@ "{{ count }} videos_other": "{{ count }} वीडियो", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} और {{ secondUser }}", "{{ imageCount }} more": "{{ imageCount }} और", + "{{ member }} will be able to message you again.": "{{ member }} आपको फिर से संदेश भेज सकेगा।", + "{{ member }} won't be able to message you anymore.": "{{ member }} अब आपको संदेश नहीं भेज सकेगा।", "{{ memberCount }} members": "{{ memberCount }} मेंबर्स", "{{ typing }} are typing": "{{ typing }} टाइप कर रहे हैं", "{{ typing }} is typing": "{{ typing }} टाइप कर रहा है", @@ -36,16 +42,24 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} ने बनाया: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} ने वोट दिया: {{pollOptionText}}", "📍Shared location": "📍साझा किया गया स्थान", + "Actions": "Actions", + "Add": "जोड़ें", + "Add {{ count }} members_one": "{{ count }} सदस्य जोड़ें", + "Add {{ count }} members_other": "{{ count }} सदस्य जोड़ें", "Add a comment": "एक टिप्पणी जोड़ें", "Add a comment to your poll answer": "अपने पोल उत्तर में एक टिप्पणी जोड़ें", "Add an option": "एक विकल्प जोड़ें", + "Add channel members": "चैनल सदस्य जोड़ें", + "Add members": "सदस्य जोड़ें", "Add reaction": "प्रतिक्रिया जोड़ें", + "Admin": "एडमिन", "All results loaded": "सभी परिणाम लोड हो गए", "Allow access to camera": "कैमरा तक पहुँच दें", "Allow access to microphone": "माइक्रोफ़ोन तक पहुँच दें", "Allow comments": "टिप्पणियाँ की अनुमति दें", "Allow option suggestion": "विकल्प सुझाव की अनुमति दें", "Allow others to add comments": "दूसरों को टिप्पणी जोड़ने दें", + "Already a member": "पहले से सदस्य", "Also send as a direct message": "सीधे संदेश के रूप में भी भेजें", "Also send in channel": "चैनल में भी भेजें", "Also sent in channel": "चैनल में भी भेजा गया", @@ -55,6 +69,7 @@ "Anonymous poll": "गुमनाम मतदान", "Archive": "आर्काइव", "Are you sure you want to delete this message?": "क्या आप वाकई इस संदेश को हटाना चाहते हैं?", + "Are you sure you want to leave this channel?": "Are you sure you want to leave this channel?", "aria/Attachment": "अनुलग्नक", "aria/Attachment Actions": "अटैचमेंट क्रियाएँ", "aria/Audio position {{ elapsed }} of {{ duration }}": "ऑडियो स्थिति {{ elapsed }} / {{ duration }}", @@ -65,6 +80,7 @@ "aria/Cancel Reply": "उत्तर रद्द करें", "aria/Cancel upload": "अपलोड रद्द करें", "aria/Channel Actions": "चैनल क्रियाएँ", + "aria/Channel details": "चैनल विवरण", "aria/Channel list": "चैनल सूची", "aria/Channel search results": "चैनल खोज परिणाम", "aria/Chat view tabs": "चैट व्यू टैब", @@ -104,10 +120,13 @@ "aria/Notifications": "सूचनाएं", "aria/Open Attachment Selector": "अटैचमेंट चयनकर्ता खोलें", "aria/Open Channel Actions Menu": "चैनल क्रियाएँ मेनू खोलें", + "aria/Open channel details": "चैनल विवरण खोलें", + "aria/Open image shared by {{ name }}": "{{ name }} द्वारा साझा की गई छवि खोलें", "aria/Open Menu": "मेन्यू खोलें", "aria/Open Message Actions Menu": "संदेश क्रिया मेन्यू खोलें", "aria/Open Reaction Selector": "प्रतिक्रिया चयनकर्ता खोलें", "aria/Open Thread": "थ्रेड खोलें", + "aria/Open video shared by {{ name }}": "{{ name }} द्वारा साझा किया गया वीडियो खोलें", "aria/Pause": "रोकें", "aria/Pause recording": "रिकॉर्डिंग रोकें", "aria/Percent complete": "{{percent}} प्रतिशत पूर्ण", @@ -151,10 +170,15 @@ "Back": "वापस", "ban-command-args": "[@उपयोगकर्तनाम] [पाठ]", "ban-command-description": "एक उपयोगकर्ता को प्रतिषेधित करें", + "Block user": "उपयोगकर्ता को ब्लॉक करें", "Block User": "उपयोगकर्ता को ब्लॉक करें", + "Browse channel members": "चैनल सदस्य देखें", + "Browse pinned messages": "पिन किए गए संदेश देखें", "Cancel": "रद्द करें", "Cannot seek in the recording": "रेकॉर्डिंग में खोज नहीं की जा सकती", + "Changes saved": "बदलाव सहेजे गए", "Channel archived": "चैनल संग्रहीत किया गया", + "Channel members": "चैनल सदस्य", "Channel Missing": "चैनल उपलब्ध नहीं है", "Channel muted": "चैनल म्यूट किया गया", "Channel pinned": "चैनल पिन किया गया", @@ -162,6 +186,7 @@ "Channel unmuted": "चैनल अनम्यूट किया गया", "Channel unpinned": "चैनल अनपिन किया गया", "Channels": "चैनल", + "Chat deleted": "Chat deleted", "Chats": "चैट", "Choose between 2 to 10 options": "2 से 10 विकल्प चुनें", "Close": "बंद करे", @@ -173,12 +198,15 @@ "Commands": "कमांड", "Commands matching": "मेल खाती है", "Connection failure, reconnecting now...": "कनेक्शन विफल रहा, अब पुनः कनेक्ट हो रहा है ...", + "Contact info": "संपर्क जानकारी", + "Contact name": "संपर्क का नाम", "Copy Message": "संदेश कॉपी करें", "Create": "बनाएँ", "Create a question, add options, and configure poll settings": "एक प्रश्न बनाएं, विकल्प जोड़ें और पोल सेटिंग्स कॉन्फ़िगर करें", "Create poll": "मतदान बनाएँ", "Current location": "वर्तमान स्थान", "Delete": "डिलीट", + "Delete chat": "चैट हटाएं", "Delete for me": "मेरे लिए डिलीट करें", "Delete message": "संदेश हटाएं", "Delivered": "पहुंच गया", @@ -194,6 +222,10 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "संपादित करें", + "Edit chat data": "चैट डेटा संपादित करें", + "Edit contact": "संपर्क संपादित करें", + "Edit group": "समूह संपादित करें", "Edit Message": "मैसेज में बदलाव करे", "Edit message request failed": "संदेश संपादित करने का अनुरोध विफल रहा", "Edited": "संपादित", @@ -206,17 +238,27 @@ "Enforce unique vote is enabled": "अनोखा वोट सक्षम है", "Error": "त्रुटि", "Error adding flag": "ध्वज जोड़ने में त्रुटि", + "Error adding members": "Error adding members", + "Error blocking user": "उपयोगकर्ता को ब्लॉक करने में त्रुटि", "Error connecting to chat, refresh the page to try again.": "चैट से कनेक्ट करने में त्रुटि, पेज को रिफ्रेश करें", + "Error deleting chat": "Error deleting chat", "Error deleting message": "संदेश हटाने में त्रुटि", "Error fetching reactions": "प्रतिक्रियाएँ लोड करने में त्रुटि", "Error marking message unread": "संदेश को अपठित चिह्नित करने में त्रुटि", "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "संदेश को अपठित मार्क करने में त्रुटि। सबसे नए 100 चैनल संदेश से पहले के सभी अपठित संदेशों को अपठित मार्क नहीं किया जा सकता है।", "Error muting a user ...": "यूजर को म्यूट करने का प्रयास फेल हुआ", + "Error muting channel": "चैनल म्यूट करने में त्रुटि", + "Error muting user": "उपयोगकर्ता को म्यूट करने में त्रुटि", + "Error opening direct message": "सीधा संदेश खोलने में त्रुटि", "Error pinning message": "संदेश को पिन करने में त्रुटि", "Error removing message pin": "संदेश पिन निकालने में त्रुटि", + "Error removing user": "उपयोगकर्ता हटाने में त्रुटि", "Error reproducing the recording": "रिकॉर्डिंग पुन: उत्पन्न करने में त्रुटि", "Error starting recording": "रेकॉर्डिंग शुरू करने में त्रुटि", + "Error unblocking user": "उपयोगकर्ता को अनब्लॉक करने में त्रुटि", "Error unmuting a user ...": "यूजर को अनम्यूट करने का प्रयास फेल हुआ", + "Error unmuting channel": "चैनल को अनम्यूट करने में त्रुटि", + "Error unmuting user": "उपयोगकर्ता को अनम्यूट करने में त्रुटि", "Error uploading attachment": "अटैचमेंट अपलोड करते समय त्रुटि", "Error uploading file": "फ़ाइल अपलोड करने में त्रुटि", "Error uploading image": "छवि अपलोड करने में त्रुटि", @@ -235,6 +277,7 @@ "Failed to mark channel as read": "चैनल को पढ़ा हुआ चिह्नित करने में विफल।", "Failed to play the recording": "रेकॉर्डिंग प्ले करने में विफल", "Failed to retrieve location": "स्थान प्राप्त करने में विफल", + "Failed to save changes": "बदलाव सहेजने में विफल", "Failed to share location": "स्थान साझा करने में विफल", "Failed to update channel archive status": "चैनल के आर्काइव स्थिति को अपडेट करने में विफल", "Failed to update channel mute status": "चैनल की म्यूट स्थिति को अपडेट करने में विफल", @@ -245,10 +288,14 @@ "File too large": "फ़ाइल बहुत बड़ी है", "fileCount_one": "1 फ़ाइल", "fileCount_other": "{{ count }} फ़ाइलें", + "Files": "फ़ाइलें", "Flag": "फ्लैग करे", "Generating...": "बना रहा है...", "giphy-command-args": "[पाठ]", "giphy-command-description": "चैनल पर एक क्रॉफिल जीआइएफ पोस्ट करें", + "Go back": "वापस जाएं", + "Group info": "समूह जानकारी", + "Group name": "समूह का नाम", "Hide who voted": "किसने वोट दिया छिपाएं", "Image": "छवि", "imageCount_one": "1 छवि", @@ -311,7 +358,9 @@ "language/vi": "वियतनामी", "language/zh": "चीनी (सरलीकृत)", "language/zh-TW": "चीनी (पारंपरिक)", + "Last seen {{ timestamp }}": "अंतिम बार {{ timestamp }} देखा गया", "Leave Channel": "चैनल छोड़ें", + "Leave chat": "चैनल छोड़ें", "Left channel": "चैनल छोड़ दिया गया", "Let others add options": "दूसरों को विकल्प जोड़ने दें", "Limit votes per person": "प्रति व्यक्ति वोट सीमित करें", @@ -327,9 +376,13 @@ "Location": "स्थान", "Location sharing ended": "स्थान साझा करना समाप्त", "Location: {{ coordinates }}": "स्थान: {{ coordinates }}", + "Manage channel": "चैनल प्रबंधित करें", + "Manage members": "सदस्य प्रबंधित करें", "Mark as unread": "अपठित चिह्नित करें", "Maximum number of votes (from 2 to 10)": "अधिकतम वोटों की संख्या (2 से 10)", "Maximum votes per person": "प्रति व्यक्ति अधिकतम वोट", + "Member detail": "सदस्य विवरण", + "Member not found": "सदस्य नहीं मिला", "Menu": "मेन्यू", "Message deleted": "मैसेज हटा दिया गया", "Message failed to send": "संदेश भेजने में विफल", @@ -340,8 +393,11 @@ "Message was blocked by moderation policies": "संदेश को मॉडरेशन नीतियों द्वारा ब्लॉक कर दिया गया है", "Messages have been marked unread.": "संदेशों को अपठित चिह्नित किया गया है।", "Missing permissions to upload the attachment": "अटैचमेंट अपलोड करने के लिए अनुमतियां गायब", + "Moderator": "मॉडरेटर", "Multiple votes": "कई वोट", "Mute": "म्यूट करे", + "Mute chat": "चैट म्यूट करें", + "Mute user": "उपयोगकर्ता को म्यूट करें", "mute-command-args": "[@उपयोगकर्तनाम]", "mute-command-description": "एक उपयोगकर्ता को म्यूट करें", "network error": "नेटवर्क त्रुटि", @@ -351,8 +407,14 @@ "Next image": "अगली छवि", "No chats here yet…": "यहां अभी तक कोई चैट नहीं...", "No conversations yet": "अभी तक कोई बातचीत नहीं है", + "No files": "कोई फ़ाइल नहीं", "No items exist": "कोई आइटम मौजूद नहीं है", + "No member found": "कोई सदस्य नहीं मिला", + "No messages found": "कोई संदेश नहीं मिला", + "No photos or videos": "कोई फ़ोटो या वीडियो नहीं", + "No pinned messages": "कोई पिन किया गया संदेश नहीं", "No results found": "कोई परिणाम नहीं मिला", + "No user found": "कोई उपयोगकर्ता नहीं मिला", "Nobody will be able to vote in this poll anymore.": "अब कोई भी इस मतदान में मतदान नहीं कर सकेगा।", "Nothing yet...": "कोई मैसेज नहीं है", "Offline": "ऑफलाइन", @@ -364,15 +426,22 @@ "Open gallery at image {{ index }}": "गैलरी को छवि {{ index }} पर खोलें", "Open image in gallery": "छवि को गैलरी में खोलें", "Open location in a map": "मानचित्र में स्थान खोलें", + "Open members actions": "Open members actions", + "Open menu": "मेन्यू खोलें", "Option already exists": "विकल्प पहले से मौजूद है", "Option is empty": "विकल्प खाली है", "Options": "विकल्प", "Original": "मूल", + "Owner": "मालिक", "People matching": "मेल खाते लोग", "Photo": "फ़ोटो", + "Photos & videos": "फ़ोटो और वीडियो", "Pin": "पिन", + "Pin a message to see it here": "इसे यहाँ देखने के लिए संदेश पिन करें", "Pinned by {{ name }}": "{{ name }} द्वारा पिन किया गया", "Pinned by You": "आपके द्वारा पिन किया गया", + "Pinned message": "पिन किया गया संदेश", + "Pinned messages": "पिन किए गए संदेश", "placeholder/PollComment": "आपकी टिप्पणी", "placeholder/PollOptionSuggestion": "नया विकल्प दर्ज करें", "Play video": "वीडियो चलाएं", @@ -392,8 +461,14 @@ "Remind me": "मुझे याद दिलाएं", "Remind Me": "मुझे याद दिलाएं", "Reminder set": "अनुस्मारक सेट किया गया", + "Remove": "हटाएं", + "Remove {{ count }} members_one": "{{ count }} सदस्य हटाएं", + "Remove {{ count }} members_other": "{{ count }} सदस्य हटाएं", + "Remove {{ member }} from this channel?": "इस चैनल से {{ member }} को हटाएं?", + "Remove channel members": "चैनल सदस्य हटाएं", "Remove reminder": "रिमाइंडर हटाएं", "Remove save for later": "बाद में देखें हटाएं", + "Remove user": "उपयोगकर्ता हटाएं", "Replied to a thread": "थ्रेड में जवाब दिया", "Reply": "जवाब दे दो", "Reply to {{ authorName }}": "{{ authorName }} को जवाब दें", @@ -408,6 +483,7 @@ "Review poll results and open an option to see detailed votes": "पोल परिणामों की समीक्षा करें और विस्तृत वोट देखने के लिए एक विकल्प खोलें", "Review this message and choose whether to delete it, edit it, or send it anyway": "इस संदेश की समीक्षा करें और चुनें कि इसे हटाना है, संपादित करना है या फिर भी भेजना है", "Review who voted for this option": "समीक्षा करें कि इस विकल्प के लिए किसने वोट किया", + "Save": "सहेजें", "Save for later": "बाद के लिए सहेजें", "Saved for later": "बाद के लिए सहेजा गया", "Search": "खोज", @@ -430,11 +506,14 @@ "Send a message": "संदेश भेजें", "Send a message to start the conversation": "बातचीत शुरू करने के लिए संदेश भेजें", "Send Anyway": "वैसे भी भेजें", + "Send direct message": "सीधा संदेश भेजें", "Send message request failed": "संदेश भेजने का अनुरोध विफल रहा", "Send poll": "पोल भेजें", "Sending...": "भेजा जा रहा है", "Sent": "भेजा गया", "Share": "साझा करें", + "Share a file to see it here": "इसे यहाँ देखने के लिए एक फ़ाइल साझा करें", + "Share a photo or video to see it here": "इसे यहाँ देखने के लिए एक फ़ोटो या वीडियो साझा करें", "Share live location for": "लाइव स्थान साझा करें", "Share Location": "स्थान साझा करें", "Shared live location": "साझा किया गया लाइव स्थान", @@ -455,6 +534,9 @@ "this content could not be displayed": "यह कॉन्टेंट लोड नहीं हो पाया", "This field cannot be empty or contain only spaces": "यह फ़ील्ड खाली नहीं हो सकता या केवल रिक्त स्थान नहीं रख सकता", "This message did not meet our content guidelines": "यह संदेश हमारे सामग्री दिशानिर्देशों के अनुरूप नहीं था", + "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This user will be able to message you again.": "यह उपयोगकर्ता आपको फिर से संदेश भेज सकेगा।", + "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", "Thread": "रिप्लाई थ्रेड", "Thread has not been found": "थ्रेड नहीं मिला", "Thread reply": "थ्रेड में उत्तर", @@ -463,6 +545,8 @@ "ThreadListUnseenThreadsBanner/unreadThreads_one": "{{ count }} अपठित थ्रेड", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} अपठित थ्रेड", "Threads": "थ्रेड्स", + "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", + "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[बीता कल]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज]\", \"nextDay\": \"[कल]\", \"lastDay\": \"[बीता कल]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[पिछला] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -486,9 +570,13 @@ "Unarchive": "अनआर्काइव", "unban-command-args": "[@उपयोगकर्तनाम]", "unban-command-description": "एक उपयोगकर्ता को प्रतिषेध से मुक्त करें", + "Unblock": "अनब्लॉक करें", + "Unblock user": "उपयोगकर्ता अनब्लॉक करें", "Unblock User": "उपयोगकर्ता अनब्लॉक करें", "unknown error": "अज्ञात त्रुटि", "Unmute": "अनम्यूट", + "Unmute chat": "चैट अनम्यूट करें", + "Unmute user": "उपयोगकर्ता को अनम्यूट करें", "unmute-command-args": "[@उपयोगकर्तनाम]", "unmute-command-description": "एक उपयोगकर्ता को अनम्यूट करें", "Unpin": "अनपिन", @@ -501,9 +589,13 @@ "Upload blocked": "अपलोड अवरुद्ध", "Upload error": "अपलोड त्रुटि", "Upload failed": "अपलोड विफल", + "Upload Picture": "चित्र अपलोड करें", "Upload type: \"{{ type }}\" is not allowed": "अपलोड प्रकार: \"{{ type }}\" की अनुमति नहीं है", "User blocked": "उपयोगकर्ता अवरुद्ध किया गया", + "User muted": "उपयोगकर्ता म्यूट किया गया", + "User removed": "उपयोगकर्ता हटा दिया गया", "User unblocked": "उपयोगकर्ता अनब्लॉक किया गया", + "User unmuted": "उपयोगकर्ता अनम्यूट किया गया", "User uploaded content": "उपयोगकर्ता अपलोड की गई सामग्री", "Video": "वीडियो", "videoCount_one": "1 वीडियो", @@ -512,6 +604,7 @@ "View {{count}} comments_one": "देखें {{count}} टिप्पणी", "View {{count}} comments_other": "देखें {{count}} टिप्पणियाँ", "View all": "सभी देखें", + "View member details for {{ member }}": "{{ member }} के सदस्य विवरण देखें", "View original": "मूल देखें", "View results": "परिणाम देखें", "View translation": "अनुवाद देखें", diff --git a/src/i18n/it.json b/src/i18n/it.json index 50150d462f..2c1caee84c 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -4,6 +4,12 @@ "{{ count }} files_one": "{{ count }} file", "{{ count }} files_many": "{{ count }} file", "{{ count }} files_other": "{{ count }} file", + "{{ count }} members_one": "{{ count }} membro", + "{{ count }} members_many": "{{ count }} membri", + "{{ count }} members_other": "{{ count }} membri", + "{{ count }} members added_one": "{{ count }} membro aggiunto", + "{{ count }} members added_many": "{{ count }} membri aggiunti", + "{{ count }} members added_other": "{{ count }} membri aggiunti", "{{ count }} people are typing_one": "{{ count }} persona sta scrivendo", "{{ count }} people are typing_many": "{{ count }} persone stanno scrivendo", "{{ count }} people are typing_other": "{{ count }} persone stanno scrivendo", @@ -18,6 +24,8 @@ "{{ count }} videos_other": "{{ count }} video", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} e {{ secondUser }}", "{{ imageCount }} more": "+ {{ imageCount }}", + "{{ member }} will be able to message you again.": "{{ member }} potrà inviarti di nuovo messaggi.", + "{{ member }} won't be able to message you anymore.": "{{ member }} non potrà più inviarti messaggi.", "{{ memberCount }} members": "{{ memberCount }} membri", "{{ typing }} are typing": "{{ typing }} stanno scrivendo", "{{ typing }} is typing": "{{ typing }} sta scrivendo", @@ -44,16 +52,25 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} ha creato: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} ha votato: {{pollOptionText}}", "📍Shared location": "📍Posizione condivisa", + "Actions": "Actions", + "Add": "Aggiungi", + "Add {{ count }} members_one": "Aggiungi {{ count }} membro", + "Add {{ count }} members_many": "Aggiungi {{ count }} membri", + "Add {{ count }} members_other": "Aggiungi {{ count }} membri", "Add a comment": "Aggiungi un commento", "Add a comment to your poll answer": "Aggiungi un commento alla tua risposta al sondaggio", "Add an option": "Aggiungi un'opzione", + "Add channel members": "Aggiungi membri al canale", + "Add members": "Aggiungi membri", "Add reaction": "Aggiungi reazione", + "Admin": "Admin", "All results loaded": "Tutti i risultati caricati", "Allow access to camera": "Consenti l'accesso alla fotocamera", "Allow access to microphone": "Consenti l'accesso al microfono", "Allow comments": "Consenti i commenti", "Allow option suggestion": "Consenti il suggerimento di opzioni", "Allow others to add comments": "Consenti ad altri di aggiungere commenti", + "Already a member": "Già membro", "Also send as a direct message": "Invia anche come messaggio diretto", "Also send in channel": "Invia anche nel canale", "Also sent in channel": "Inviato anche nel canale", @@ -63,6 +80,7 @@ "Anonymous poll": "Sondaggio anonimo", "Archive": "Archivia", "Are you sure you want to delete this message?": "Sei sicuro di voler eliminare questo messaggio?", + "Are you sure you want to leave this channel?": "Are you sure you want to leave this channel?", "aria/Attachment": "Allegato", "aria/Attachment Actions": "Azioni allegato", "aria/Audio position {{ elapsed }} of {{ duration }}": "Posizione audio {{ elapsed }} di {{ duration }}", @@ -73,6 +91,7 @@ "aria/Cancel Reply": "Annulla risposta", "aria/Cancel upload": "Annulla caricamento", "aria/Channel Actions": "Azioni canale", + "aria/Channel details": "Dettagli canale", "aria/Channel list": "Elenco dei canali", "aria/Channel search results": "Risultati della ricerca dei canali", "aria/Chat view tabs": "Schede visualizzazione chat", @@ -112,10 +131,13 @@ "aria/Notifications": "Notifiche", "aria/Open Attachment Selector": "Apri selettore allegati", "aria/Open Channel Actions Menu": "Apri menu azioni canale", + "aria/Open channel details": "Apri dettagli canale", + "aria/Open image shared by {{ name }}": "Apri l'immagine condivisa da {{ name }}", "aria/Open Menu": "Apri menu", "aria/Open Message Actions Menu": "Apri il menu delle azioni di messaggio", "aria/Open Reaction Selector": "Apri il selettore di reazione", "aria/Open Thread": "Apri discussione", + "aria/Open video shared by {{ name }}": "Apri il video condiviso da {{ name }}", "aria/Pause": "Pausa", "aria/Pause recording": "Metti in pausa registrazione", "aria/Percent complete": "{{percent}} percento completato", @@ -159,10 +181,15 @@ "Back": "Indietro", "ban-command-args": "[@nomeutente] [testo]", "ban-command-description": "Vietare un utente", + "Block user": "Blocca utente", "Block User": "Blocca utente", + "Browse channel members": "Sfoglia membri del canale", + "Browse pinned messages": "Sfoglia messaggi fissati", "Cancel": "Annulla", "Cannot seek in the recording": "Impossibile cercare nella registrazione", + "Changes saved": "Modifiche salvate", "Channel archived": "Canale archiviato", + "Channel members": "Membri del canale", "Channel Missing": "Il canale non esiste", "Channel muted": "Canale silenziato", "Channel pinned": "Canale fissato", @@ -170,6 +197,7 @@ "Channel unmuted": "Canale non più silenziato", "Channel unpinned": "Canale rimosso dai fissati", "Channels": "Canali", + "Chat deleted": "Chat deleted", "Chats": "Chat", "Choose between 2 to 10 options": "Scegli tra 2 e 10 opzioni", "Close": "Chiudi", @@ -181,12 +209,15 @@ "Commands": "Comandi", "Commands matching": "Comandi corrispondenti", "Connection failure, reconnecting now...": "Errore di connessione, riconnessione in corso...", + "Contact info": "Informazioni contatto", + "Contact name": "Nome del contatto", "Copy Message": "Copia messaggio", "Create": "Crea", "Create a question, add options, and configure poll settings": "Crea una domanda, aggiungi opzioni e configura le impostazioni del sondaggio", "Create poll": "Crea sondaggio", "Current location": "Posizione attuale", "Delete": "Elimina", + "Delete chat": "Elimina chat", "Delete for me": "Elimina per me", "Delete message": "Elimina messaggio", "Delivered": "Consegnato", @@ -202,6 +233,10 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "Modifica", + "Edit chat data": "Modifica dati chat", + "Edit contact": "Modifica contatto", + "Edit group": "Modifica gruppo", "Edit Message": "Modifica messaggio", "Edit message request failed": "Richiesta di modifica del messaggio non riuscita", "Edited": "Modificato", @@ -214,16 +249,26 @@ "Enforce unique vote is enabled": "Il voto unico è abilitato", "Error": "Errore", "Error adding flag": "Errore durante l'aggiunta del flag", + "Error adding members": "Error adding members", + "Error blocking user": "Errore durante il blocco dell'utente", "Error connecting to chat, refresh the page to try again.": "Errore di connessione alla chat, aggiorna la pagina per riprovare.", + "Error deleting chat": "Error deleting chat", "Error deleting message": "Errore durante l'eliminazione del messaggio", "Error fetching reactions": "Errore nel caricamento delle reazioni", "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Errore durante la marcatura del messaggio come non letto. Impossibile marcare messaggi non letti più vecchi dei più recenti 100 messaggi del canale.", "Error muting a user ...": "Errore nel silenziare un utente ...", + "Error muting channel": "Errore durante la disattivazione delle notifiche del canale", + "Error muting user": "Errore durante la disattivazione delle notifiche dell'utente", + "Error opening direct message": "Errore nell'apertura del messaggio diretto", "Error pinning message": "Errore durante il blocco del messaggio", "Error removing message pin": "Errore durante la rimozione del PIN del messaggio", + "Error removing user": "Errore nella rimozione dell'utente", "Error reproducing the recording": "Errore durante la riproduzione della registrazione", "Error starting recording": "Errore durante l'avvio della registrazione", + "Error unblocking user": "Errore durante lo sblocco dell'utente", "Error unmuting a user ...": "Errore nel riattivare un utente ...", + "Error unmuting channel": "Errore durante la riattivazione del canale", + "Error unmuting user": "Errore durante la riattivazione dell'utente", "Error uploading attachment": "Errore durante il caricamento dell'allegato", "Error uploading file": "Errore durante il caricamento del file", "Error uploading image": "Errore durante il caricamento dell'immagine", @@ -242,6 +287,7 @@ "Failed to mark channel as read": "Impossibile contrassegnare il canale come letto", "Failed to play the recording": "Impossibile riprodurre la registrazione", "Failed to retrieve location": "Impossibile recuperare la posizione", + "Failed to save changes": "Impossibile salvare le modifiche", "Failed to share location": "Impossibile condividere la posizione", "Failed to update channel archive status": "Impossibile aggiornare lo stato di archiviazione del canale", "Failed to update channel mute status": "Impossibile aggiornare lo stato di silenziamento del canale", @@ -253,10 +299,14 @@ "fileCount_one": "1 file", "fileCount_many": "{{ count }} file", "fileCount_other": "{{ count }} file", + "Files": "File", "Flag": "Segnala", "Generating...": "Generando...", "giphy-command-args": "[testo]", "giphy-command-description": "Pubblica un gif casuale sul canale", + "Go back": "Indietro", + "Group info": "Informazioni gruppo", + "Group name": "Nome del gruppo", "Hide who voted": "Nascondi chi ha votato", "Image": "Immagine", "imageCount_one": "Immagine", @@ -320,7 +370,9 @@ "language/vi": "Vietnamita", "language/zh": "Cinese (semplificato)", "language/zh-TW": "Cinese (tradizionale)", + "Last seen {{ timestamp }}": "Ultimo accesso {{ timestamp }}", "Leave Channel": "Lascia il canale", + "Leave chat": "Lascia il canale", "Left channel": "Canale lasciato", "Let others add options": "Lascia che altri aggiungano opzioni", "Limit votes per person": "Limita i voti per persona", @@ -337,9 +389,13 @@ "Location": "Posizione", "Location sharing ended": "Condivisione posizione terminata", "Location: {{ coordinates }}": "Posizione: {{ coordinates }}", + "Manage channel": "Gestisci canale", + "Manage members": "Gestisci membri", "Mark as unread": "Contrassegna come non letto", "Maximum number of votes (from 2 to 10)": "Numero massimo di voti (da 2 a 10)", "Maximum votes per person": "Voti massimi per persona", + "Member detail": "Dettagli membro", + "Member not found": "Membro non trovato", "Menu": "Menù", "Message deleted": "Messaggio cancellato", "Message failed to send": "Invio del messaggio non riuscito", @@ -350,8 +406,11 @@ "Message was blocked by moderation policies": "Il messaggio è stato bloccato dalle politiche di moderazione", "Messages have been marked unread.": "I messaggi sono stati contrassegnati come non letti.", "Missing permissions to upload the attachment": "Autorizzazioni mancanti per caricare l'allegato", + "Moderator": "Moderatore", "Multiple votes": "Voti multipli", "Mute": "Silenzia", + "Mute chat": "Disattiva notifiche chat", + "Mute user": "Disattiva notifiche utente", "mute-command-args": "[@nomeutente]", "mute-command-description": "Silenzia un utente", "network error": "errore di rete", @@ -361,8 +420,14 @@ "Next image": "Immagine successiva", "No chats here yet…": "Non ci sono ancora messaggi qui...", "No conversations yet": "Ancora nessuna conversazione", + "No files": "Nessun file", "No items exist": "Nessun elemento presente", + "No member found": "Nessun membro trovato", + "No messages found": "Nessun messaggio trovato", + "No photos or videos": "Nessuna foto o video", + "No pinned messages": "Nessun messaggio fissato", "No results found": "Nessun risultato trovato", + "No user found": "Nessun utente trovato", "Nobody will be able to vote in this poll anymore.": "Nessuno potrà più votare in questo sondaggio.", "Nothing yet...": "Ancora niente...", "Offline": "Offline", @@ -374,15 +439,22 @@ "Open gallery at image {{ index }}": "Apri la galleria all'immagine {{ index }}", "Open image in gallery": "Apri immagine nella galleria", "Open location in a map": "Apri posizione in una mappa", + "Open members actions": "Open members actions", + "Open menu": "Apri menu", "Option already exists": "L'opzione esiste già", "Option is empty": "L'opzione è vuota", "Options": "Opzioni", "Original": "Originale", + "Owner": "Proprietario", "People matching": "Persone che corrispondono", "Photo": "Foto", + "Photos & videos": "Foto e video", "Pin": "Appunta", + "Pin a message to see it here": "Appunta un messaggio per vederlo qui", "Pinned by {{ name }}": "Appuntato da {{ name }}", "Pinned by You": "Fissato da te", + "Pinned message": "Messaggio fissato", + "Pinned messages": "Messaggi fissati", "placeholder/PollComment": "Il tuo commento", "placeholder/PollOptionSuggestion": "Inserisci una nuova opzione", "Play video": "Riproduci video", @@ -402,8 +474,15 @@ "Remind me": "Promemoria", "Remind Me": "Ricordami", "Reminder set": "Promemoria impostato", + "Remove": "Rimuovi", + "Remove {{ count }} members_one": "Rimuovi {{ count }} membro", + "Remove {{ count }} members_many": "Rimuovi {{ count }} membri", + "Remove {{ count }} members_other": "Rimuovi {{ count }} membri", + "Remove {{ member }} from this channel?": "Rimuovere {{ member }} da questo canale?", + "Remove channel members": "Rimuovi membri del canale", "Remove reminder": "Rimuovi promemoria", "Remove save for later": "Rimuovi Salva per dopo", + "Remove user": "Rimuovi utente", "Replied to a thread": "Ha risposto in un thread", "Reply": "Rispondi", "Reply to {{ authorName }}": "Rispondi a {{ authorName }}", @@ -419,6 +498,7 @@ "Review poll results and open an option to see detailed votes": "Rivedi i risultati del sondaggio e apri un'opzione per vedere i voti dettagliati", "Review this message and choose whether to delete it, edit it, or send it anyway": "Rivedi questo messaggio e scegli se eliminarlo, modificarlo o inviarlo comunque", "Review who voted for this option": "Rivedi chi ha votato per questa opzione", + "Save": "Salva", "Save for later": "Salva per dopo", "Saved for later": "Salvato per dopo", "Search": "Cerca", @@ -443,11 +523,14 @@ "Send a message": "Invia un messaggio", "Send a message to start the conversation": "Invia un messaggio per iniziare la conversazione", "Send Anyway": "Invia comunque", + "Send direct message": "Invia messaggio diretto", "Send message request failed": "Richiesta di invio messaggio non riuscita", "Send poll": "Invia sondaggio", "Sending...": "Invio in corso...", "Sent": "Inviato", "Share": "Condividi", + "Share a file to see it here": "Condividi un file per vederlo qui", + "Share a photo or video to see it here": "Condividi una foto o un video per vederlo qui", "Share live location for": "Condividi posizione live per", "Share Location": "Condividi posizione", "Shared live location": "Posizione live condivisa", @@ -468,6 +551,9 @@ "this content could not be displayed": "questo contenuto non può essere mostrato", "This field cannot be empty or contain only spaces": "Questo campo non può essere vuoto o contenere solo spazi", "This message did not meet our content guidelines": "Questo messaggio non soddisfa le nostre linee guida sui contenuti", + "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This user will be able to message you again.": "Questo utente potrà inviarti di nuovo messaggi.", + "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", "Thread": "Discussione", "Thread has not been found": "Discussione non trovata", "Thread reply": "Risposta nella discussione", @@ -477,6 +563,8 @@ "ThreadListUnseenThreadsBanner/unreadThreads_many": "{{ count }} thread non letti", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} thread non letti", "Threads": "Thread", + "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", + "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Ieri]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "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) }}", @@ -501,9 +589,13 @@ "Unarchive": "Ripristina", "unban-command-args": "[@nomeutente]", "unban-command-description": "Togliere il divieto a un utente", + "Unblock": "Sblocca", + "Unblock user": "Sblocca utente", "Unblock User": "Sblocca utente", "unknown error": "errore sconosciuto", "Unmute": "Riattiva il notifiche", + "Unmute chat": "Riattiva chat", + "Unmute user": "Riattiva utente", "unmute-command-args": "[@nomeutente]", "unmute-command-description": "Togliere il silenzio a un utente", "Unpin": "Sblocca", @@ -516,9 +608,13 @@ "Upload blocked": "Caricamento bloccato", "Upload error": "Errore di caricamento", "Upload failed": "Caricamento non riuscito", + "Upload Picture": "Carica immagine", "Upload type: \"{{ type }}\" is not allowed": "Tipo di caricamento: \"{{ type }}\" non è consentito", "User blocked": "Utente bloccato", + "User muted": "Utente silenziato", + "User removed": "Utente rimosso", "User unblocked": "Utente sbloccato", + "User unmuted": "Utente riattivato", "User uploaded content": "Contenuto caricato dall'utente", "Video": "Video", "videoCount_one": "Video", @@ -529,6 +625,7 @@ "View {{count}} comments_many": "Visualizza {{count}} commenti", "View {{count}} comments_other": "Visualizza {{count}} commenti", "View all": "Visualizza tutto", + "View member details for {{ member }}": "Visualizza dettagli membro per {{ member }}", "View original": "Visualizza originale", "View results": "Vedi risultati", "View translation": "Visualizza traduzione", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 2baa86ad3c..35e9c637ef 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -3,6 +3,8 @@ "{{ commaSeparatedUsers }}, and {{ lastUser }}": "{{ commaSeparatedUsers }} と {{ lastUser }}", "{{ count }} files_one": "{{ count }} ファイル", "{{ count }} files_other": "{{ count }} ファイル", + "{{ count }} members_other": "{{ count }}人のメンバー", + "{{ count }} members added_other": "{{ count }}人のメンバーを追加しました", "{{ count }} people are typing_one": "{{ count }}人が入力中です", "{{ count }} people are typing_many": "{{ count }}人が入力中です", "{{ count }} people are typing_other": "{{ count }}人が入力中です", @@ -13,6 +15,8 @@ "{{ count }} videos_other": "{{ count }} 動画", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} と {{ secondUser }}", "{{ imageCount }} more": "{{ imageCount }} イメージ", + "{{ member }} will be able to message you again.": "{{ member }}は再びメッセージを送信できるようになります。", + "{{ member }} won't be able to message you anymore.": "{{ member }}はメッセージを送信できなくなります。", "{{ memberCount }} members": "{{ memberCount }} メンバー", "{{ typing }} are typing": "{{ typing }}が入力中です", "{{ typing }} is typing": "{{ typing }}が入力中です", @@ -35,16 +39,23 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} が作成: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} が投票: {{pollOptionText}}", "📍Shared location": "📍共有された位置情報", + "Actions": "Actions", + "Add": "追加", + "Add {{ count }} members_other": "{{ count }}人のメンバーを追加", "Add a comment": "コメントを追加", "Add a comment to your poll answer": "投票回答にコメントを追加", "Add an option": "オプションを追加", + "Add channel members": "チャンネルメンバーを追加", + "Add members": "メンバーを追加", "Add reaction": "リアクションを追加", + "Admin": "管理者", "All results loaded": "すべての結果が読み込まれました", "Allow access to camera": "カメラへのアクセスを許可する", "Allow access to microphone": "マイクロフォンへのアクセスを許可する", "Allow comments": "コメントを許可", "Allow option suggestion": "オプションの提案を許可", "Allow others to add comments": "他の人にコメントを追加することを許可する", + "Already a member": "すでにメンバーです", "Also send as a direct message": "ダイレクトメッセージとしても送信", "Also send in channel": "チャンネルにも送信", "Also sent in channel": "チャンネルにも送信済み", @@ -54,6 +65,7 @@ "Anonymous poll": "匿名投票", "Archive": "アーカイブ", "Are you sure you want to delete this message?": "このメッセージを削除してもよろしいですか?", + "Are you sure you want to leave this channel?": "Are you sure you want to leave this channel?", "aria/Attachment": "添付ファイル", "aria/Attachment Actions": "添付ファイルの操作", "aria/Audio position {{ elapsed }} of {{ duration }}": "音声位置 {{ elapsed }} / {{ duration }}", @@ -64,6 +76,7 @@ "aria/Cancel Reply": "返信をキャンセル", "aria/Cancel upload": "アップロードをキャンセル", "aria/Channel Actions": "チャンネル操作", + "aria/Channel details": "チャンネル詳細", "aria/Channel list": "チャンネル一覧", "aria/Channel search results": "チャンネル検索結果", "aria/Chat view tabs": "チャットビューのタブ", @@ -103,10 +116,13 @@ "aria/Notifications": "通知", "aria/Open Attachment Selector": "添付ファイル選択を開く", "aria/Open Channel Actions Menu": "チャンネルアクションメニューを開く", + "aria/Open channel details": "チャンネル詳細を開く", + "aria/Open image shared by {{ name }}": "{{ name }}が共有した画像を開く", "aria/Open Menu": "メニューを開く", "aria/Open Message Actions Menu": "メッセージアクションメニューを開く", "aria/Open Reaction Selector": "リアクションセレクターを開く", "aria/Open Thread": "スレッドを開く", + "aria/Open video shared by {{ name }}": "{{ name }}が共有した動画を開く", "aria/Pause": "一時停止", "aria/Pause recording": "録音を一時停止", "aria/Percent complete": "{{percent}}パーセント完了", @@ -150,10 +166,15 @@ "Back": "戻る", "ban-command-args": "[@ユーザ名] [テキスト]", "ban-command-description": "ユーザーを禁止する", + "Block user": "ユーザーをブロック", "Block User": "ユーザーをブロック", + "Browse channel members": "チャンネルメンバーを表示", + "Browse pinned messages": "ピン留めメッセージを表示", "Cancel": "キャンセル", "Cannot seek in the recording": "録音中にシークできません", + "Changes saved": "変更を保存しました", "Channel archived": "チャンネルをアーカイブしました", + "Channel members": "チャンネルメンバー", "Channel Missing": "チャネルがありません", "Channel muted": "チャンネルをミュートしました", "Channel pinned": "チャンネルをピン留めしました", @@ -161,6 +182,7 @@ "Channel unmuted": "チャンネルのミュートを解除しました", "Channel unpinned": "チャンネルのピン留めを解除しました", "Channels": "チャンネル", + "Chat deleted": "Chat deleted", "Chats": "チャット", "Choose between 2 to 10 options": "2〜10の選択肢から選ぶ", "Close": "閉める", @@ -172,12 +194,15 @@ "Commands": "コマンド", "Commands matching": "一致するコマンド", "Connection failure, reconnecting now...": "接続が失敗しました。再接続中...", + "Contact info": "連絡先情報", + "Contact name": "連絡先名", "Copy Message": "メッセージをコピー", "Create": "作成", "Create a question, add options, and configure poll settings": "質問を作成し、選択肢を追加して投票設定を構成", "Create poll": "投票を作成", "Current location": "現在の位置", "Delete": "消去", + "Delete chat": "チャットを削除", "Delete for me": "自分用に削除", "Delete message": "メッセージを削除", "Delivered": "配信しました", @@ -193,6 +218,10 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "編集", + "Edit chat data": "チャットデータを編集", + "Edit contact": "連絡先を編集", + "Edit group": "グループを編集", "Edit Message": "メッセージを編集", "Edit message request failed": "メッセージの編集要求が失敗しました", "Edited": "編集済み", @@ -205,16 +234,26 @@ "Enforce unique vote is enabled": "一意の投票が有効になっています", "Error": "エラー", "Error adding flag": "フラグを追加のエラーが発生しました", + "Error adding members": "Error adding members", + "Error blocking user": "ユーザーのブロック中にエラーが発生しました", "Error connecting to chat, refresh the page to try again.": "チャットへの接続ができませんでした。ページを更新してください。", + "Error deleting chat": "Error deleting chat", "Error deleting message": "メッセージを削除するエラーが発生しました", "Error fetching reactions": "反応の読み込みエラー", "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "メッセージを未読にする際にエラーが発生しました。最新の100件のチャンネルメッセージより古い未読メッセージはマークできません。", "Error muting a user ...": "ユーザーを無音するエラーが発生しました...", + "Error muting channel": "チャンネルのミュート中にエラーが発生しました", + "Error muting user": "ユーザーのミュート中にエラーが発生しました", + "Error opening direct message": "ダイレクトメッセージを開く際にエラーが発生しました", "Error pinning message": "メッセージをピンのエラーが発生しました", "Error removing message pin": "メッセージのピンを削除のエラーが発生しました", + "Error removing user": "ユーザーの削除中にエラーが発生しました", "Error reproducing the recording": "録音の再生中にエラーが発生しました", "Error starting recording": "録音の開始時にエラーが発生しました", + "Error unblocking user": "ユーザーのブロック解除中にエラーが発生しました", "Error unmuting a user ...": "ユーザーの無音解除のエラーが発生しました...", + "Error unmuting channel": "チャンネルのミュート解除中にエラーが発生しました", + "Error unmuting user": "ユーザーのミュート解除中にエラーが発生しました", "Error uploading attachment": "添付ファイルのアップロード中にエラーが発生しました", "Error uploading file": "ファイルをアップロードのエラーが発生しました", "Error uploading image": "画像をアップロードのエラーが発生しました", @@ -233,6 +272,7 @@ "Failed to mark channel as read": "チャンネルを既読にすることができませんでした", "Failed to play the recording": "録音の再生に失敗しました", "Failed to retrieve location": "位置情報の取得に失敗しました", + "Failed to save changes": "変更を保存できませんでした", "Failed to share location": "位置情報の共有に失敗しました", "Failed to update channel archive status": "チャンネルのアーカイブ状態の更新に失敗しました", "Failed to update channel mute status": "チャンネルのミュート状態の更新に失敗しました", @@ -242,10 +282,14 @@ "File is too large: {{ size }}, maximum upload size is {{ limit }}": "ファイルが大きすぎます:{{ size }}、最大アップロードサイズは{{ limit }}です", "File too large": "ファイルが大きすぎます", "fileCount_other": "{{ count }}件のファイル", + "Files": "ファイル", "Flag": "フラグ", "Generating...": "生成中...", "giphy-command-args": "[テキスト]", "giphy-command-description": "チャンネルにランダムなGIFを投稿する", + "Go back": "戻る", + "Group info": "グループ情報", + "Group name": "グループ名", "Hide who voted": "誰が投票したかを非表示にする", "Image": "画像", "imageCount_other": "{{ count }}件の画像", @@ -307,7 +351,9 @@ "language/vi": "ベトナム語", "language/zh": "中国語(簡体字)", "language/zh-TW": "中国語(繁体字)", + "Last seen {{ timestamp }}": "最終表示 {{ timestamp }}", "Leave Channel": "チャンネルを退出", + "Leave chat": "チャンネルを退出", "Left channel": "チャンネルを退出しました", "Let others add options": "他の人が選択肢を追加できるようにする", "Limit votes per person": "1人あたりの投票数を制限する", @@ -322,9 +368,13 @@ "Location": "位置情報", "Location sharing ended": "位置情報の共有が終了しました", "Location: {{ coordinates }}": "位置: {{ coordinates }}", + "Manage channel": "チャンネルを管理", + "Manage members": "メンバーを管理", "Mark as unread": "未読としてマーク", "Maximum number of votes (from 2 to 10)": "最大投票数(2から10まで)", "Maximum votes per person": "1人あたりの最大投票数", + "Member detail": "メンバー詳細", + "Member not found": "メンバーが見つかりません", "Menu": "メニュー", "Message deleted": "メッセージが削除されました", "Message failed to send": "メッセージの送信に失敗しました", @@ -335,8 +385,11 @@ "Message was blocked by moderation policies": "メッセージはモデレーションポリシーによってブロックされました", "Messages have been marked unread.": "メッセージは未読としてマークされました。", "Missing permissions to upload the attachment": "添付ファイルをアップロードするための許可がありません", + "Moderator": "モデレーター", "Multiple votes": "複数投票", "Mute": "無音", + "Mute chat": "チャットをミュート", + "Mute user": "ユーザーをミュート", "mute-command-args": "[@ユーザ名]", "mute-command-description": "ユーザーをミュートする", "network error": "ネットワークエラー", @@ -346,8 +399,14 @@ "Next image": "次の画像", "No chats here yet…": "ここにはまだチャットはありません…", "No conversations yet": "まだ会話はありません", + "No files": "ファイルはありません", "No items exist": "項目がありません", + "No member found": "メンバーが見つかりません", + "No messages found": "メッセージが見つかりません", + "No photos or videos": "写真や動画はありません", + "No pinned messages": "ピン留めメッセージはありません", "No results found": "結果が見つかりません", + "No user found": "ユーザーが見つかりません", "Nobody will be able to vote in this poll anymore.": "この投票では、誰も投票できなくなります。", "Nothing yet...": "まだ何もありません...", "Offline": "オフライン", @@ -359,15 +418,22 @@ "Open gallery at image {{ index }}": "画像 {{ index }} でギャラリーを開く", "Open image in gallery": "画像をギャラリーで開く", "Open location in a map": "地図で位置情報を開く", + "Open members actions": "Open members actions", + "Open menu": "メニューを開く", "Option already exists": "オプションは既に存在します", "Option is empty": "オプションが空です", "Options": "オプション", "Original": "原文", + "Owner": "オーナー", "People matching": "一致する人", "Photo": "写真", + "Photos & videos": "写真と動画", "Pin": "ピン", + "Pin a message to see it here": "ここに表示するにはメッセージをピン留めしてください", "Pinned by {{ name }}": "{{ name }}がピンしました", "Pinned by You": "あなたがピン留めしました", + "Pinned message": "ピン留めメッセージ", + "Pinned messages": "ピン留めメッセージ", "placeholder/PollComment": "コメント", "placeholder/PollOptionSuggestion": "新しい選択肢を入力", "Play video": "動画を再生", @@ -387,8 +453,13 @@ "Remind me": "リマインド", "Remind Me": "リマインダー", "Reminder set": "リマインダーを設定しました", + "Remove": "削除", + "Remove {{ count }} members_other": "{{ count }}人のメンバーを削除", + "Remove {{ member }} from this channel?": "{{ member }}をこのチャンネルから削除しますか?", + "Remove channel members": "チャンネルメンバーを削除", "Remove reminder": "リマインダーを削除", "Remove save for later": "「後で見る」を削除", + "Remove user": "ユーザーを削除", "Replied to a thread": "スレッドに返信しました", "Reply": "返事", "Reply to {{ authorName }}": "{{ authorName }} に返信", @@ -403,6 +474,7 @@ "Review poll results and open an option to see detailed votes": "投票結果を確認し、選択肢を開いて詳細な投票を表示", "Review this message and choose whether to delete it, edit it, or send it anyway": "このメッセージを確認し、削除・編集・そのまま送信するかを選択", "Review who voted for this option": "この選択肢に投票した人を確認", + "Save": "保存", "Save for later": "後で保存", "Saved for later": "後で保存済み", "Search": "探す", @@ -425,11 +497,14 @@ "Send a message": "メッセージを送る", "Send a message to start the conversation": "メッセージを送って会話を始めましょう", "Send Anyway": "とにかく送信する", + "Send direct message": "ダイレクトメッセージを送信", "Send message request failed": "メッセージ送信リクエストが失敗しました", "Send poll": "アンケートを送信", "Sending...": "送信中...", "Sent": "送信済み", "Share": "共有", + "Share a file to see it here": "ファイルを共有するとここに表示されます", + "Share a photo or video to see it here": "写真や動画を共有するとここに表示されます", "Share live location for": "ライブ位置情報を共有", "Share Location": "位置情報を共有", "Shared live location": "共有されたライブ位置情報", @@ -450,6 +525,9 @@ "this content could not be displayed": "このコンテンツは表示できませんでした", "This field cannot be empty or contain only spaces": "このフィールドは空にすることはできません。また、空白文字のみを含むこともできません", "This message did not meet our content guidelines": "このメッセージはコンテンツガイドラインに適合していません", + "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This user will be able to message you again.": "このユーザーは再びあなたにメッセージを送信できるようになります。", + "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", "Thread": "スレッド", "Thread has not been found": "スレッドが見つかりませんでした", "Thread reply": "スレッドの返信", @@ -457,6 +535,8 @@ "ThreadListUnseenThreadsBanner/loading": "読み込み中...", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }}件の未読スレッド", "Threads": "スレッド", + "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", + "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[昨日]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日]\", \"nextDay\": \"[明日]\", \"lastDay\": \"[昨日]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[先週の] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -479,9 +559,13 @@ "Unarchive": "アーカイブ解除", "unban-command-args": "[@ユーザ名]", "unban-command-description": "ユーザーの禁止を解除する", + "Unblock": "ブロック解除", + "Unblock user": "ユーザーのブロックを解除", "Unblock User": "ユーザーのブロックを解除", "unknown error": "不明なエラー", "Unmute": "無音を解除する", + "Unmute chat": "チャットのミュートを解除", + "Unmute user": "ユーザーのミュートを解除", "unmute-command-args": "[@ユーザ名]", "unmute-command-description": "ユーザーのミュートを解除する", "Unpin": "ピンを解除する", @@ -494,9 +578,13 @@ "Upload blocked": "アップロードがブロックされました", "Upload error": "アップロードエラー", "Upload failed": "アップロードに失敗しました", + "Upload Picture": "画像をアップロード", "Upload type: \"{{ type }}\" is not allowed": "アップロードタイプ:\"{{ type }}\"は許可されていません", "User blocked": "ユーザーをブロックしました", + "User muted": "ユーザーをミュートしました", + "User removed": "ユーザーを削除しました", "User unblocked": "ユーザーのブロックを解除しました", + "User unmuted": "ユーザーのミュートを解除しました", "User uploaded content": "ユーザーがアップロードしたコンテンツ", "Video": "動画", "videoCount_other": "{{ count }}件の動画", @@ -504,6 +592,7 @@ "View {{count}} comments_one": "{{count}} コメントを表示", "View {{count}} comments_other": "{{count}} コメントを表示", "View all": "すべて表示", + "View member details for {{ member }}": "{{ member }}のメンバー詳細を表示", "View original": "原文を表示", "View results": "結果を表示", "View translation": "翻訳を表示", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index fadbdd5e88..6ebe0854d5 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -3,6 +3,8 @@ "{{ commaSeparatedUsers }}, and {{ lastUser }}": "{{ commaSeparatedUsers }} 그리고 {{ lastUser }}", "{{ count }} files_one": "{{ count }}개 파일", "{{ count }} files_other": "{{ count }}개 파일", + "{{ count }} members_other": "{{ count }}명 멤버", + "{{ count }} members added_other": "{{ count }}명 멤버가 추가됨", "{{ count }} people are typing_one": "{{ count }}명이 입력 중입니다", "{{ count }} people are typing_many": "{{ count }}명이 입력 중입니다", "{{ count }} people are typing_other": "{{ count }}명이 입력 중입니다", @@ -13,6 +15,8 @@ "{{ count }} videos_other": "{{ count }}개 동영상", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} 그리고 {{ secondUser }}", "{{ imageCount }} more": "{{ imageCount }}개 더", + "{{ member }} will be able to message you again.": "{{ member }}님이 다시 메시지를 보낼 수 있습니다.", + "{{ member }} won't be able to message you anymore.": "{{ member }}님은 더 이상 메시지를 보낼 수 없습니다.", "{{ memberCount }} members": "{{ memberCount }}명", "{{ typing }} are typing": "{{ typing }} 입력 중입니다", "{{ typing }} is typing": "{{ typing }} 입력 중입니다", @@ -35,16 +39,23 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}}이(가) 생성함: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}}이(가) 투표함: {{pollOptionText}}", "📍Shared location": "📍공유된 위치", + "Actions": "Actions", + "Add": "추가", + "Add {{ count }} members_other": "{{ count }}명 멤버 추가", "Add a comment": "댓글 추가", "Add a comment to your poll answer": "투표 답변에 댓글 추가", "Add an option": "옵션 추가", + "Add channel members": "채널 멤버 추가", + "Add members": "멤버 추가", "Add reaction": "반응 추가", + "Admin": "관리자", "All results loaded": "모든 결과가 로드되었습니다", "Allow access to camera": "카메라에 대한 액세스 허용", "Allow access to microphone": "마이크로폰에 대한 액세스 허용", "Allow comments": "댓글 허용", "Allow option suggestion": "옵션 제안 허용", "Allow others to add comments": "다른 사람이 댓글을 추가할 수 있도록 허용", + "Already a member": "이미 멤버입니다", "Also send as a direct message": "다이렉트 메시지로도 보내기", "Also send in channel": "채널에도 보내기", "Also sent in channel": "채널에도 전송됨", @@ -54,6 +65,7 @@ "Anonymous poll": "익명 투표", "Archive": "아카이브", "Are you sure you want to delete this message?": "이 메시지를 삭제하시겠습니까?", + "Are you sure you want to leave this channel?": "Are you sure you want to leave this channel?", "aria/Attachment": "첨부 파일", "aria/Attachment Actions": "첨부 파일 작업", "aria/Audio position {{ elapsed }} of {{ duration }}": "오디오 위치 {{ elapsed }} / {{ duration }}", @@ -64,6 +76,7 @@ "aria/Cancel Reply": "답장 취소", "aria/Cancel upload": "업로드 취소", "aria/Channel Actions": "채널 작업", + "aria/Channel details": "채널 세부 정보", "aria/Channel list": "채널 목록", "aria/Channel search results": "채널 검색 결과", "aria/Chat view tabs": "채팅 보기 탭", @@ -103,10 +116,13 @@ "aria/Notifications": "알림", "aria/Open Attachment Selector": "첨부 파일 선택기 열기", "aria/Open Channel Actions Menu": "채널 작업 메뉴 열기", + "aria/Open channel details": "채널 세부 정보 열기", + "aria/Open image shared by {{ name }}": "{{ name }}님이 공유한 이미지 열기", "aria/Open Menu": "메뉴 열기", "aria/Open Message Actions Menu": "메시지 액션 메뉴 열기", "aria/Open Reaction Selector": "반응 선택기 열기", "aria/Open Thread": "스레드 열기", + "aria/Open video shared by {{ name }}": "{{ name }}님이 공유한 동영상 열기", "aria/Pause": "일시정지", "aria/Pause recording": "녹음 일시정지", "aria/Percent complete": "{{percent}}퍼센트 완료", @@ -150,10 +166,15 @@ "Back": "뒤로", "ban-command-args": "[@사용자이름] [텍스트]", "ban-command-description": "사용자를 차단", + "Block user": "사용자 차단", "Block User": "사용자 차단", + "Browse channel members": "채널 멤버 보기", + "Browse pinned messages": "고정된 메시지 보기", "Cancel": "취소", "Cannot seek in the recording": "녹음에서 찾을 수 없습니다", + "Changes saved": "변경 사항이 저장되었습니다", "Channel archived": "채널이 보관됨", + "Channel members": "채널 멤버", "Channel Missing": "채널 누락", "Channel muted": "채널이 음소거됨", "Channel pinned": "채널이 고정됨", @@ -161,6 +182,7 @@ "Channel unmuted": "채널 음소거가 해제됨", "Channel unpinned": "채널 고정이 해제됨", "Channels": "채널", + "Chat deleted": "Chat deleted", "Chats": "채팅", "Choose between 2 to 10 options": "2~10개의 선택지 중에서 선택", "Close": "닫기", @@ -172,12 +194,15 @@ "Commands": "명령어", "Commands matching": "일치하는 명령", "Connection failure, reconnecting now...": "연결 실패, 지금 다시 연결 중...", + "Contact info": "연락처 정보", + "Contact name": "연락처 이름", "Copy Message": "메시지 복사", "Create": "생성", "Create a question, add options, and configure poll settings": "질문을 만들고 옵션을 추가한 뒤 투표 설정 구성", "Create poll": "투표 생성", "Current location": "현재 위치", "Delete": "삭제", + "Delete chat": "채팅 삭제", "Delete for me": "나만 삭제", "Delete message": "메시지 삭제", "Delivered": "배달됨", @@ -193,6 +218,10 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "수정", + "Edit chat data": "채팅 데이터 수정", + "Edit contact": "연락처 편집", + "Edit group": "그룹 편집", "Edit Message": "메시지 수정", "Edit message request failed": "메시지 수정 요청 실패", "Edited": "편집됨", @@ -205,16 +234,26 @@ "Enforce unique vote is enabled": "고유 투표가 활성화되었습니다", "Error": "오류", "Error adding flag": "플래그를 추가하는 동안 오류가 발생했습니다.", + "Error adding members": "Error adding members", + "Error blocking user": "사용자 차단 중 오류 발생", "Error connecting to chat, refresh the page to try again.": "채팅에 연결하는 동안 오류가 발생했습니다. 페이지를 새로고침하여 다시 시도하세요.", + "Error deleting chat": "Error deleting chat", "Error deleting message": "메시지를 삭제하는 중에 오류가 발생했습니다.", "Error fetching reactions": "반응 로딩 오류.", "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "메시지를 읽지 않음으로 표시하는 중 오류가 발생했습니다. 가장 최근 100개의 채널 메시지보다 오래된 읽지 않은 메시지는 표시할 수 없습니다.", "Error muting a user ...": "사용자를 음소거하는 중에 오류가 발생했습니다...", + "Error muting channel": "채널 음소거 중 오류 발생", + "Error muting user": "사용자 음소거 중 오류 발생", + "Error opening direct message": "다이렉트 메시지를 여는 중 오류가 발생했습니다", "Error pinning message": "메시지를 핀하는 중에 오류가 발생했습니다.", "Error removing message pin": "메시지 핀을 제거하는 중에 오류가 발생했습니다.", + "Error removing user": "사용자를 제거하는 중 오류가 발생했습니다", "Error reproducing the recording": "녹음 재생 중 오류 발생", "Error starting recording": "녹음 시작 중 오류가 발생했습니다", + "Error unblocking user": "사용자 차단 해제 중 오류가 발생했습니다", "Error unmuting a user ...": "사용자 음소거 해제 중 오류 발생...", + "Error unmuting channel": "채널 음소거 해제 중 오류 발생", + "Error unmuting user": "사용자 음소거 해제 중 오류 발생", "Error uploading attachment": "첨부 파일 업로드 중 오류가 발생했습니다", "Error uploading file": "파일 업로드 오류", "Error uploading image": "이미지를 업로드하는 동안 오류가 발생했습니다.", @@ -233,6 +272,7 @@ "Failed to mark channel as read": "채널을 읽음으로 표시하는 데 실패했습니다", "Failed to play the recording": "녹음을 재생하지 못했습니다", "Failed to retrieve location": "위치를 가져오지 못했습니다", + "Failed to save changes": "변경 사항을 저장하지 못했습니다", "Failed to share location": "위치를 공유하지 못했습니다", "Failed to update channel archive status": "채널 아카이브 상태 업데이트에 실패했습니다", "Failed to update channel mute status": "채널 음소거 상태 업데이트에 실패했습니다", @@ -242,10 +282,14 @@ "File is too large: {{ size }}, maximum upload size is {{ limit }}": "파일이 너무 큽니다: {{ size }}, 최대 업로드 크기는 {{ limit }}입니다", "File too large": "파일이 너무 큽니다", "fileCount_other": "파일 {{ count }}개", + "Files": "파일", "Flag": "플래그", "Generating...": "생성 중...", "giphy-command-args": "[텍스트]", "giphy-command-description": "채널에 무작위 GIF 게시", + "Go back": "뒤로 가기", + "Group info": "그룹 정보", + "Group name": "그룹 이름", "Hide who voted": "누가 투표했는지 숨기기", "Image": "이미지", "imageCount_other": "이미지 {{ count }}개", @@ -307,7 +351,9 @@ "language/vi": "베트남어", "language/zh": "중국어(간체)", "language/zh-TW": "중국어(번체)", + "Last seen {{ timestamp }}": "마지막 접속 {{ timestamp }}", "Leave Channel": "채널 나가기", + "Leave chat": "채널 나가기", "Left channel": "채널을 나갔습니다", "Let others add options": "다른 사람이 선택지를 추가할 수 있도록 허용", "Limit votes per person": "1인당 투표 수 제한", @@ -322,9 +368,13 @@ "Location": "위치", "Location sharing ended": "위치 공유가 종료되었습니다", "Location: {{ coordinates }}": "위치: {{ coordinates }}", + "Manage channel": "채널 관리", + "Manage members": "멤버 관리", "Mark as unread": "읽지 않음으로 표시", "Maximum number of votes (from 2 to 10)": "최대 투표 수 (2에서 10까지)", "Maximum votes per person": "1인당 최대 투표 수", + "Member detail": "멤버 상세 정보", + "Member not found": "멤버를 찾을 수 없습니다", "Menu": "메뉴", "Message deleted": "메시지가 삭제되었습니다.", "Message failed to send": "메시지 전송 실패", @@ -335,8 +385,11 @@ "Message was blocked by moderation policies": "메시지가 관리 정책에 의해 차단되었습니다.", "Messages have been marked unread.": "메시지가 읽지 않음으로 표시되었습니다.", "Missing permissions to upload the attachment": "첨부 파일을 업로드하려면 권한이 필요합니다", + "Moderator": "운영자", "Multiple votes": "복수 투표", "Mute": "무음", + "Mute chat": "채팅 음소거", + "Mute user": "사용자 음소거", "mute-command-args": "[@사용자이름]", "mute-command-description": "사용자 음소거", "network error": "네트워크 오류", @@ -346,8 +399,14 @@ "Next image": "다음 이미지", "No chats here yet…": "아직 채팅이 없습니다...", "No conversations yet": "아직 대화가 없습니다.", + "No files": "파일이 없습니다", "No items exist": "항목이 없습니다.", + "No member found": "멤버를 찾을 수 없습니다", + "No messages found": "메시지를 찾을 수 없습니다", + "No photos or videos": "사진 또는 동영상이 없습니다", + "No pinned messages": "고정된 메시지가 없습니다", "No results found": "검색 결과가 없습니다", + "No user found": "사용자를 찾을 수 없습니다", "Nobody will be able to vote in this poll anymore.": "이 투표에 더 이상 아무도 투표할 수 없습니다.", "Nothing yet...": "아직 아무것도...", "Offline": "오프라인", @@ -359,15 +418,22 @@ "Open gallery at image {{ index }}": "이미지 {{ index }}에서 갤러리 열기", "Open image in gallery": "갤러리에서 이미지 열기", "Open location in a map": "지도에서 위치 열기", + "Open members actions": "Open members actions", + "Open menu": "메뉴 열기", "Option already exists": "옵션이 이미 존재합니다", "Option is empty": "옵션이 비어 있습니다", "Options": "옵션", "Original": "원문", + "Owner": "소유자", "People matching": "일치하는 사람", "Photo": "사진", + "Photos & videos": "사진 및 동영상", "Pin": "핀", + "Pin a message to see it here": "여기에서 보려면 메시지를 고정하세요", "Pinned by {{ name }}": "{{ name }}님이 핀함", "Pinned by You": "내가 고정함", + "Pinned message": "고정된 메시지", + "Pinned messages": "고정된 메시지", "placeholder/PollComment": "댓글", "placeholder/PollOptionSuggestion": "새 옵션 입력", "Play video": "동영상 재생", @@ -387,8 +453,13 @@ "Remind me": "알림", "Remind Me": "알림 설정", "Reminder set": "알림 설정됨", + "Remove": "제거", + "Remove {{ count }} members_other": "{{ count }}명의 멤버 제거", + "Remove {{ member }} from this channel?": "이 채널에서 {{ member }}님을 제거하시겠습니까?", + "Remove channel members": "채널 멤버 제거", "Remove reminder": "알림 제거", "Remove save for later": "나중에 보기 제거", + "Remove user": "사용자 제거", "Replied to a thread": "스레드에 답글을 남겼습니다", "Reply": "답장", "Reply to {{ authorName }}": "{{ authorName }}님에게 답장", @@ -403,6 +474,7 @@ "Review poll results and open an option to see detailed votes": "투표 결과를 검토하고 옵션을 열어 상세 득표 보기", "Review this message and choose whether to delete it, edit it, or send it anyway": "이 메시지를 검토하고 삭제, 수정 또는 그대로 전송할지 선택", "Review who voted for this option": "이 옵션에 투표한 사람 검토", + "Save": "저장", "Save for later": "나중에 저장", "Saved for later": "나중에 저장됨", "Search": "찾다", @@ -425,11 +497,14 @@ "Send a message": "메시지 보내기", "Send a message to start the conversation": "대화를 시작하려면 메시지를 보내세요", "Send Anyway": "어쨌든 보내기", + "Send direct message": "다이렉트 메시지 보내기", "Send message request failed": "메시지 보내기 요청 실패", "Send poll": "투표 보내기", "Sending...": "배상중...", "Sent": "전송됨", "Share": "공유", + "Share a file to see it here": "파일을 공유하면 여기에 표시됩니다", + "Share a photo or video to see it here": "사진이나 동영상을 공유하면 여기에 표시됩니다", "Share live location for": "라이브 위치 공유", "Share Location": "위치 공유", "Shared live location": "공유된 라이브 위치", @@ -450,6 +525,9 @@ "this content could not be displayed": "이 콘텐츠를 표시할 수 없습니다", "This field cannot be empty or contain only spaces": "이 필드는 비워둘 수 없으며 공백만 포함할 수도 없습니다", "This message did not meet our content guidelines": "이 메시지는 콘텐츠 가이드라인을 충족하지 않습니다.", + "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This user will be able to message you again.": "이 사용자가 다시 메시지를 보낼 수 있습니다.", + "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", "Thread": "스레드", "Thread has not been found": "스레드를 찾을 수 없습니다", "Thread reply": "스레드 답장", @@ -457,6 +535,8 @@ "ThreadListUnseenThreadsBanner/loading": "로딩 중...", "ThreadListUnseenThreadsBanner/unreadThreads_other": "읽지 않은 스레드 {{ count }}개", "Threads": "스레드", + "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", + "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[어제]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘]\", \"nextDay\": \"[내일]\", \"lastDay\": \"[어제]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[지난] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -479,9 +559,13 @@ "Unarchive": "아카이브 해제", "unban-command-args": "[@사용자이름]", "unban-command-description": "사용자 차단 해제", + "Unblock": "차단 해제", + "Unblock user": "사용자 차단 해제", "Unblock User": "사용자 차단 해제", "unknown error": "알 수 없는 오류", "Unmute": "음소거 해제", + "Unmute chat": "채팅 음소거 해제", + "Unmute user": "사용자 음소거 해제", "unmute-command-args": "[@사용자이름]", "unmute-command-description": "사용자 음소거 해제", "Unpin": "핀 해제", @@ -494,9 +578,13 @@ "Upload blocked": "업로드가 차단되었습니다", "Upload error": "업로드 오류", "Upload failed": "업로드에 실패했습니다", + "Upload Picture": "사진 업로드", "Upload type: \"{{ type }}\" is not allowed": "업로드 유형: \"{{ type }}\"은(는) 허용되지 않습니다.", "User blocked": "사용자가 차단됨", + "User muted": "사용자가 음소거되었습니다", + "User removed": "사용자가 제거되었습니다", "User unblocked": "사용자 차단이 해제됨", + "User unmuted": "사용자 음소거가 해제되었습니다", "User uploaded content": "사용자 업로드 콘텐츠", "Video": "동영상", "videoCount_other": "동영상 {{ count }}개", @@ -504,6 +592,7 @@ "View {{count}} comments_one": "{{count}}개의 댓글 보기", "View {{count}} comments_other": "{{count}}개의 댓글 보기", "View all": "전체 보기", + "View member details for {{ member }}": "{{ member }} 멤버 상세 정보 보기", "View original": "원문 보기", "View results": "결과 보기", "View translation": "번역 보기", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 64ee02770e..9fde3747e8 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -3,6 +3,10 @@ "{{ commaSeparatedUsers }}, and {{ lastUser }}": "{{ commaSeparatedUsers }} en {{ lastUser }}", "{{ count }} files_one": "{{ count }} bestand", "{{ count }} files_other": "{{ count }} bestanden", + "{{ count }} members_one": "{{ count }} lid", + "{{ count }} members_other": "{{ count }} leden", + "{{ count }} members added_one": "{{ count }} lid toegevoegd", + "{{ count }} members added_other": "{{ count }} leden toegevoegd", "{{ count }} people are typing_one": "{{ count }} persoon typt", "{{ count }} people are typing_many": "{{ count }} personen typen", "{{ count }} people are typing_other": "{{ count }} personen typen", @@ -14,6 +18,8 @@ "{{ count }} videos_other": "{{ count }} video's", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} en {{ secondUser }}", "{{ imageCount }} more": "+{{ imageCount }}", + "{{ member }} will be able to message you again.": "{{ member }} kan je weer berichten sturen.", + "{{ member }} won't be able to message you anymore.": "{{ member }} kan je geen berichten meer sturen.", "{{ memberCount }} members": "{{ memberCount }} deelnemers", "{{ typing }} are typing": "{{ typing }} typen", "{{ typing }} is typing": "{{ typing }} typt", @@ -36,16 +42,24 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} heeft gemaakt: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} heeft gestemd: {{pollOptionText}}", "📍Shared location": "📍Gedeelde locatie", + "Actions": "Actions", + "Add": "Toevoegen", + "Add {{ count }} members_one": "{{ count }} lid toevoegen", + "Add {{ count }} members_other": "{{ count }} leden toevoegen", "Add a comment": "Voeg een opmerking toe", "Add a comment to your poll answer": "Voeg een reactie toe aan je pollantwoord", "Add an option": "Voeg een optie toe", + "Add channel members": "Kanaalleden toevoegen", + "Add members": "Leden toevoegen", "Add reaction": "Reactie toevoegen", + "Admin": "Beheerder", "All results loaded": "Alle resultaten geladen", "Allow access to camera": "Toegang tot camera toestaan", "Allow access to microphone": "Toegang tot microfoon toestaan", "Allow comments": "Sta opmerkingen toe", "Allow option suggestion": "Sta optie-suggesties toe", "Allow others to add comments": "Sta anderen toe om opmerkingen toe te voegen", + "Already a member": "Al lid", "Also send as a direct message": "Ook als direct bericht versturen", "Also send in channel": "Ook in kanaal versturen", "Also sent in channel": "Ook in kanaal verzonden", @@ -55,6 +69,7 @@ "Anonymous poll": "Anonieme peiling", "Archive": "Archief", "Are you sure you want to delete this message?": "Weet je zeker dat je dit bericht wilt verwijderen?", + "Are you sure you want to leave this channel?": "Are you sure you want to leave this channel?", "aria/Attachment": "Bijlage", "aria/Attachment Actions": "Bijlageacties", "aria/Audio position {{ elapsed }} of {{ duration }}": "Audiopositie {{ elapsed }} van {{ duration }}", @@ -65,6 +80,7 @@ "aria/Cancel Reply": "Antwoord annuleren", "aria/Cancel upload": "Upload annuleren", "aria/Channel Actions": "Kanaalacties", + "aria/Channel details": "Kanaaldetails", "aria/Channel list": "Kanaallijst", "aria/Channel search results": "Zoekresultaten voor kanalen", "aria/Chat view tabs": "Tabbladen chatweergave", @@ -104,10 +120,13 @@ "aria/Notifications": "Meldingen", "aria/Open Attachment Selector": "Open bijlage selector", "aria/Open Channel Actions Menu": "Kanaalactiemenu openen", + "aria/Open channel details": "Kanaaldetails openen", + "aria/Open image shared by {{ name }}": "Door {{ name }} gedeelde afbeelding openen", "aria/Open Menu": "Menu openen", "aria/Open Message Actions Menu": "Menu voor berichtacties openen", "aria/Open Reaction Selector": "Reactiekiezer openen", "aria/Open Thread": "Draad openen", + "aria/Open video shared by {{ name }}": "Door {{ name }} gedeelde video openen", "aria/Pause": "Pauzeren", "aria/Pause recording": "Opname pauzeren", "aria/Percent complete": "{{percent}} procent voltooid", @@ -151,10 +170,15 @@ "Back": "Terug", "ban-command-args": "[@gebruikersnaam] [tekst]", "ban-command-description": "Een gebruiker verbannen", + "Block user": "Gebruiker blokkeren", "Block User": "Gebruiker blokkeren", + "Browse channel members": "Kanaalleden bekijken", + "Browse pinned messages": "Vastgemaakte berichten bekijken", "Cancel": "Annuleer", "Cannot seek in the recording": "Kan niet zoeken in de opname", + "Changes saved": "Wijzigingen opgeslagen", "Channel archived": "Kanaal gearchiveerd", + "Channel members": "Kanaalleden", "Channel Missing": "Kanaal niet gevonden", "Channel muted": "Kanaal gedempt", "Channel pinned": "Kanaal vastgezet", @@ -162,6 +186,7 @@ "Channel unmuted": "Dempen van kanaal opgeheven", "Channel unpinned": "Kanaal losgemaakt", "Channels": "Kanalen", + "Chat deleted": "Chat deleted", "Chats": "Chats", "Choose between 2 to 10 options": "Kies tussen 2 en 10 opties", "Close": "Sluit", @@ -173,12 +198,15 @@ "Commands": "Commando's", "Commands matching": "Bijpassende opdrachten", "Connection failure, reconnecting now...": "Verbindingsfout, opnieuw verbinden...", + "Contact info": "Contactgegevens", + "Contact name": "Contactnaam", "Copy Message": "Bericht kopiëren", "Create": "Maak", "Create a question, add options, and configure poll settings": "Maak een vraag, voeg opties toe en stel de pollinstellingen in", "Create poll": "Maak peiling", "Current location": "Huidige locatie", "Delete": "Verwijder", + "Delete chat": "Chat verwijderen", "Delete for me": "Voor mij verwijderen", "Delete message": "Bericht verwijderen", "Delivered": "Afgeleverd", @@ -194,6 +222,10 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "Bewerken", + "Edit chat data": "Chatgegevens bewerken", + "Edit contact": "Contact bewerken", + "Edit group": "Groep bewerken", "Edit Message": "Bericht bewerken", "Edit message request failed": "Verzoek om bericht bewerken mislukt", "Edited": "Bewerkt", @@ -206,16 +238,26 @@ "Enforce unique vote is enabled": "Unieke stem is ingeschakeld", "Error": "Fout", "Error adding flag": "Fout bij toevoegen van vlag", + "Error adding members": "Error adding members", + "Error blocking user": "Fout bij blokkeren van gebruiker", "Error connecting to chat, refresh the page to try again.": "Fout bij het verbinden, ververs de pagina om nogmaals te proberen", + "Error deleting chat": "Error deleting chat", "Error deleting message": "Fout bij verwijderen van bericht", "Error fetching reactions": "Fout bij het laden van reacties", "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Fout bij markeren van bericht als ongelezen. Kan geen oudere ongelezen berichten markeren dan de nieuwste 100 kanaalberichten.", "Error muting a user ...": "Fout bij het muten van de gebruiker", + "Error muting channel": "Fout bij dempen van kanaal", + "Error muting user": "Fout bij dempen van gebruiker", + "Error opening direct message": "Fout bij het openen van het directe bericht", "Error pinning message": "Fout bij vastzetten van bericht", "Error removing message pin": "Fout bij verwijderen van berichtpin", + "Error removing user": "Fout bij het verwijderen van de gebruiker", "Error reproducing the recording": "Fout bij het afspelen van de opname", "Error starting recording": "Fout bij het starten van de opname", + "Error unblocking user": "Fout bij deblokkeren van gebruiker", "Error unmuting a user ...": "Fout bij het unmuten van de gebruiker", + "Error unmuting channel": "Fout bij opheffen van kanaaldemping", + "Error unmuting user": "Fout bij opheffen van gebruikersdemping", "Error uploading attachment": "Fout bij het uploaden van de bijlage", "Error uploading file": "Fout bij uploaden bestand", "Error uploading image": "Fout bij uploaden afbeelding", @@ -234,6 +276,7 @@ "Failed to mark channel as read": "Kanaal kon niet als gelezen worden gemarkeerd", "Failed to play the recording": "Kan de opname niet afspelen", "Failed to retrieve location": "Locatie kon niet worden opgehaald", + "Failed to save changes": "Wijzigingen opslaan mislukt", "Failed to share location": "Locatie kon niet worden gedeeld", "Failed to update channel archive status": "Archiefstatus van kanaal kon niet worden bijgewerkt", "Failed to update channel mute status": "Muteerstatus van kanaal kon niet worden bijgewerkt", @@ -244,10 +287,14 @@ "File too large": "Bestand is te groot", "fileCount_one": "1 bestand", "fileCount_other": "{{ count }} bestanden", + "Files": "Bestanden", "Flag": "Markeer", "Generating...": "Genereren...", "giphy-command-args": "[tekst]", "giphy-command-description": "Plaats een willekeurige gif in het kanaal", + "Go back": "Terug", + "Group info": "Groepsinformatie", + "Group name": "Groepsnaam", "Hide who voted": "Verberg wie heeft gestemd", "Image": "Afbeelding", "imageCount_one": "Afbeelding", @@ -310,7 +357,9 @@ "language/vi": "Vietnamees", "language/zh": "Chinees (vereenvoudigd)", "language/zh-TW": "Chinees (traditioneel)", + "Last seen {{ timestamp }}": "Laatst gezien {{ timestamp }}", "Leave Channel": "Kanaal verlaten", + "Leave chat": "Kanaal verlaten", "Left channel": "Kanaal verlaten", "Let others add options": "Laat anderen opties toevoegen", "Limit votes per person": "Stemmen per persoon beperken", @@ -326,9 +375,13 @@ "Location": "Locatie", "Location sharing ended": "Locatie delen beëindigd", "Location: {{ coordinates }}": "Locatie: {{ coordinates }}", + "Manage channel": "Kanaal beheren", + "Manage members": "Leden beheren", "Mark as unread": "Markeren als ongelezen", "Maximum number of votes (from 2 to 10)": "Maximaal aantal stemmen (van 2 tot 10)", "Maximum votes per person": "Maximum aantal stemmen per persoon", + "Member detail": "Lidgegevens", + "Member not found": "Lid niet gevonden", "Menu": "Menu", "Message deleted": "Bericht verwijderd", "Message failed to send": "Bericht kon niet worden verzonden", @@ -339,8 +392,11 @@ "Message was blocked by moderation policies": "Bericht is geblokkeerd door moderatiebeleid", "Messages have been marked unread.": "Berichten zijn gemarkeerd als ongelezen.", "Missing permissions to upload the attachment": "Missende toestemmingen om de bijlage te uploaden", + "Moderator": "Moderator", "Multiple votes": "Meerdere stemmen", "Mute": "Dempen", + "Mute chat": "Chat dempen", + "Mute user": "Gebruiker dempen", "mute-command-args": "[@gebruikersnaam]", "mute-command-description": "Een gebruiker dempen", "network error": "netwerkfout", @@ -350,8 +406,14 @@ "Next image": "Volgende afbeelding", "No chats here yet…": "Nog geen chats hier...", "No conversations yet": "Nog geen gesprekken", + "No files": "Geen bestanden", "No items exist": "Er zijn geen items", + "No member found": "Geen lid gevonden", + "No messages found": "Geen berichten gevonden", + "No photos or videos": "Geen foto's of video's", + "No pinned messages": "Geen vastgemaakte berichten", "No results found": "Geen resultaten gevonden", + "No user found": "Geen gebruiker gevonden", "Nobody will be able to vote in this poll anymore.": "Niemand kan meer stemmen in deze peiling.", "Nothing yet...": "Nog niets ...", "Offline": "Offline", @@ -363,15 +425,22 @@ "Open gallery at image {{ index }}": "Galerij openen bij afbeelding {{ index }}", "Open image in gallery": "Afbeelding openen in galerij", "Open location in a map": "Locatie op een kaart openen", + "Open members actions": "Open members actions", + "Open menu": "Menu openen", "Option already exists": "Optie bestaat al", "Option is empty": "Optie is leeg", "Options": "Opties", "Original": "Origineel", + "Owner": "Eigenaar", "People matching": "Mensen die matchen", "Photo": "Foto", + "Photos & videos": "Foto's en video's", "Pin": "Vastmaken", + "Pin a message to see it here": "Maak een bericht vast om het hier te zien", "Pinned by {{ name }}": "Vastgemaakt door {{ name }}", "Pinned by You": "Door jou vastgezet", + "Pinned message": "Vastgemaakt bericht", + "Pinned messages": "Vastgemaakte berichten", "placeholder/PollComment": "Jouw reactie", "placeholder/PollOptionSuggestion": "Voer een nieuwe optie in", "Play video": "Video afspelen", @@ -391,8 +460,14 @@ "Remind me": "Herinner me", "Remind Me": "Herinner mij", "Reminder set": "Herinnering ingesteld", + "Remove": "Verwijderen", + "Remove {{ count }} members_one": "{{ count }} lid verwijderen", + "Remove {{ count }} members_other": "{{ count }} leden verwijderen", + "Remove {{ member }} from this channel?": "{{ member }} uit dit kanaal verwijderen?", + "Remove channel members": "Kanaalleden verwijderen", "Remove reminder": "Herinnering verwijderen", "Remove save for later": "Verwijder 'Bewaren voor later'", + "Remove user": "Gebruiker verwijderen", "Replied to a thread": "Heeft gereageerd in een thread", "Reply": "Antwoord", "Reply to {{ authorName }}": "Antwoord aan {{ authorName }}", @@ -407,6 +482,7 @@ "Review poll results and open an option to see detailed votes": "Bekijk pollresultaten en open een optie om gedetailleerde stemmen te zien", "Review this message and choose whether to delete it, edit it, or send it anyway": "Bekijk dit bericht en kies of je het verwijdert, bewerkt of toch verstuurt", "Review who voted for this option": "Bekijk wie op deze optie heeft gestemd", + "Save": "Opslaan", "Save for later": "Bewaren voor later", "Saved for later": "Bewaard voor later", "Search": "Zoeken", @@ -429,11 +505,14 @@ "Send a message": "Stuur een bericht", "Send a message to start the conversation": "Stuur een bericht om het gesprek te beginnen", "Send Anyway": "Toch versturen", + "Send direct message": "Direct bericht sturen", "Send message request failed": "Verzoek om bericht te verzenden mislukt", "Send poll": "Peiling versturen", "Sending...": "Aan het verzenden...", "Sent": "Verzonden", "Share": "Delen", + "Share a file to see it here": "Deel een bestand om het hier te zien", + "Share a photo or video to see it here": "Deel een foto of video om deze hier te zien", "Share live location for": "Live locatie delen voor", "Share Location": "Locatie delen", "Shared live location": "Gedeelde live locatie", @@ -456,6 +535,9 @@ "this content could not be displayed": "Deze inhoud kan niet weergegeven worden", "This field cannot be empty or contain only spaces": "Dit veld mag niet leeg zijn of alleen spaties bevatten", "This message did not meet our content guidelines": "Dit bericht voldeed niet aan onze inhoudsrichtlijnen", + "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This user will be able to message you again.": "Deze gebruiker kan je weer berichten sturen.", + "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", "Thread": "Draadje", "Thread has not been found": "Draadje niet gevonden", "Thread reply": "Draadje antwoord", @@ -464,6 +546,8 @@ "ThreadListUnseenThreadsBanner/unreadThreads_one": "{{ count }} ongelezen thread", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} ongelezen threads", "Threads": "Discussies", + "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", + "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Gisteren]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "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) }}", @@ -487,9 +571,13 @@ "Unarchive": "Uit archief halen", "unban-command-args": "[@gebruikersnaam]", "unban-command-description": "Een gebruiker debannen", + "Unblock": "Deblokkeren", + "Unblock user": "Gebruiker deblokkeren", "Unblock User": "Gebruiker deblokkeren", "unknown error": "onbekende fout", "Unmute": "Dempen opheffen", + "Unmute chat": "Chat dempen opheffen", + "Unmute user": "Gebruiker dempen opheffen", "unmute-command-args": "[@gebruikersnaam]", "unmute-command-description": "Een gebruiker niet meer dempen", "Unpin": "Losmaken", @@ -502,9 +590,13 @@ "Upload blocked": "Upload geblokkeerd", "Upload error": "Uploadfout", "Upload failed": "Upload mislukt", + "Upload Picture": "Afbeelding uploaden", "Upload type: \"{{ type }}\" is not allowed": "Uploadtype: \"{{ type }}\" is niet toegestaan", "User blocked": "Gebruiker geblokkeerd", + "User muted": "Gebruiker gedempt", + "User removed": "Gebruiker verwijderd", "User unblocked": "Gebruiker gedeblokkeerd", + "User unmuted": "Gebruiker niet meer gedempt", "User uploaded content": "Gebruikersgeüploade inhoud", "Video": "Video", "videoCount_one": "Video", @@ -513,6 +605,7 @@ "View {{count}} comments_one": "Bekijk {{count}} opmerkingen", "View {{count}} comments_other": "Bekijk {{count}} opmerkingen", "View all": "Alles bekijken", + "View member details for {{ member }}": "Lidgegevens voor {{ member }} bekijken", "View original": "Origineel bekijken", "View results": "Bekijk resultaten", "View translation": "Vertaling bekijken", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index b5621c297e..c343cf1f7a 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -4,6 +4,12 @@ "{{ count }} files_one": "{{ count }} arquivo", "{{ count }} files_many": "{{ count }} arquivos", "{{ count }} files_other": "{{ count }} arquivos", + "{{ count }} members_one": "{{ count }} membro", + "{{ count }} members_many": "{{ count }} membros", + "{{ count }} members_other": "{{ count }} membros", + "{{ count }} members added_one": "{{ count }} membro adicionado", + "{{ count }} members added_many": "{{ count }} membros adicionados", + "{{ count }} members added_other": "{{ count }} membros adicionados", "{{ count }} people are typing_one": "{{ count }} pessoa está digitando", "{{ count }} people are typing_many": "{{ count }} pessoas estão digitando", "{{ count }} people are typing_other": "{{ count }} pessoas estão digitando", @@ -18,6 +24,8 @@ "{{ count }} videos_other": "{{ count }} vídeos", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} e {{ secondUser }}", "{{ imageCount }} more": "{{ imageCount }} mais", + "{{ member }} will be able to message you again.": "{{ member }} poderá enviar mensagens para você novamente.", + "{{ member }} won't be able to message you anymore.": "{{ member }} não poderá mais enviar mensagens para você.", "{{ memberCount }} members": "{{ memberCount }} membros", "{{ typing }} are typing": "{{ typing }} estão digitando", "{{ typing }} is typing": "{{ typing }} está digitando", @@ -44,16 +52,25 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} criou: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} votou: {{pollOptionText}}", "📍Shared location": "📍Localização compartilhada", + "Actions": "Actions", + "Add": "Adicionar", + "Add {{ count }} members_one": "Adicionar {{ count }} membro", + "Add {{ count }} members_many": "Adicionar {{ count }} membros", + "Add {{ count }} members_other": "Adicionar {{ count }} membros", "Add a comment": "Adicionar um comentário", "Add a comment to your poll answer": "Adicione um comentário à sua resposta da enquete", "Add an option": "Adicionar uma opção", + "Add channel members": "Adicionar membros ao canal", + "Add members": "Adicionar membros", "Add reaction": "Adicionar reação", + "Admin": "Admin", "All results loaded": "Todos os resultados carregados", "Allow access to camera": "Permitir acesso à câmera", "Allow access to microphone": "Permitir acesso ao microfone", "Allow comments": "Permitir comentários", "Allow option suggestion": "Permitir sugestão de opção", "Allow others to add comments": "Permitir que outros adicionem comentários", + "Already a member": "Já é membro", "Also send as a direct message": "Também enviar como mensagem direta", "Also send in channel": "Também enviar no canal", "Also sent in channel": "Também enviado no canal", @@ -63,6 +80,7 @@ "Anonymous poll": "Enquete anônima", "Archive": "Arquivar", "Are you sure you want to delete this message?": "Tem certeza de que deseja excluir esta mensagem?", + "Are you sure you want to leave this channel?": "Are you sure you want to leave this channel?", "aria/Attachment": "Anexo", "aria/Attachment Actions": "Ações do anexo", "aria/Audio position {{ elapsed }} of {{ duration }}": "Posição do áudio {{ elapsed }} de {{ duration }}", @@ -73,6 +91,7 @@ "aria/Cancel Reply": "Cancelar resposta", "aria/Cancel upload": "Cancelar upload", "aria/Channel Actions": "Ações do canal", + "aria/Channel details": "Detalhes do canal", "aria/Channel list": "Lista de canais", "aria/Channel search results": "Resultados de pesquisa de canais", "aria/Chat view tabs": "Abas da visualização do chat", @@ -112,10 +131,13 @@ "aria/Notifications": "Notificações", "aria/Open Attachment Selector": "Abrir seletor de anexos", "aria/Open Channel Actions Menu": "Abrir menu de ações do canal", + "aria/Open channel details": "Abrir detalhes do canal", + "aria/Open image shared by {{ name }}": "Abrir imagem compartilhada por {{ name }}", "aria/Open Menu": "Abrir menu", "aria/Open Message Actions Menu": "Abrir menu de ações de mensagem", "aria/Open Reaction Selector": "Abrir seletor de reações", "aria/Open Thread": "Abrir tópico", + "aria/Open video shared by {{ name }}": "Abrir vídeo compartilhado por {{ name }}", "aria/Pause": "Pausar", "aria/Pause recording": "Pausar gravação", "aria/Percent complete": "{{percent}} por cento concluído", @@ -159,10 +181,15 @@ "Back": "Voltar", "ban-command-args": "[@nomedeusuário] [texto]", "ban-command-description": "Banir um usuário", + "Block user": "Bloquear usuário", "Block User": "Bloquear usuário", + "Browse channel members": "Ver membros do canal", + "Browse pinned messages": "Ver mensagens fixadas", "Cancel": "Cancelar", "Cannot seek in the recording": "Não é possível buscar na gravação", + "Changes saved": "Alterações salvas", "Channel archived": "Canal arquivado", + "Channel members": "Membros do canal", "Channel Missing": "Canal ausente", "Channel muted": "Canal silenciado", "Channel pinned": "Canal fixado", @@ -170,6 +197,7 @@ "Channel unmuted": "Silêncio do canal desativado", "Channel unpinned": "Canal desafixado", "Channels": "Canais", + "Chat deleted": "Chat deleted", "Chats": "Conversas", "Choose between 2 to 10 options": "Escolha entre 2 a 10 opções", "Close": "Fechar", @@ -181,12 +209,15 @@ "Commands": "Comandos", "Commands matching": "Comandos correspondentes", "Connection failure, reconnecting now...": "Falha de conexão, reconectando agora...", + "Contact info": "Informações do contato", + "Contact name": "Nome do contato", "Copy Message": "Copiar mensagem", "Create": "Criar", "Create a question, add options, and configure poll settings": "Crie uma pergunta, adicione opções e configure as definições da enquete", "Create poll": "Criar enquete", "Current location": "Localização atual", "Delete": "Excluir", + "Delete chat": "Excluir chat", "Delete for me": "Excluir para mim", "Delete message": "Excluir mensagem", "Delivered": "Entregue", @@ -202,6 +233,10 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "Editar", + "Edit chat data": "Editar dados do chat", + "Edit contact": "Editar contato", + "Edit group": "Editar grupo", "Edit Message": "Editar Mensagem", "Edit message request failed": "O pedido de edição da mensagem falhou", "Edited": "Editada", @@ -214,16 +249,26 @@ "Enforce unique vote is enabled": "Voto único está habilitado", "Error": "Erro", "Error adding flag": "Erro ao reportar", + "Error adding members": "Error adding members", + "Error blocking user": "Erro ao bloquear usuário", "Error connecting to chat, refresh the page to try again.": "Erro ao conectar ao bate-papo, atualize a página para tentar novamente.", + "Error deleting chat": "Error deleting chat", "Error deleting message": "Erro ao deletar mensagem", "Error fetching reactions": "Erro ao carregar reações", "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Erro ao marcar a mensagem como não lida. Não é possível marcar mensagens não lidas mais antigas do que as 100 mensagens mais recentes do canal.", "Error muting a user ...": "Erro ao silenciar um usuário...", + "Error muting channel": "Erro ao silenciar canal", + "Error muting user": "Erro ao silenciar usuário", + "Error opening direct message": "Erro ao abrir mensagem direta", "Error pinning message": "Erro ao fixar mensagem", "Error removing message pin": "Erro ao remover o PIN da mensagem", + "Error removing user": "Erro ao remover usuário", "Error reproducing the recording": "Erro ao reproduzir a gravação", "Error starting recording": "Erro ao iniciar a gravação", + "Error unblocking user": "Erro ao desbloquear usuário", "Error unmuting a user ...": "Erro ao ativar o som de um usuário...", + "Error unmuting channel": "Erro ao remover silenciamento do canal", + "Error unmuting user": "Erro ao remover silenciamento do usuário", "Error uploading attachment": "Erro ao carregar o anexo", "Error uploading file": "Erro ao enviar arquivo", "Error uploading image": "Erro ao carregar a imagem", @@ -242,6 +287,7 @@ "Failed to mark channel as read": "Falha ao marcar o canal como lido", "Failed to play the recording": "Falha ao reproduzir a gravação", "Failed to retrieve location": "Falha ao obter localização", + "Failed to save changes": "Falha ao salvar alterações", "Failed to share location": "Falha ao compartilhar localização", "Failed to update channel archive status": "Falha ao atualizar o status de arquivamento do canal", "Failed to update channel mute status": "Falha ao atualizar o status de mudo do canal", @@ -253,10 +299,14 @@ "fileCount_one": "1 arquivo", "fileCount_many": "{{ count }} arquivos", "fileCount_other": "{{ count }} arquivos", + "Files": "Arquivos", "Flag": "Reportar", "Generating...": "Gerando...", "giphy-command-args": "[texto]", "giphy-command-description": "Postar um gif aleatório no canal", + "Go back": "Voltar", + "Group info": "Informações do grupo", + "Group name": "Nome do grupo", "Hide who voted": "Ocultar quem votou", "Image": "Imagem", "imageCount_one": "Imagem", @@ -320,7 +370,9 @@ "language/vi": "Vietnamita", "language/zh": "Chinês (simplificado)", "language/zh-TW": "Chinês (tradicional)", + "Last seen {{ timestamp }}": "Visto pela última vez {{ timestamp }}", "Leave Channel": "Sair do canal", + "Leave chat": "Sair do canal", "Left channel": "Canal abandonado", "Let others add options": "Permitir que outros adicionem opções", "Limit votes per person": "Limitar votos por pessoa", @@ -337,9 +389,13 @@ "Location": "Localização", "Location sharing ended": "Compartilhamento de localização encerrado", "Location: {{ coordinates }}": "Localização: {{ coordinates }}", + "Manage channel": "Gerenciar canal", + "Manage members": "Gerenciar membros", "Mark as unread": "Marcar como não lida", "Maximum number of votes (from 2 to 10)": "Número máximo de votos (de 2 a 10)", "Maximum votes per person": "Máximo de votos por pessoa", + "Member detail": "Detalhes do membro", + "Member not found": "Membro não encontrado", "Menu": "Menu", "Message deleted": "Mensagem apagada", "Message failed to send": "Falha ao enviar a mensagem", @@ -350,8 +406,11 @@ "Message was blocked by moderation policies": "A mensagem foi bloqueada pelas políticas de moderação", "Messages have been marked unread.": "Mensagens foram marcadas como não lidas.", "Missing permissions to upload the attachment": "Faltando permissões para enviar o anexo", + "Moderator": "Moderador", "Multiple votes": "Votos múltiplos", "Mute": "Silenciar", + "Mute chat": "Silenciar chat", + "Mute user": "Silenciar usuário", "mute-command-args": "[@nomedeusuário]", "mute-command-description": "Silenciar um usuário", "network error": "erro de rede", @@ -361,8 +420,14 @@ "Next image": "Próxima imagem", "No chats here yet…": "Ainda não há conversas aqui...", "No conversations yet": "Ainda não há conversas", + "No files": "Nenhum arquivo", "No items exist": "Não existem itens", + "No member found": "Nenhum membro encontrado", + "No messages found": "Nenhuma mensagem encontrada", + "No photos or videos": "Nenhuma foto ou vídeo", + "No pinned messages": "Nenhuma mensagem fixada", "No results found": "Nenhum resultado encontrado", + "No user found": "Nenhum usuário encontrado", "Nobody will be able to vote in this poll anymore.": "Ninguém mais poderá votar nesta pesquisa.", "Nothing yet...": "Nada ainda...", "Offline": "Offline", @@ -374,15 +439,22 @@ "Open gallery at image {{ index }}": "Abrir galeria na imagem {{ index }}", "Open image in gallery": "Abrir imagem na galeria", "Open location in a map": "Abrir localização em um mapa", + "Open members actions": "Open members actions", + "Open menu": "Abrir menu", "Option already exists": "Opção já existe", "Option is empty": "A opção está vazia", "Options": "Opções", "Original": "Original", + "Owner": "Proprietário", "People matching": "Pessoas correspondentes", "Photo": "Foto", + "Photos & videos": "Fotos e vídeos", "Pin": "Fixar", + "Pin a message to see it here": "Fixe uma mensagem para vê-la aqui", "Pinned by {{ name }}": "Fixado por {{ name }}", "Pinned by You": "Fixado por você", + "Pinned message": "Mensagem fixada", + "Pinned messages": "Mensagens fixadas", "placeholder/PollComment": "O seu comentário", "placeholder/PollOptionSuggestion": "Introduza uma nova opção", "Play video": "Reproduzir vídeo", @@ -402,8 +474,15 @@ "Remind me": "Lembrar-me", "Remind Me": "Lembrar-me", "Reminder set": "Lembrete definido", + "Remove": "Remover", + "Remove {{ count }} members_one": "Remover {{ count }} membro", + "Remove {{ count }} members_many": "Remover {{ count }} membros", + "Remove {{ count }} members_other": "Remover {{ count }} membros", + "Remove {{ member }} from this channel?": "Remover {{ member }} deste canal?", + "Remove channel members": "Remover membros do canal", "Remove reminder": "Remover lembrete", "Remove save for later": "Remover Salvar para depois", + "Remove user": "Remover usuário", "Replied to a thread": "Respondeu em um tópico", "Reply": "Responder", "Reply to {{ authorName }}": "Responder a {{ authorName }}", @@ -419,6 +498,7 @@ "Review poll results and open an option to see detailed votes": "Revise os resultados da enquete e abra uma opção para ver votos detalhados", "Review this message and choose whether to delete it, edit it, or send it anyway": "Revise esta mensagem e escolha se deseja excluí-la, editá-la ou enviá-la mesmo assim", "Review who voted for this option": "Revise quem votou nesta opção", + "Save": "Salvar", "Save for later": "Salvar para depois", "Saved for later": "Salvo para depois", "Search": "Buscar", @@ -443,11 +523,14 @@ "Send a message": "Envie uma mensagem", "Send a message to start the conversation": "Envie uma mensagem para iniciar a conversa", "Send Anyway": "Enviar de qualquer forma", + "Send direct message": "Enviar mensagem direta", "Send message request failed": "O pedido de envio da mensagem falhou", "Send poll": "Enviar enquete", "Sending...": "Enviando...", "Sent": "Enviado", "Share": "Compartilhar", + "Share a file to see it here": "Compartilhe um arquivo para vê-lo aqui", + "Share a photo or video to see it here": "Compartilhe uma foto ou vídeo para vê-lo aqui", "Share live location for": "Compartilhar localização ao vivo por", "Share Location": "Compartilhar localização", "Shared live location": "Localização ao vivo compartilhada", @@ -468,6 +551,9 @@ "this content could not be displayed": "este conteúdo não pôde ser exibido", "This field cannot be empty or contain only spaces": "Este campo não pode estar vazio ou conter apenas espaços", "This message did not meet our content guidelines": "Esta mensagem não corresponde às nossas diretrizes de conteúdo", + "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This user will be able to message you again.": "Este usuário poderá enviar mensagens para você novamente.", + "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", "Thread": "Fio", "Thread has not been found": "Fio não encontrado", "Thread reply": "Resposta no fio", @@ -477,6 +563,8 @@ "ThreadListUnseenThreadsBanner/unreadThreads_many": "{{ count }} tópicos não lidos", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} tópicos não lidos", "Threads": "Tópicos", + "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", + "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Ontem]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "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) }}", @@ -501,9 +589,13 @@ "Unarchive": "Desarquivar", "unban-command-args": "[@nomedeusuário]", "unban-command-description": "Desbanir um usuário", + "Unblock": "Desbloquear", + "Unblock user": "Desbloquear usuário", "Unblock User": "Desbloquear usuário", "unknown error": "erro desconhecido", "Unmute": "Ativar som", + "Unmute chat": "Remover silenciamento do chat", + "Unmute user": "Remover silenciamento do usuário", "unmute-command-args": "[@nomedeusuário]", "unmute-command-description": "Retirar o silenciamento de um usuário", "Unpin": "Desfixar", @@ -516,9 +608,13 @@ "Upload blocked": "Envio bloqueado", "Upload error": "Erro no envio", "Upload failed": "Falha no envio", + "Upload Picture": "Enviar imagem", "Upload type: \"{{ type }}\" is not allowed": "Tipo de upload: \"{{ type }}\" não é permitido", "User blocked": "Usuário bloqueado", + "User muted": "Usuário silenciado", + "User removed": "Usuário removido", "User unblocked": "Usuário desbloqueado", + "User unmuted": "Silenciamento do usuário removido", "User uploaded content": "Conteúdo enviado pelo usuário", "Video": "Vídeo", "videoCount_one": "Vídeo", @@ -529,6 +625,7 @@ "View {{count}} comments_many": "Ver {{count}} comentários", "View {{count}} comments_other": "Ver {{count}} comentários", "View all": "Ver tudo", + "View member details for {{ member }}": "Ver detalhes do membro {{ member }}", "View original": "Ver original", "View results": "Ver resultados", "View translation": "Ver tradução", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index ae523c5215..d8412761c9 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -5,6 +5,14 @@ "{{ count }} files_few": "{{ count }} файла", "{{ count }} files_many": "{{ count }} файлов", "{{ count }} files_other": "{{ count }} файла", + "{{ count }} members_one": "{{ count }} участник", + "{{ count }} members_few": "{{ count }} участника", + "{{ count }} members_many": "{{ count }} участников", + "{{ count }} members_other": "{{ count }} участника", + "{{ count }} members added_one": "{{ count }} участник добавлен", + "{{ count }} members added_few": "{{ count }} участника добавлены", + "{{ count }} members added_many": "{{ count }} участников добавлено", + "{{ count }} members added_other": "{{ count }} участника добавлены", "{{ count }} people are typing_one": "{{ count }} человек печатает", "{{ count }} people are typing_few": "{{ count }} человека печатают", "{{ count }} people are typing_many": "{{ count }} человек печатают", @@ -23,6 +31,8 @@ "{{ count }} videos_other": "{{ count }} видео", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} и {{ secondUser }}", "{{ imageCount }} more": "Ещё {{ imageCount }}", + "{{ member }} will be able to message you again.": "{{ member }} снова сможет отправлять вам сообщения.", + "{{ member }} won't be able to message you anymore.": "{{ member }} больше не сможет отправлять вам сообщения.", "{{ memberCount }} members": "{{ memberCount }} участников", "{{ typing }} are typing": "{{ typing }} печатают", "{{ typing }} is typing": "{{ typing }} печатает", @@ -53,16 +63,26 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} создал(а): {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} проголосовал(а): {{pollOptionText}}", "📍Shared location": "📍Общее местоположение", + "Actions": "Actions", + "Add": "Добавить", + "Add {{ count }} members_one": "Добавить {{ count }} участника", + "Add {{ count }} members_few": "Добавить {{ count }} участников", + "Add {{ count }} members_many": "Добавить {{ count }} участников", + "Add {{ count }} members_other": "Добавить {{ count }} участника", "Add a comment": "Добавить комментарий", "Add a comment to your poll answer": "Добавьте комментарий к вашему ответу в опросе", "Add an option": "Добавить вариант", + "Add channel members": "Добавить участников канала", + "Add members": "Добавить участников", "Add reaction": "Добавить реакцию", + "Admin": "Администратор", "All results loaded": "Все результаты загружены", "Allow access to camera": "Разрешить доступ к камере", "Allow access to microphone": "Разрешить доступ к микрофону", "Allow comments": "Разрешить комментарии", "Allow option suggestion": "Разрешить предложение вариантов", "Allow others to add comments": "Разрешить другим добавлять комментарии", + "Already a member": "Уже участник", "Also send as a direct message": "Также отправить как личное сообщение", "Also send in channel": "Также отправить в канал", "Also sent in channel": "Также отправлено в канал", @@ -72,6 +92,7 @@ "Anonymous poll": "Анонимный опрос", "Archive": "Aрхивировать", "Are you sure you want to delete this message?": "Вы уверены, что хотите удалить это сообщение?", + "Are you sure you want to leave this channel?": "Are you sure you want to leave this channel?", "aria/Attachment": "Вложение", "aria/Attachment Actions": "Действия с вложением", "aria/Audio position {{ elapsed }} of {{ duration }}": "Позиция аудио {{ elapsed }} из {{ duration }}", @@ -82,6 +103,7 @@ "aria/Cancel Reply": "Отменить ответ", "aria/Cancel upload": "Отменить загрузку", "aria/Channel Actions": "Действия канала", + "aria/Channel details": "Сведения о канале", "aria/Channel list": "Список каналов", "aria/Channel search results": "Результаты поиска по каналам", "aria/Chat view tabs": "Вкладки вида чата", @@ -121,10 +143,13 @@ "aria/Notifications": "Уведомления", "aria/Open Attachment Selector": "Открыть выбор вложений", "aria/Open Channel Actions Menu": "Открыть меню действий канала", + "aria/Open channel details": "Открыть сведения о канале", + "aria/Open image shared by {{ name }}": "Открыть изображение от {{ name }}", "aria/Open Menu": "Открыть меню", "aria/Open Message Actions Menu": "Открыть меню действий с сообщениями", "aria/Open Reaction Selector": "Открыть селектор реакций", "aria/Open Thread": "Открыть тему", + "aria/Open video shared by {{ name }}": "Открыть видео от {{ name }}", "aria/Pause": "Пауза", "aria/Pause recording": "Приостановить запись", "aria/Percent complete": "{{percent}} процентов завершено", @@ -168,10 +193,15 @@ "Back": "Назад", "ban-command-args": "[@имяпользователя] [текст]", "ban-command-description": "Заблокировать пользователя", + "Block user": "Заблокировать пользователя", "Block User": "Заблокировать пользователя", + "Browse channel members": "Просмотреть участников канала", + "Browse pinned messages": "Просмотреть закрепленные сообщения", "Cancel": "Отмена", "Cannot seek in the recording": "Невозможно осуществить поиск в записи", + "Changes saved": "Изменения сохранены", "Channel archived": "Канал в архиве", + "Channel members": "Участники канала", "Channel Missing": "Канал не найден", "Channel muted": "Канал заглушён", "Channel pinned": "Канал закреплён", @@ -179,6 +209,7 @@ "Channel unmuted": "Заглушение канала снято", "Channel unpinned": "Канал откреплён", "Channels": "Каналы", + "Chat deleted": "Chat deleted", "Chats": "Чаты", "Choose between 2 to 10 options": "Выберите от 2 до 10 вариантов", "Close": "Закрыть", @@ -190,12 +221,15 @@ "Commands": "Команды", "Commands matching": "Соответствие команд", "Connection failure, reconnecting now...": "Ошибка соединения, переподключение...", + "Contact info": "Информация о контакте", + "Contact name": "Имя контакта", "Copy Message": "Копировать сообщение", "Create": "Создать", "Create a question, add options, and configure poll settings": "Создайте вопрос, добавьте варианты и настройте параметры опроса", "Create poll": "Создать опрос", "Current location": "Текущее местоположение", "Delete": "Удалить", + "Delete chat": "Удалить чат", "Delete for me": "Удалить для меня", "Delete message": "Удалить сообщение", "Delivered": "Отправлено", @@ -211,6 +245,10 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "Редактировать", + "Edit chat data": "Редактировать данные чата", + "Edit contact": "Редактировать контакт", + "Edit group": "Редактировать группу", "Edit Message": "Редактировать сообщение", "Edit message request failed": "Не удалось изменить запрос сообщения", "Edited": "Отредактировано", @@ -223,16 +261,26 @@ "Enforce unique vote is enabled": "Уникальное голосование включено", "Error": "Ошибка", "Error adding flag": "Ошибка добавления флага", + "Error adding members": "Error adding members", + "Error blocking user": "Ошибка при блокировке пользователя", "Error connecting to chat, refresh the page to try again.": "Ошибка подключения к чату, обновите страницу чтобы попробовать снова.", + "Error deleting chat": "Error deleting chat", "Error deleting message": "Ошибка при удалении сообщения", "Error fetching reactions": "Ошибка при загрузке реакций", "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Ошибка при отметке сообщения как непрочитанного. Невозможно отметить как непрочитанные сообщения старше последних 100 сообщений в канале.", "Error muting a user ...": "Ошибка отключения уведомлений от пользователя...", + "Error muting channel": "Ошибка при отключении уведомлений канала", + "Error muting user": "Ошибка при отключении уведомлений пользователя", + "Error opening direct message": "Ошибка при открытии личного сообщения", "Error pinning message": "Сообщение об ошибке при закреплении", "Error removing message pin": "Ошибка при удалении булавки сообщения", + "Error removing user": "Ошибка при удалении пользователя", "Error reproducing the recording": "Ошибка воспроизведения записи", "Error starting recording": "Ошибка при запуске записи", + "Error unblocking user": "Ошибка при разблокировке пользователя", "Error unmuting a user ...": "Ошибка включения уведомлений...", + "Error unmuting channel": "Ошибка при включении уведомлений канала", + "Error unmuting user": "Ошибка при включении уведомлений пользователя", "Error uploading attachment": "Ошибка при загрузке вложения", "Error uploading file": "Ошибка при загрузке файла", "Error uploading image": "Ошибка загрузки изображения", @@ -251,6 +299,7 @@ "Failed to mark channel as read": "Не удалось пометить канал как прочитанный", "Failed to play the recording": "Не удалось воспроизвести запись", "Failed to retrieve location": "Не удалось получить местоположение", + "Failed to save changes": "Не удалось сохранить изменения", "Failed to share location": "Не удалось поделиться местоположением", "Failed to update channel archive status": "Не удалось обновить статус архивации канала", "Failed to update channel mute status": "Не удалось обновить статус отключения звука канала", @@ -259,17 +308,21 @@ "File is required for upload attachment": "Для загрузки вложения требуется файл", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Файл слишком большой: {{ size }}, максимальный размер загрузки составляет {{ limit }}", "File too large": "Файл слишком большой", - "fileCount_four": "{{ count }} файла", "fileCount_one": "{{ count }} файл", "fileCount_few": "{{ count }} файла", + "fileCount_four": "{{ count }} файла", + "fileCount_two": "{{ count }} файла", "fileCount_many": "{{ count }} файлов", "fileCount_other": "{{ count }} файлов", "fileCount_three": "{{ count }} файла", - "fileCount_two": "{{ count }} файла", + "Files": "Файлы", "Flag": "Пожаловаться", "Generating...": "Генерирую...", "giphy-command-args": "[текст]", "giphy-command-description": "Опубликовать случайную GIF-анимацию в канале", + "Go back": "Назад", + "Group info": "Информация о группе", + "Group name": "Название группы", "Hide who voted": "Скрыть, кто голосовал", "Image": "Изображение", "imageCount_one": "{{ count }} изображение", @@ -334,7 +387,9 @@ "language/vi": "Вьетнамский", "language/zh": "Китайский (упрощённый)", "language/zh-TW": "Китайский (традиционный)", + "Last seen {{ timestamp }}": "Был(а) в сети {{ timestamp }}", "Leave Channel": "Покинуть канал", + "Leave chat": "Покинуть канал", "Left channel": "Канал покинут", "Let others add options": "Разрешить другим добавлять варианты", "Limit votes per person": "Ограничить голоса на человека", @@ -352,9 +407,13 @@ "Location": "Местоположение", "Location sharing ended": "Обмен местоположением завершен", "Location: {{ coordinates }}": "Местоположение: {{ coordinates }}", + "Manage channel": "Управлять каналом", + "Manage members": "Управлять участниками", "Mark as unread": "Отметить как непрочитанное", "Maximum number of votes (from 2 to 10)": "Максимальное количество голосов (от 2 до 10)", "Maximum votes per person": "Максимум голосов на человека", + "Member detail": "Сведения об участнике", + "Member not found": "Участник не найден", "Menu": "Меню", "Message deleted": "Сообщение удалено", "Message failed to send": "Не удалось отправить сообщение", @@ -365,8 +424,11 @@ "Message was blocked by moderation policies": "Сообщение было заблокировано модерацией", "Messages have been marked unread.": "Сообщения были отмечены как непрочитанные.", "Missing permissions to upload the attachment": "Отсутствуют разрешения для загрузки вложения", + "Moderator": "Модератор", "Multiple votes": "Несколько голосов", "Mute": "Отключить уведомления", + "Mute chat": "Отключить уведомления чата", + "Mute user": "Отключить уведомления пользователя", "mute-command-args": "[@имяпользователя]", "mute-command-description": "Выключить микрофон у пользователя", "network error": "ошибка сети", @@ -376,8 +438,14 @@ "Next image": "Следующее изображение", "No chats here yet…": "Здесь еще нет чатов...", "No conversations yet": "Пока нет бесед", + "No files": "Нет файлов", "No items exist": "Элементов нет", + "No member found": "Участник не найден", + "No messages found": "Сообщения не найдены", + "No photos or videos": "Нет фото или видео", + "No pinned messages": "Нет закрепленных сообщений", "No results found": "Результаты не найдены", + "No user found": "Пользователь не найден", "Nobody will be able to vote in this poll anymore.": "Никто больше не сможет голосовать в этом опросе.", "Nothing yet...": "Пока ничего нет...", "Offline": "Не в сети", @@ -389,15 +457,22 @@ "Open gallery at image {{ index }}": "Открыть галерею на изображении {{ index }}", "Open image in gallery": "Открыть изображение в галерее", "Open location in a map": "Открыть местоположение на карте", + "Open members actions": "Open members actions", + "Open menu": "Открыть меню", "Option already exists": "Вариант уже существует", "Option is empty": "Вариант пуст", "Options": "Варианты", "Original": "Оригинал", + "Owner": "Владелец", "People matching": "Совпадающие люди", "Photo": "Фото", + "Photos & videos": "Фото и видео", "Pin": "Закрепить", + "Pin a message to see it here": "Закрепите сообщение, чтобы увидеть его здесь", "Pinned by {{ name }}": "Закреплено: {{ name }}", "Pinned by You": "Закреплено вами", + "Pinned message": "Закрепленное сообщение", + "Pinned messages": "Закрепленные сообщения", "placeholder/PollComment": "Ваш комментарий", "placeholder/PollOptionSuggestion": "Введите новый вариант", "Play video": "Воспроизвести видео", @@ -417,8 +492,16 @@ "Remind me": "Напомнить мне", "Remind Me": "Напомнить мне", "Reminder set": "Напоминание установлено", + "Remove": "Удалить", + "Remove {{ count }} members_one": "Удалить {{ count }} участника", + "Remove {{ count }} members_few": "Удалить {{ count }} участника", + "Remove {{ count }} members_many": "Удалить {{ count }} участников", + "Remove {{ count }} members_other": "Удалить {{ count }} участника", + "Remove {{ member }} from this channel?": "Удалить {{ member }} из этого канала?", + "Remove channel members": "Удалить участников канала", "Remove reminder": "Удалить напоминание", "Remove save for later": "Удалить «Сохранить на потом»", + "Remove user": "Удалить пользователя", "Replied to a thread": "Ответил в ветке", "Reply": "Ответить", "Reply to {{ authorName }}": "Ответить {{ authorName }}", @@ -435,6 +518,7 @@ "Review poll results and open an option to see detailed votes": "Просмотрите результаты опроса и откройте вариант, чтобы увидеть подробные голоса", "Review this message and choose whether to delete it, edit it, or send it anyway": "Просмотрите это сообщение и выберите, удалить его, отредактировать или отправить все равно", "Review who voted for this option": "Просмотрите, кто проголосовал за этот вариант", + "Save": "Сохранить", "Save for later": "Сохранить на потом", "Saved for later": "Сохранено на потом", "Search": "Поиск", @@ -461,11 +545,14 @@ "Send a message": "Отправьте сообщение", "Send a message to start the conversation": "Отправьте сообщение, чтобы начать разговор", "Send Anyway": "Мне всё равно, отправить", + "Send direct message": "Отправить личное сообщение", "Send message request failed": "Не удалось отправить запрос на отправку сообщения", "Send poll": "Отправить опрос", "Sending...": "Отправка...", "Sent": "Отправлено", "Share": "Поделиться", + "Share a file to see it here": "Поделитесь файлом, чтобы увидеть его здесь", + "Share a photo or video to see it here": "Поделитесь фото или видео, чтобы увидеть их здесь", "Share live location for": "Поделиться местоположением в прямом эфире на", "Share Location": "Поделиться местоположением", "Shared live location": "Общее местоположение в прямом эфире", @@ -486,6 +573,9 @@ "this content could not be displayed": "Этот контент не может быть отображен в данный момент", "This field cannot be empty or contain only spaces": "Это поле не может быть пустым или содержать только пробелы", "This message did not meet our content guidelines": "Сообщение не соответствует правилам", + "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This user will be able to message you again.": "Этот пользователь снова сможет отправлять вам сообщения.", + "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", "Thread": "Ветка", "Thread has not been found": "Ветка не найдена", "Thread reply": "Ответ в ветке", @@ -496,6 +586,8 @@ "ThreadListUnseenThreadsBanner/unreadThreads_many": "{{ count }} непрочитанных веток", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} непрочитанных веток", "Threads": "Треды", + "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", + "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Вчера]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня]\", \"nextDay\": \"[Завтра]\", \"lastDay\": \"[Вчера]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[В прошлый] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -521,9 +613,13 @@ "Unarchive": "Удалить из архива", "unban-command-args": "[@имяпользователя]", "unban-command-description": "Разблокировать пользователя", + "Unblock": "Разблокировать", + "Unblock user": "Разблокировать пользователя", "Unblock User": "Разблокировать пользователя", "unknown error": "неизвестная ошибка", "Unmute": "Включить уведомления", + "Unmute chat": "Включить уведомления чата", + "Unmute user": "Включить уведомления пользователя", "unmute-command-args": "[@имяпользователя]", "unmute-command-description": "Включить микрофон у пользователя", "Unpin": "Открепить", @@ -536,9 +632,13 @@ "Upload blocked": "Загрузка заблокирована", "Upload error": "Ошибка загрузки", "Upload failed": "Загрузка не удалась", + "Upload Picture": "Загрузить изображение", "Upload type: \"{{ type }}\" is not allowed": "Тип загрузки: \"{{ type }}\" не разрешен", "User blocked": "Пользователь заблокирован", + "User muted": "Уведомления пользователя отключены", + "User removed": "Пользователь удалён", "User unblocked": "Пользователь разблокирован", + "User unmuted": "Уведомления пользователя включены", "User uploaded content": "Пользователь загрузил контент", "Video": "Видео", "videoCount_one": "{{ count }} видео", @@ -551,6 +651,7 @@ "View {{count}} comments_many": "Просмотреть {{count}} комментариев", "View {{count}} comments_other": "Просмотреть {{count}} комментариев", "View all": "Показать все", + "View member details for {{ member }}": "Просмотреть сведения об участнике {{ member }}", "View original": "Показать оригинал", "View results": "Посмотреть результаты", "View translation": "Показать перевод", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 189eadb911..a3aae8b019 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -3,6 +3,10 @@ "{{ commaSeparatedUsers }}, and {{ lastUser }}": "{{ commaSeparatedUsers }} ve {{ lastUser }}", "{{ count }} files_one": "{{ count }} dosya", "{{ count }} files_other": "{{ count }} dosya", + "{{ count }} members_one": "{{ count }} üye", + "{{ count }} members_other": "{{ count }} üye", + "{{ count }} members added_one": "{{ count }} üye eklendi", + "{{ count }} members added_other": "{{ count }} üye eklendi", "{{ count }} people are typing_one": "{{ count }} kişi yazıyor", "{{ count }} people are typing_many": "{{ count }} kişi yazıyor", "{{ count }} people are typing_other": "{{ count }} kişi yazıyor", @@ -14,6 +18,8 @@ "{{ count }} videos_other": "{{ count }} video", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} ve {{ secondUser }}", "{{ imageCount }} more": "{{ imageCount }} adet daha", + "{{ member }} will be able to message you again.": "{{ member }} size tekrar mesaj gönderebilecek.", + "{{ member }} won't be able to message you anymore.": "{{ member }} artık size mesaj gönderemeyecek.", "{{ memberCount }} members": "{{ memberCount }} üye", "{{ typing }} are typing": "{{ typing }} yazıyor", "{{ typing }} is typing": "{{ typing }} yazıyor", @@ -36,16 +42,24 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} oluşturdu: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} oy verdi: {{pollOptionText}}", "📍Shared location": "📍Paylaşılan konum", + "Actions": "Actions", + "Add": "Ekle", + "Add {{ count }} members_one": "{{ count }} üye ekle", + "Add {{ count }} members_other": "{{ count }} üye ekle", "Add a comment": "Yorum ekle", "Add a comment to your poll answer": "Anket yanıtınıza bir yorum ekleyin", "Add an option": "Bir seçenek ekle", + "Add channel members": "Kanal üyeleri ekle", + "Add members": "Üye ekle", "Add reaction": "Tepki ekle", + "Admin": "Yönetici", "All results loaded": "Tüm sonuçlar yüklendi", "Allow access to camera": "Kameraya erişime izin ver", "Allow access to microphone": "Mikrofona erişime izin ver", "Allow comments": "Yorumlara izin ver", "Allow option suggestion": "Seçenek önerisine izin ver", "Allow others to add comments": "Diğerlerinin yorum eklemesine izin ver", + "Already a member": "Zaten üye", "Also send as a direct message": "Ayrıca doğrudan mesaj olarak gönder", "Also send in channel": "Ayrıca kanala gönder", "Also sent in channel": "Kanala da gönderildi", @@ -55,6 +69,7 @@ "Anonymous poll": "Anonim anket", "Archive": "Arşivle", "Are you sure you want to delete this message?": "Bu mesajı silmek istediğinizden emin misiniz?", + "Are you sure you want to leave this channel?": "Are you sure you want to leave this channel?", "aria/Attachment": "Ek", "aria/Attachment Actions": "Ek işlemleri", "aria/Audio position {{ elapsed }} of {{ duration }}": "Ses konumu {{ elapsed }} / {{ duration }}", @@ -65,6 +80,7 @@ "aria/Cancel Reply": "Cevabı İptal Et", "aria/Cancel upload": "Yüklemeyi İptal Et", "aria/Channel Actions": "Kanal işlemleri", + "aria/Channel details": "Kanal ayrıntıları", "aria/Channel list": "Kanal listesi", "aria/Channel search results": "Kanal arama sonuçları", "aria/Chat view tabs": "Sohbet görünümü sekmeleri", @@ -104,10 +120,13 @@ "aria/Notifications": "Bildirimler", "aria/Open Attachment Selector": "Ek Seçiciyi Aç", "aria/Open Channel Actions Menu": "Kanal işlemleri menüsünü aç", + "aria/Open channel details": "Kanal ayrıntılarını aç", + "aria/Open image shared by {{ name }}": "{{ name }} tarafından paylaşılan resmi aç", "aria/Open Menu": "Menüyü Aç", "aria/Open Message Actions Menu": "Mesaj İşlemleri Menüsünü Aç", "aria/Open Reaction Selector": "Tepki Seçiciyi Aç", "aria/Open Thread": "Konuyu Aç", + "aria/Open video shared by {{ name }}": "{{ name }} tarafından paylaşılan videoyu aç", "aria/Pause": "Duraklat", "aria/Pause recording": "Kaydı duraklat", "aria/Percent complete": "Yüzde {{percent}} tamamlandı", @@ -151,10 +170,15 @@ "Back": "Geri", "ban-command-args": "[@kullanıcıadı] [metin]", "ban-command-description": "Bir kullanıcıyı yasakla", + "Block user": "Kullanıcıyı engelle", "Block User": "Kullanıcıyı engelle", + "Browse channel members": "Kanal üyelerine göz at", + "Browse pinned messages": "Sabitlenmiş mesajlara göz at", "Cancel": "İptal", "Cannot seek in the recording": "Kayıtta arama yapılamıyor", + "Changes saved": "Değişiklikler kaydedildi", "Channel archived": "Kanal arşivlendi", + "Channel members": "Kanal üyeleri", "Channel Missing": "Kanal bulunamıyor", "Channel muted": "Kanal sessize alındı", "Channel pinned": "Kanal sabitlendi", @@ -162,6 +186,7 @@ "Channel unmuted": "Kanal sesi açıldı", "Channel unpinned": "Kanal sabitlemesi kaldırıldı", "Channels": "Kanallar", + "Chat deleted": "Chat deleted", "Chats": "Sohbetler", "Choose between 2 to 10 options": "2 ile 10 seçenek arasından seçin", "Close": "Kapat", @@ -173,12 +198,15 @@ "Commands": "Komutlar", "Commands matching": "Eşleşen komutlar", "Connection failure, reconnecting now...": "Bağlantı hatası, tekrar bağlanılıyor...", + "Contact info": "İletişim bilgileri", + "Contact name": "Kişi adı", "Copy Message": "Mesajı kopyala", "Create": "Oluştur", "Create a question, add options, and configure poll settings": "Bir soru oluşturun, seçenekler ekleyin ve anket ayarlarını yapılandırın", "Create poll": "Anket oluştur", "Current location": "Mevcut konum", "Delete": "Sil", + "Delete chat": "Sohbeti sil", "Delete for me": "Benim için sil", "Delete message": "Mesajı sil", "Delivered": "İletildi", @@ -194,6 +222,10 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "Düzenle", + "Edit chat data": "Sohbet verilerini düzenle", + "Edit contact": "Kişiyi düzenle", + "Edit group": "Grubu düzenle", "Edit Message": "Mesajı Düzenle", "Edit message request failed": "Mesaj düzenleme isteği başarısız oldu", "Edited": "Düzenlendi", @@ -206,16 +238,26 @@ "Enforce unique vote is enabled": "Benzersiz oy etkinleştirildi", "Error": "Hata", "Error adding flag": "Bayrak eklenirken hata oluştu", + "Error adding members": "Error adding members", + "Error blocking user": "Kullanıcı engellenirken hata oluştu", "Error connecting to chat, refresh the page to try again.": "Bağlantı hatası, sayfayı yenileyip tekrar deneyin.", + "Error deleting chat": "Error deleting chat", "Error deleting message": "Mesaj silinirken hata oluştu", "Error fetching reactions": "Reaksiyonlar alınırken hata oluştu", "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Mesajı okunmamış olarak işaretleme hatası. En yeni 100 kanal mesajından daha eski okunmamış mesajları işaretleme yapılamaz.", "Error muting a user ...": "Kullanıcıyı sessize alırken hata oluştu ...", + "Error muting channel": "Kanal sessize alınırken hata oluştu", + "Error muting user": "Kullanıcı sessize alınırken hata oluştu", + "Error opening direct message": "Direkt mesaj açılırken hata oluştu", "Error pinning message": "Mesaj sabitlenirken hata oluştu", "Error removing message pin": "Mesaj PIN'i kaldırılırken hata oluştu", + "Error removing user": "Kullanıcı kaldırılırken hata oluştu", "Error reproducing the recording": "Kaydı yeniden üretme hatası", "Error starting recording": "Kayıt başlatılırken hata oluştu", + "Error unblocking user": "Kullanıcının engeli kaldırılırken hata oluştu", "Error unmuting a user ...": "Kullanıcının sesini açarken hata oluştu ...", + "Error unmuting channel": "Kanalın sesi açılırken hata oluştu", + "Error unmuting user": "Kullanıcının sesi açılırken hata oluştu", "Error uploading attachment": "Ek yüklenirken hata oluştu", "Error uploading file": "Dosya yüklenirken hata oluştu", "Error uploading image": "Resmi yüklerken hata", @@ -234,6 +276,7 @@ "Failed to mark channel as read": "Kanalı okundu olarak işaretleme başarısız oldu", "Failed to play the recording": "Kayıt oynatılamadı", "Failed to retrieve location": "Konum alınamadı", + "Failed to save changes": "Değişiklikler kaydedilemedi", "Failed to share location": "Konum paylaşılamadı", "Failed to update channel archive status": "Kanalın arşiv durumu güncellenemedi", "Failed to update channel mute status": "Kanalın sessiz durumu güncellenemedi", @@ -244,10 +287,14 @@ "File too large": "Dosya çok büyük", "fileCount_one": "1 dosya", "fileCount_other": "{{ count }} dosya", + "Files": "Dosyalar", "Flag": "Bayrak", "Generating...": "Oluşturuluyor...", "giphy-command-args": "[metin]", "giphy-command-description": "Rastgele bir gif'i kanala gönder", + "Go back": "Geri dön", + "Group info": "Grup bilgileri", + "Group name": "Grup adı", "Hide who voted": "Kimin oy verdiğini gizle", "Image": "Görsel", "imageCount_one": "Görsel", @@ -310,7 +357,9 @@ "language/vi": "Vietnamca", "language/zh": "Çince (basitleştirilmiş)", "language/zh-TW": "Çince (geleneksel)", + "Last seen {{ timestamp }}": "Son görülme {{ timestamp }}", "Leave Channel": "Kanaldan ayrıl", + "Leave chat": "Kanaldan ayrıl", "Left channel": "Kanaldan ayrıldınız", "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ı", @@ -326,9 +375,13 @@ "Location": "Konum", "Location sharing ended": "Konum paylaşımı sona erdi", "Location: {{ coordinates }}": "Konum: {{ coordinates }}", + "Manage channel": "Kanalı yönet", + "Manage members": "Üyeleri yönet", "Mark as unread": "Okunmamış olarak işaretle", "Maximum number of votes (from 2 to 10)": "Maksimum oy sayısı (2 ile 10 arası)", "Maximum votes per person": "Kişi başına maksimum oy", + "Member detail": "Üye detayı", + "Member not found": "Üye bulunamadı", "Menu": "Menü", "Message deleted": "Mesaj silindi", "Message failed to send": "Mesaj gönderilemedi", @@ -339,8 +392,11 @@ "Message was blocked by moderation policies": "Mesaj moderasyon politikaları tarafından engellendi", "Messages have been marked unread.": "Mesajlar okunmamış olarak işaretlendi.", "Missing permissions to upload the attachment": "Ek yüklemek için izinler eksik", + "Moderator": "Moderatör", "Multiple votes": "Çoklu oy", "Mute": "Sessiz", + "Mute chat": "Sohbeti sessize al", + "Mute user": "Kullanıcıyı sessize al", "mute-command-args": "[@kullanıcıadı]", "mute-command-description": "Bir kullanıcının sesini kapat", "network error": "ağ hatası", @@ -350,8 +406,14 @@ "Next image": "Sonraki görsel", "No chats here yet…": "Henüz burada sohbet yok...", "No conversations yet": "Henüz konuşma yok", + "No files": "Dosya yok", "No items exist": "Hiç öğe yok", + "No member found": "Üye bulunamadı", + "No messages found": "Mesaj bulunamadı", + "No photos or videos": "Fotoğraf veya video yok", + "No pinned messages": "Sabitlenmiş mesaj yok", "No results found": "Sonuç bulunamadı", + "No user found": "Kullanıcı 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ışı", @@ -363,15 +425,22 @@ "Open gallery at image {{ index }}": "Galeriyi {{ index }}. görselde aç", "Open image in gallery": "Görseli galeride aç", "Open location in a map": "Konumu haritada aç", + "Open members actions": "Open members actions", + "Open menu": "Menüyü aç", "Option already exists": "Seçenek zaten mevcut", "Option is empty": "Seçenek boş", "Options": "Seçenekler", "Original": "Orijinal", + "Owner": "Sahip", "People matching": "Eşleşen kişiler", "Photo": "Fotoğraf", + "Photos & videos": "Fotoğraflar ve videolar", "Pin": "Sabitle", + "Pin a message to see it here": "Burada görmek için bir mesaj sabitle", "Pinned by {{ name }}": "{{ name }} sabitledi", "Pinned by You": "Sizin sabitlediğiniz", + "Pinned message": "Sabitlenmiş mesaj", + "Pinned messages": "Sabitlenmiş mesajlar", "placeholder/PollComment": "Yorumunuz", "placeholder/PollOptionSuggestion": "Yeni bir seçenek girin", "Play video": "Videoyu oynat", @@ -391,8 +460,14 @@ "Remind me": "Bana hatırlat", "Remind Me": "Hatırlat", "Reminder set": "Hatırlatıcı ayarlandı", + "Remove": "Kaldır", + "Remove {{ count }} members_one": "{{ count }} üyeyi kaldır", + "Remove {{ count }} members_other": "{{ count }} üyeyi kaldır", + "Remove {{ member }} from this channel?": "{{ member }} bu kanaldan kaldırılsın mı?", + "Remove channel members": "Kanal üyelerini kaldır", "Remove reminder": "Hatırlatıcıyı kaldır", "Remove save for later": "Sonraya kaydet'i kaldır", + "Remove user": "Kullanıcıyı kaldır", "Replied to a thread": "Bir iş parçacığına yanıt verdi", "Reply": "Cevapla", "Reply to {{ authorName }}": "{{ authorName }} kişisine yanıt ver", @@ -407,6 +482,7 @@ "Review poll results and open an option to see detailed votes": "Anket sonuçlarını inceleyin ve ayrıntılı oyları görmek için bir seçenek açın", "Review this message and choose whether to delete it, edit it, or send it anyway": "Bu mesajı inceleyin ve silmeyi, düzenlemeyi veya yine de göndermeyi seçin", "Review who voted for this option": "Bu seçenek için kimlerin oy verdiğini inceleyin", + "Save": "Kaydet", "Save for later": "Daha sonra kaydet", "Saved for later": "Daha sonra kaydedildi", "Search": "Arama", @@ -429,11 +505,14 @@ "Send a message": "Bir mesaj gönderin", "Send a message to start the conversation": "Sohbete başlamak için bir mesaj gönderin", "Send Anyway": "Yine de gönder", + "Send direct message": "Direkt mesaj gönder", "Send message request failed": "Mesaj gönderme isteği başarısız oldu", "Send poll": "Anketi gönder", "Sending...": "Gönderiliyor...", "Sent": "Gönderildi", "Share": "Paylaş", + "Share a file to see it here": "Burada görmek için bir dosya paylaşın", + "Share a photo or video to see it here": "Burada görmek için bir fotoğraf veya video paylaşın", "Share live location for": "Canlı konum paylaş", "Share Location": "Konum Paylaş", "Shared live location": "Paylaşılan canlı konum", @@ -454,6 +533,9 @@ "this content could not be displayed": "bu içerik gösterilemiyor", "This field cannot be empty or contain only spaces": "Bu alan boş olamaz veya sadece boşluk içeremez", "This message did not meet our content guidelines": "Bu mesaj içerik yönergelerimize uygun değil", + "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This user will be able to message you again.": "Bu kullanıcı size tekrar mesaj gönderebilecek.", + "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", "Thread": "Konu", "Thread has not been found": "Konu bulunamadı", "Thread reply": "Konu yanıtı", @@ -462,6 +544,8 @@ "ThreadListUnseenThreadsBanner/unreadThreads_one": "{{ count }} okunmamış ileti dizisi", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} okunmamış ileti dizisi", "Threads": "İleti dizileri", + "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", + "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Dün]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "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) }}", @@ -485,9 +569,13 @@ "Unarchive": "Arşivden çıkar", "unban-command-args": "[@kullanıcıadı]", "unban-command-description": "Bir kullanıcının yasağını kaldır", + "Unblock": "Engeli kaldır", + "Unblock user": "Kullanıcının engelini kaldır", "Unblock User": "Kullanıcının engelini kaldır", "unknown error": "bilinmeyen hata", "Unmute": "Sesini aç", + "Unmute chat": "Sohbetin sesini aç", + "Unmute user": "Kullanıcının sesini aç", "unmute-command-args": "[@kullanıcıadı]", "unmute-command-description": "Bir kullanıcının sesini aç", "Unpin": "Sabitlemeyi kaldır", @@ -500,9 +588,13 @@ "Upload blocked": "Yükleme engellendi", "Upload error": "Yükleme hatası", "Upload failed": "Yükleme başarısız oldu", + "Upload Picture": "Resim yükle", "Upload type: \"{{ type }}\" is not allowed": "Yükleme türü: \"{{ type }}\" izin verilmez", "User blocked": "Kullanıcı engellendi", + "User muted": "Kullanıcı sessize alındı", + "User removed": "Kullanıcı kaldırıldı", "User unblocked": "Kullanıcının engeli kaldırıldı", + "User unmuted": "Kullanıcının sesi açıldı", "User uploaded content": "Kullanıcı tarafından yüklenen içerik", "Video": "Video", "videoCount_one": "Video", @@ -511,6 +603,7 @@ "View {{count}} comments_one": "{{count}} yorumu görüntüle", "View {{count}} comments_other": "{{count}} yorumu görüntüle", "View all": "Tümünü görüntüle", + "View member details for {{ member }}": "{{ member }} için üye detaylarını 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", diff --git a/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx b/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx new file mode 100644 index 0000000000..b1dc43e6ba --- /dev/null +++ b/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx @@ -0,0 +1,73 @@ +import clsx from 'clsx'; +import React, { useCallback, useState } from 'react'; + +import { + useChannelStateContext, + useComponentContext, + useTranslationContext, +} from '../../context'; +import { + type ChannelAvatarProps, + ChannelAvatar as DefaultChannelAvatar, +} from '../../components/Avatar/index'; +import { + type ChannelDetailProps, + ChannelDetail as DefaultChannelDetail, +} from './ChannelDetail'; +import { GlobalModal } from '../../components/Modal'; + +export type AvatarWithChannelDetailProps = ChannelAvatarProps & { + Avatar?: React.ComponentType; + ChannelDetail?: React.ComponentType; +}; + +const avatarWithChannelDetailDialogRootProps = { + className: 'str-chat__channel-detail-modal', +}; + +export const AvatarWithChannelDetail = ({ + Avatar, + ChannelDetail = DefaultChannelDetail, + className, + ...avatarProps +}: AvatarWithChannelDetailProps) => { + const { t } = useTranslationContext(); + const { channel } = useChannelStateContext(); + const { Avatar: ContextAvatar, Modal = GlobalModal } = useComponentContext(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const openModal = useCallback(() => setIsModalOpen(true), []); + const closeModal = useCallback(() => setIsModalOpen(false), []); + + const AvatarComponent = + Avatar ?? + (ContextAvatar === AvatarWithChannelDetail ? undefined : ContextAvatar) ?? + DefaultChannelAvatar; + + return ( + <> + + + + + + ); +}; diff --git a/src/plugins/ChannelDetail/ChannelDetail.tsx b/src/plugins/ChannelDetail/ChannelDetail.tsx new file mode 100644 index 0000000000..87aebc945a --- /dev/null +++ b/src/plugins/ChannelDetail/ChannelDetail.tsx @@ -0,0 +1,152 @@ +import clsx from 'clsx'; +import React, { useState } from 'react'; +import type { Channel } from 'stream-chat'; + +import { + SECTION_NAVIGATOR_LAYOUT, + SectionNavigator, + type SectionNavigatorLayout, + type SectionNavigatorNavButtonProps, + type SectionNavigatorProps, + type SectionNavigatorSection, +} from '../../components/SectionNavigator'; +import { ChannelDetailNavButton } from './ChannelDetailNavButton'; +import { ChannelDetailProvider } from './ChannelDetailContext'; +import { ChannelFilesView } from './Views/ChannelFilesView'; +import { ChannelManagementView } from './Views/ChannelManagementView'; +import { ChannelMediaView } from './Views/ChannelMediaView'; +import { ChannelMembersView } from './Views/ChannelMembersView'; +import { PinnedMessagesView } from './Views/PinnedMessagesView'; +import { Prompt } from '../../components/Dialog'; +import { + IconFolder, + IconImage, + IconInfo, + IconPin, + IconUser, +} from '../../components/Icons'; + +const ChannelManagementNavButtonIcon = () => ( + +); + +const ChannelMembersNavButtonIcon = () => ( + +); + +const PinnedMessagesNavButtonIcon = () => ( + +); + +const ChannelMediaNavButtonIcon = () => ( + +); + +const ChannelFilesNavButtonIcon = () => ( + +); + +export const ChannelManagementNavButton = (props: SectionNavigatorNavButtonProps) => ( + +); + +export const ChannelMembersNavButton = (props: SectionNavigatorNavButtonProps) => ( + +); + +export const PinnedMessagesNavButton = (props: SectionNavigatorNavButtonProps) => ( + +); + +export const ChannelMediaNavButton = (props: SectionNavigatorNavButtonProps) => ( + +); + +export const ChannelFilesNavButton = (props: SectionNavigatorNavButtonProps) => ( + +); + +export const defaultChannelDetailSections: SectionNavigatorSection[] = [ + { + id: 'channel-info', + NavButton: ChannelManagementNavButton, + SectionContent: ChannelManagementView, + }, + { + id: 'channel-members', + NavButton: ChannelMembersNavButton, + SectionContent: ChannelMembersView, + }, + { + id: 'pinned-messages', + NavButton: PinnedMessagesNavButton, + SectionContent: PinnedMessagesView, + }, + { + id: 'channel-media', + NavButton: ChannelMediaNavButton, + SectionContent: ChannelMediaView, + }, + { + id: 'channel-files', + NavButton: ChannelFilesNavButton, + SectionContent: ChannelFilesView, + }, +]; + +export type ChannelDetailProps = Omit & { + channel: Channel; + sections?: SectionNavigatorSection[]; +}; + +export const ChannelDetail = ({ + channel, + className, + defaultLayout = SECTION_NAVIGATOR_LAYOUT.tabs, + sections = defaultChannelDetailSections, + ...props +}: ChannelDetailProps) => { + const [layout, setLayout] = useState(defaultLayout); + + return ( + + + + + + ); +}; diff --git a/src/plugins/ChannelDetail/ChannelDetailContext.tsx b/src/plugins/ChannelDetail/ChannelDetailContext.tsx new file mode 100644 index 0000000000..da0f7bdd4e --- /dev/null +++ b/src/plugins/ChannelDetail/ChannelDetailContext.tsx @@ -0,0 +1,40 @@ +import type { PropsWithChildren } from 'react'; +import React, { useContext, useMemo } from 'react'; +import type { Channel } from 'stream-chat'; + +export type ChannelDetailContextValue = { + channel: Channel; +}; + +const ChannelDetailContext = React.createContext( + undefined, +); + +export type ChannelDetailProviderProps = PropsWithChildren<{ + channel: Channel; +}>; + +export const ChannelDetailProvider = ({ + channel, + children, +}: ChannelDetailProviderProps) => { + const value = useMemo(() => ({ channel }), [channel]); + + return ( + + {children} + + ); +}; + +export const useChannelDetailContext = () => { + const contextValue = useContext(ChannelDetailContext); + + if (!contextValue) { + throw new Error( + 'The useChannelDetailContext hook was called outside of ChannelDetailProvider.', + ); + } + + return contextValue; +}; diff --git a/src/plugins/ChannelDetail/ChannelDetailEmptyList.tsx b/src/plugins/ChannelDetail/ChannelDetailEmptyList.tsx new file mode 100644 index 0000000000..0991c91676 --- /dev/null +++ b/src/plugins/ChannelDetail/ChannelDetailEmptyList.tsx @@ -0,0 +1,9 @@ +import { IconSearch } from '../../components/Icons'; +import type { PropsWithChildrenOnly } from '../../types/types'; + +export const ChannelDetailEmptyList = ({ children }: PropsWithChildrenOnly) => ( +
    + +
    {children}
    +
    +); diff --git a/src/plugins/ChannelDetail/ChannelDetailListLoadingIndicator.tsx b/src/plugins/ChannelDetail/ChannelDetailListLoadingIndicator.tsx new file mode 100644 index 0000000000..86293a1351 --- /dev/null +++ b/src/plugins/ChannelDetail/ChannelDetailListLoadingIndicator.tsx @@ -0,0 +1,29 @@ +import type { SearchSource, SearchSourceState } from 'stream-chat'; +import { useStateStore } from '../../store'; +import { LoadingIndicator } from '../../components/Loading'; + +const searchSourceFooterStateSelector = (state: SearchSourceState) => ({ + hasNextPage: state.hasNext, + isLoading: state.isLoading, +}); + +export type ChannelMembersViewListFooterProps = { + searchSource: SearchSource; +}; + +export const ChannelDetailListLoadingIndicator = ({ + searchSource, +}: ChannelMembersViewListFooterProps) => { + const { hasNextPage, isLoading } = useStateStore( + searchSource.state, + searchSourceFooterStateSelector, + ); + + if (!hasNextPage || !isLoading) return null; + + return ( +
    + {isLoading && } +
    + ); +}; diff --git a/src/plugins/ChannelDetail/ChannelDetailNavButton.tsx b/src/plugins/ChannelDetail/ChannelDetailNavButton.tsx new file mode 100644 index 0000000000..885e8b42a3 --- /dev/null +++ b/src/plugins/ChannelDetail/ChannelDetailNavButton.tsx @@ -0,0 +1,46 @@ +import React, { type ComponentType, useMemo } from 'react'; + +import type { SectionNavigatorNavButtonProps } from '../../components/SectionNavigator'; +import { ListItemLayout } from '../../components/ListItemLayout'; +import clsx from 'clsx'; + +export type ChannelDetailNavButtonProps = SectionNavigatorNavButtonProps & { + /** Icon rendered as the leading element of the nav button. */ + LeadingIcon: ComponentType; + /** Label displayed for the section. */ + title: string; +}; + +/** + * Underlying button shared by all ChannelDetail section nav buttons. Renders a + * `ListItemLayout` as a ` + + + +); + +const useOtherMember = () => { + const { client } = useChatContext(); + const { channel } = useChannelDetailContext(); + + return useMemo(() => { + const stateMembers = Object.values(channel.state?.members ?? {}); + const members = stateMembers.length ? stateMembers : (channel.data?.members ?? []); + + return members.find( + (member) => member.user?.id && member.user.id !== client.user?.id, + ); + }, [channel, client.user?.id]); +}; + +const useChannelManagementActionFilterState = () => { + const { client } = useChatContext(); + const { channel } = useChannelDetailContext(); + const otherMember = useOtherMember(); + const resolvedIsDmChannel = isDmChannel({ + channel, + ownUserId: client.user?.id, + }); + const isGroupChannel = !resolvedIsDmChannel; + const ownCapabilities = channel.data?.own_capabilities; + const isDmChannelWithOtherUser = + resolvedIsDmChannel && otherMember?.user?.id !== client.user?.id; + + return { + canBlockUser: + isDmChannelWithOtherUser && ownCapabilities?.includes('ban-channel-members'), + canLeaveChannel: isGroupChannel && ownCapabilities?.includes('leave-channel'), + canMuteChannel: ownCapabilities?.includes('mute-channel'), + canMuteUser: isDmChannelWithOtherUser, + }; +}; + +export const useBaseChannelManagementActionSetFilter = ( + channelManagementActionSet: ChannelManagementActionItem[], +) => { + const { canBlockUser, canLeaveChannel, canMuteChannel, canMuteUser } = + useChannelManagementActionFilterState(); + + return useMemo( + () => + channelManagementActionSet.filter((action) => { + switch (action.type) { + case 'blockUser': + return canBlockUser; + case 'muteChannel': + return canMuteChannel; + case 'muteUser': + return canMuteUser; + case 'leaveChannel': + return canLeaveChannel; + default: + return true; + } + }), + [ + canBlockUser, + canLeaveChannel, + canMuteChannel, + canMuteUser, + channelManagementActionSet, + ], + ); +}; + +const ChannelMuteAction = () => { + const { channel } = useChannelDetailContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const { muted: channelMuted } = useIsChannelMuted(channel); + const [optimisticChannelMuted, setOptimisticChannelMuted] = useState(channelMuted); + + useEffect(() => { + setOptimisticChannelMuted(channelMuted); + }, [channelMuted]); + + const toggleChannelMuteRequest = useStableCallback( + (nextMuted: boolean, targetChannel: Channel) => { + if (!nextMuted) { + return targetChannel + .unmute() + .then(() => + addNotification({ + context: { channel: targetChannel }, + emitter: 'ChannelManagementView', + message: t('Channel unmuted'), + severity: 'success', + type: 'api:channel:unmute:success', + }), + ) + .catch((error) => { + setOptimisticChannelMuted(true); + + return addNotification({ + context: { channel: targetChannel }, + emitter: 'ChannelManagementView', + error: toError(error), + message: t('Error unmuting channel'), + severity: 'error', + type: 'api:channel:unmute:failed', + }); + }); + } + + return targetChannel + .mute() + .then(() => + addNotification({ + context: { channel: targetChannel }, + emitter: 'ChannelManagementView', + message: t('Channel muted'), + severity: 'success', + type: 'api:channel:mute:success', + }), + ) + .catch((error) => { + setOptimisticChannelMuted(false); + + return addNotification({ + context: { channel: targetChannel }, + emitter: 'ChannelManagementView', + error: toError(error), + message: t('Error muting channel'), + severity: 'error', + type: 'api:channel:mute:failed', + }); + }); + }, + ); + + const toggleChannelMute = useMemo( + () => debounce(toggleChannelMuteRequest, 1000), + [toggleChannelMuteRequest], + ); + + useEffect( + () => () => { + toggleChannelMute.cancel(); + }, + [toggleChannelMute], + ); + + const toggleOptimisticChannelMute = useCallback(() => { + const nextMuted = !optimisticChannelMuted; + + setOptimisticChannelMuted(nextMuted); + toggleChannelMute(nextMuted, channel); + }, [channel, optimisticChannelMuted, toggleChannelMute]); + + const rootProps = useMemo( + () => ({ + 'aria-pressed': optimisticChannelMuted, + className: clsx( + 'str-chat__form__switch-field', + channelManagementViewActionClassName, + ), + onClick: toggleOptimisticChannelMute, + }), + [optimisticChannelMuted, toggleOptimisticChannelMute], + ); + const TrailingSlot = useMemo(() => { + function ChannelMuteSwitch() { + return ; + } + + return ChannelMuteSwitch; + }, [optimisticChannelMuted]); + + return ( + + ); +}; + +const UserMuteAction = () => { + const { client, mutes } = useChatContext(); + const { channel } = useChannelDetailContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const otherMember = useOtherMember(); + const userMuted = !!mutes.find((mute) => mute.target.id === otherMember?.user?.id); + const [optimisticUserMuted, setOptimisticUserMuted] = useState(userMuted); + + useEffect(() => { + setOptimisticUserMuted(userMuted); + }, [userMuted]); + + const otherMemberUserId = otherMember?.user?.id; + const toggleUserMuteRequest = useStableCallback( + (nextMuted: boolean, targetUserId?: string) => { + if (!targetUserId) return; + + if (!nextMuted) { + return client + .unmuteUser(targetUserId) + .then(() => + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + message: t('User unmuted'), + severity: 'success', + type: 'api:user:unmute:success', + }), + ) + .catch((error) => { + setOptimisticUserMuted(true); + + return addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + error: toError(error), + message: t('Error unmuting user'), + severity: 'error', + type: 'api:user:unmute:failed', + }); + }); + } + + return client + .muteUser(targetUserId) + .then(() => + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + message: t('User muted'), + severity: 'success', + type: 'api:user:mute:success', + }), + ) + .catch((error) => { + setOptimisticUserMuted(false); + + return addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + error: toError(error), + message: t('Error muting user'), + severity: 'error', + type: 'api:user:mute:failed', + }); + }); + }, + ); + + const toggleUserMute = useMemo( + () => debounce(toggleUserMuteRequest, 1000), + [toggleUserMuteRequest], + ); + + useEffect( + () => () => { + toggleUserMute.cancel(); + }, + [toggleUserMute], + ); + + const toggleOptimisticUserMute = useCallback(() => { + const nextMuted = !optimisticUserMuted; + + setOptimisticUserMuted(nextMuted); + toggleUserMute(nextMuted, otherMemberUserId); + }, [optimisticUserMuted, otherMemberUserId, toggleUserMute]); + + const rootProps = useMemo( + () => ({ + 'aria-pressed': optimisticUserMuted, + className: clsx( + 'str-chat__form__switch-field', + channelManagementViewActionClassName, + ), + onClick: toggleOptimisticUserMute, + }), + [optimisticUserMuted, toggleOptimisticUserMute], + ); + const TrailingSlot = useMemo(() => { + function UserMuteSwitch() { + return ; + } + + return UserMuteSwitch; + }, [optimisticUserMuted]); + + return ( + + ); +}; + +const BlockUserAction = () => { + const { client } = useChatContext(); + const { Modal = GlobalModal } = useComponentContext(); + const { channel } = useChannelDetailContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const otherMember = useOtherMember(); + const targetUserId = otherMember?.user?.id; + const { userIds: blockedUserIds } = useStateStore( + client.blockedUsers, + blockedUsersSelector, + ); + const isBlocked = useMemo( + () => !!targetUserId && new Set(blockedUserIds).has(targetUserId), + [blockedUserIds, targetUserId], + ); + const [alertOpen, setAlertOpen] = useState(false); + const [userBlockInProgress, setUserBlockInProgress] = useState(false); + + const closeBlockUserAlert = useCallback(() => { + setAlertOpen(false); + }, []); + + const openBlockUserAlert = useCallback(() => { + setAlertOpen(true); + }, []); + + const unblockUser = useCallback(async () => { + if (!targetUserId) return; + + try { + setUserBlockInProgress(true); + await client.unBlockUser(targetUserId); + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + message: t('User unblocked'), + severity: 'success', + type: 'api:user:unblock:success', + }); + } catch (error) { + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + error: toError(error), + message: t('Error unblocking user'), + severity: 'error', + type: 'api:user:unblock:failed', + }); + } finally { + setAlertOpen(false); + setUserBlockInProgress(false); + } + }, [addNotification, channel, client, targetUserId, t]); + + const blockUser = useCallback(async () => { + if (!targetUserId) return; + + try { + setUserBlockInProgress(true); + await client.blockUser(targetUserId); + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + message: t('User blocked'), + severity: 'success', + type: 'api:user:block:success', + }); + } catch (error) { + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + error: toError(error), + message: t('Error blocking user'), + severity: 'error', + type: 'api:user:block:failed', + }); + } finally { + setAlertOpen(false); + setUserBlockInProgress(false); + } + }, [addNotification, channel, client, targetUserId, t]); + + const rootProps = useMemo( + () => ({ + className: channelManagementViewActionClassName, + disabled: userBlockInProgress, + onClick: openBlockUserAlert, + }), + [openBlockUserAlert, userBlockInProgress], + ); + + return ( + <> + + + + + + ); +}; + +const LeaveChannelAction = () => { + const { client } = useChatContext(); + const { channel } = useChannelDetailContext(); + const { Modal = GlobalModal } = useComponentContext(); + const { close } = useModalContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const [alertOpen, setAlertOpen] = useState(false); + const [leaveChannelInProgress, setLeaveChannelInProgress] = useState(false); + + const closeLeaveChannelAlert = useCallback(() => { + setAlertOpen(false); + }, []); + + const openLeaveChannelAlert = useCallback(() => { + setAlertOpen(true); + }, []); + + const leaveChannel = useCallback(async () => { + if (!client.userID) return; + + try { + setLeaveChannelInProgress(true); + await channel.removeMembers([client.userID]); + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + message: t('Left channel'), + severity: 'success', + type: 'api:channel:leave:success', + }); + setAlertOpen(false); + close(); + } catch (error) { + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + error: toError(error), + message: t('Failed to leave channel'), + severity: 'error', + type: 'api:channel:leave:failed', + }); + } finally { + setLeaveChannelInProgress(false); + } + }, [addNotification, channel, client.userID, close, t]); + + const rootProps = useMemo( + () => ({ + className: channelManagementViewActionClassName, + disabled: leaveChannelInProgress, + onClick: openLeaveChannelAlert, + }), + [leaveChannelInProgress, openLeaveChannelAlert], + ); + + return ( + <> + + + + + + ); +}; + +const DeleteChatAction = () => { + const { channel } = useChannelDetailContext(); + const { Modal = GlobalModal } = useComponentContext(); + const { close: closeChannelDetail } = useModalContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const otherMember = useOtherMember(); + const [alertOpen, setAlertOpen] = useState(false); + const [deleteChatInProgress, setDeleteChatInProgress] = useState(false); + const userName = getDisplayName(otherMember?.user?.name, otherMember?.user?.id); + + const closeDeleteChatAlert = useCallback(() => { + setAlertOpen(false); + }, []); + + const openDeleteChatAlert = useCallback(() => { + setAlertOpen(true); + }, []); + + const deleteChat = useCallback(async () => { + try { + setDeleteChatInProgress(true); + await channel.delete(); + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + message: t('Chat deleted'), + severity: 'success', + type: 'api:channel:delete:success', + }); + setAlertOpen(false); + closeChannelDetail(); + } catch (error) { + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + error: toError(error), + message: t('Error deleting chat'), + severity: 'error', + type: 'api:channel:delete:failed', + }); + } finally { + setDeleteChatInProgress(false); + } + }, [addNotification, channel, closeChannelDetail, t]); + + const rootProps = useMemo( + () => ({ + className: channelManagementViewActionClassName, + disabled: deleteChatInProgress, + onClick: openDeleteChatAlert, + }), + [deleteChatInProgress, openDeleteChatAlert], + ); + + return ( + <> + + + + + + ); +}; + +export const DefaultChannelManagementActions = { + BlockUser: BlockUserAction, + DeleteChat: DeleteChatAction, + LeaveChannel: LeaveChannelAction, + MuteChannel: ChannelMuteAction, + MuteUser: UserMuteAction, +}; + +export const defaultChannelManagementActionSet: ChannelManagementActionItem[] = [ + { + Component: DefaultChannelManagementActions.MuteChannel, + type: 'muteChannel', + }, + { + Component: DefaultChannelManagementActions.MuteUser, + type: 'muteUser', + }, + { + Component: DefaultChannelManagementActions.BlockUser, + type: 'blockUser', + }, + { + Component: DefaultChannelManagementActions.LeaveChannel, + type: 'leaveChannel', + }, + { + Component: DefaultChannelManagementActions.DeleteChat, + type: 'deleteChat', + }, +]; diff --git a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx new file mode 100644 index 0000000000..1ca4b383ff --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx @@ -0,0 +1,469 @@ +import React, { + type SyntheticEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { + useChatContext, + useComponentContext, + useModalContext, + useTranslationContext, +} from '../../../../context'; +import { isDmChannel } from '../../../../utils'; +import { + SectionNavigatorHeader, + type SectionNavigatorSectionContentProps, +} from '../../../../components/SectionNavigator'; +import { ChannelAvatar as DefaultChannelAvatar } from '../../../../components/Avatar'; +import { + useChannelPreviewInfo, + useIsUserMuted, +} from '../../../../components/ChannelListItem'; +import { IconCheckmark, IconMute, IconPin } from '../../../../components/Icons'; +import { useChannelMembershipState } from '../../../../components/ChannelList'; +import { useIsChannelMuted } from '../../../../components/ChannelListItem/hooks/useIsChannelMuted'; +import { useChannelHasMembersOnline } from '../../../../components/ChannelHeader/hooks/useChannelHasMembersOnline'; +import { Prompt } from '../../../../components/Dialog'; +import { + type ChannelManagementActionItem, + defaultChannelManagementActionSet, + useBaseChannelManagementActionSetFilter, +} from './ChannelManagementActions.defaults'; +import { useChannelHeaderOnlineStatus } from '../../../../components/ChannelHeader/hooks/useChannelHeaderOnlineStatus'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { Button } from '../../../../components/Button'; +import { TextInput } from '../../../../components/Form'; +import { useNotificationApi } from '../../../../components/Notifications/hooks/useNotificationApi'; + +export type ChannelManagementViewProps = SectionNavigatorSectionContentProps & { + channelManagementActionSet?: ChannelManagementActionItem[]; + EditModeComponent?: React.ComponentType; + uploadImage?: ChannelManagementImageUpload; + ViewModeComponent?: React.ComponentType; +}; + +export type ChannelManagementImageUpload = (file: File) => Promise | string; + +export type ChannelManagementInfoBodyProps = { + actions: ChannelManagementActionItem[]; +}; + +export const ChannelManagementInfoBody = ({ + actions, +}: ChannelManagementInfoBodyProps) => { + const { client } = useChatContext(); + const { channel } = useChannelDetailContext(); + const { Avatar = DefaultChannelAvatar } = useComponentContext(); + const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ + channel, + }); + const resolvedIsDmChannel = isDmChannel({ + channel, + ownUserId: client.user?.id, + }); + const otherMemberUserId = useMemo(() => { + if (!resolvedIsDmChannel) return; + + return Object.values(channel.state?.members ?? {}).find( + (member) => member.user?.id && member.user.id !== client.user?.id, + )?.user?.id; + }, [channel, client.user?.id, resolvedIsDmChannel]); + const isOnline = useChannelHasMembersOnline({ channel }); + const { muted: channelMuted } = useIsChannelMuted(channel); + const userMuted = useIsUserMuted(otherMemberUserId); + const membership = useChannelMembershipState(channel); + const onlineStatusText = useChannelHeaderOnlineStatus({ channel }); + const pinned = !!membership.pinned_at; + + return ( + +
    + +
    +
    + {displayTitle && {displayTitle}} + {pinned && } + {(resolvedIsDmChannel && userMuted) || + (!resolvedIsDmChannel && channelMuted) ? ( + + ) : null} +
    + {onlineStatusText && ( +
    + {onlineStatusText} +
    + )} +
    +
    + +
    + {actions.map(({ Component, type }) => ( + + ))} +
    +
    + ); +}; + +export type ChannelManagementEditBodyProps = { + uploadImage?: ChannelManagementImageUpload; +}; + +const EDIT_BODY_EMITTER = 'ChannelManagementEditBody'; + +type ChannelUpdatePayload = { + set?: { image?: string; name?: string }; + unset?: ['image']; +}; + +/** + * Assembles the argument for `channel.updatePartial` from the pending edits, + * or returns `null` when there is nothing to persist. `image` is a tri-state: + * a string sets a new avatar, `null` clears it, `undefined` leaves it untouched. + */ +const buildChannelUpdatePayload = ({ + image, + name, +}: { + image?: string | null; + name?: string; +}): ChannelUpdatePayload | null => { + const payload: ChannelUpdatePayload = {}; + + const set: { image?: string; name?: string } = {}; + if (name !== undefined) set.name = name; + if (typeof image === 'string') set.image = image; + if (Object.keys(set).length > 0) payload.set = set; + + if (image === null) payload.unset = ['image']; + + return Object.keys(payload).length > 0 ? payload : null; +}; + +/** + * Owns the channel-edit form: field state, the local image preview lifecycle, + * the derived "can save" flags, and the save orchestration (upload → persist → + * notify). The component is left to render the values this returns. + */ +const useChannelManagementEditForm = ({ + uploadImage, +}: ChannelManagementEditBodyProps) => { + const { t } = useTranslationContext(); + const { client } = useChatContext(); + const { channel } = useChannelDetailContext(); + const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ + channel, + }); + const { addNotification } = useNotificationApi(); + + const resolvedIsDmChannel = isDmChannel({ channel, ownUserId: client.user?.id }); + const hasMembersOnline = useChannelHasMembersOnline({ channel }); + const isOnline = resolvedIsDmChannel ? hasMembersOnline : undefined; + const nameLabel = resolvedIsDmChannel ? t('Contact name') : t('Group name'); + + const initialName = channel.data?.name ?? ''; + const [name, setName] = useState(initialName); + // null = keep current avatar, File = replace it, 'removed' = clear it + const [imageEdit, setImageEdit] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const fileInputRef = useRef(null); + + const pickedFile = imageEdit instanceof File ? imageEdit : null; + + // Preview the locally picked file, releasing the object URL when it changes or unmounts. + const objectUrl = useMemo( + () => (pickedFile ? URL.createObjectURL(pickedFile) : null), + [pickedFile], + ); + + useEffect( + () => () => { + if (objectUrl) URL.revokeObjectURL(objectUrl); + }, + [objectUrl], + ); + + const previewImageUrl = + objectUrl ?? (imageEdit === 'removed' ? undefined : displayImage); + + const trimmedName = name.trim(); + const nameChanged = trimmedName !== initialName.trim(); + const imageChanged = imageEdit !== null; + const hasChanges = (trimmedName.length > 0 && nameChanged) || imageChanged; + const canSubmit = trimmedName.length > 0 && !isSaving && hasChanges; + + const handleOpenFilePicker = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback((event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) setImageEdit(file); + event.target.value = ''; + }, []); + + const handleDeleteImage = useCallback(() => setImageEdit('removed'), []); + + const resolveImageUrl = useCallback( + async (file: File) => { + const url = uploadImage + ? await uploadImage(file) + : (await channel.sendImage(file)).file; + if (!url) throw new Error('Image upload did not return a URL'); + return url; + }, + [channel, uploadImage], + ); + + const handleSubmit = useCallback( + async (event: SyntheticEvent) => { + event.preventDefault(); + if (!canSubmit) return; + + setIsSaving(true); + try { + let image: string | null | undefined; + if (pickedFile) image = await resolveImageUrl(pickedFile); + else if (imageEdit === 'removed') image = null; + + const payload = buildChannelUpdatePayload({ + image, + name: nameChanged ? trimmedName : undefined, + }); + if (payload) await channel.updatePartial(payload); + + setImageEdit(null); + + addNotification({ + duration: 3000, + emitter: EDIT_BODY_EMITTER, + incident: { + domain: 'channel', + entity: 'channel', + operation: 'update', + status: 'success', + }, + message: t('Changes saved'), + severity: 'success', + }); + } catch (error) { + addNotification({ + emitter: EDIT_BODY_EMITTER, + error: error instanceof Error ? error : undefined, + incident: { + domain: 'api', + entity: 'channel', + operation: 'update', + status: 'failed', + }, + message: t('Failed to save changes'), + severity: 'error', + }); + } finally { + setIsSaving(false); + } + }, + [ + addNotification, + canSubmit, + channel, + imageEdit, + nameChanged, + pickedFile, + resolveImageUrl, + t, + trimmedName, + ], + ); + + return { + canSubmit, + displayTitle, + fileInputRef, + groupChannelDisplayInfo, + handleDeleteImage, + handleFileChange, + handleOpenFilePicker, + handleSubmit, + hasAvatarImage: !!previewImageUrl, + isOnline, + name, + nameLabel, + previewImageUrl, + setName, + t, + trimmedName, + }; +}; + +export const ChannelManagementEditBody = (props: ChannelManagementEditBodyProps) => { + const { Avatar = DefaultChannelAvatar } = useComponentContext(); + const { + canSubmit, + displayTitle, + fileInputRef, + groupChannelDisplayInfo, + handleDeleteImage, + handleFileChange, + handleOpenFilePicker, + handleSubmit, + hasAvatarImage, + isOnline, + name, + nameLabel, + previewImageUrl, + setName, + t, + trimmedName, + } = useChannelManagementEditForm(props); + + return ( +
    + +
    + +
    + + {hasAvatarImage && ( + + )} + +
    +
    + + setName(event.target.value)} + placeholder={nameLabel} + value={name} + /> +
    + + + + {canSubmit && ( + + + {t('Save')} + + )} + + +
    + ); +}; + +export const ChannelManagementView = ({ + channelManagementActionSet = defaultChannelManagementActionSet, + EditModeComponent = ChannelManagementEditBody, + uploadImage, + ViewModeComponent = ChannelManagementInfoBody, +}: ChannelManagementViewProps) => { + const { t } = useTranslationContext(); + const { client } = useChatContext(); + const { channel } = useChannelDetailContext(); + const { close } = useModalContext(); + const resolvedIsDmChannel = isDmChannel({ + channel, + ownUserId: client.user?.id, + }); + const actions = useBaseChannelManagementActionSetFilter(channelManagementActionSet); + const [isEditing, setIsEditing] = useState(false); + const canEditChannel = channel.data?.own_capabilities?.includes('update-channel'); + + const EditChannelButton = useMemo( + () => + function EditChannelButton() { + return ( + + ); + }, + [t], + ); + + const headerTitle = isEditing + ? resolvedIsDmChannel + ? t('Edit contact') + : t('Edit group') + : resolvedIsDmChannel + ? t('Contact info') + : t('Group info'); + + return ( +
    + setIsEditing(false) : undefined} + title={headerTitle} + TrailingContent={!isEditing && canEditChannel ? EditChannelButton : undefined} + /> + {isEditing ? ( + + ) : ( + + )} +
    + ); +}; diff --git a/src/plugins/ChannelDetail/Views/ChannelManagementView/index.ts b/src/plugins/ChannelDetail/Views/ChannelManagementView/index.ts new file mode 100644 index 0000000000..54cd581422 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelManagementView/index.ts @@ -0,0 +1,2 @@ +export * from './ChannelManagementView'; +export * from './ChannelManagementActions.defaults'; diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx new file mode 100644 index 0000000000..a03128e786 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx @@ -0,0 +1,20 @@ +import { useTranslationContext } from '../../../../context'; +import { IconImage } from '../../../../components/Icons'; + +export const ChannelMediaEmptyList = () => { + const { t } = useTranslationContext('ChannelMediaEmptyList'); + + return ( +
    + +
    +

    + {t('No photos or videos')} +

    +

    + {t('Share a photo or video to see it here')} +

    +
    +
    + ); +}; diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx new file mode 100644 index 0000000000..41d4983e3b --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx @@ -0,0 +1,168 @@ +import clsx from 'clsx'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { + useComponentContext, + useModalContext, + useTranslationContext, +} from '../../../../context'; +import { formatTime } from '../../../../components/AudioPlayback'; +import { Avatar } from '../../../../components/Avatar'; +import { Badge } from '../../../../components/Badge'; +import { + type BaseImageProps, + BaseImage as DefaultBaseImage, +} from '../../../../components/BaseImage'; +import { Prompt } from '../../../../components/Dialog'; +import { Gallery as DefaultGallery, GalleryUI } from '../../../../components/Gallery'; +import { IconImage, IconVideoFill } from '../../../../components/Icons'; +import { InfiniteScrollPaginator } from '../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator'; +import { GlobalModal } from '../../../../components/Modal'; +import { + SectionNavigatorHeader, + type SectionNavigatorSectionContentProps, +} from '../../../../components/SectionNavigator'; +import { ChannelDetailListLoadingIndicator } from '../../ChannelDetailListLoadingIndicator'; +import { getUserDisplayName } from '../ChannelMembersView/ChannelMembersView.utils'; +import { ChannelMediaEmptyList } from './ChannelMediaEmptyList'; +import type { ChannelMediaItem } from './ChannelMediaView.utils'; +import { useChannelMediaSearch } from './useChannelMediaSearch'; + +type ChannelMediaGridItemProps = { + BaseImage: React.ComponentType; + item: ChannelMediaItem; + onClick: () => void; +}; + +const ChannelMediaGridItem = ({ + BaseImage, + item, + onClick, +}: ChannelMediaGridItemProps) => { + const { t } = useTranslationContext('ChannelMediaView'); + const displayName = getUserDisplayName(item.user); + const mediaSrc = + item.type === 'video' + ? item.galleryItem.videoThumbnailUrl + : item.galleryItem.imageUrl; + const durationLabel = formatTime(item.durationSeconds, 'floor'); + const label = + item.type === 'video' + ? t('aria/Open video shared by {{ name }}', { name: displayName }) + : t('aria/Open image shared by {{ name }}', { name: displayName }); + + return ( + + ); +}; + +export type ChannelMediaViewProps = SectionNavigatorSectionContentProps; + +export const ChannelMediaView: React.ComponentType = () => { + const { t } = useTranslationContext(); + const { close } = useModalContext(); + const { + BaseImage = DefaultBaseImage, + Gallery = DefaultGallery, + Modal = GlobalModal, + } = useComponentContext(); + const { channelMediaSearchSource, hasResultsLoaded, mediaItems } = + useChannelMediaSearch(); + + const [viewerOpen, setViewerOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + + const galleryItems = useMemo( + () => mediaItems.map((item) => item.galleryItem), + [mediaItems], + ); + + const openViewer = useCallback((index: number) => { + setSelectedIndex(index); + setViewerOpen(true); + }, []); + + const closeViewer = useCallback(() => { + setViewerOpen(false); + }, []); + + return ( +
    + + + + {mediaItems.length > 0 ? ( +
    + {mediaItems.map((item, index) => ( + openViewer(index)} + /> + ))} +
    + ) : hasResultsLoaded ? ( + + ) : null} + +
    +
    + + + +
    + ); +}; diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts new file mode 100644 index 0000000000..e49805b3ae --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts @@ -0,0 +1,73 @@ +import { + type Attachment, + isImageAttachment, + isVideoAttachment, + type LocalMessage, + type MessageResponse, + type UserResponse, +} from 'stream-chat'; + +import { toBaseImageDescriptors } from '../../../../components/BaseImage'; +import type { GalleryItem } from '../../../../components/Gallery'; + +/** Attachment types rendered by the media gallery. */ +export const MEDIA_ATTACHMENT_TYPES = ['image', 'video'] as const; + +export type ChannelMediaItemType = (typeof MEDIA_ATTACHMENT_TYPES)[number]; + +export type ChannelMediaItem = { + /** Item to hand over to the full-screen `Gallery` viewer. */ + galleryItem: GalleryItem; + /** Stable identity (messageId + attachment index). */ + id: string; + type: ChannelMediaItemType; + /** Video duration in seconds, when known. */ + durationSeconds?: number; + /** User who shared the media. */ + user?: UserResponse; +}; + +const getMediaAttachmentType = ( + attachment: Attachment, +): ChannelMediaItemType | undefined => { + if (isVideoAttachment(attachment)) return 'video'; + if (isImageAttachment(attachment)) return 'image'; + return undefined; +}; + +/** + * Flattens messages into one renderable media item per image/video attachment, + * carrying over the gallery descriptor, posting user, and video duration. + */ +export const toChannelMediaItems = ( + messages: Array, +): ChannelMediaItem[] => { + const items: ChannelMediaItem[] = []; + + messages.forEach((message) => { + message.attachments?.forEach((attachment, index) => { + const type = getMediaAttachmentType(attachment); + if (!type) return; + + const descriptor = toBaseImageDescriptors(attachment); + if (!descriptor) return; + + const hasRenderableSource = + type === 'video' + ? Boolean(descriptor.videoThumbnailUrl || descriptor.videoUrl) + : Boolean(descriptor.imageUrl); + if (!hasRenderableSource) return; + + items.push({ + durationSeconds: + typeof attachment.duration === 'number' ? attachment.duration : undefined, + galleryItem: { ...descriptor }, + id: `${message.id}-${index}`, + type, + user: message.user ?? undefined, + }); + }); + }); + + return items; +}; diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx new file mode 100644 index 0000000000..e56f324fc8 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx @@ -0,0 +1,196 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import type { Channel, MessageResponse } from 'stream-chat'; +import { fromPartial } from '@total-typescript/shoehorn'; + +import { + useChatContext, + useComponentContext, + useModalContext, + useTranslationContext, +} from '../../../../../context'; +import { useStateStore } from '../../../../../store'; +import { ChannelDetailProvider } from '../../../ChannelDetailContext'; +import { ChannelMediaView } from '../ChannelMediaView'; + +const mocks = vi.hoisted(() => ({ + searchSourceActivate: vi.fn(), + searchSourceCancelScheduledQuery: vi.fn(), + searchSourceInstances: [] as Array<{ + messageSearchChannelFilters?: unknown; + messageSearchFilters?: unknown; + }>, + searchSourceOptions: [] as unknown[], + searchSourceSearch: vi.fn(), +})); + +vi.mock('stream-chat', async (importOriginal) => { + const actual = await importOriginal(); + + class MessageSearchSource { + messageSearchChannelFilters?: unknown; + messageSearchFilters?: unknown; + state = {}; + + constructor(_client: unknown, options: unknown) { + mocks.searchSourceOptions.push(options); + mocks.searchSourceInstances.push(this); + } + + activate = mocks.searchSourceActivate; + search = mocks.searchSourceSearch; + cancelScheduledQuery = mocks.searchSourceCancelScheduledQuery; + } + + return { + ...actual, + MessageSearchSource, + }; +}); + +vi.mock('../../../../../context'); +vi.mock('../../../../../store'); + +vi.mock( + '../../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator', + () => ({ + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + }), +); + +vi.mock('../../../../../components/Dialog', () => ({ + Prompt: { + Body: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + Header: ({ title }: { title?: React.ReactNode }) => ( +
    +

    {title}

    +
    + ), + }, +})); + +const messages: MessageResponse[] = [ + { + attachments: [ + { image_url: 'https://cdn.test/image-1.png', title: 'image-1', type: 'image' }, + ], + cid: 'messaging:test-channel', + created_at: '2026-01-01T15:53:00.000Z', + id: 'message-1', + type: 'regular', + updated_at: '2026-01-01T15:53:00.000Z', + user: { id: 'user-1', image: 'https://cdn.test/avatar-1.png', name: 'Alice' }, + }, + { + attachments: [ + { + asset_url: 'https://cdn.test/video-1.mp4', + duration: 8, + thumb_url: 'https://cdn.test/video-1-thumb.png', + title: 'video-1', + type: 'video', + }, + ], + cid: 'messaging:test-channel', + created_at: '2026-01-02T15:53:00.000Z', + id: 'message-2', + type: 'regular', + updated_at: '2026-01-02T15:53:00.000Z', + user: { id: 'user-2', name: 'Bob' }, + }, +]; + +const channel = fromPartial({ cid: 'messaging:test-channel' }); + +const renderView = () => + render( + + + , + ); + +describe('ChannelMediaView', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.searchSourceInstances.length = 0; + mocks.searchSourceOptions.length = 0; + + vi.mocked(useTranslationContext).mockReturnValue({ + t: (key: string) => key, + } as ReturnType); + + vi.mocked(useChatContext).mockReturnValue({ + client: { userID: 'user-1' }, + } as ReturnType); + + vi.mocked(useModalContext).mockReturnValue({ + close: vi.fn(), + } as ReturnType); + + vi.mocked(useComponentContext).mockReturnValue({ + BaseImage: (props: React.ComponentProps<'img'>) => , + Gallery: () =>
    , + Modal: ({ children, open }: { children: React.ReactNode; open: boolean }) => + open ?
    {children}
    : null, + } as unknown as ReturnType); + + vi.mocked(useStateStore).mockReturnValue({ + hasNextPage: false, + isLoading: false, + messages, + }); + }); + + it('configures MessageSearchSource to paginate image and video attachments in the channel', () => { + renderView(); + + expect(mocks.searchSourceOptions[0]).toMatchObject({ + allowEmptySearchString: true, + pageSize: 30, + resetOnNewSearchQuery: false, + }); + expect(mocks.searchSourceInstances[0]).toMatchObject({ + messageSearchChannelFilters: { cid: 'messaging:test-channel' }, + messageSearchFilters: { 'attachments.type': { $in: ['image', 'video'] } }, + }); + expect(mocks.searchSourceActivate).toHaveBeenCalled(); + }); + + it('renders a media item per image/video attachment with avatar and video duration badge', () => { + renderView(); + + expect(screen.getByRole('heading', { name: 'Photos & videos' })).toBeInTheDocument(); + + const items = screen.getAllByRole('button'); + expect(items).toHaveLength(2); + + expect(screen.getAllByTestId('avatar')).toHaveLength(2); + expect(screen.getByText('00:08')).toBeInTheDocument(); + }); + + it('opens the full-screen viewer when a media item is clicked', () => { + renderView(); + + expect(screen.queryByTestId('media-viewer')).not.toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole('button')[0]); + + expect(screen.getByTestId('media-viewer')).toBeInTheDocument(); + expect(screen.getByTestId('gallery')).toBeInTheDocument(); + }); + + it('renders an empty state once results load with no media', () => { + vi.mocked(useStateStore).mockReturnValue({ + hasNextPage: false, + isLoading: false, + messages: [], + }); + + renderView(); + + expect(screen.getByText('No photos or videos')).toBeInTheDocument(); + expect(screen.getByText('Share a photo or video to see it here')).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/index.ts b/src/plugins/ChannelDetail/Views/ChannelMediaView/index.ts new file mode 100644 index 0000000000..232152b8b9 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/index.ts @@ -0,0 +1,4 @@ +export * from './ChannelMediaEmptyList'; +export * from './ChannelMediaView'; +export * from './ChannelMediaView.utils'; +export * from './useChannelMediaSearch'; diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/useChannelMediaSearch.ts b/src/plugins/ChannelDetail/Views/ChannelMediaView/useChannelMediaSearch.ts new file mode 100644 index 0000000000..fa23309348 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/useChannelMediaSearch.ts @@ -0,0 +1,66 @@ +import { + type LocalMessage, + type MessageResponse, + MessageSearchSource, + type SearchSourceState, +} from 'stream-chat'; +import { useEffect, useMemo } from 'react'; + +import { useChatContext } from '../../../../context'; +import { useStateStore } from '../../../../store'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { MEDIA_ATTACHMENT_TYPES, toChannelMediaItems } from './ChannelMediaView.utils'; + +const CHANNEL_MEDIA_SEARCH_PAGE_SIZE = 30; + +const channelMediaSearchSourceItemsStateSelector = ( + state: SearchSourceState, +) => ({ + isLoading: state.isLoading, + messages: state.items, +}); + +export const useChannelMediaSearch = () => { + const { client } = useChatContext(); + const { channel } = useChannelDetailContext(); + + const channelMediaSearchSource = useMemo(() => { + const source = new MessageSearchSource(client, { + allowEmptySearchString: true, + pageSize: CHANNEL_MEDIA_SEARCH_PAGE_SIZE, + resetOnNewSearchQuery: false, + }); + + source.messageSearchChannelFilters = { cid: channel.cid }; + source.messageSearchFilters = { + 'attachments.type': { $in: [...MEDIA_ATTACHMENT_TYPES] }, + }; + source.activate(); + + return source; + }, [channel.cid, client]); + + const { isLoading, messages } = useStateStore( + channelMediaSearchSource.state, + channelMediaSearchSourceItemsStateSelector, + ); + + const mediaItems = useMemo( + () => toChannelMediaItems((messages ?? []) as Array), + [messages], + ); + + useEffect( + () => () => { + channelMediaSearchSource.cancelScheduledQuery(); + }, + [channelMediaSearchSource], + ); + + return { + channelMediaSearchSource, + hasResultsLoaded: Array.isArray(messages), + isLoading, + mediaItems, + }; +}; diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx new file mode 100644 index 0000000000..100b09bb59 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx @@ -0,0 +1,616 @@ +import clsx from 'clsx'; +import debounce from 'lodash.debounce'; +import uniqBy from 'lodash.uniqby'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import type { ChannelMemberResponse } from 'stream-chat'; + +import { + useChannelListContext, + useChatContext, + useComponentContext, + useModalContext, + useTranslationContext, +} from '../../../../context'; +import { useStableCallback } from '../../../../utils'; +import { useStateStore } from '../../../../store'; +import { Alert } from '../../../../components/Dialog'; +import { Button } from '../../../../components/Button'; +import { Switch } from '../../../../components/Form'; +import { + IconAudio, + IconMessageBubble, + IconMute, + IconNoSign, + IconUserRemove, +} from '../../../../components/Icons'; +import { ListItemLayout } from '../../../../components/ListItemLayout'; +import { GlobalModal } from '../../../../components/Modal'; +import { useNotificationApi } from '../../../../components/Notifications'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; + +export type ChannelMemberActionType = + | 'blockUser' + | 'muteUser' + | 'removeUser' + | 'sendMessage' + | (string & {}); + +export type ChannelMemberActionItem = { + Component: React.ComponentType; + type: ChannelMemberActionType; +}; + +type ChannelMemberActionContextValue = { + member: ChannelMemberResponse; + memberDisplayName: string; + targetUserId?: string; +}; + +const ChannelMemberActionContext = createContext< + ChannelMemberActionContextValue | undefined +>(undefined); + +export const ChannelMemberActionProvider = ({ + children, + value, +}: React.PropsWithChildren<{ value: ChannelMemberActionContextValue }>) => ( + + {children} + +); + +export const useChannelMemberActionContext = () => { + const contextValue = useContext(ChannelMemberActionContext); + if (!contextValue) { + throw new Error( + 'The useChannelMemberActionContext hook was called outside of ChannelMemberActionProvider.', + ); + } + + return contextValue; +}; + +const toError = (error: unknown) => + error instanceof Error ? error : new Error('An unknown error occurred'); + +const MemberMuteActionIcon = () => ( + +); + +const MemberUnmuteActionIcon = () => ( + +); + +const SendDirectMessageActionIcon = () => ( + +); + +const BlockUserActionIcon = () => ( + +); + +const RemoveUserActionIcon = () => ( + +); + +const channelMemberDetailActionClassName = 'str-chat__channel-member-detail-action'; + +const blockedUsersSelector = ({ userIds }: { userIds: string[] }) => ({ userIds }); + +type ChannelMemberConfirmationAlertProps = { + action: 'blockUser' | 'removeUser'; + cancelLabel: string; + confirmLabel: string; + description: string; + isSubmitting?: boolean; + onCancel: () => void; + onConfirm: () => void; + testId: string; + title: string; +}; + +const ChannelMemberConfirmationAlert = ({ + action, + cancelLabel, + confirmLabel, + description, + isSubmitting, + onCancel, + onConfirm, + testId, + title, +}: ChannelMemberConfirmationAlertProps) => ( + + + + + + + +); + +const useChannelMemberActionFilterState = () => { + const { client } = useChatContext(); + const { channel } = useChannelDetailContext(); + const { targetUserId } = useChannelMemberActionContext(); + const ownCapabilities = channel.data?.own_capabilities; + const isCurrentUser = targetUserId === client.user?.id; + + return { + canBlockUser: + !isCurrentUser && + !!targetUserId && + ownCapabilities?.includes('ban-channel-members'), + canMuteUser: !isCurrentUser && !!targetUserId, + canRemoveUser: + !isCurrentUser && + !!targetUserId && + ownCapabilities?.includes('update-channel-members'), + canSendMessage: !isCurrentUser && !!targetUserId, + }; +}; + +export const useBaseChannelMemberActionSetFilter = ( + channelMemberActionSet: ChannelMemberActionItem[], +) => { + const { canBlockUser, canMuteUser, canRemoveUser, canSendMessage } = + useChannelMemberActionFilterState(); + + return useMemo( + () => + channelMemberActionSet.filter((action) => { + switch (action.type) { + case 'blockUser': + return canBlockUser; + case 'muteUser': + return canMuteUser; + case 'removeUser': + return canRemoveUser; + case 'sendMessage': + return canSendMessage; + default: + return true; + } + }), + [canBlockUser, canMuteUser, canRemoveUser, canSendMessage, channelMemberActionSet], + ); +}; + +const SendDirectMessageAction = () => { + const { client, setActiveChannel } = useChatContext(); + const { setChannels } = useChannelListContext(); + const { close } = useModalContext(); + const { channel } = useChannelDetailContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const { targetUserId } = useChannelMemberActionContext(); + const [isSending, setIsSending] = useState(false); + + const openDirectMessage = useCallback(async () => { + if (!client.userID || !targetUserId || isSending) return; + + setIsSending(true); + try { + const directMessageChannel = client.channel(channel.type, { + members: [client.userID, targetUserId], + }); + await directMessageChannel.watch(); + setActiveChannel(directMessageChannel); + setChannels?.((channels) => uniqBy([directMessageChannel, ...channels], 'cid')); + close(); + } catch (error) { + addNotification({ + context: { channel }, + emitter: 'ChannelMemberDetail', + error: toError(error), + message: t('Error opening direct message'), + severity: 'error', + type: 'api:channel:watch:failed', + }); + } finally { + setIsSending(false); + } + }, [ + addNotification, + channel, + client, + close, + isSending, + setActiveChannel, + setChannels, + t, + targetUserId, + ]); + + const rootProps = useMemo( + () => ({ + className: channelMemberDetailActionClassName, + disabled: isSending, + onClick: openDirectMessage, + }), + [isSending, openDirectMessage], + ); + + return ( + + ); +}; + +const UserMuteAction = () => { + const { channel } = useChannelDetailContext(); + const { client, mutes } = useChatContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const { targetUserId } = useChannelMemberActionContext(); + const userMuted = + !!targetUserId && mutes.some((mute) => mute.target.id === targetUserId); + const [optimisticUserMuted, setOptimisticUserMuted] = useState(userMuted); + + useEffect(() => { + setOptimisticUserMuted(userMuted); + }, [userMuted]); + + const toggleUserMuteRequest = useStableCallback( + (nextMuted: boolean, userId?: string) => { + if (!userId) return; + + if (!nextMuted) { + return client + .unmuteUser(userId) + .then(() => + addNotification({ + context: { channel }, + emitter: 'ChannelMemberDetail', + message: t('User unmuted'), + severity: 'success', + type: 'api:user:unmute:success', + }), + ) + .catch((error) => { + setOptimisticUserMuted(true); + return addNotification({ + context: { channel }, + emitter: 'ChannelMemberDetail', + error: toError(error), + message: t('Error unmuting user'), + severity: 'error', + type: 'api:user:unmute:failed', + }); + }); + } + + return client + .muteUser(userId) + .then(() => + addNotification({ + context: { channel }, + emitter: 'ChannelMemberDetail', + message: t('User muted'), + severity: 'success', + type: 'api:user:mute:success', + }), + ) + .catch((error) => { + setOptimisticUserMuted(false); + return addNotification({ + context: { channel }, + emitter: 'ChannelMemberDetail', + error: toError(error), + message: t('Error muting user'), + severity: 'error', + type: 'api:user:mute:failed', + }); + }); + }, + ); + + const toggleUserMute = useMemo( + () => debounce(toggleUserMuteRequest, 1000), + [toggleUserMuteRequest], + ); + + useEffect( + () => () => { + toggleUserMute.cancel(); + }, + [toggleUserMute], + ); + + const toggleOptimisticUserMute = useCallback(() => { + const nextMuted = !optimisticUserMuted; + setOptimisticUserMuted(nextMuted); + toggleUserMute(nextMuted, targetUserId); + }, [optimisticUserMuted, targetUserId, toggleUserMute]); + + const rootProps = useMemo( + () => ({ + 'aria-pressed': optimisticUserMuted, + className: clsx('str-chat__form__switch-field', channelMemberDetailActionClassName), + onClick: toggleOptimisticUserMute, + }), + [optimisticUserMuted, toggleOptimisticUserMute], + ); + const TrailingSlot = useMemo(() => { + function UserMuteSwitch() { + return ; + } + + return UserMuteSwitch; + }, [optimisticUserMuted]); + + return ( + + ); +}; + +const BlockUserAction = () => { + const { client } = useChatContext(); + const { Modal = GlobalModal } = useComponentContext(); + const { channel } = useChannelDetailContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const { memberDisplayName, targetUserId } = useChannelMemberActionContext(); + const { userIds: blockedUserIds } = useStateStore( + client.blockedUsers, + blockedUsersSelector, + ); + const isBlocked = !!targetUserId && new Set(blockedUserIds).has(targetUserId); + const [alertOpen, setAlertOpen] = useState(false); + const [userBlockInProgress, setUserBlockInProgress] = useState(false); + + const closeBlockUserAlert = useCallback(() => { + setAlertOpen(false); + }, []); + + const openBlockUserAlert = useCallback(() => { + setAlertOpen(true); + }, []); + + const unblockUser = useCallback(async () => { + if (!targetUserId) return; + + try { + setUserBlockInProgress(true); + await client.unBlockUser(targetUserId); + addNotification({ + context: { channel }, + emitter: 'ChannelMemberDetail', + message: t('User unblocked'), + severity: 'success', + type: 'api:user:unblock:success', + }); + } catch (error) { + addNotification({ + context: { channel }, + emitter: 'ChannelMemberDetail', + error: toError(error), + message: t('Error unblocking user'), + severity: 'error', + type: 'api:user:unblock:failed', + }); + } finally { + setAlertOpen(false); + setUserBlockInProgress(false); + } + }, [addNotification, channel, client, t, targetUserId]); + + const blockUser = useCallback(async () => { + if (!targetUserId) return; + + try { + setUserBlockInProgress(true); + await client.blockUser(targetUserId); + addNotification({ + context: { channel }, + emitter: 'ChannelMemberDetail', + message: t('User blocked'), + severity: 'success', + type: 'api:user:block:success', + }); + } catch (error) { + addNotification({ + context: { channel }, + emitter: 'ChannelMemberDetail', + error: toError(error), + message: t('Error blocking user'), + severity: 'error', + type: 'api:user:block:failed', + }); + } finally { + setAlertOpen(false); + setUserBlockInProgress(false); + } + }, [addNotification, channel, client, t, targetUserId]); + + const rootProps = useMemo( + () => ({ + className: channelMemberDetailActionClassName, + disabled: userBlockInProgress, + onClick: openBlockUserAlert, + }), + [openBlockUserAlert, userBlockInProgress], + ); + + return ( + <> + + + + + + ); +}; + +const RemoveUserAction = () => { + const { Modal = GlobalModal } = useComponentContext(); + const { channel } = useChannelDetailContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const { memberDisplayName, targetUserId } = useChannelMemberActionContext(); + const [alertOpen, setAlertOpen] = useState(false); + const [removeMemberInProgress, setRemoveMemberInProgress] = useState(false); + + const closeRemoveUserAlert = useCallback(() => { + setAlertOpen(false); + }, []); + + const openRemoveUserAlert = useCallback(() => { + setAlertOpen(true); + }, []); + + const removeUser = useCallback(async () => { + if (!targetUserId) return; + + try { + setRemoveMemberInProgress(true); + await channel.removeMembers([targetUserId]); + addNotification({ + context: { channel }, + emitter: 'ChannelMemberDetail', + message: t('User removed'), + severity: 'success', + type: 'api:channel:remove-members:success', + }); + setAlertOpen(false); + } catch (error) { + addNotification({ + context: { channel }, + emitter: 'ChannelMemberDetail', + error: toError(error), + message: t('Error removing user'), + severity: 'error', + type: 'api:channel:remove-members:failed', + }); + } finally { + setRemoveMemberInProgress(false); + } + }, [addNotification, channel, t, targetUserId]); + + const rootProps = useMemo( + () => ({ + className: channelMemberDetailActionClassName, + disabled: removeMemberInProgress, + onClick: openRemoveUserAlert, + }), + [openRemoveUserAlert, removeMemberInProgress], + ); + + return ( + <> + + + + + + ); +}; + +export const DefaultChannelMemberActions = { + BlockUser: BlockUserAction, + MuteUser: UserMuteAction, + RemoveUser: RemoveUserAction, + SendDirectMessage: SendDirectMessageAction, +}; + +export const defaultChannelMemberActionSet: ChannelMemberActionItem[] = [ + { + Component: DefaultChannelMemberActions.SendDirectMessage, + type: 'sendMessage', + }, + { + Component: DefaultChannelMemberActions.MuteUser, + type: 'muteUser', + }, + { + Component: DefaultChannelMemberActions.BlockUser, + type: 'blockUser', + }, + { + Component: DefaultChannelMemberActions.RemoveUser, + type: 'removeUser', + }, +]; diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx new file mode 100644 index 0000000000..50542ae762 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx @@ -0,0 +1,161 @@ +import React, { useMemo } from 'react'; +import type { ChannelMemberResponse } from 'stream-chat'; + +import { + useChatContext, + useComponentContext, + useModalContext, + useTranslationContext, +} from '../../../../context'; +import { + SectionNavigatorHeader, + type SectionNavigatorSectionContentProps, +} from '../../../../components/SectionNavigator'; +import { ChannelAvatar as DefaultChannelAvatar } from '../../../../components/Avatar'; +import { Prompt } from '../../../../components/Dialog'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { + type ChannelMemberActionItem, + ChannelMemberActionProvider, + defaultChannelMemberActionSet, + useBaseChannelMemberActionSetFilter, + useChannelMemberActionContext, +} from './ChannelMemberActions.defaults'; +import { getMemberDisplayName } from '../ChannelMembersView/ChannelMembersView.utils'; + +export type ChannelMemberDetailProps = SectionNavigatorSectionContentProps & { + channelMemberActionSet?: ChannelMemberActionItem[]; + member?: ChannelMemberResponse; + onBack?: () => void; +}; + +const getPresenceStatusText = ( + user: ChannelMemberResponse['user'], + t: ReturnType['t'], +) => { + if (user?.online) return t('Online'); + + if (user?.last_active) { + return t('Last seen {{ timestamp }}', { + timestamp: t('timestamp/ChannelMembersLastActive', { + timestamp: user.last_active, + }), + }); + } + + return t('Offline'); +}; + +export const ChannelMemberDetail = ({ + channelMemberActionSet = defaultChannelMemberActionSet, + member, + onBack, +}: ChannelMemberDetailProps) => { + const { client } = useChatContext(); + const { channel } = useChannelDetailContext(); + const { close } = useModalContext(); + const { t } = useTranslationContext(); + + const fallbackMember = useMemo( + () => + Object.values(channel.state.members ?? {}).find( + (stateMember) => stateMember.user?.id !== client.user?.id, + ), + [channel, client.user?.id], + ); + const resolvedMember = member ?? fallbackMember; + + const memberDisplayName = resolvedMember ? getMemberDisplayName(resolvedMember) : ''; + const memberStatusText = getPresenceStatusText(resolvedMember?.user, t); + + const actionContextValue = useMemo( + () => + resolvedMember + ? { + member: resolvedMember, + memberDisplayName, + targetUserId: resolvedMember.user?.id || resolvedMember.user_id, + } + : undefined, + [memberDisplayName, resolvedMember], + ); + + if (!actionContextValue) { + return ( +
    + + +
    + {t('Member not found')} +
    +
    +
    + ); + } + + return ( + + + + ); +}; + +type ChannelMemberDetailContentProps = { + channelMemberActionSet: ChannelMemberActionItem[]; + memberDisplayName: string; + memberStatusText: string; + onBack?: () => void; +}; + +const ChannelMemberDetailContent = ({ + channelMemberActionSet, + memberDisplayName, + memberStatusText, + onBack, +}: ChannelMemberDetailContentProps) => { + const { close } = useModalContext(); + const { t } = useTranslationContext(); + const { Avatar = DefaultChannelAvatar } = useComponentContext(); + const { member } = useChannelMemberActionContext(); + + const filteredActions = useBaseChannelMemberActionSetFilter(channelMemberActionSet); + + return ( +
    + + +
    + +
    +
    + {memberDisplayName} +
    +
    + {memberStatusText} +
    +
    +
    + +
    + {filteredActions.map(({ Component, type }) => ( + + ))} +
    +
    +
    + ); +}; diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx new file mode 100644 index 0000000000..fa199b796c --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx @@ -0,0 +1,146 @@ +import { render, screen } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import React from 'react'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; + +import { + useChatContext, + useComponentContext, + useModalContext, + useTranslationContext, +} from '../../../../../context'; +import { ChannelDetailProvider } from '../../../ChannelDetailContext'; +import { ChannelMemberDetail } from '../ChannelMemberDetail'; + +vi.mock('../../../../../context'); + +vi.mock('../../../../../components/Dialog', () => ({ + Prompt: { + Body: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + Header: ({ title }: { title: string }) =>

    {title}

    , + }, +})); + +const createChannel = ({ + members, + ownCapabilities = ['update-channel-members', 'ban-channel-members'], +}: { + members?: Channel['state']['members']; + ownCapabilities?: string[]; +} = {}) => + fromPartial({ + data: { + own_capabilities: ownCapabilities, + }, + state: { + members: members ?? { + me: { + user: { id: 'user-me', name: 'Me' }, + user_id: 'user-me', + }, + 'user-2': { + user: { + id: 'user-2', + last_active: '2026-01-01T00:00:00.000000000Z', + name: 'Bob', + }, + user_id: 'user-2', + }, + }, + }, + }); + +const renderWithChannel = (ui: React.ReactElement, channel: Channel = createChannel()) => + render({ui}); + +const createAction = (type: string, label: string) => ({ + Component: () => {label}, + type, +}); + +describe('ChannelMemberDetail', () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(useTranslationContext).mockReturnValue({ + t: (key: string, options?: { timestamp?: string }) => + options?.timestamp ? `${key}:${options.timestamp}` : key, + } as ReturnType); + + vi.mocked(useChatContext).mockReturnValue({ + client: { user: { id: 'user-me' } }, + mutes: [], + } as ReturnType); + + vi.mocked(useModalContext).mockReturnValue({ + close: vi.fn(), + } as ReturnType); + + vi.mocked(useComponentContext).mockReturnValue( + {} as ReturnType, + ); + }); + + it('uses fallback member from channel state when member prop is not provided', () => { + renderWithChannel( + , + ); + + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('Member detail')).toBeInTheDocument(); + }); + + it('renders empty state when no member is available', () => { + renderWithChannel( + , + createChannel({ members: {} }), + ); + + expect(screen.getByText('Member not found')).toBeInTheDocument(); + }); + + it('filters actions by capabilities for another member', () => { + const actionSet = [ + createAction('sendMessage', 'Send'), + createAction('muteUser', 'Mute'), + createAction('blockUser', 'Block'), + createAction('removeUser', 'Remove'), + ]; + + renderWithChannel( + , + createChannel({ ownCapabilities: ['ban-channel-members'] }), + ); + + expect(screen.getByText('Send')).toBeInTheDocument(); + expect(screen.getByText('Mute')).toBeInTheDocument(); + expect(screen.getByText('Block')).toBeInTheDocument(); + expect(screen.queryByText('Remove')).not.toBeInTheDocument(); + }); + + it('hides member actions when viewing current user details', () => { + const ownMember = fromPartial({ + user: { id: 'user-me', name: 'Me' }, + user_id: 'user-me', + }); + const actionSet = [ + createAction('sendMessage', 'Send'), + createAction('muteUser', 'Mute'), + createAction('blockUser', 'Block'), + createAction('removeUser', 'Remove'), + ]; + + renderWithChannel( + , + ); + + expect(screen.queryByText('Send')).not.toBeInTheDocument(); + expect(screen.queryByText('Mute')).not.toBeInTheDocument(); + expect(screen.queryByText('Block')).not.toBeInTheDocument(); + expect(screen.queryByText('Remove')).not.toBeInTheDocument(); + }); +}); diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/index.ts b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/index.ts new file mode 100644 index 0000000000..a2197b2eac --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/index.ts @@ -0,0 +1 @@ +export * from './ChannelMemberDetail'; diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx new file mode 100644 index 0000000000..3d3d23c77f --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx @@ -0,0 +1,219 @@ +import { type SearchSourceState, type UserResponse, UserSearchSource } from 'stream-chat'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useChatContext, useTranslationContext } from '../../../../context'; +import { useStateStore } from '../../../../store'; +import { Avatar } from '../../../../components/Avatar'; +import { Checkbox } from '../../../../components/Form'; +import { IconMute } from '../../../../components/Icons'; +import { InfiniteScrollPaginator } from '../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator'; +import { ListItemLayout } from '../../../../components/ListItemLayout'; +import { Prompt } from '../../../../components/Dialog'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { + canUpdateChannelMembers, + getChannelMemberUserIds, + getUserDisplayName, +} from './ChannelMembersView.utils'; +import { useNotificationApi } from '../../../../components/Notifications'; +import { ChannelDetailSearchInput } from '../../ChannelDetailSearchInput'; +import { ChannelDetailEmptyList } from '../../ChannelDetailEmptyList'; +import { ChannelDetailListLoadingIndicator } from '../../ChannelDetailListLoadingIndicator'; + +export type ChannelMembersAddViewProps = { + onMembersAdded: (memberCount: number) => void; + searchSource?: UserSearchSource; +}; + +const USER_SEARCH_PAGE_SIZE = 30; + +const searchSourceItemsStateSelector = (state: SearchSourceState) => ({ + isLoading: state.isLoading, + users: state.items, +}); + +export const ChannelMembersAddView = ({ + onMembersAdded, + searchSource, +}: ChannelMembersAddViewProps) => { + const { client, mutes } = useChatContext(); + const { t } = useTranslationContext(); + const { channel } = useChannelDetailContext(); + const canManageChannelMembers = canUpdateChannelMembers(channel); + const { addNotification } = useNotificationApi(); + + const memberUserIds = useMemo(() => getChannelMemberUserIds(channel), [channel]); + const excludedMemberIds = useMemo(() => new Set(memberUserIds), [memberUserIds]); + + const userSearchSource = useMemo(() => { + const source = + searchSource ?? + new UserSearchSource(client, { + allowEmptySearchString: true, + pageSize: USER_SEARCH_PAGE_SIZE, + resetOnNewSearchQuery: false, + }); + + source.activate(); + return source; + }, [client, searchSource]); + + const { isLoading, users: searchUsers } = useStateStore( + userSearchSource.state, + searchSourceItemsStateSelector, + ); + + const users = useMemo( + () => searchUsers?.filter((user) => !excludedMemberIds.has(user.id)), + [excludedMemberIds, searchUsers], + ); + + const [isSaving, setIsSaving] = useState(false); + const [selectedUserIds, setSelectedUserIds] = useState([]); + + useEffect(() => () => userSearchSource.cancelScheduledQuery(), [userSearchSource]); + + useEffect(() => { + userSearchSource.search(''); + }, [userSearchSource]); + + const loadNextPageOnScroll = useCallback( + (distanceFromBottom: number, distanceFromTop: number, threshold: number) => { + if (distanceFromTop > 0 && distanceFromBottom < threshold) { + userSearchSource.search(); + } + }, + [userSearchSource], + ); + + const selectedUserIdSet = useMemo(() => new Set(selectedUserIds), [selectedUserIds]); + const mutedUserIdSet = useMemo( + () => new Set(mutes.map((mute) => mute.target.id)), + [mutes], + ); + + const handleSearchChange = useCallback( + (query: string) => { + userSearchSource.search(query); + }, + [userSearchSource], + ); + + const toggleSelectedUser = useCallback( + (userId: string) => + setSelectedUserIds((currentSelectedUserIds) => + currentSelectedUserIds.includes(userId) + ? currentSelectedUserIds.filter((id) => id !== userId) + : [...currentSelectedUserIds, userId], + ), + [], + ); + + const handleSave = async () => { + if (!canManageChannelMembers || !selectedUserIds.length || isSaving) return; + + setIsSaving(true); + try { + await channel.addMembers(selectedUserIds); + onMembersAdded(selectedUserIds.length); + addNotification({ + context: { channel }, + emitter: 'ChannelMembersView', + message: t('{{ count }} members added', { count: selectedUserIds.length }), + severity: 'success', + type: 'api:channel:addMembers:success', + }); + } catch (error) { + setIsSaving(false); + addNotification({ + context: { channel }, + emitter: 'ChannelMembersView', + error: error as Error, + message: t('Error adding members'), + severity: 'error', + type: 'api:channel:addMembers:failed', + }); + } + }; + + return ( + <> + + + + {users && users.length > 0 ? ( + users.map((user) => { + const displayName = getUserDisplayName(user); + const isMuted = mutedUserIdSet.has(user.id); + const avatar = ( + + ); + + if (canManageChannelMembers) { + const selected = selectedUserIdSet.has(user.id); + + return ( + avatar} + RootElement='button' + rootProps={{ + 'aria-pressed': selected, + className: + 'str-chat__channel-detail__channel-members-view__list-item', + onClick: () => toggleSelectedUser(user.id), + }} + title={displayName} + TrailingSlot={() => } + /> + ); + } + + return ( + avatar} + rootProps={{ + className: + 'str-chat__channel-detail__channel-members-view__list-item', + }} + title={displayName} + TrailingSlot={ + isMuted + ? () => ( + + ) + : undefined + } + /> + ); + }) + ) : !isLoading && users ? ( + {t('No user found')} + ) : null} + {isLoading && ( + + )} + + + {canManageChannelMembers && selectedUserIds.length > 0 && ( + + + + {t('Add {{ count }} members', { count: selectedUserIds.length })} + + + + )} + + ); +}; diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx new file mode 100644 index 0000000000..46cd8ff802 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx @@ -0,0 +1,135 @@ +import type { ChannelMemberResponse } from 'stream-chat'; +import React, { useMemo } from 'react'; + +import { useChatContext, useTranslationContext } from '../../../../context'; +import { Avatar } from '../../../../components/Avatar'; +import { IconMute } from '../../../../components/Icons'; +import { InfiniteScrollPaginator } from '../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator'; +import { ListItemLayout } from '../../../../components/ListItemLayout'; +import { Prompt } from '../../../../components/Dialog'; +import { + getMemberDisplayName, + getMemberUserId, + getUserDisplayName, +} from './ChannelMembersView.utils'; +import { ChannelDetailEmptyList } from '../../ChannelDetailEmptyList'; +import { ChannelDetailListLoadingIndicator } from '../../ChannelDetailListLoadingIndicator'; +import { ChannelDetailSearchInput } from '../../ChannelDetailSearchInput'; +import { useChannelMembersSearch } from './useChannelMembersSearch'; + +const getMemberRoleTranslation = ( + member: ChannelMemberResponse, + t: ReturnType['t'], +) => { + if ([member.user?.role, member.channel_role].includes('admin')) return t('Admin'); + if (member.channel_role === 'channel_moderator' || member.channel_role === 'moderator') + return t('Moderator'); + if (member.role === 'owner') return t('Owner'); + + return undefined; +}; + +const getPresenceStatusText = ( + user: ChannelMemberResponse['user'], + t: ReturnType['t'], +) => { + if (user?.online) return t('Online'); + + if (user?.last_active) { + return t('Last seen {{ timestamp }}', { + timestamp: t('timestamp/ChannelMembersLastActive', { + timestamp: user.last_active, + }), + }); + } + + return t('Offline'); +}; + +export type ChannelMembersBrowseViewProps = { + onMemberSelect?: (member: ChannelMemberResponse) => void; +}; + +export const ChannelMembersBrowseView = ({ + onMemberSelect, +}: ChannelMembersBrowseViewProps) => { + const { mutes } = useChatContext(); + const { t } = useTranslationContext(); + const { + displayedMembers, + handleSearchChange, + hasMembers, + membersSearchSource, + searchInputResetKey, + } = useChannelMembersSearch(); + const mutedUserIdSet = useMemo( + () => new Set(mutes.map((mute) => mute.target.id)), + [mutes], + ); + + return ( + + {hasMembers && ( + + )} + + {displayedMembers.length > 0 ? ( + displayedMembers.map((member) => { + const memberUserId = getMemberUserId(member); + if (!memberUserId) return null; + + const user = member.user; + const displayName = getMemberDisplayName(member); + const roleTranslation = getMemberRoleTranslation(member, t); + const isMuted = mutedUserIdSet.has(memberUserId); + + return ( + ( + + )} + RootElement='button' + rootProps={{ + 'aria-label': t('View member details for {{ member }}', { + member: displayName, + }), + className: 'str-chat__channel-detail__channel-members-view__list-item', + onClick: () => onMemberSelect?.(member), + }} + subtitle={getPresenceStatusText(user, t)} + title={displayName} + TrailingSlot={() => ( +
    + {roleTranslation ? ( + + {roleTranslation} + + ) : null} + {isMuted ? ( + + ) : null} +
    + )} + /> + ); + }) + ) : ( + {t('No member found')} + )} + +
    +
    + ); +}; diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx new file mode 100644 index 0000000000..f8fae77d1f --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx @@ -0,0 +1,268 @@ +import React, { useMemo, useState } from 'react'; + +import { useComponentContext, useTranslationContext } from '../../../../context'; +import { Button } from '../../../../components/Button'; +import { + ContextMenu, + ContextMenuButton, + useDialogIsOpen, + useDialogOnNearestManager, +} from '../../../../components/Dialog'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { canUpdateChannelMembers } from './ChannelMembersView.utils'; +import type { + ChannelMembersHeaderActionsProps, + ChannelMembersViewController, +} from './ChannelMembersView'; +import { IconUserAdd, IconUserRemove } from '../../../../components/Icons'; + +export type ChannelMembersHeaderActionType = + | 'addMembers' + | 'removeMembers' + | (string & {}); + +export type ChannelMembersHeaderActionComponentProps = { + closeMenu?: () => void; + controller: ChannelMembersViewController; +}; + +export type ChannelMembersHeaderActionItem = { + type: ChannelMembersHeaderActionType; + quick?: React.ComponentType; + menu?: React.ComponentType; +}; + +const useChannelMembersHeaderActionFilterState = () => { + const { channel } = useChannelDetailContext(); + + return { + canManageChannelMembers: canUpdateChannelMembers(channel), + }; +}; + +export const useBaseChannelMembersHeaderActionSetFilter = ( + channelMembersHeaderActionSet: ChannelMembersHeaderActionItem[], +) => { + const { canManageChannelMembers } = useChannelMembersHeaderActionFilterState(); + + return useMemo( + () => + channelMembersHeaderActionSet.filter((action) => { + switch (action.type) { + case 'addMembers': + case 'removeMembers': + return canManageChannelMembers; + default: + return true; + } + }), + [canManageChannelMembers, channelMembersHeaderActionSet], + ); +}; + +const AddMembersHeaderAction = ({ + controller, +}: ChannelMembersHeaderActionComponentProps) => { + const { t } = useTranslationContext(); + + if (controller.mode !== 'browse') return null; + + return ( + + ); +}; + +const AddMembersMenuAction = ({ + closeMenu, + controller, +}: ChannelMembersHeaderActionComponentProps) => { + const { t } = useTranslationContext(); + + if (controller.mode !== 'browse') return null; + + return ( + { + controller.setMode('add'); + closeMenu?.(); + }} + > + {t('Add')} + + ); +}; + +const RemoveMembersHeaderAction = ({ + controller, +}: ChannelMembersHeaderActionComponentProps) => { + const { t } = useTranslationContext(); + + if (controller.mode !== 'browse') return null; + + return ( + + ); +}; + +const RemoveMembersMenuAction = ({ + closeMenu, + controller, +}: ChannelMembersHeaderActionComponentProps) => { + const { t } = useTranslationContext(); + + if (controller.mode !== 'browse') return null; + + return ( + { + controller.setMode('remove'); + closeMenu?.(); + }} + > + {t('Remove')} + + ); +}; + +export const DefaultChannelMembersHeaderActions = { + AddMembers: AddMembersHeaderAction, + AddMembersMenu: AddMembersMenuAction, + RemoveMembers: RemoveMembersHeaderAction, + RemoveMembersMenu: RemoveMembersMenuAction, +}; + +export const defaultChannelMembersHeaderActionSet: ChannelMembersHeaderActionItem[] = [ + { + quick: DefaultChannelMembersHeaderActions.AddMembers, + type: 'addMembers', + }, +]; + +export type ChannelMembersHeaderActionsMenuTriggerProps = { + 'aria-expanded': boolean; + onClick: () => void; + referenceRef?: React.Ref; +}; + +export const DefaultHeaderActionsMenuTrigger = ({ + referenceRef, + ...props +}: ChannelMembersHeaderActionsMenuTriggerProps) => { + const { t } = useTranslationContext(); + + return ( + + ); +}; + +const getHeaderActionsDialogId = (channelId?: string) => + `channel-members-header-actions-${channelId ?? 'unknown'}`; + +export const DefaultHeaderActions = ({ + controller, + headerActionSet, + HeaderActionsMenuTrigger = DefaultHeaderActionsMenuTrigger, +}: ChannelMembersHeaderActionsProps) => { + const { ContextMenu: ContextMenuComponent = ContextMenu } = useComponentContext(); + const { channel } = useChannelDetailContext(); + const actions = useBaseChannelMembersHeaderActionSetFilter(headerActionSet); + const [referenceElement, setReferenceElement] = useState( + null, + ); + const dialogId = getHeaderActionsDialogId(channel.id); + const { dialog, dialogManager } = useDialogOnNearestManager({ id: dialogId }); + const dialogManagerId = dialogManager?.id; + const dialogIsOpen = useDialogIsOpen(dialogId, dialogManagerId); + + if (!actions.length) return null; + + const quickActions = actions.filter((action) => !!action.quick); + const menuActions = actions.filter((action) => !!action.menu); + const shouldRenderSingleQuickAction = actions.length === 1 && quickActions.length === 1; + const shouldRenderMenu = + (actions.length === 1 && !shouldRenderSingleQuickAction && menuActions.length > 0) || + (actions.length > 1 && menuActions.length > 0); + const quickActionsOutsideMenu = shouldRenderSingleQuickAction + ? [] + : shouldRenderMenu + ? quickActions.filter((action) => !action.menu) + : quickActions; + + return ( +
    + {shouldRenderSingleQuickAction && + quickActions.map(({ quick: QuickComponent, type }) => + QuickComponent ? : null, + )} + + {quickActionsOutsideMenu.map(({ quick: QuickComponent, type }) => + QuickComponent ? : null, + )} + + {shouldRenderMenu && ( + <> + { + dialog.toggle(); + }} + referenceRef={setReferenceElement} + /> + dialog.close()} + placement='bottom-start' + referenceElement={referenceElement} + tabIndex={-1} + trapFocus + > + {menuActions.map(({ menu: MenuComponent, type }) => + MenuComponent ? ( + dialog.close()} + controller={controller} + key={type} + /> + ) : null, + )} + + + )} +
    + ); +}; diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx new file mode 100644 index 0000000000..3fa210c0fc --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx @@ -0,0 +1,160 @@ +import type { ChannelMemberResponse } from 'stream-chat'; +import React, { useMemo, useState } from 'react'; + +import { useTranslationContext } from '../../../../context'; +import { Avatar } from '../../../../components/Avatar'; +import { Checkbox } from '../../../../components/Form'; +import { InfiniteScrollPaginator } from '../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator'; +import { ListItemLayout } from '../../../../components/ListItemLayout'; +import { Prompt } from '../../../../components/Dialog'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { + canUpdateChannelMembers, + getMemberDisplayName, + getMemberUserId, + getUserDisplayName, +} from './ChannelMembersView.utils'; +import { ChannelMembersBrowseView } from './ChannelMembersBrowseView'; +import { ChannelDetailEmptyList } from '../../ChannelDetailEmptyList'; +import { ChannelDetailListLoadingIndicator } from '../../ChannelDetailListLoadingIndicator'; +import { ChannelDetailSearchInput } from '../../ChannelDetailSearchInput'; +import { useChannelMembersSearch } from './useChannelMembersSearch'; + +const getPresenceStatusText = ( + user: ChannelMemberResponse['user'], + t: ReturnType['t'], +) => { + if (user?.online) return t('Online'); + + if (user?.last_active) { + return t('Last seen {{ timestamp }}', { + timestamp: t('timestamp/ChannelMembersLastActive', { + timestamp: user.last_active, + }), + }); + } + + return t('Offline'); +}; + +export type ChannelMembersRemoveViewProps = { + onMembersRemoved?: (memberCount: number) => void; +}; + +const ChannelMembersRemoveList = ({ + onMembersRemoved, +}: ChannelMembersRemoveViewProps) => { + const { t } = useTranslationContext(); + const { channel } = useChannelDetailContext(); + const { + displayedMembers, + handleSearchChange, + membersSearchSource, + resetMembersSearch, + searchInputResetKey, + } = useChannelMembersSearch(); + const [isRemoving, setIsRemoving] = useState(false); + const [selectedMemberUserIds, setSelectedMemberUserIds] = useState([]); + const selectedMemberUserIdSet = useMemo( + () => new Set(selectedMemberUserIds), + [selectedMemberUserIds], + ); + + const toggleSelectedMember = (memberUserId: string) => { + setSelectedMemberUserIds((currentSelectedMemberUserIds) => + currentSelectedMemberUserIds.includes(memberUserId) + ? currentSelectedMemberUserIds.filter((id) => id !== memberUserId) + : [...currentSelectedMemberUserIds, memberUserId], + ); + }; + + const handleRemove = async () => { + if (!selectedMemberUserIds.length || isRemoving) return; + + setIsRemoving(true); + const memberCount = selectedMemberUserIds.length; + + try { + await channel.removeMembers(selectedMemberUserIds); + setSelectedMemberUserIds([]); + resetMembersSearch(); + onMembersRemoved?.(memberCount); + } finally { + setIsRemoving(false); + } + }; + + return ( + <> + + + + {displayedMembers.length > 0 ? ( + displayedMembers.map((member) => { + const memberUserId = getMemberUserId(member); + if (!memberUserId) return null; + + const user = member.user; + const displayName = getMemberDisplayName(member); + const selected = selectedMemberUserIdSet.has(memberUserId); + + return ( + ( + + )} + RootElement='button' + rootProps={{ + 'aria-pressed': selected, + className: + 'str-chat__channel-detail__channel-members-view__list-item', + onClick: () => toggleSelectedMember(memberUserId), + }} + subtitle={getPresenceStatusText(user, t)} + title={displayName} + TrailingSlot={() => } + /> + ); + }) + ) : ( + {t('No member found')} + )} + + + + {selectedMemberUserIds.length > 0 && ( + + + + {t('Remove {{ count }} members', { count: selectedMemberUserIds.length })} + + + + )} + + ); +}; + +export const ChannelMembersRemoveView = (props: ChannelMembersRemoveViewProps) => { + const { channel } = useChannelDetailContext(); + const canManageChannelMembers = canUpdateChannelMembers(channel); + + if (!canManageChannelMembers) return ; + + return ; +}; diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx new file mode 100644 index 0000000000..dcb1eb5c03 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx @@ -0,0 +1,155 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ChannelMemberResponse } from 'stream-chat'; + +import { useModalContext, useTranslationContext } from '../../../../context'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { ChannelMemberDetail } from '../ChannelMemberDetailView'; +import { + type ChannelMembersHeaderActionItem, + type ChannelMembersHeaderActionsMenuTriggerProps, + defaultChannelMembersHeaderActionSet, + DefaultHeaderActions, +} from './ChannelMembersHeaderActions.defaults'; +import { ChannelMembersAddView } from './ChannelMembersAddView'; +import { ChannelMembersBrowseView } from './ChannelMembersBrowseView'; +import { ChannelMembersRemoveView } from './ChannelMembersRemoveView'; +import { + SectionNavigatorHeader, + type SectionNavigatorSectionContentProps, +} from '../../../../components/SectionNavigator'; + +export type ChannelMembersHeaderActionsProps = { + controller: ChannelMembersViewController; + HeaderActionsMenuTrigger?: React.ComponentType; + headerActionSet: ChannelMembersHeaderActionItem[]; +}; + +export type ChannelMembersViewMode = 'add' | 'browse' | 'remove' | 'memberDetail'; + +export type ChannelMembersViewController = { + mode: ChannelMembersViewMode; + setMode: (mode: ChannelMembersViewMode) => void; +}; + +export type ChannelMembersViewProps = SectionNavigatorSectionContentProps & { + HeaderActions?: React.ComponentType; + HeaderActionsMenuTrigger?: React.ComponentType; + headerActionSet?: ChannelMembersHeaderActionItem[]; +}; + +export const ChannelMembersView = ({ + HeaderActions = DefaultHeaderActions, + headerActionSet = defaultChannelMembersHeaderActionSet, + HeaderActionsMenuTrigger, + layout, +}: ChannelMembersViewProps) => { + const { t } = useTranslationContext(); + const { channel } = useChannelDetailContext(); + const { close } = useModalContext(); + const [mode, setMode] = useState('browse'); + const [selectedMember, setSelectedMember] = useState(); + const [memberCount, setMemberCount] = useState(channel.data?.member_count ?? 0); + const [membersRefreshKey, setMembersRefreshKey] = useState(0); + const [membersAddedCount, setMembersAddedCount] = useState(0); + + const isAddingMember = mode === 'add'; + const isManagingMembers = mode === 'remove'; + const isViewingMemberDetail = mode === 'memberDetail'; + const isAlternateMode = isAddingMember || isManagingMembers || isViewingMemberDetail; + + useEffect(() => { + setMemberCount(channel.data?.member_count ?? 0); + }, [channel.data?.member_count]); + + useEffect(() => { + if (!membersAddedCount) return; + + const timeout = setTimeout(() => setMembersAddedCount(0), 3000); + + return () => clearTimeout(timeout); + }, [membersAddedCount]); + + const setViewMode = useCallback((nextMode: ChannelMembersViewMode) => { + setMode(nextMode); + if (nextMode !== 'memberDetail') { + setSelectedMember(undefined); + } + }, []); + + const goBack = useCallback(() => setViewMode('browse'), [setViewMode]); + + const controller = useMemo( + () => ({ + mode, + setMode: setViewMode, + }), + [mode, setViewMode], + ); + + const HeaderTrailingActions = useMemo( + () => + function HeaderTrailingActions() { + if (mode !== 'browse') return null; + return ( + + ); + }, + [HeaderActions, HeaderActionsMenuTrigger, controller, headerActionSet, mode], + ); + + const headerTitle = isAddingMember + ? t('Add members') + : isManagingMembers + ? t('Manage members') + : t('{{ count }} members', { count: memberCount }); + + if (isViewingMemberDetail && selectedMember) { + return ( + + ); + } + + return ( +
    + + {isAddingMember ? ( + { + setMemberCount((currentCount) => currentCount + count); + setMembersAddedCount(count); + setMembersRefreshKey((currentKey) => currentKey + 1); + goBack(); + }} + /> + ) : isManagingMembers ? ( + { + setMemberCount((currentCount) => currentCount - count); + }} + /> + ) : ( + { + setSelectedMember(member); + setViewMode('memberDetail'); + }} + /> + )} +
    + ); +}; diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts new file mode 100644 index 0000000000..4a832e3102 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts @@ -0,0 +1,20 @@ +import { type Channel, type ChannelMemberResponse, type UserResponse } from 'stream-chat'; + +export const CHANNEL_MEMBERS_QUERY_LIMIT = 100; + +export const getMemberDisplayName = (member: ChannelMemberResponse) => + getUserDisplayName(member.user) || member.user_id || ''; + +export const getMemberUserId = (member: ChannelMemberResponse) => + member.user?.id || member.user_id; + +export const getUserDisplayName = (user?: UserResponse) => + user?.name || user?.username || user?.id || ''; + +export const getChannelMemberUserIds = (channel: Channel) => + Object.values(channel.state?.members ?? {}) + .map((member) => member.user?.id || member.user_id) + .filter((userId): userId is string => !!userId); + +export const canUpdateChannelMembers = (channel: Channel) => + channel.data?.own_capabilities?.includes('update-channel-members') ?? false; diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx new file mode 100644 index 0000000000..0531dda4a8 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx @@ -0,0 +1,178 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import type { UserResponse } from 'stream-chat'; + +import { useChatContext, useTranslationContext } from '../../../../../context'; +import { useStateStore } from '../../../../../store'; +import { ChannelMembersAddView } from '../ChannelMembersAddView'; +import { + createChannel, + createUserSearchSource, + getSelectableMemberButton, + querySelectableMemberButton, + renderWithChannel, +} from './testUtils'; + +const mocks = vi.hoisted(() => ({ + infiniteScrollPaginatorRenderCount: 0, +})); + +vi.mock('../../../../../context'); +vi.mock('../../../../../store'); + +vi.mock('../../../../../components/Notifications', () => ({ + useNotificationApi: () => ({ + addNotification: vi.fn(), + }), +})); + +vi.mock( + '../../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator', + () => ({ + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => { + mocks.infiniteScrollPaginatorRenderCount += 1; + return
    {children}
    ; + }, + }), +); + +vi.mock('../../../../../components/Dialog', () => ({ + Prompt: { + Body: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + Footer: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + FooterControls: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + FooterControlsButtonPrimary: ( + props: React.ButtonHTMLAttributes, + ) => + ), + useDialog: ({ id }: { id: string }) => ({ + close: vi.fn(), + id, + toggle: vi.fn(), + }), + useDialogIsOpen: () => false, + useDialogOnNearestManager: ({ id }: { id: string }) => ({ + dialog: { + close: vi.fn(), + id, + toggle: vi.fn(), + }, + dialogManager: { id: 'nearest-dialog-manager' }, + }), +})); + +const createChannel = (ownCapabilities: string[] = ['update-channel-members']) => + fromPartial({ + data: { + own_capabilities: ownCapabilities, + }, + id: 'channel-1', + }); + +const renderWithChannel = (ui: React.ReactElement, channel: Channel = createChannel()) => + render({ui}); + +const createController = ( + mode: ChannelMembersViewMode = 'browse', +): ChannelMembersViewController => ({ + mode, + setMode: vi.fn(), +}); + +describe('ChannelMembersHeaderActions.defaults', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useTranslationContext).mockReturnValue({ + t: (key: string) => key, + } as ReturnType); + vi.mocked(useModalContext).mockReturnValue({} as ReturnType); + vi.mocked(useComponentContext).mockReturnValue( + {} as ReturnType, + ); + }); + + it('renders quick variant for single action when available', () => { + const actionSet: ChannelMembersHeaderActionItem[] = [ + { + quick: () => Quick Add, + type: 'addMembers', + }, + ]; + + renderWithChannel( + , + ); + + expect(screen.getByText('Quick Add')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Open members actions' }), + ).not.toBeInTheDocument(); + }); + + it('renders menu fallback for single action without quick variant', () => { + const actionSet: ChannelMembersHeaderActionItem[] = [ + { + menu: () => Menu Add, + type: 'addMembers', + }, + ]; + + renderWithChannel( + , + ); + + expect( + screen.getByRole('button', { name: 'Open members actions' }), + ).toBeInTheDocument(); + expect(screen.getByText('Menu Add')).toBeInTheDocument(); + }); + + it('prefers menu variants when multiple actions exist', () => { + const actionSet: ChannelMembersHeaderActionItem[] = [ + { + menu: () => Menu Add, + quick: () => Quick Add, + type: 'addMembers', + }, + { + menu: () => Menu Manage, + type: 'removeMembers', + }, + ]; + + renderWithChannel( + , + ); + + expect(screen.getByText('Menu Add')).toBeInTheDocument(); + expect(screen.getByText('Menu Manage')).toBeInTheDocument(); + expect(screen.queryByText('Quick Add')).not.toBeInTheDocument(); + }); + + it('uses custom menu trigger component when provided', () => { + const actionSet: ChannelMembersHeaderActionItem[] = [ + { + menu: () => Menu Add, + type: 'addMembers', + }, + ]; + const CustomTrigger = ({ + onClick, + referenceRef, + }: { + onClick: () => void; + referenceRef?: React.Ref; + }) => ( + + ); + + renderWithChannel( + , + ); + + expect( + screen.getByRole('button', { name: 'Custom members actions trigger' }), + ).toBeInTheDocument(); + }); + + it('filters actions out when update-channel-members capability is missing', () => { + const actionSet: ChannelMembersHeaderActionItem[] = [ + { + quick: () => Quick Add, + type: 'addMembers', + }, + ]; + + renderWithChannel( + , + createChannel([]), + ); + + expect(screen.queryByText('Quick Add')).not.toBeInTheDocument(); + }); +}); diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx new file mode 100644 index 0000000000..6425c69afc --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx @@ -0,0 +1,164 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import type { ChannelMemberResponse } from 'stream-chat'; + +import { useChatContext, useTranslationContext } from '../../../../../context'; +import { useStateStore } from '../../../../../store'; +import { ChannelMembersRemoveView } from '../ChannelMembersRemoveView'; +import { createChannel, getSelectableMemberButton, renderWithChannel } from './testUtils'; + +const mocks = vi.hoisted(() => ({ + searchSourceActivate: vi.fn(), + searchSourceCancelScheduledQuery: vi.fn(), + searchSourceResetState: vi.fn(), + searchSourceSearch: vi.fn(), +})); + +vi.mock('stream-chat', async (importOriginal) => { + const actual = await importOriginal(); + + class ChannelMemberSearchSource { + state = {}; + + activate = mocks.searchSourceActivate; + + search = mocks.searchSourceSearch; + + resetState = mocks.searchSourceResetState; + + cancelScheduledQuery = mocks.searchSourceCancelScheduledQuery; + } + + return { + ...actual, + ChannelMemberSearchSource, + }; +}); + +vi.mock('../../../../../context'); +vi.mock('../../../../../store'); + +vi.mock( + '../../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator', + () => ({ + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + }), +); + +vi.mock('../../../../../components/Dialog', () => ({ + Prompt: { + Body: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + Footer: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + FooterControls: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + FooterControlsButtonPrimary: ( + props: React.ButtonHTMLAttributes, + ) => +
    + ), +})); + +vi.mock('../ChannelMembersAddView', () => ({ + ChannelMembersAddView: ({ + onMembersAdded, + }: { + onMembersAdded: (count: number) => void; + }) => ( +
    + +
    + ), +})); + +vi.mock('../ChannelMembersBrowseView', () => ({ + ChannelMembersBrowseView: ({ + onMemberSelect, + }: { + onMemberSelect?: (member: { + user: { id: string; name: string }; + user_id: string; + }) => void; + }) => ( +
    + Mock browse members + +
    + ), +})); + +vi.mock('../ChannelMembersRemoveView', () => ({ + ChannelMembersRemoveView: ({ + onMembersRemoved, + }: { + onMembersRemoved?: (count: number) => void; + }) => ( +
    + +
    + ), +})); + +vi.mock('../../../../../components/Dialog', () => ({ + ContextMenu: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + ContextMenuButton: ({ + children, + Icon, + onClick, + ...props + }: { + children: React.ReactNode; + Icon?: React.ComponentType; + onClick?: () => void; + } & React.ComponentProps<'button'>) => ( + + ), + Prompt: { + Header: ({ + description, + goBack, + title, + TrailingContent, + }: { + description?: string; + goBack?: () => void; + title: string; + TrailingContent?: React.ComponentType; + }) => ( +
    +

    {title}

    + {description &&

    {description}

    } + {goBack && ( + + )} + {TrailingContent && } +
    + ), + }, + useDialog: ({ id }: { id: string }) => ({ + close: vi.fn(), + id, + toggle: vi.fn(), + }), + useDialogIsOpen: () => false, + useDialogOnNearestManager: ({ id }: { id: string }) => ({ + dialog: { + close: vi.fn(), + id, + toggle: vi.fn(), + }, + dialogManager: { id: 'nearest-dialog-manager' }, + }), +})); + +describe('ChannelMembersView', () => { + const close = vi.fn(); + const customHeaderActionSet: ChannelMembersHeaderActionItem[] = [ + { + menu: () => null, + type: 'removeMembers', + }, + { + quick: () => null, + type: 'addMembers', + }, + ]; + const CustomHeaderActions = ({ + controller, + headerActionSet, + }: { + controller: { + mode: 'add' | 'browse' | 'remove' | 'memberDetail'; + setMode: (mode: 'add' | 'browse' | 'remove' | 'memberDetail') => void; + }; + headerActionSet: ChannelMembersHeaderActionItem[]; + }) => { + if (controller.mode !== 'browse') return null; + + const hasManageAction = headerActionSet.some( + (action) => action.type === 'removeMembers', + ); + const hasAddAction = headerActionSet.some((action) => action.type === 'addMembers'); + + return ( +
    + {hasManageAction && ( + + )} + {hasAddAction && ( + + )} +
    + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(useTranslationContext).mockReturnValue({ + t: (key: string, options?: { count?: number }) => + options?.count ? `${key}:${options.count}` : key, + } as ReturnType); + + vi.mocked(useModalContext).mockReturnValue({ + close, + } as ReturnType); + vi.mocked(useComponentContext).mockReturnValue( + {} as ReturnType, + ); + }); + + it('shows only Add button by default when update-channel-members capability is granted', () => { + renderWithChannel(); + + expect( + screen.getByRole('button', { name: 'Add channel members' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Remove channel members' }), + ).not.toBeInTheDocument(); + expect(screen.getByTestId('channel-members-browse-view')).toBeInTheDocument(); + }); + + it('hides Add button without update-channel-members capability', () => { + renderWithChannel(, createChannel({ ownCapabilities: [] })); + + expect( + screen.queryByRole('button', { name: 'Add channel members' }), + ).not.toBeInTheDocument(); + expect(screen.getByTestId('channel-members-browse-view')).toBeInTheDocument(); + }); + + it('switches to add-member search mode from the header action', () => { + renderWithChannel(); + + fireEvent.click(screen.getByRole('button', { name: 'Add channel members' })); + + expect(screen.getByTestId('channel-members-add-view')).toBeInTheDocument(); + expect(screen.queryByTestId('channel-members-browse-view')).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Add members' })).toBeInTheDocument(); + }); + + it('does not render header trailing actions outside browse mode', () => { + const AlwaysRenderingHeaderActions = ({ + controller, + }: { + controller: { + setMode: (mode: 'add') => void; + }; + }) => ( + + ); + + renderWithChannel( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Always visible header action' })); + + expect(screen.getByRole('heading', { name: 'Add members' })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Always visible header action' }), + ).not.toBeInTheDocument(); + }); + + it('returns to member list after members are added', () => { + renderWithChannel( + , + createChannel({ ownCapabilities: ['update-channel-members'] }), + ); + + fireEvent.click(screen.getByRole('button', { name: 'Add channel members' })); + fireEvent.click(screen.getByRole('button', { name: 'Mock add members' })); + + expect(screen.getByTestId('channel-members-browse-view')).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: '{{ count }} members:3' }), + ).toBeInTheDocument(); + }); + + it('renders member detail from ChannelMembersView after browse member selection', () => { + renderWithChannel(); + + fireEvent.click(screen.getByRole('button', { name: 'Mock select member' })); + + expect(screen.getByTestId('channel-member-detail-view')).toHaveTextContent('Alice'); + expect(screen.queryByTestId('channel-members-browse-view')).not.toBeInTheDocument(); + }); + + it('returns to browse mode from member detail', () => { + renderWithChannel(); + + fireEvent.click(screen.getByRole('button', { name: 'Mock select member' })); + fireEvent.click(screen.getByRole('button', { name: 'Mock detail back' })); + + expect(screen.getByTestId('channel-members-browse-view')).toBeInTheDocument(); + }); + + it('switches to manage-members mode via custom HeaderActions', () => { + renderWithChannel( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Remove channel members' })); + + expect(screen.getByTestId('channel-members-remove-view')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Manage members' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Go back' })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Add channel members' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Remove channel members' }), + ).not.toBeInTheDocument(); + }); + + it('returns to browse mode from manage mode via go back', () => { + renderWithChannel( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Remove channel members' })); + fireEvent.click(screen.getByRole('button', { name: 'Go back' })); + + expect(screen.getByTestId('channel-members-browse-view')).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: '{{ count }} members:2' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Remove channel members' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Add channel members' }), + ).toBeInTheDocument(); + }); + + it('stays in manage mode after members are removed', () => { + renderWithChannel( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Remove channel members' })); + fireEvent.click(screen.getByRole('button', { name: 'Mock remove members' })); + + expect(screen.getByTestId('channel-members-remove-view')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Manage members' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Go back' })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Add channel members' }), + ).not.toBeInTheDocument(); + }); + + it('renders menu fallback for a single menu-only header action', () => { + const menuOnlyActionSet: ChannelMembersHeaderActionItem[] = [ + { + menu: () => Menu only action, + type: 'addMembers', + }, + ]; + + renderWithChannel(); + + expect( + screen.getByRole('button', { name: 'Open members actions' }), + ).toBeInTheDocument(); + expect(screen.getByText('Menu only action')).toBeInTheDocument(); + }); + + it('prefers menu rendering when multiple actions provide menu variants', () => { + const mixedActionSet: ChannelMembersHeaderActionItem[] = [ + { + menu: () => Menu add action, + quick: () => Quick add action, + type: 'addMembers', + }, + { + menu: () => Menu manage action, + type: 'removeMembers', + }, + ]; + + renderWithChannel(); + + expect( + screen.getByRole('button', { name: 'Open members actions' }), + ).toBeInTheDocument(); + expect(screen.getByText('Menu add action')).toBeInTheDocument(); + expect(screen.getByText('Menu manage action')).toBeInTheDocument(); + expect(screen.queryByText('Quick add action')).not.toBeInTheDocument(); + }); + + it('uses custom menu trigger component for default header actions', () => { + const menuOnlyActionSet: ChannelMembersHeaderActionItem[] = [ + { + menu: () => Menu only action, + type: 'addMembers', + }, + ]; + + const CustomMenuTrigger = ({ + onClick, + referenceRef, + }: { + onClick: () => void; + referenceRef?: React.Ref; + }) => ( + + ); + + renderWithChannel( + , + ); + + expect( + screen.getByRole('button', { name: 'Custom menu trigger' }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.utils.test.ts b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.utils.test.ts new file mode 100644 index 0000000000..2d1792bf6f --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.utils.test.ts @@ -0,0 +1,99 @@ +import { fromPartial } from '@total-typescript/shoehorn'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; + +import { + canUpdateChannelMembers, + getChannelMemberUserIds, + getMemberDisplayName, + getUserDisplayName, +} from '../ChannelMembersView.utils'; + +describe('ChannelMembersView.utils', () => { + describe('canUpdateChannelMembers', () => { + it('returns true when update-channel-members capability is present', () => { + const channel = fromPartial({ + data: { own_capabilities: ['update-channel-members'] }, + }); + + expect(canUpdateChannelMembers(channel)).toBe(true); + }); + + it('returns false when update-channel-members capability is missing', () => { + const channel = fromPartial({ + data: { own_capabilities: ['read-channel'] }, + }); + + expect(canUpdateChannelMembers(channel)).toBe(false); + }); + + it('returns false when own_capabilities is undefined', () => { + const channel = fromPartial({ + data: {}, + }); + + expect(canUpdateChannelMembers(channel)).toBe(false); + }); + }); + + describe('getUserDisplayName', () => { + it('prefers name over username and id', () => { + expect( + getUserDisplayName({ id: 'user-1', name: 'Alice', username: 'alice_user' }), + ).toBe('Alice'); + }); + + it('falls back to username then id', () => { + expect(getUserDisplayName({ id: 'user-1', username: 'alice_user' })).toBe( + 'alice_user', + ); + expect(getUserDisplayName({ id: 'user-1' })).toBe('user-1'); + }); + }); + + describe('getMemberDisplayName', () => { + it('uses nested user display name', () => { + const member = fromPartial({ + user: { id: 'user-1', name: 'Alice' }, + user_id: 'user-1', + }); + + expect(getMemberDisplayName(member)).toBe('Alice'); + }); + + it('falls back to user_id', () => { + const member = fromPartial({ + user_id: 'user-2', + }); + + expect(getMemberDisplayName(member)).toBe('user-2'); + }); + }); + + describe('getChannelMemberUserIds', () => { + it('collects user ids from channel members', () => { + const channel = fromPartial({ + state: { + members: { + 'user-1': { user: { id: 'user-1' }, user_id: 'user-1' }, + 'user-2': { user_id: 'user-2' }, + }, + }, + }); + + expect(getChannelMemberUserIds(channel)).toEqual(['user-1', 'user-2']); + }); + + it('filters out members without ids', () => { + const channel = fromPartial({ + state: { + members: { + invalid: {}, + 'user-1': { user: { id: 'user-1' }, user_id: 'user-1' }, + }, + }, + }); + + expect(getChannelMemberUserIds(channel)).toEqual(['user-1']); + }); + }); +}); diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/testUtils.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/testUtils.tsx new file mode 100644 index 0000000000..1e52c6760e --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/testUtils.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import type { Channel, UserSearchSource } from 'stream-chat'; +import { fromPartial } from '@total-typescript/shoehorn'; + +import { ChannelDetailProvider } from '../../../ChannelDetailContext'; + +const MEMBER_LIST_ITEM_CLASS = + 'str-chat__channel-detail__channel-members-view__list-item'; + +export const getSelectableMemberButton = (displayName: string) => { + const button = screen + .getAllByRole('button', { name: new RegExp(displayName) }) + .find((element) => element.classList.contains(MEMBER_LIST_ITEM_CLASS)); + + if (!button) { + throw new Error(`Selectable member button not found for "${displayName}"`); + } + + return button; +}; + +export const querySelectableMemberButton = (displayName: string) => + screen + .queryAllByRole('button', { name: new RegExp(displayName) }) + .find((element) => element.classList.contains(MEMBER_LIST_ITEM_CLASS)) ?? null; + +export const createChannel = ( + overrides: { + members?: Channel['state']['members']; + ownCapabilities?: string[]; + } = {}, +) => + fromPartial({ + addMembers: vi.fn().mockResolvedValue({}), + data: { + member_count: 2, + own_capabilities: overrides.ownCapabilities ?? ['update-channel-members'], + }, + removeMembers: vi.fn().mockResolvedValue({}), + state: { + members: overrides.members ?? { + 'user-1': { + user: { id: 'user-1', name: 'Alice' }, + user_id: 'user-1', + }, + }, + }, + }); + +export const renderWithChannel = ( + ui: React.ReactElement, + channel: Channel = createChannel(), +) => render({ui}); + +export const createUserSearchSource = () => { + const search = vi.fn(); + const activate = vi.fn(); + const cancelScheduledQuery = vi.fn(); + + return { + activate, + cancelScheduledQuery, + search, + searchSource: fromPartial({ + activate, + cancelScheduledQuery, + search, + state: {}, + }), + }; +}; diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/index.ts b/src/plugins/ChannelDetail/Views/ChannelMembersView/index.ts new file mode 100644 index 0000000000..ad7c63fe47 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/index.ts @@ -0,0 +1,2 @@ +export * from './ChannelMembersHeaderActions.defaults'; +export * from './ChannelMembersView'; diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts b/src/plugins/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts new file mode 100644 index 0000000000..5215f6fb59 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts @@ -0,0 +1,82 @@ +import { + type ChannelMemberResponse, + ChannelMemberSearchSource, + type SearchSourceState, +} from 'stream-chat'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useStateStore } from '../../../../store'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { CHANNEL_MEMBERS_QUERY_LIMIT } from './ChannelMembersView.utils'; + +const MEMBERS_SEARCH_DEBOUNCE_MS = 300; + +const membersSearchSourceItemsStateSelector = ( + state: SearchSourceState, +) => ({ + members: state.items, +}); + +export const useChannelMembersSearch = () => { + const { channel } = useChannelDetailContext(); + const fallbackMembers = useMemo( + () => Object.values(channel.state?.members ?? {}), + [channel], + ); + // Skip activating/searching only when the server explicitly reports zero + // members. `member_count` being undefined is treated as "has members" so we + // never suppress loading on incomplete channel data. + const hasMembers = channel.data?.member_count !== 0; + const membersSearchSource = useMemo(() => { + const source = new ChannelMemberSearchSource(channel, { + allowEmptySearchString: true, + debounceMs: MEMBERS_SEARCH_DEBOUNCE_MS, + pageSize: CHANNEL_MEMBERS_QUERY_LIMIT, + resetOnNewSearchQuery: false, + }); + + if (hasMembers) source.activate(); + return source; + }, [channel, hasMembers]); + const { members } = useStateStore( + membersSearchSource.state, + membersSearchSourceItemsStateSelector, + ); + const [searchInputResetKey, setSearchInputResetKey] = useState(0); + + const resetMembersSearch = useCallback(() => { + membersSearchSource.cancelScheduledQuery(); + setSearchInputResetKey((currentResetKey) => currentResetKey + 1); + membersSearchSource.resetState(); + membersSearchSource.activate(); + void membersSearchSource.search(''); + }, [membersSearchSource]); + + const handleSearchChange = useCallback( + (query: string) => { + membersSearchSource.search(query.trim()); + }, + [membersSearchSource], + ); + + useEffect(() => { + if (!hasMembers) return; + void membersSearchSource.search(''); + }, [hasMembers, membersSearchSource]); + + useEffect( + () => () => { + membersSearchSource.cancelScheduledQuery(); + }, + [membersSearchSource], + ); + + return { + displayedMembers: members ?? fallbackMembers, + handleSearchChange, + hasMembers, + membersSearchSource, + resetMembersSearch, + searchInputResetKey, + }; +}; diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx new file mode 100644 index 0000000000..210c9610a2 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx @@ -0,0 +1,20 @@ +import { IconPin } from '../../../../components/Icons'; +import { useTranslationContext } from '../../../../context'; + +export const PinnedMessagesEmptyList = () => { + const { t } = useTranslationContext(); + + return ( +
    + +
    +

    + {t('No pinned messages')} +

    +

    + {t('Pin a message to see it here')} +

    +
    +
    + ); +}; diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx new file mode 100644 index 0000000000..310b197b3d --- /dev/null +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx @@ -0,0 +1,150 @@ +import type { LocalMessage, MessageResponse } from 'stream-chat'; +import React, { useMemo } from 'react'; + +import { + useChannelActionContext, + useChatContext, + useModalContext, + useTranslationContext, +} from '../../../../context'; +import { getDateString, isDate } from '../../../../i18n/utils'; +import { Avatar } from '../../../../components/Avatar'; +import { InfiniteScrollPaginator } from '../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator'; +import { ListItemLayout } from '../../../../components/ListItemLayout'; +import { Prompt } from '../../../../components/Dialog'; +import { + SectionNavigatorHeader, + type SectionNavigatorSectionContentProps, +} from '../../../../components/SectionNavigator'; +import { ChannelDetailSearchInput } from '../../ChannelDetailSearchInput'; +import { getUserDisplayName } from '../ChannelMembersView/ChannelMembersView.utils'; +import { ChannelDetailListLoadingIndicator } from '../../ChannelDetailListLoadingIndicator'; +import { PinnedMessagesEmptyList } from './PinnedMessagesEmptyList'; +import { usePinnedMessagesSearch } from './usePinnedMessagesSearch'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { ChannelDetailEmptyList } from '../../ChannelDetailEmptyList'; + +type PinnedMessage = MessageResponse | LocalMessage; + +const normalizeTimestamp = (timestamp: PinnedMessage['created_at']) => { + if (!timestamp) return undefined; + return isDate(timestamp) ? timestamp.toISOString() : timestamp; +}; + +const getPinnedMessagePreview = ( + message: PinnedMessage, + t: ReturnType['t'], +) => { + const text = message.text?.trim(); + if (text) return text; + + const attachment = message.attachments?.[0]; + const attachmentPreview = + attachment?.title || attachment?.text || attachment?.fallback || attachment?.type; + + return attachmentPreview || t('Pinned message'); +}; + +const PinnedMessageDate = ({ message }: { message: PinnedMessage }) => { + const { t, tDateTimeParser } = useTranslationContext('PinnedMessageDate'); + const normalizedTimestamp = normalizeTimestamp(message.created_at); + + const when = useMemo( + () => + getDateString({ + messageCreatedAt: normalizedTimestamp, + t, + tDateTimeParser, + timestampTranslationKey: 'timestamp/ChannelDetailPinnedMessageTimestamp', + }), + [normalizedTimestamp, t, tDateTimeParser], + ); + + if (!when) return null; + + return ( + + ); +}; + +export type PinnedMessagesViewProps = SectionNavigatorSectionContentProps; + +export const PinnedMessagesView: React.ComponentType = () => { + const { setActiveChannel } = useChatContext(); + const { t } = useTranslationContext(); + const { close } = useModalContext(); + // fixme: it is not right to couple the ChannelDetail view with Channel component. We need to have access to channel.messagePaginator.jumpToMessage() + const { jumpToMessage } = useChannelActionContext(); + const { channel } = useChannelDetailContext(); + const { + displayedMessages, + handleSearchChange, + hasPinnedMessages, + hasSearchResultsLoaded, + pinnedMessagesSearchSource, + } = usePinnedMessagesSearch(); + + return ( +
    + + + {hasPinnedMessages && ( + + )} + + {displayedMessages.length > 0 ? ( + displayedMessages.map((message) => { + const displayName = getUserDisplayName(message.user ?? undefined); + + return ( + ( + + )} + RootElement='button' + rootProps={{ + className: + 'str-chat__channel-detail__pinned-messages-view__list-item', + onClick: () => { + setActiveChannel(channel); + jumpToMessage(message.id); + close(); + }, + }} + subtitle={getPinnedMessagePreview(message, t)} + subtitleClassName='str-chat__channel-detail__pinned-messages-view__list-item__message-preview' + title={displayName} + TrailingSlot={() => } + /> + ); + }) + ) : hasSearchResultsLoaded ? ( + {t('No messages found')} + ) : ( + + )} + + + +
    + ); +}; diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx new file mode 100644 index 0000000000..61401d02d8 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx @@ -0,0 +1,268 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import type { Channel, LocalMessage } from 'stream-chat'; +import { fromPartial } from '@total-typescript/shoehorn'; + +import { + useChannelActionContext, + useChatContext, + useModalContext, + useTranslationContext, +} from '../../../../../context'; +import { useStateStore } from '../../../../../store'; +import { ChannelDetailProvider } from '../../../ChannelDetailContext'; +import { PinnedMessagesView } from '../PinnedMessagesView'; + +const mocks = vi.hoisted(() => ({ + infiniteScrollPaginatorRenderCount: 0, + searchSourceActivate: vi.fn(), + searchSourceCancelScheduledQuery: vi.fn(), + searchSourceFilterBuilderOptions: [] as Array<{ + messageSearch?: { + initialFilterConfig?: { + text?: { + generate: (context: { searchQuery?: string }) => unknown; + }; + }; + }; + }>, + searchSourceInstances: [] as Array<{ + messageSearchChannelFilters?: unknown; + messageSearchFilters?: unknown; + }>, + searchSourceOptions: [] as unknown[], + searchSourceResetState: vi.fn(), + searchSourceSearch: vi.fn(), +})); + +vi.mock('stream-chat', async (importOriginal) => { + const actual = await importOriginal(); + + class MessageSearchSource { + messageSearchChannelFilters?: unknown; + messageSearchFilters?: unknown; + state = {}; + + constructor(_client: unknown, options: unknown, filterBuilderOptions: unknown) { + mocks.searchSourceOptions.push(options); + mocks.searchSourceFilterBuilderOptions.push( + filterBuilderOptions as (typeof mocks.searchSourceFilterBuilderOptions)[number], + ); + mocks.searchSourceInstances.push(this); + } + + activate = mocks.searchSourceActivate; + + search = mocks.searchSourceSearch; + + resetState = mocks.searchSourceResetState; + + cancelScheduledQuery = mocks.searchSourceCancelScheduledQuery; + } + + return { + ...actual, + MessageSearchSource, + }; +}); + +vi.mock('../../../../../context'); +vi.mock('../../../../../store'); + +vi.mock( + '../../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator', + () => ({ + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => { + mocks.infiniteScrollPaginatorRenderCount += 1; + return
    {children}
    ; + }, + }), +); + +vi.mock('../../../../../components/Dialog', () => ({ + Prompt: { + Body: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + Header: ({ + description, + title, + }: { + description?: React.ReactNode; + title?: React.ReactNode; + }) => ( +
    +

    {title}

    + {description} +
    + ), + }, +})); + +const pinnedMessages: LocalMessage[] = [ + fromPartial({ + cid: 'messaging:test-channel', + created_at: new Date('2026-01-01T15:53:00.000Z'), + id: 'message-1', + pinned: true, + text: 'Release timeline: Code freeze March 18', + type: 'regular', + updated_at: new Date('2026-01-01T15:53:00.000Z'), + user: { id: 'user-1', name: 'Alice' }, + }), + fromPartial({ + attachments: [{ title: 'Roadmap.pdf', type: 'file' }], + cid: 'messaging:test-channel', + created_at: new Date('2026-01-02T15:53:00.000Z'), + id: 'message-2', + pinned: true, + type: 'regular', + updated_at: new Date('2026-01-02T15:53:00.000Z'), + user: { id: 'user-2', name: 'Bob' }, + }), +]; + +const createChannel = ( + overrides: { + pinnedMessages?: Channel['state']['pinnedMessages']; + } = {}, +) => + fromPartial({ + cid: 'messaging:test-channel', + state: { + pinnedMessages: overrides.pinnedMessages ?? pinnedMessages, + }, + }); + +const renderWithChannel = (ui: React.ReactElement, channel: Channel = createChannel()) => + render({ui}); + +describe('PinnedMessagesView', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.infiniteScrollPaginatorRenderCount = 0; + mocks.searchSourceFilterBuilderOptions.length = 0; + mocks.searchSourceInstances.length = 0; + mocks.searchSourceOptions.length = 0; + + vi.mocked(useTranslationContext).mockReturnValue({ + t: (key: string, options?: { timestamp?: Date }) => { + if (key === 'timestamp/ChannelDetailPinnedMessageTimestamp') { + return options?.timestamp?.toISOString() ?? key; + } + return key; + }, + tDateTimeParser: (input?: string | Date) => new Date(input ?? Date.now()), + } as ReturnType); + + vi.mocked(useChatContext).mockReturnValue({ + client: { userID: 'user-1' }, + } as ReturnType); + + vi.mocked(useModalContext).mockReturnValue({ + close: vi.fn(), + } as ReturnType); + + vi.mocked(useChannelActionContext).mockReturnValue({ + jumpToMessage: vi.fn(), + } as unknown as ReturnType); + + vi.mocked(useStateStore).mockReturnValue({ + hasNextPage: false, + isLoading: false, + messages: undefined, + }); + }); + + it('renders pinned messages from channel state before a search is loaded', () => { + renderWithChannel(); + + expect(screen.getByRole('heading', { name: 'Pinned messages' })).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect( + screen.getByText('Release timeline: Code freeze March 18'), + ).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('Roadmap.pdf')).toBeInTheDocument(); + expect(screen.getByText('2026-01-01T15:53:00.000Z')).toBeInTheDocument(); + }); + + it('configures MessageSearchSource for pinned messages in the current channel', () => { + renderWithChannel(); + + expect(mocks.searchSourceOptions[0]).toMatchObject({ + debounceMs: 300, + pageSize: 30, + resetOnNewSearchQuery: false, + }); + expect(mocks.searchSourceInstances[0]).toMatchObject({ + messageSearchChannelFilters: { cid: 'messaging:test-channel' }, + messageSearchFilters: { pinned: true }, + }); + expect( + mocks.searchSourceFilterBuilderOptions[0].messageSearch?.initialFilterConfig?.text?.generate( + { searchQuery: 'alice' }, + ), + ).toEqual({ + text: { $autocomplete: 'alice' }, + }); + }); + + it('searches pinned messages with the trimmed query', () => { + renderWithChannel(); + + fireEvent.change(screen.getByRole('searchbox', { name: 'Search' }), { + target: { value: ' release ' }, + }); + + expect(mocks.searchSourceSearch).toHaveBeenCalledWith('release'); + }); + + it('resets to channel pinned messages without issuing an empty message search', () => { + renderWithChannel(); + + fireEvent.change(screen.getByRole('searchbox', { name: 'Search' }), { + target: { value: 'release' }, + }); + mocks.searchSourceSearch.mockClear(); + + fireEvent.change(screen.getByRole('searchbox', { name: 'Search' }), { + target: { value: ' ' }, + }); + + expect(mocks.searchSourceCancelScheduledQuery).toHaveBeenCalled(); + expect(mocks.searchSourceResetState).toHaveBeenCalled(); + expect(mocks.searchSourceActivate).toHaveBeenCalled(); + expect(mocks.searchSourceSearch).not.toHaveBeenCalled(); + }); + + it('does not re-render pinned message results while typing before source state changes', () => { + renderWithChannel(); + + const renderCount = mocks.infiniteScrollPaginatorRenderCount; + fireEvent.change(screen.getByRole('searchbox', { name: 'Search' }), { + target: { value: 'release' }, + }); + + expect(mocks.infiniteScrollPaginatorRenderCount).toBe(renderCount); + }); + + it('renders an empty state when there are no pinned messages', () => { + renderWithChannel( + , + createChannel({ pinnedMessages: [] }), + ); + + expect(screen.getByText('No pinned messages')).toBeInTheDocument(); + expect(screen.getByText('Pin a message to see it here')).toBeInTheDocument(); + }); + + it('does not activate or search when there are no pinned messages', () => { + renderWithChannel( + , + createChannel({ pinnedMessages: [] }), + ); + + expect(screen.queryByRole('searchbox', { name: 'Search' })).not.toBeInTheDocument(); + expect(mocks.searchSourceActivate).not.toHaveBeenCalled(); + expect(mocks.searchSourceSearch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/index.ts b/src/plugins/ChannelDetail/Views/PinnedMessagesView/index.ts new file mode 100644 index 0000000000..af7960fe68 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/index.ts @@ -0,0 +1 @@ +export * from './PinnedMessagesView'; diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts b/src/plugins/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts new file mode 100644 index 0000000000..7d0b873af9 --- /dev/null +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts @@ -0,0 +1,105 @@ +import { + type LocalMessage, + type MessageResponse, + MessageSearchSource, + type SearchSourceState, +} from 'stream-chat'; +import { useCallback, useEffect, useMemo } from 'react'; + +import { useChatContext } from '../../../../context'; +import { useStateStore } from '../../../../store'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; + +const PINNED_MESSAGES_SEARCH_PAGE_SIZE = 30; +const PINNED_MESSAGES_SEARCH_DEBOUNCE_MS = 300; + +const pinnedMessagesSearchSourceItemsStateSelector = ( + state: SearchSourceState, +) => ({ + messages: state.items, +}); + +export const usePinnedMessagesSearch = () => { + const { client } = useChatContext(); + const { channel } = useChannelDetailContext(); + const fallbackPinnedMessages = useMemo( + // sort descending by creation date + () => + channel.state?.pinnedMessages?.sort( + (a, b) => b.created_at.getTime() - a.created_at.getTime(), + ) ?? [], + [channel], + ); + // When the channel has no pinned messages, there is nothing to search for - + // skip activating/searching the source entirely. + const hasPinnedMessages = fallbackPinnedMessages.length > 0; + const pinnedMessagesSearchSource = useMemo(() => { + const source = new MessageSearchSource( + client, + { + allowEmptySearchString: true, + debounceMs: PINNED_MESSAGES_SEARCH_DEBOUNCE_MS, + pageSize: PINNED_MESSAGES_SEARCH_PAGE_SIZE, + resetOnNewSearchQuery: false, + }, + { + messageSearch: { + initialFilterConfig: { + text: { + enabled: true, + generate: ({ searchQuery }) => + searchQuery + ? { + text: { $autocomplete: searchQuery }, + } + : null, + }, + }, + }, + }, + ); + + source.messageSearchChannelFilters = { cid: channel.cid }; + source.messageSearchFilters = { pinned: true }; + if (hasPinnedMessages) source.activate(); + + return source; + }, [channel.cid, client, hasPinnedMessages]); + const { messages } = useStateStore( + pinnedMessagesSearchSource.state, + pinnedMessagesSearchSourceItemsStateSelector, + ); + + const handleSearchChange = useCallback( + (query: string) => { + const trimmedQuery = query.trim(); + + if (!trimmedQuery) { + pinnedMessagesSearchSource.cancelScheduledQuery(); + pinnedMessagesSearchSource.resetState(); + pinnedMessagesSearchSource.activate(); + return; + } + + pinnedMessagesSearchSource.search(trimmedQuery); + }, + [pinnedMessagesSearchSource], + ); + + useEffect( + () => () => { + pinnedMessagesSearchSource.cancelScheduledQuery(); + }, + [pinnedMessagesSearchSource], + ); + + return { + displayedMessages: (messages ?? fallbackPinnedMessages) as Array< + MessageResponse | LocalMessage + >, + handleSearchChange, + hasPinnedMessages, + hasSearchResultsLoaded: Array.isArray(messages), + pinnedMessagesSearchSource, + }; +}; diff --git a/src/plugins/ChannelDetail/__tests__/ChannelDetail.test.tsx b/src/plugins/ChannelDetail/__tests__/ChannelDetail.test.tsx new file mode 100644 index 0000000000..1f20404e13 --- /dev/null +++ b/src/plugins/ChannelDetail/__tests__/ChannelDetail.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, vi } from 'vitest'; +import type { Channel } from 'stream-chat'; + +import { ChannelDetail } from '../ChannelDetail'; +import type { SectionNavigatorSection } from '../../../components/SectionNavigator'; + +const sections: SectionNavigatorSection[] = [ + { + id: 'channel-info', + NavButton: () => , + SectionContent: () =>
    Channel info
    , + }, +]; + +const channel = { + cid: 'messaging:test-channel', +} as Channel; + +describe('ChannelDetail', () => { + const OriginalResizeObserver = globalThis.ResizeObserver; + + beforeEach(() => { + globalThis.ResizeObserver = class MockResizeObserver implements ResizeObserver { + disconnect = vi.fn(); + observe = vi.fn(); + unobserve = vi.fn(); + }; + }); + + afterEach(() => { + globalThis.ResizeObserver = OriginalResizeObserver; + }); + + it('applies the channel-detail width class to the prompt wrapper', () => { + const { container } = render( + , + ); + + const prompt = container.querySelector('.str-chat__prompt'); + const sectionNavigator = container.querySelector('.str-chat__section-navigator'); + + expect(prompt).toHaveClass('str-chat__channel-detail'); + expect(prompt).toHaveClass('custom-channel-detail'); + expect(sectionNavigator).not.toHaveClass('str-chat__channel-detail'); + expect(screen.getByText('Channel info')).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx b/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx new file mode 100644 index 0000000000..ae20277ba3 --- /dev/null +++ b/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx @@ -0,0 +1,464 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, vi } from 'vitest'; +import type { Channel } from 'stream-chat'; + +import { ChannelDetailProvider } from '../ChannelDetailContext'; +import { + type ChannelManagementActionItem, + DefaultChannelManagementActions, + defaultChannelManagementActionSet, + useBaseChannelManagementActionSetFilter, +} from '../Views/ChannelManagementView/ChannelManagementActions.defaults'; + +const mocks = vi.hoisted(() => { + const addNotification = vi.fn(); + const blockUser = vi.fn(); + const close = vi.fn(); + const deleteChannel = vi.fn(); + const mute = vi.fn(); + const muteUser = vi.fn(); + const removeMembers = vi.fn(); + const t = vi.fn((key: string) => key); + const unBlockUser = vi.fn(); + const unmute = vi.fn(); + const unmuteUser = vi.fn(); + const blockedUsers = (() => { + let currentValue = { userIds: [] as string[] }; + const listeners = new Set<() => void>(); + + return { + getLatestValue: () => currentValue, + next: (nextValue: { userIds: string[] }) => { + currentValue = nextValue; + listeners.forEach((listener) => listener()); + }, + subscribeWithSelector: ( + _selector: (value: { userIds: string[] }) => Readonly>, + listener: () => void, + ) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + }; + })(); + + const channel = { + data: { + member_count: 2, + members: [{ user: { id: 'own-user' } }, { user: { id: 'other-user' } }], + own_capabilities: ['ban-channel-members', 'leave-channel', 'mute-channel'], + }, + delete: deleteChannel, + mute, + removeMembers, + state: { + members: { + 'other-user': { user: { id: 'other-user' } }, + 'own-user': { user: { id: 'own-user' } }, + }, + }, + unmute, + }; + + const client = { + blockedUsers, + blockUser, + muteUser, + unBlockUser, + unmuteUser, + user: { id: 'own-user' }, + userID: 'own-user', + }; + + return { + addNotification, + blockUser, + channel, + channelMuted: false, + client, + close, + deleteChannel, + mute, + mutes: [] as Array<{ target: { id: string } }>, + muteUser, + removeMembers, + t, + unBlockUser, + unmute, + unmuteUser, + useStableTranslationFunction: true, + }; +}); + +vi.mock('../../../context', () => ({ + useChatContext: () => ({ + client: mocks.client, + mutes: mocks.mutes, + }), + useComponentContext: () => ({ + Modal: ({ + children, + open, + role, + }: { + children: React.ReactNode; + open: boolean; + role?: string; + }) => (open ?
    {children}
    : null), + }), + useModalContext: () => ({ close: mocks.close }), + useTranslationContext: () => ({ + t: mocks.useStableTranslationFunction ? mocks.t : (key: string) => mocks.t(key), + }), +})); + +vi.mock('../../../components/Notifications', () => ({ + useNotificationApi: () => ({ + addNotification: mocks.addNotification, + }), +})); + +vi.mock('../../../components/ChannelListItem/hooks/useIsChannelMuted', () => ({ + useIsChannelMuted: () => ({ muted: mocks.channelMuted }), +})); + +const advanceDebounce = async () => { + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); +}; + +const renderAction = (action: React.ReactElement) => + render( + + {action} + , + ); + +const PermissionProbe = ({ + actionSet = defaultChannelManagementActionSet, +}: { + actionSet?: ChannelManagementActionItem[]; +}) => { + const filteredActions = useBaseChannelManagementActionSetFilter(actionSet); + + return ( + <> + {filteredActions.map((action) => ( + + {action.type} + + ))} + + ); +}; + +const renderPermissionProbe = () => + render( + + + , + ); + +const getRenderedActionTypes = () => + screen + .queryAllByTestId('channel-management-action-type') + .map((actionType) => actionType.textContent); + +describe('DefaultChannelManagementActions', () => { + beforeEach(() => { + vi.useFakeTimers(); + mocks.addNotification.mockReset(); + mocks.blockUser.mockReset(); + mocks.close.mockReset(); + mocks.deleteChannel.mockReset(); + mocks.mute.mockReset(); + mocks.muteUser.mockReset(); + mocks.removeMembers.mockReset(); + mocks.t.mockClear(); + mocks.unBlockUser.mockReset(); + mocks.unmute.mockReset(); + mocks.unmuteUser.mockReset(); + mocks.useStableTranslationFunction = true; + mocks.channelMuted = false; + mocks.channel.data.member_count = 2; + mocks.channel.data.members = [ + { user: { id: 'own-user' } }, + { user: { id: 'other-user' } }, + ]; + mocks.channel.data.own_capabilities = [ + 'ban-channel-members', + 'leave-channel', + 'mute-channel', + ]; + mocks.channel.state.members = { + 'other-user': { user: { id: 'other-user' } }, + 'own-user': { user: { id: 'own-user' } }, + }; + mocks.client.blockedUsers.next({ userIds: [] }); + mocks.mutes = []; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('optimistically toggles channel mute and rolls back when the request fails', async () => { + mocks.mute.mockRejectedValueOnce(new Error('mute failed')); + + renderAction(); + + const muteButton = screen.getByRole('button', { name: 'Mute chat' }); + + expect(muteButton).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(muteButton); + + expect(screen.getByRole('button', { name: 'Unmute chat' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + expect(mocks.mute).not.toHaveBeenCalled(); + + await advanceDebounce(); + + expect(mocks.mute).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', { name: 'Mute chat' })).toHaveAttribute( + 'aria-pressed', + 'false', + ); + expect(mocks.addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error muting channel', + severity: 'error', + type: 'api:channel:mute:failed', + }), + ); + }); + + it('optimistically toggles user mute and rolls back when the request fails', async () => { + mocks.muteUser.mockRejectedValueOnce(new Error('mute failed')); + + renderAction(); + + const muteButton = screen.getByRole('button', { name: 'Mute user' }); + + expect(muteButton).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(muteButton); + + expect(screen.getByRole('button', { name: 'Unmute user' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + expect(mocks.muteUser).not.toHaveBeenCalled(); + + await advanceDebounce(); + + expect(mocks.muteUser).toHaveBeenCalledWith('other-user'); + expect(screen.getByRole('button', { name: 'Mute user' })).toHaveAttribute( + 'aria-pressed', + 'false', + ); + expect(mocks.addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error muting user', + severity: 'error', + type: 'api:user:mute:failed', + }), + ); + }); + + it('keeps the pending user mute request after the optimistic rerender', async () => { + mocks.muteUser.mockResolvedValueOnce(undefined); + mocks.useStableTranslationFunction = false; + const channelData = mocks.channel.data as { + members?: typeof mocks.channel.data.members; + }; + delete channelData.members; + + renderAction(); + + fireEvent.click(screen.getByRole('button', { name: 'Mute user' })); + + expect(screen.getByRole('button', { name: 'Unmute user' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + expect(mocks.muteUser).not.toHaveBeenCalled(); + + await advanceDebounce(); + + expect(mocks.muteUser).toHaveBeenCalledWith('other-user'); + expect(mocks.addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User muted', + severity: 'success', + type: 'api:user:mute:success', + }), + ); + }); + + it('opens a block user alert and runs the API from the confirm button', async () => { + mocks.blockUser.mockResolvedValueOnce(undefined); + + renderAction(); + + fireEvent.click(screen.getByRole('button', { name: 'Block user' })); + + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Block User' })).toBeInTheDocument(); + expect(mocks.blockUser).not.toHaveBeenCalled(); + + await act(async () => { + fireEvent.click( + screen.getByTestId('channel-detail-block-user-alert-confirm-button'), + ); + await Promise.resolve(); + }); + + expect(mocks.blockUser).toHaveBeenCalledWith('other-user'); + expect(mocks.addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User blocked', + severity: 'success', + type: 'api:user:block:success', + }), + ); + }); + + it('opens an unblock user alert and runs the API from the confirm button', async () => { + mocks.client.blockedUsers.next({ userIds: ['other-user'] }); + mocks.unBlockUser.mockResolvedValueOnce(undefined); + + renderAction(); + + fireEvent.click(screen.getByRole('button', { name: 'Unblock' })); + + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Unblock' })).toBeInTheDocument(); + expect(mocks.unBlockUser).not.toHaveBeenCalled(); + + await act(async () => { + fireEvent.click( + screen.getByTestId('channel-detail-block-user-alert-confirm-button'), + ); + await Promise.resolve(); + }); + + expect(mocks.unBlockUser).toHaveBeenCalledWith('other-user'); + expect(mocks.addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User unblocked', + severity: 'success', + type: 'api:user:unblock:success', + }), + ); + }); + + it('opens a leave channel alert and runs the API from the confirm button', async () => { + mocks.removeMembers.mockResolvedValueOnce(undefined); + + renderAction(); + + fireEvent.click(screen.getByRole('button', { name: 'Leave chat' })); + + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Leave chat' })).toBeInTheDocument(); + expect(mocks.removeMembers).not.toHaveBeenCalled(); + + await act(async () => { + fireEvent.click( + screen.getByTestId('channel-detail-leave-channel-alert-confirm-button'), + ); + await Promise.resolve(); + }); + + expect(mocks.removeMembers).toHaveBeenCalledWith(['own-user']); + expect(mocks.close).toHaveBeenCalledTimes(1); + expect(mocks.addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Left channel', + severity: 'success', + type: 'api:channel:leave:success', + }), + ); + }); + + it('opens a delete chat alert and runs the API from the confirm button', async () => { + mocks.deleteChannel.mockResolvedValueOnce(undefined); + + renderAction(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete chat' })); + + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Delete chat' })).toBeInTheDocument(); + expect(mocks.deleteChannel).not.toHaveBeenCalled(); + + await act(async () => { + fireEvent.click( + screen.getByTestId('channel-detail-delete-chat-alert-confirm-button'), + ); + await Promise.resolve(); + }); + + expect(mocks.deleteChannel).toHaveBeenCalledTimes(1); + expect(mocks.close).toHaveBeenCalledTimes(1); + expect(mocks.addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Chat deleted', + severity: 'success', + type: 'api:channel:delete:success', + }), + ); + }); + + it('filters DM actions by channel capabilities', () => { + mocks.channel.data.own_capabilities = ['ban-channel-members', 'mute-channel']; + + renderPermissionProbe(); + + expect(getRenderedActionTypes()).toEqual([ + 'muteChannel', + 'muteUser', + 'blockUser', + 'deleteChat', + ]); + }); + + it('filters group actions by channel capabilities', () => { + mocks.channel.data.member_count = 3; + mocks.channel.data.members = [ + { user: { id: 'own-user' } }, + { user: { id: 'other-user' } }, + { user: { id: 'third-user' } }, + ]; + mocks.channel.state.members = { + 'other-user': { user: { id: 'other-user' } }, + 'own-user': { user: { id: 'own-user' } }, + 'third-user': { user: { id: 'third-user' } }, + }; + + renderPermissionProbe(); + + expect(getRenderedActionTypes()).toEqual([ + 'muteChannel', + 'leaveChannel', + 'deleteChat', + ]); + }); + + it('hides capability-gated group actions when capabilities are missing', () => { + mocks.channel.data.member_count = 3; + mocks.channel.data.own_capabilities = []; + + renderPermissionProbe(); + + expect(getRenderedActionTypes()).toEqual(['deleteChat']); + }); +}); diff --git a/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx b/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx new file mode 100644 index 0000000000..3d3b1c75b3 --- /dev/null +++ b/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx @@ -0,0 +1,390 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { Channel, Mute } from 'stream-chat'; + +import { ChannelDetailProvider } from '../ChannelDetailContext'; +import { ChannelManagementView } from '../Views/ChannelManagementView/ChannelManagementView'; + +const mocks = vi.hoisted(() => ({ + addNotification: vi.fn(), + channel: { + data: { + member_count: 2, + name: 'Test channel', + own_capabilities: ['update-channel'], + }, + sendImage: vi.fn(), + state: { + members: { + 'other-user': { user: { id: 'other-user' } }, + 'own-user': { user: { id: 'own-user' } }, + }, + membership: {}, + }, + updatePartial: vi.fn(), + }, + close: vi.fn(), + displayImage: undefined as string | undefined, + mutes: [] as Mute[], +})); + +vi.mock('../../../context', () => ({ + useChatContext: () => ({ + client: { + user: { id: 'own-user' }, + }, + mutes: mocks.mutes, + }), + useComponentContext: () => ({ + Avatar: () =>
    , + }), + useModalContext: () => ({ close: mocks.close }), + useTranslationContext: () => ({ t: (key: string) => key }), +})); + +vi.mock('../../../context/ChatContext', () => ({ + useChatContext: () => ({ + client: { + user: { id: 'own-user' }, + }, + mutes: mocks.mutes, + }), +})); + +vi.mock('../../../components/ChannelList', () => ({ + useChannelMembershipState: () => mocks.channel.state.membership, +})); + +vi.mock('../../../components/ChannelListItem', async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + useChannelPreviewInfo: () => ({ + displayImage: mocks.displayImage, + displayTitle: 'Other user', + groupChannelDisplayInfo: { members: [] }, + }), + }; +}); + +vi.mock('../../../components/ChannelListItem/hooks/useIsChannelMuted', () => ({ + useIsChannelMuted: () => ({ muted: false }), +})); + +vi.mock('../../../components/ChannelHeader/hooks/useChannelHasMembersOnline', () => ({ + useChannelHasMembersOnline: () => false, +})); + +vi.mock('../../../components/ChannelHeader/hooks/useChannelHeaderOnlineStatus', () => ({ + useChannelHeaderOnlineStatus: () => undefined, +})); + +vi.mock('../../../components/Dialog', () => ({ + Prompt: { + Body: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) =>
    {children}
    , + Footer: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) =>
    {children}
    , + FooterControls: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) =>
    {children}
    , + FooterControlsButtonPrimary: ( + props: React.ButtonHTMLAttributes, + ) => + )} + {TrailingContent && } + + ), + }, +})); + +vi.mock('../../../components/Icons', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + IconMute: () => , + IconPin: () => , + }; +}); + +vi.mock('../../../components/Notifications/hooks/useNotificationApi', () => ({ + useNotificationApi: () => ({ addNotification: mocks.addNotification }), +})); + +const renderChannelManagementView = ( + props: Partial> = {}, +) => + render( + + + , + ); + +describe('ChannelManagementView', () => { + beforeEach(() => { + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:preview'), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + mocks.addNotification.mockReset(); + mocks.channel.sendImage.mockReset(); + mocks.channel.updatePartial.mockReset(); + mocks.channel.sendImage.mockResolvedValue({ file: 'https://stream-upload.example' }); + mocks.channel.updatePartial.mockResolvedValue({}); + mocks.channel.data.name = 'Test channel'; + mocks.channel.data.member_count = 2; + mocks.displayImage = undefined; + mocks.mutes = []; + }); + + it('reacts to muted DM user state from ChatContext mutes', () => { + const { rerender } = renderChannelManagementView(); + + expect(screen.queryByTestId('channel-management-muted-icon')).not.toBeInTheDocument(); + + mocks.mutes = [ + { + target: { id: 'other-user' }, + } as Mute, + ]; + + rerender( + + + , + ); + + expect(screen.getByTestId('channel-management-muted-icon')).toBeInTheDocument(); + }); + + it('renders custom view and edit mode components', () => { + const ViewModeComponent = vi.fn(() =>
    ); + const EditModeComponent = vi.fn(() =>
    ); + + renderChannelManagementView({ EditModeComponent, ViewModeComponent }); + + expect(screen.getByTestId('custom-view-mode')).toBeInTheDocument(); + expect(ViewModeComponent).toHaveBeenCalledWith({ actions: [] }, undefined); + + fireEvent.click(screen.getByRole('button', { name: 'Edit chat data' })); + + expect(screen.getByTestId('custom-edit-mode')).toBeInTheDocument(); + expect(EditModeComponent).toHaveBeenCalledWith({ uploadImage: undefined }, undefined); + }); + + it('uses custom image upload callback when saving a new image', async () => { + const uploadImage = vi.fn().mockResolvedValue('https://custom-upload.example'); + const { container } = renderChannelManagementView({ uploadImage }); + + fireEvent.click(screen.getByRole('button', { name: 'Edit chat data' })); + fireEvent.change(container.querySelector('input[type="file"]')!, { + target: { + files: [new File(['avatar'], 'avatar.png', { type: 'image/png' })], + }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(uploadImage).toHaveBeenCalledTimes(1)); + expect(mocks.channel.sendImage).not.toHaveBeenCalled(); + expect(mocks.channel.updatePartial).toHaveBeenCalledWith({ + set: { image: 'https://custom-upload.example' }, + }); + }); + + describe('edit mode save', () => { + const enterEditMode = () => + fireEvent.click(screen.getByRole('button', { name: 'Edit chat data' })); + + const setName = (value: string) => + fireEvent.change(screen.getByRole('textbox'), { + target: { value }, + }); + + const uploadFile = (container: HTMLElement) => + fireEvent.change(container.querySelector('input[type="file"]')!, { + target: { files: [new File(['avatar'], 'avatar.png', { type: 'image/png' })] }, + }); + + const save = () => fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + it('persists a name-only change without uploading an image', async () => { + renderChannelManagementView(); + + enterEditMode(); + setName('Renamed channel'); + save(); + + await waitFor(() => + expect(mocks.channel.updatePartial).toHaveBeenCalledWith({ + set: { name: 'Renamed channel' }, + }), + ); + expect(mocks.channel.sendImage).not.toHaveBeenCalled(); + }); + + it('uploads via channel.sendImage when no custom upload is provided', async () => { + const { container } = renderChannelManagementView(); + + enterEditMode(); + uploadFile(container); + save(); + + await waitFor(() => expect(mocks.channel.sendImage).toHaveBeenCalledTimes(1)); + expect(mocks.channel.updatePartial).toHaveBeenCalledWith({ + set: { image: 'https://stream-upload.example' }, + }); + }); + + it('persists both name and image when both change', async () => { + const { container } = renderChannelManagementView(); + + enterEditMode(); + setName('Renamed channel'); + uploadFile(container); + save(); + + await waitFor(() => + expect(mocks.channel.updatePartial).toHaveBeenCalledWith({ + set: { image: 'https://stream-upload.example', name: 'Renamed channel' }, + }), + ); + }); + + it('unsets the image when an existing image is deleted', async () => { + mocks.displayImage = 'https://existing.example'; + renderChannelManagementView(); + + enterEditMode(); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + save(); + + await waitFor(() => + expect(mocks.channel.updatePartial).toHaveBeenCalledWith({ + unset: ['image'], + }), + ); + expect(mocks.channel.sendImage).not.toHaveBeenCalled(); + }); + + it('emits a success notification after saving', async () => { + renderChannelManagementView(); + + enterEditMode(); + setName('Renamed channel'); + save(); + + await waitFor(() => + expect(mocks.addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Changes saved', + severity: 'success', + }), + ), + ); + }); + + it('emits an error notification when the update fails', async () => { + mocks.channel.updatePartial.mockRejectedValueOnce(new Error('boom')); + renderChannelManagementView(); + + enterEditMode(); + setName('Renamed channel'); + save(); + + await waitFor(() => + expect(mocks.addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.any(Error), + message: 'Failed to save changes', + severity: 'error', + }), + ), + ); + }); + + it('does not persist when the upload returns no URL', async () => { + mocks.channel.sendImage.mockResolvedValueOnce({ file: undefined }); + const { container } = renderChannelManagementView(); + + enterEditMode(); + uploadFile(container); + save(); + + await waitFor(() => + expect(mocks.addNotification).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error' }), + ), + ); + expect(mocks.channel.updatePartial).not.toHaveBeenCalled(); + }); + + it('labels the name field "Contact name" for a DM channel', () => { + renderChannelManagementView(); + + enterEditMode(); + + expect(screen.getByLabelText('Contact name')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Contact name')).toBeInTheDocument(); + }); + + it('labels the name field "Group name" for a group channel', () => { + mocks.channel.data.member_count = 5; + renderChannelManagementView(); + + enterEditMode(); + + expect(screen.getByLabelText('Group name')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Group name')).toBeInTheDocument(); + }); + + it('only renders the save button once something changes', () => { + renderChannelManagementView(); + + enterEditMode(); + + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(); + + setName('Renamed channel'); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); + }); + }); +}); diff --git a/src/plugins/ChannelDetail/index.ts b/src/plugins/ChannelDetail/index.ts new file mode 100644 index 0000000000..f6685604a1 --- /dev/null +++ b/src/plugins/ChannelDetail/index.ts @@ -0,0 +1,10 @@ +export * from './AvatarWithChannelDetail'; +export * from './ChannelDetail'; +export * from './ChannelDetailContext'; +export * from './ChannelDetailNavButton'; +export * from './Views/ChannelManagementView/ChannelManagementView'; +export * from './Views/ChannelManagementView/ChannelManagementActions.defaults'; +export * from './Views/ChannelFilesView'; +export * from './Views/ChannelMediaView'; +export * from './Views/ChannelMembersView'; +export * from './Views/PinnedMessagesView'; diff --git a/src/plugins/ChannelDetail/styling/AvatarWithChannelDetail.scss b/src/plugins/ChannelDetail/styling/AvatarWithChannelDetail.scss new file mode 100644 index 0000000000..f6441d360a --- /dev/null +++ b/src/plugins/ChannelDetail/styling/AvatarWithChannelDetail.scss @@ -0,0 +1,10 @@ +.str-chat__avatar-with-channel-detail-button { + appearance: none; + background: none; + border: 0; + border-radius: 50%; + color: inherit; + cursor: pointer; + display: flex; + padding: 0; +} diff --git a/src/plugins/ChannelDetail/styling/ChannelDetail.scss b/src/plugins/ChannelDetail/styling/ChannelDetail.scss new file mode 100644 index 0000000000..324409f41e --- /dev/null +++ b/src/plugins/ChannelDetail/styling/ChannelDetail.scss @@ -0,0 +1,76 @@ +.str-chat__channel-detail { + display: flex; + flex-direction: column; + width: min(800px, calc(100vw - (2 * var(--str-chat__spacing-lg)))); + max-width: 100%; + height: min(640px, calc(100vh - (2 * var(--str-chat__spacing-lg)))); + min-height: 0; + overflow: hidden; + + .str-chat__section-navigator { + min-height: 0; + } + + .str-chat__section-navigator__content { + display: flex; + flex-direction: column; + min-height: 0; + } + + .str-chat__prompt__header__description { + display: none; + } + + .str-chat__prompt__body { + display: flex; + flex-direction: column; + gap: var(--str-chat__spacing-md); + padding: 0; + } +} + +.str-chat__channel-detail--inline { + width: 100dvw; + height: 100dvh; + max-width: none; + border-radius: 0; + box-shadow: none; + + .str-chat__channel-detail__search-input { + padding-inline: var(--str-chat__spacing-md); + } + + .str-chat__prompt__footer { + padding: var(--str-chat__spacing-md); + + .str-chat__prompt__footer__controls { + width: 100%; + + button { + width: 100%; + } + } + } +} + +.str-chat__channel-detail__nav-button { + width: 100%; + text-transform: capitalize; +} + +.str-chat__channel-detail__header { + display: flex; + gap: var(--str-chat__spacing-md); + padding: var(--str-chat__spacing-xl); +} + +.str-chat__channel-detail__search-input { + flex-shrink: 0; + width: 100%; + padding-inline: var(--str-chat__spacing-xl); + padding-block: var(--str-chat__spacing-xxxs); + + .str-chat__form-text-input__wrapper--outline { + border-radius: var(--str-chat__radius-max); + } +} diff --git a/src/plugins/ChannelDetail/styling/ChannelFilesView.scss b/src/plugins/ChannelDetail/styling/ChannelFilesView.scss new file mode 100644 index 0000000000..aef2ffd9ef --- /dev/null +++ b/src/plugins/ChannelDetail/styling/ChannelFilesView.scss @@ -0,0 +1,117 @@ +@use '../../../styling/utils'; + +.str-chat__channel-detail__files-view { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.str-chat__channel-detail__files-view__body { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.str-chat__channel-detail__files-view__empty-state { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--str-chat__spacing-sm); + width: 100%; + padding-block: var(--str-chat__spacing-3xl); + padding-inline: var(--str-chat__spacing-md); + text-align: center; +} + +.str-chat__channel-detail__files-view__empty-state__icon { + width: 32px; + height: 32px; + color: var(--str-chat__text-tertiary); +} + +.str-chat__channel-detail__files-view__empty-state__content { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--str-chat__spacing-xxs); + width: 100%; +} + +.str-chat__channel-detail__files-view__empty-state__title, +.str-chat__channel-detail__files-view__empty-state__description { + margin: 0; + overflow-wrap: anywhere; +} + +.str-chat__channel-detail__files-view__empty-state__title { + color: var(--str-chat__text-primary); + font: var(--str-chat__font-caption-emphasis); +} + +.str-chat__channel-detail__files-view__empty-state__description { + max-width: 200px; + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); +} + +.str-chat__channel-detail__files-view__list { + @include utils.hide-scrollbar; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + max-height: 100%; + overflow-y: auto; + overscroll-behavior: contain; + + .str-chat__infinite-scroll-paginator__content { + display: flex; + flex-direction: column; + min-height: 100%; + } +} + +.str-chat__channel-detail__files-view__section { + display: flex; + flex-direction: column; +} + +.str-chat__channel-detail__files-view__section-header { + padding-block: var(--str-chat__spacing-xs); + padding-inline: var(--str-chat__spacing-xs); + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-emphasis); + background: var(--str-chat__background-core-surface-subtle); +} + +.str-chat__channel-detail__files-view__section-items { + display: flex; + flex-direction: column; + padding-inline: var(--str-chat__spacing-xs); +} + +.str-chat__channel-detail--inline { + .str-chat__channel-detail__files-view__section-items { + padding-inline: var(--str-chat__spacing-xxs); + } +} + +.str-chat__channel-detail__files-view__list-item { + color: inherit; + text-decoration: none; +} + +.str-chat__channel-detail__files-view__list-item__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.str-chat__channel-detail__files-view__list-item__size { + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); +} diff --git a/src/plugins/ChannelDetail/styling/ChannelManagementView.scss b/src/plugins/ChannelDetail/styling/ChannelManagementView.scss new file mode 100644 index 0000000000..26f76d1b0b --- /dev/null +++ b/src/plugins/ChannelDetail/styling/ChannelManagementView.scss @@ -0,0 +1,117 @@ +.str-chat__channel-detail__channel-management-view { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.str-chat__channel-detail { + .str-chat__channel-detail__channel-management-view__body { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + gap: var(--str-chat__spacing-2xl); + padding: 0 var(--str-chat__spacing-xl) var(--str-chat__spacing-2xl); + } + + .str-chat__channel-detail__channel-management-view__profile { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--str-chat__spacing-md); + width: 100%; + + .str-chat__channel-detail__channel-management-view__profile__details { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--str-chat__spacing-xs); + width: 100%; + + .str-chat__channel-detail__channel-management-view__profile__details__title { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xs); + font: var(--str-chat__font-heading-lg); + } + + .str-chat__channel-detail__channel-management-view__profile__details__connection-status { + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-secondary); + } + } + } +} + +.str-chat__channel-detail--inline { + .str-chat__channel-detail__channel-management-view__body { + padding: var(--str-chat__spacing-2xl) var(--str-chat__spacing-md); + } +} + +.str-chat__channel-detail__channel-management-view__actions { + display: flex; + flex-direction: column; + padding-block: var(--str-chat__spacing-xs); + gap: var(--str-chat__spacing-xxs); + + .str-chat__form__switch-field + .str-chat__form__switch-field__label + .str-chat__form__switch-field__label__text { + font: var(--str-chat__font-caption-default); + } +} + +.str-chat__channel-management-view-action { + text-transform: capitalize; +} + +.str-chat__channel-detail__action-icon { + width: var(--str-chat__icon-size-sm); + height: var(--str-chat__icon-size-sm); + flex-shrink: 0; + color: var(--str-chat__text-secondary); +} + +.str-chat__channel-management-confirmation-alert { + min-width: min(304px, calc(100vw - 32px)); + max-width: min(304px, calc(100vw - 32px)); +} + +.str-chat__channel-detail__channel-management-view__form { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.str-chat__channel-detail__channel-management-view__avatar-row { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xl); + flex-wrap: wrap; +} + +.str-chat__channel-detail__channel-management-view__avatar-row__actions { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xs); + flex-wrap: wrap; +} + +.str-chat__channel-detail__channel-management-view__file-input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.str-chat__channel-detail__channel-management-view__name-input { + width: 100%; +} diff --git a/src/plugins/ChannelDetail/styling/ChannelMediaView.scss b/src/plugins/ChannelDetail/styling/ChannelMediaView.scss new file mode 100644 index 0000000000..7430b047e1 --- /dev/null +++ b/src/plugins/ChannelDetail/styling/ChannelMediaView.scss @@ -0,0 +1,157 @@ +@use '../../../styling/utils'; + +.str-chat__channel-detail__media-view { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.str-chat__channel-detail__media-view__body { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.str-chat__channel-detail__media-view__empty-state { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--str-chat__spacing-sm); + width: 100%; + padding-block: var(--str-chat__spacing-3xl); + padding-inline: var(--str-chat__spacing-md); + text-align: center; +} + +.str-chat__channel-detail__media-view__empty-state__icon { + width: 32px; + height: 32px; + color: var(--str-chat__text-tertiary); +} + +.str-chat__channel-detail__media-view__empty-state__content { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--str-chat__spacing-xxs); + width: 100%; +} + +.str-chat__channel-detail__media-view__empty-state__title, +.str-chat__channel-detail__media-view__empty-state__description { + margin: 0; + overflow-wrap: anywhere; +} + +.str-chat__channel-detail__media-view__empty-state__title { + color: var(--str-chat__text-primary); + font: var(--str-chat__font-caption-emphasis); +} + +.str-chat__channel-detail__media-view__empty-state__description { + max-width: 200px; + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); +} + +.str-chat__channel-detail__media-view__grid { + @include utils.hide-scrollbar; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + max-height: 100%; + overflow-y: auto; + overscroll-behavior: contain; + + .str-chat__infinite-scroll-paginator__content { + display: flex; + flex-direction: column; + min-height: 100%; + padding-inline: var(--str-chat__spacing-xl); + padding-block: var(--str-chat__spacing-xxs); + } +} + +.str-chat__channel-detail--inline { + .str-chat__channel-detail__media-view__grid { + .str-chat__infinite-scroll-paginator__content { + padding: 0; + + .str-chat__channel-detail__media-view__grid__items { + gap: var(--str-chat__spacing-xxxs); + } + } + } +} + +.str-chat__channel-detail__media-view__grid__items { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: var(--str-chat__spacing-xxs); + width: 100%; +} + +.str-chat__channel-detail__media-view__item { + position: relative; + display: block; + aspect-ratio: 1; + width: 100%; + padding: 0; + border: none; + border-radius: var(--str-chat__radius-xs); + background: var(--str-chat__background-core-surface-subtle); + cursor: pointer; + overflow: hidden; + + img.str-chat__base-image { + object-fit: cover; + } +} + +.str-chat__channel-detail__media-view__item__media { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.str-chat__channel-detail__media-view__item__placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--str-chat__text-tertiary); + + .str-chat__icon { + width: var(--str-chat__icon-size-lg, 32px); + height: var(--str-chat__icon-size-lg, 32px); + } +} + +.str-chat__avatar.str-chat__channel-detail__media-view__item__avatar { + position: absolute; + inset-block-start: var(--str-chat__spacing-xs); + inset-inline-start: var(--str-chat__spacing-xs); + pointer-events: none; +} + +.str-chat__badge.str-chat__channel-detail__media-view__item__duration { + position: absolute; + inset-block-end: var(--str-chat__spacing-xs); + inset-inline-start: var(--str-chat__spacing-xs); + gap: var(--str-chat__spacing-xxs); + padding-block: var(--str-chat__spacing-xxs); + padding-inline: var(--str-chat__spacing-xs); + pointer-events: none; +} + +.str-chat__channel-detail__media-view__item__duration-icon { + width: 12px; + height: 12px; +} diff --git a/src/plugins/ChannelDetail/styling/ChannelMemberDetailView.scss b/src/plugins/ChannelDetail/styling/ChannelMemberDetailView.scss new file mode 100644 index 0000000000..f8f8b6b221 --- /dev/null +++ b/src/plugins/ChannelDetail/styling/ChannelMemberDetailView.scss @@ -0,0 +1,72 @@ +.str-chat__channel-detail { + .str-chat__channel-detail__channel-member-detail-view__body { + gap: var(--str-chat__spacing-2xl); + padding: 0 var(--str-chat__spacing-xl) var(--str-chat__spacing-2xl); + } +} + +.str-chat__channel-detail--inline { + .str-chat__channel-detail__channel-member-detail-view__body { + padding: var(--str-chat__spacing-2xl) var(--str-chat__spacing-md); + } +} + +.str-chat__channel-detail__channel-member-detail-view__profile { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--str-chat__spacing-md); + width: 100%; + + .str-chat__channel-detail__channel-member-detail-view__profile__details { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--str-chat__spacing-xs); + width: 100%; + + .str-chat__channel-detail__channel-member-detail-view__profile__details__title { + text-align: center; + font: var(--str-chat__font-heading-lg); + color: var(--str-chat__text-primary); + } + + .str-chat__channel-detail__channel-member-detail-view__profile__details__connection-status { + text-align: center; + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-secondary); + } + } +} + +.str-chat__channel-detail__channel-member-detail-view__actions { + display: flex; + flex-direction: column; + gap: var(--str-chat__spacing-xxs); + width: 100%; + border-radius: var(--str-chat__radius-lg); + background-color: var(--str-chat__surface-card); + padding-block: var(--str-chat__spacing-xs); +} + +.str-chat__channel-member-detail-action { + text-transform: capitalize; +} + +.str-chat__channel-detail__action-icon--remove-user { + color: var(--str-chat__accent-error); +} + +.str-chat__channel-member-confirmation-alert { + min-width: min(304px, calc(100vw - 32px)); + max-width: min(304px, calc(100vw - 32px)); +} + +.str-chat__channel-detail__channel-member-detail-view__empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 128px; + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-secondary); +} diff --git a/src/plugins/ChannelDetail/styling/ChannelMembersView.scss b/src/plugins/ChannelDetail/styling/ChannelMembersView.scss new file mode 100644 index 0000000000..49315ad85c --- /dev/null +++ b/src/plugins/ChannelDetail/styling/ChannelMembersView.scss @@ -0,0 +1,96 @@ +@use '../../../styling/utils'; + +.str-chat__channel-detail__channel-members-view { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.str-chat__channel-detail__channel-members-view__header-actions { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-sm); +} + +.str-chat__channel-members-view__body { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.str-chat__channel-detail__channel-members-view__list { + @include utils.hide-scrollbar; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + max-height: 100%; + overflow-y: auto; + overscroll-behavior: contain; + + .str-chat__infinite-scroll-paginator__content { + display: flex; + flex-direction: column; + min-height: 100%; + padding-inline: var(--str-chat__spacing-xs); + padding-block: var(--str-chat__spacing-xxs); + } +} + +.str-chat__channel-detail--inline { + .str-chat__channel-detail__channel-members-view__list { + .str-chat__infinite-scroll-paginator__content { + padding-inline: var(--str-chat__spacing-xs); + } + } +} + +.str-chat__channel-detail__channel-members-view__list-item { + .str-chat__channel-detail__channel-members-view__list-item__indicator-icon { + color: var(--str-chat__text-tertiary); + } +} + +.str-chat__channel-detail__channel-members-view__list-item--disabled { + color: var(--str-chat__text-low-emphasis); +} + +.str-chat__channel-detail__channel-members-view__list-item__trailing-slot { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xs); +} + +.str-chat__channel-detail__channel-members-view__role-label, +.str-chat__channel-detail__channel-members-view__already-member-label { + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); +} + +.str-chat__channel-detail__channel-members-view__empty-state { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--str-chat__spacing-sm); + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-body); + + svg { + height: 32px; + width: 32px; + } +} + +.str-chat__channel-detail__channel-members-view__toast { + align-self: center; + padding-block: var(--str-chat__spacing-xs); + padding-inline: var(--str-chat__spacing-md); + border-radius: var(--str-chat__radius-md); + background: var(--str-chat__background-core-inverse); + color: var(--str-chat__text-on-inverse); + font: var(--str-chat__font-caption-default); +} diff --git a/src/plugins/ChannelDetail/styling/ChannelMembersViewListFooter.scss b/src/plugins/ChannelDetail/styling/ChannelMembersViewListFooter.scss new file mode 100644 index 0000000000..a4984db213 --- /dev/null +++ b/src/plugins/ChannelDetail/styling/ChannelMembersViewListFooter.scss @@ -0,0 +1,6 @@ +.str-chat__loading-indicator-placeholder { + width: 100%; + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/plugins/ChannelDetail/styling/PinnedMessagesView.scss b/src/plugins/ChannelDetail/styling/PinnedMessagesView.scss new file mode 100644 index 0000000000..8fcb461996 --- /dev/null +++ b/src/plugins/ChannelDetail/styling/PinnedMessagesView.scss @@ -0,0 +1,98 @@ +@use '../../../styling/utils'; + +.str-chat__channel-detail__pinned-messages-view { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.str-chat__channel-detail__pinned-messages-view__body { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.str-chat__channel-detail__pinned-messages-view__list { + @include utils.hide-scrollbar; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + max-height: 100%; + overflow-y: auto; + overscroll-behavior: contain; + + .str-chat__infinite-scroll-paginator__content { + display: flex; + flex-direction: column; + min-height: 100%; + padding-inline: var(--str-chat__spacing-xs); + padding-block: var(--str-chat__spacing-xxs); + } +} + +.str-chat__channel-detail--inline { + .str-chat__channel-detail__pinned-messages-view__list { + .str-chat__infinite-scroll-paginator__content { + padding-inline: var(--str-chat__spacing-xxs); + } + } +} + +.str-chat__channel-detail__pinned-messages-view__list-item__message-preview { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.str-chat__channel-detail__pinned-messages-view__list-item__date { + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); + white-space: nowrap; +} + +.str-chat__channel-detail__pinned-messages-view__empty-state { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--str-chat__spacing-sm); + width: 100%; + padding-block: var(--str-chat__spacing-3xl); + padding-inline: var(--str-chat__spacing-md); + text-align: center; +} + +.str-chat__channel-detail__pinned-messages-view__empty-state__icon { + width: 32px; + height: 32px; + color: var(--str-chat__text-tertiary); +} + +.str-chat__channel-detail__pinned-messages-view__empty-state__content { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--str-chat__spacing-xxs); + width: 100%; +} + +.str-chat__channel-detail__pinned-messages-view__empty-state__title, +.str-chat__channel-detail__pinned-messages-view__empty-state__description { + margin: 0; + overflow-wrap: anywhere; +} + +.str-chat__channel-detail__pinned-messages-view__empty-state__title { + color: var(--str-chat__text-primary); + font: var(--str-chat__font-caption-emphasis); +} + +.str-chat__channel-detail__pinned-messages-view__empty-state__description { + max-width: 200px; + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); +} diff --git a/src/plugins/ChannelDetail/styling/index.scss b/src/plugins/ChannelDetail/styling/index.scss new file mode 100644 index 0000000000..dcbbb0d1ee --- /dev/null +++ b/src/plugins/ChannelDetail/styling/index.scss @@ -0,0 +1,9 @@ +@use 'AvatarWithChannelDetail'; +@use 'ChannelDetail'; +@use 'ChannelFilesView'; +@use 'ChannelMemberDetailView'; +@use 'ChannelManagementView'; +@use 'ChannelMediaView'; +@use 'ChannelMembersView'; +@use 'ChannelMembersViewListFooter'; +@use 'PinnedMessagesView'; diff --git a/src/styling/_utils.scss b/src/styling/_utils.scss index 8ad59b58c1..2a9e8b2c13 100644 --- a/src/styling/_utils.scss +++ b/src/styling/_utils.scss @@ -131,3 +131,26 @@ max-width: 100%; } } + +@mixin hide-scrollbar($axis: both) { + @if $axis == x { + overflow-x: auto; + overflow-y: hidden; + } @else if $axis == y { + overflow-y: auto; + overflow-x: hidden; + } @else { + overflow: auto; + } + + // Firefox + scrollbar-width: none; + + // IE and old Edge + -ms-overflow-style: none; + + // Chrome, Safari, Opera + &::-webkit-scrollbar { + display: none; + } +} diff --git a/src/styling/index.scss b/src/styling/index.scss index e0de310db4..ef5a74300b 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -21,9 +21,7 @@ @use '../components/AIStateIndicator/styling' as AIStateIndicator; @use '../components/Attachment/styling' as Attachment; @use '../components/AudioPlayback/styling' as AudioPlayback; -@use '../components/Avatar/styling/Avatar' as Avatar; -@use '../components/Avatar/styling/AvatarStack' as AvatarStack; -@use '../components/Avatar/styling/GroupAvatar' as GroupAvatar; +@use '../components/Avatar/styling' as Avatar; @use '../components/Channel/styling' as Channel; @use '../components/ChannelHeader/styling' as ChannelHeader; @use '../components/ChannelList/styling' as ChannelList; @@ -34,6 +32,7 @@ @use '../components/EmptyStateIndicator/styling' as EmptyStateIndicator; @use '../components/Gallery/styling' as Gallery; @use '../components/InfiniteScrollPaginator/styling' as InfiniteScrollPaginator; +@use '../components/ListItemLayout/styling' as ListItemLayout; @use '../components/Loading/styling' as Loading; @use '../components/Location/styling' as Location; @use '../components/MediaRecorder/AudioRecorder/styling' as AudioRecorder; @@ -46,6 +45,7 @@ @use '../components/Poll/styling' as Poll; @use '../components/Reactions/styling' as Reactions; @use '../components/Search/styling' as Search; +@use '../components/SectionNavigator/styling' as SectionNavigator; @use '../components/SkipNavigation/styling' as SkipNavigation; @use '../components/SummarizedMessagePreview/styling' as SummarizedMessagePreview; @use '../components/TextareaComposer/styling' as TextareaComposer; diff --git a/src/utils/__tests__/isDmChannel.test.ts b/src/utils/__tests__/isDmChannel.test.ts new file mode 100644 index 0000000000..ab7d5b7460 --- /dev/null +++ b/src/utils/__tests__/isDmChannel.test.ts @@ -0,0 +1,50 @@ +import { fromPartial } from '@total-typescript/shoehorn'; +import type { Channel, ChannelState } from 'stream-chat'; +import { describe, expect, it } from 'vitest'; +import { isDmChannel } from '../isDmChannel'; + +describe('isDmChannel', () => { + it('returns true for one-member channels', () => { + const channel = fromPartial({ + data: { member_count: 1 }, + state: { members: {} }, + }); + + expect(isDmChannel({ channel })).toBe(true); + }); + + it('returns true for two-member channels that include the current user', () => { + const members = fromPartial({ + 'user-1': { user: { id: 'user-1' } }, + 'user-2': { user: { id: 'user-2' } }, + }); + + expect( + isDmChannel({ + channel: fromPartial({ + data: { member_count: 2 }, + state: { members }, + }), + ownUserId: 'user-1', + }), + ).toBe(true); + }); + + it('returns false for group channels', () => { + const members = fromPartial({ + 'user-1': { user: { id: 'user-1' } }, + 'user-2': { user: { id: 'user-2' } }, + 'user-3': { user: { id: 'user-3' } }, + }); + + expect( + isDmChannel({ + channel: fromPartial({ + data: { member_count: 3 }, + state: { members }, + }), + ownUserId: 'user-1', + }), + ).toBe(false); + }); +}); diff --git a/src/utils/index.ts b/src/utils/index.ts index a6f6751bfb..6d1fbdbc09 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,5 @@ export * from './getChannel'; export * from './getTextareaCaretRect'; export * from './getWholeChar'; +export * from './isDmChannel'; +export * from './useStableCallback'; diff --git a/src/utils/isDmChannel.ts b/src/utils/isDmChannel.ts new file mode 100644 index 0000000000..49e4b2137f --- /dev/null +++ b/src/utils/isDmChannel.ts @@ -0,0 +1,17 @@ +import type { Channel } from 'stream-chat'; + +export const isDmChannel = ({ + channel, + ownUserId, +}: { + channel: Channel; + ownUserId?: string; +}) => { + const memberCount = channel.data?.member_count ?? 0; + return ( + memberCount === 1 || + (memberCount === 2 && + !!ownUserId && + Object.values(channel.state?.members).some(({ user }) => user?.id === ownUserId)) + ); +}; diff --git a/vite.config.ts b/vite.config.ts index b06e1c89f0..633485c58a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ lib: { entry: { index: resolve(__dirname, './src/index.ts'), + 'channel-detail': resolve(__dirname, './src/plugins/ChannelDetail/index.ts'), emojis: resolve(__dirname, './src/plugins/Emojis/index.ts'), 'mp3-encoder': resolve(__dirname, './src/plugins/encoders/mp3.ts'), },