From 233c446c872e598cc46ec457c7610eb88f3ece8d Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 2 Jun 2026 10:19:17 +0200 Subject: [PATCH 01/21] feat: add ChannelDetail component --- .../ActionsMenu/NotificationPromptDialog.tsx | 3 +- examples/vite/src/ChatLayout/Panels.tsx | 3 +- .../SystemNotification/SystemNotification.tsx | 4 +- examples/vite/src/icons.tsx | 12 - src/components/Button/ListItemButton.tsx | 78 ++++ src/components/Button/index.ts | 1 + .../ChannelDetail/ChannelDetail.tsx | 46 +++ .../Views/ChannelInfoActions.defaults.tsx | 368 ++++++++++++++++++ .../Views/ChannelManagementView.tsx | 94 +++++ .../__tests__/ChannelDetail.test.tsx | 44 +++ src/components/ChannelDetail/index.ts | 3 + .../ChannelDetail/styling/ChannelDetail.scss | 20 + .../styling/ChannelManagementView.scss | 44 +++ .../ChannelDetail/styling/index.scss | 2 + .../ChannelHeader/AvatarWithChannelDetail.tsx | 62 +++ .../hooks/useChannelHasMembersOnline.ts | 45 +++ .../hooks/useChannelHeaderOnlineStatus.ts | 41 +- src/components/ChannelHeader/index.ts | 1 + .../styling/AvatarWithChannelDetail.scss | 3 + .../ChannelHeader/styling/ChannelHeader.scss | 11 + .../ChannelHeader/styling/index.scss | 1 + src/components/Dialog/components/Prompt.tsx | 2 +- src/components/Dialog/styling/Prompt.scss | 5 +- src/components/Form/SwitchField.tsx | 152 ++++++-- .../Form/__tests__/SwitchField.test.tsx | 20 + src/components/Form/styling/SwitchField.scss | 12 +- src/components/Icons/icons.tsx | 18 + .../ListItemLayout/ListItemLayout.tsx | 107 +++++ src/components/ListItemLayout/index.ts | 1 + .../styling/ListItemLayout.scss | 121 ++++++ .../ListItemLayout/styling/index.scss | 1 + .../__tests__/MessageInput.test.tsx | 4 +- src/components/Modal/GlobalModal.tsx | 17 +- .../Modal/__tests__/GlobalModal.test.tsx | 44 +++ .../MultipleAnswersField.tsx | 3 +- .../Poll/styling/PollCreationDialog.scss | 4 + .../SectionNavigator/SectionNavigator.tsx | 227 +++++++++++ .../__tests__/SectionNavigator.test.tsx | 220 +++++++++++ src/components/SectionNavigator/index.ts | 1 + .../styling/SectionNavigator.scss | 35 ++ .../SectionNavigator/styling/index.scss | 1 + src/components/index.ts | 3 + src/i18n/de.json | 18 + src/i18n/en.json | 18 + src/i18n/es.json | 18 + src/i18n/fr.json | 18 + src/i18n/hi.json | 18 + src/i18n/it.json | 18 + src/i18n/ja.json | 18 + src/i18n/ko.json | 18 + src/i18n/nl.json | 18 + src/i18n/pt.json | 18 + src/i18n/ru.json | 18 + src/i18n/tr.json | 18 + src/styling/_utils.scss | 23 ++ src/styling/index.scss | 3 + src/utils/__tests__/isDmChannel.test.ts | 41 ++ src/utils/index.ts | 1 + src/utils/isDmChannel.ts | 17 + 59 files changed, 2087 insertions(+), 98 deletions(-) create mode 100644 src/components/Button/ListItemButton.tsx create mode 100644 src/components/ChannelDetail/ChannelDetail.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelInfoActions.defaults.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelManagementView.tsx create mode 100644 src/components/ChannelDetail/__tests__/ChannelDetail.test.tsx create mode 100644 src/components/ChannelDetail/index.ts create mode 100644 src/components/ChannelDetail/styling/ChannelDetail.scss create mode 100644 src/components/ChannelDetail/styling/ChannelManagementView.scss create mode 100644 src/components/ChannelDetail/styling/index.scss create mode 100644 src/components/ChannelHeader/AvatarWithChannelDetail.tsx create mode 100644 src/components/ChannelHeader/hooks/useChannelHasMembersOnline.ts create mode 100644 src/components/ChannelHeader/styling/AvatarWithChannelDetail.scss create mode 100644 src/components/ListItemLayout/ListItemLayout.tsx create mode 100644 src/components/ListItemLayout/index.ts create mode 100644 src/components/ListItemLayout/styling/ListItemLayout.scss create mode 100644 src/components/ListItemLayout/styling/index.scss create mode 100644 src/components/SectionNavigator/SectionNavigator.tsx create mode 100644 src/components/SectionNavigator/__tests__/SectionNavigator.test.tsx create mode 100644 src/components/SectionNavigator/index.ts create mode 100644 src/components/SectionNavigator/styling/SectionNavigator.scss create mode 100644 src/components/SectionNavigator/styling/index.scss create mode 100644 src/utils/__tests__/isDmChannel.test.ts create mode 100644 src/utils/isDmChannel.ts diff --git a/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx b/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx index 15cd52a748..c407519975 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx +++ b/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx @@ -11,6 +11,7 @@ import { IconClock, IconExclamationMark, IconExclamationTriangleFill, + IconInfo, IconMinus, IconPlusSmall, IconRefresh, @@ -45,7 +46,7 @@ const severityIcons: Partial< Record> > = { error: IconExclamationMark, - info: IconExclamationMark, + info: IconInfo, loading: IconRefresh, success: IconCheckmark, warning: IconExclamationTriangleFill, diff --git a/examples/vite/src/ChatLayout/Panels.tsx b/examples/vite/src/ChatLayout/Panels.tsx index 74e091abff..64bee4f507 100644 --- a/examples/vite/src/ChatLayout/Panels.tsx +++ b/examples/vite/src/ChatLayout/Panels.tsx @@ -3,6 +3,7 @@ import type { ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat'; import { useEffect, useRef } from 'react'; import { AIStateIndicator, + AvatarWithChannelDetail, Channel, ChannelAvatar, ChannelHeader, @@ -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/src/components/Button/ListItemButton.tsx b/src/components/Button/ListItemButton.tsx new file mode 100644 index 0000000000..f799f7892a --- /dev/null +++ b/src/components/Button/ListItemButton.tsx @@ -0,0 +1,78 @@ +import type { ComponentProps, ComponentType } from 'react'; +import React, { useMemo } from 'react'; +import clsx from 'clsx'; +import { ListItemLayout, type ListItemLayoutBaseProps } from '../ListItemLayout'; + +export type ListItemButtonProps = Omit, 'children' | 'title'> & + Omit & { + LeadingIcon?: ComponentType>; + TrailingIcon?: ComponentType>; + }; + +export const ListItemButton = ({ + 'aria-current': ariaCurrent, + 'aria-label': ariaLabel, + className, + description, + destructive, + disabled, + LeadingIcon, + LeadingSlot, + onClick, + selected, + subtitle, + title, + TrailingIcon, + TrailingSlot, + type, +}: ListItemButtonProps) => { + const LayoutLeadingIcon = useMemo(() => { + if (!LeadingIcon) return undefined; + + const Icon = LeadingIcon; + + function ListItemButtonLeadingIcon() { + return ; + } + + return ListItemButtonLeadingIcon; + }, [LeadingIcon]); + const LayoutTrailingIcon = useMemo(() => { + if (!TrailingIcon) return undefined; + + const Icon = TrailingIcon; + + function ListItemButtonTrailingIcon() { + return ; + } + + return ListItemButtonTrailingIcon; + }, [TrailingIcon]); + const rootProps = useMemo( + () => ({ + 'aria-current': ariaCurrent, + 'aria-label': ariaLabel, + className: clsx('str-chat__list-item-button', className), + disabled, + onClick, + type: type ?? 'button', + }), + [ariaCurrent, ariaLabel, className, disabled, onClick, type], + ); + + return ( + + ); +}; diff --git a/src/components/Button/index.ts b/src/components/Button/index.ts index c8179d9bf5..89931bf475 100644 --- a/src/components/Button/index.ts +++ b/src/components/Button/index.ts @@ -1,2 +1,3 @@ export * from './Button'; +export * from './ListItemButton'; export * from './PlayButton'; diff --git a/src/components/ChannelDetail/ChannelDetail.tsx b/src/components/ChannelDetail/ChannelDetail.tsx new file mode 100644 index 0000000000..5fb9883fc4 --- /dev/null +++ b/src/components/ChannelDetail/ChannelDetail.tsx @@ -0,0 +1,46 @@ +import clsx from 'clsx'; +import React from 'react'; + +import { + SectionNavigator, + type SectionNavigatorNavButtonProps, + type SectionNavigatorProps, + type SectionNavigatorSection, +} from '../SectionNavigator'; +import { ChannelManagementView } from './Views/ChannelManagementView'; +import { Prompt } from '../Dialog'; +import { ListItemButton } from '../Button'; +import { IconInfo } from '../Icons'; + +const ChannelDetailNavButtonClassName = 'str-chat__channel-detail__nav-button'; + +const defaultSections: SectionNavigatorSection[] = [ + { + id: 'channel-info', + NavButton: ({ select, selected }: SectionNavigatorNavButtonProps) => ( + + ), + SectionContent: ChannelManagementView, + }, +]; + +export type ChannelDetailProps = Omit & { + sections?: SectionNavigatorSection[]; +}; + +export const ChannelDetail = ({ + className, + sections = defaultSections, + ...props +}: ChannelDetailProps) => ( + + + +); diff --git a/src/components/ChannelDetail/Views/ChannelInfoActions.defaults.tsx b/src/components/ChannelDetail/Views/ChannelInfoActions.defaults.tsx new file mode 100644 index 0000000000..c91a415179 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelInfoActions.defaults.tsx @@ -0,0 +1,368 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import debounce from 'lodash.debounce'; + +import { + useChannelStateContext, + useChatContext, + useModalContext, + useTranslationContext, +} from '../../../context'; +import { isDmChannel } from '../../../utils'; +import { ListItemButton } from '../../Button'; +import { useIsChannelMuted } from '../../ChannelListItem/hooks/useIsChannelMuted'; +import { SwitchField } from '../../Form'; +import { IconAudio, IconDelete, IconLeave, IconMute, IconNoSign } from '../../Icons'; +import { useNotificationApi } from '../../Notifications'; + +export type ChannelInfoActionType = + | 'blockUser' + | 'deleteChat' + | 'leaveChannel' + | 'muteChannel' + | 'muteUser' + | (string & {}); + +export type ChannelInfoActionItem = { + Component: React.ComponentType; + type: ChannelInfoActionType; +}; + +const toError = (error: unknown) => + error instanceof Error ? error : new Error('An unknown error occurred'); + +const useOtherMember = () => { + const { client } = useChatContext(); + const { channel } = useChannelStateContext(); + + return useMemo( + () => + channel.data?.members?.find( + (member) => member.user?.id && member.user.id !== client.user?.id, + ), + [channel, client.user?.id], + ); +}; + +const useChannelInfoActionFilterState = () => { + const { client } = useChatContext(); + const { channel } = useChannelStateContext(); + 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 useBaseChannelInfoActionSetFilter = ( + channelInfoActionSet: ChannelInfoActionItem[], +) => { + const { canBlockUser, canLeaveChannel, canMuteChannel, canMuteUser } = + useChannelInfoActionFilterState(); + + return useMemo( + () => + channelInfoActionSet.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, channelInfoActionSet], + ); +}; + +const ChannelMuteAction = () => { + const { channel } = useChannelStateContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const { muted: channelMuted } = useIsChannelMuted(channel); + + const toggleChannelMute = useMemo( + () => + debounce(() => { + if (channelMuted) { + return channel + .unmute() + .then(() => + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + message: t('Channel unmuted'), + severity: 'success', + type: 'api:channel:unmute:success', + }), + ) + .catch((error) => + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + error: toError(error), + message: t('Error unmuting channel'), + severity: 'error', + type: 'api:channel:unmute:failed', + }), + ); + } + + return channel + .mute() + .then(() => + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + message: t('Channel muted'), + severity: 'success', + type: 'api:channel:mute:success', + }), + ) + .catch((error) => + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + error: toError(error), + message: t('Error muting channel'), + severity: 'error', + type: 'api:channel:mute:failed', + }), + ); + }, 1000), + [addNotification, channel, channelMuted, t], + ); + + return ( + + ); +}; + +const UserMuteAction = () => { + const { client, mutes } = useChatContext(); + const { channel } = useChannelStateContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const otherMember = useOtherMember(); + const userMuted = !!mutes.find((mute) => mute.target.id === otherMember?.user?.id); + + const toggleUserMute = useMemo( + () => + debounce(() => { + if (!otherMember?.user?.id) return; + + if (userMuted) { + return client + .unmuteUser(otherMember.user.id) + .then(() => + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + message: t('User unmuted'), + severity: 'success', + type: 'api:user:unmute:success', + }), + ) + .catch((error) => + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + error: toError(error), + message: t('Error unmuting user'), + severity: 'error', + type: 'api:user:unmute:failed', + }), + ); + } + + return client + .muteUser(otherMember.user.id) + .then(() => + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + message: t('User muted'), + severity: 'success', + type: 'api:user:mute:success', + }), + ) + .catch((error) => + addNotification({ + context: { channel }, + emitter: 'ChannelManagementView', + error: toError(error), + message: t('Error muting user'), + severity: 'error', + type: 'api:user:mute:failed', + }), + ); + }, 1000), + [addNotification, channel, client, otherMember, t, userMuted], + ); + + return ( + + ); +}; + +const BlockUserAction = () => { + const { client } = useChatContext(); + const { channel } = useChannelStateContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const otherMember = useOtherMember(); + const [userBlockInProgress, setUserBlockInProgress] = useState(false); + + const blockUser = useCallback(async () => { + if (!otherMember?.user?.id) return; + + try { + setUserBlockInProgress(true); + await client.blockUser(otherMember.user.id); + 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 { + setUserBlockInProgress(false); + } + }, [addNotification, channel, client, otherMember, t]); + + return ( + + ); +}; + +const LeaveChannelAction = () => { + const { client } = useChatContext(); + const { channel } = useChannelStateContext(); + const { close } = useModalContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const [leaveChannelInProgress, setLeaveChannelInProgress] = useState(false); + + const leaveChannel = useCallback( + async (event: React.MouseEvent) => { + event.stopPropagation(); + 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', + }); + 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], + ); + + return ( + + ); +}; + +const DeleteChatAction = () => { + const { t } = useTranslationContext(); + + return ; +}; + +export const DefaultChannelInfoActions = { + BlockUser: BlockUserAction, + DeleteChat: DeleteChatAction, + LeaveChannel: LeaveChannelAction, + MuteChannel: ChannelMuteAction, + MuteUser: UserMuteAction, +}; + +export const defaultChannelInfoActionSet: ChannelInfoActionItem[] = [ + { + Component: DefaultChannelInfoActions.MuteChannel, + type: 'muteChannel', + }, + { + Component: DefaultChannelInfoActions.MuteUser, + type: 'muteUser', + }, + { + Component: DefaultChannelInfoActions.BlockUser, + type: 'blockUser', + }, + { + Component: DefaultChannelInfoActions.LeaveChannel, + type: 'leaveChannel', + }, + { + Component: DefaultChannelInfoActions.DeleteChat, + type: 'deleteChat', + }, +]; diff --git a/src/components/ChannelDetail/Views/ChannelManagementView.tsx b/src/components/ChannelDetail/Views/ChannelManagementView.tsx new file mode 100644 index 0000000000..bd41a3a06c --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelManagementView.tsx @@ -0,0 +1,94 @@ +import { + useChannelStateContext, + useChatContext, + useComponentContext, + useModalContext, + useTranslationContext, +} from '../../../context'; +import { isDmChannel } from '../../../utils'; +import type { SectionNavigatorSectionContentProps } from '../../SectionNavigator'; +import { ChannelAvatar as DefaultChannelAvatar } from '../../Avatar'; +import { useChannelPreviewInfo } from '../../ChannelListItem'; +import { IconMute, IconPin } from '../../Icons'; +import React from 'react'; +import { useChannelMembershipState } from '../../ChannelList'; +import { useIsChannelMuted } from '../../ChannelListItem/hooks/useIsChannelMuted'; +import { useChannelHasMembersOnline } from '../../ChannelHeader/hooks/useChannelHasMembersOnline'; +import { Prompt } from '../../Dialog'; +import { + type ChannelInfoActionItem, + defaultChannelInfoActionSet, + useBaseChannelInfoActionSetFilter, +} from './ChannelInfoActions.defaults'; +import { useChannelHeaderOnlineStatus } from '../../ChannelHeader/hooks/useChannelHeaderOnlineStatus'; + +export type ChannelManagementViewProps = SectionNavigatorSectionContentProps & { + channelInfoActionSet?: ChannelInfoActionItem[]; +}; + +export const ChannelManagementView = ({ + channelInfoActionSet = defaultChannelInfoActionSet, +}: ChannelManagementViewProps) => { + const { t } = useTranslationContext(); + const { client } = useChatContext(); + const { channel } = useChannelStateContext(); + const { close } = useModalContext(); + const { Avatar = DefaultChannelAvatar } = useComponentContext(); + const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ + channel, + }); + const isOnline = useChannelHasMembersOnline(); + const { muted: channelMuted } = useIsChannelMuted(channel); + const userMuted = false; + const membership = useChannelMembershipState(channel); + const actions = useBaseChannelInfoActionSetFilter(channelInfoActionSet); + const onlineStatusText = useChannelHeaderOnlineStatus(); + + const pinned = !!membership.pinned_at; + const resolvedIsDmChannel = isDmChannel({ + channel, + ownUserId: client.user?.id, + }); + + return ( +
+ + +
+ +
+
+ {displayTitle && {displayTitle}} + {pinned && } + {(resolvedIsDmChannel && userMuted) || + (!resolvedIsDmChannel && channelMuted) ? ( + + ) : null} +
+ {onlineStatusText && ( +
+ {onlineStatusText} +
+ )} +
+
+ +
+ {actions.map(({ Component, type }) => ( + + ))} +
+
+
+ ); +}; diff --git a/src/components/ChannelDetail/__tests__/ChannelDetail.test.tsx b/src/components/ChannelDetail/__tests__/ChannelDetail.test.tsx new file mode 100644 index 0000000000..2000a100ef --- /dev/null +++ b/src/components/ChannelDetail/__tests__/ChannelDetail.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, vi } from 'vitest'; + +import { ChannelDetail } from '../ChannelDetail'; +import type { SectionNavigatorSection } from '../../SectionNavigator'; + +const sections: SectionNavigatorSection[] = [ + { + id: 'channel-info', + NavButton: () => , + SectionContent: () =>
Channel info
, + }, +]; + +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/components/ChannelDetail/index.ts b/src/components/ChannelDetail/index.ts new file mode 100644 index 0000000000..c0d57b031a --- /dev/null +++ b/src/components/ChannelDetail/index.ts @@ -0,0 +1,3 @@ +export * from './ChannelDetail'; +export * from './Views/ChannelManagementView'; +export * from './Views/ChannelInfoActions.defaults'; diff --git a/src/components/ChannelDetail/styling/ChannelDetail.scss b/src/components/ChannelDetail/styling/ChannelDetail.scss new file mode 100644 index 0000000000..bf9e774fbc --- /dev/null +++ b/src/components/ChannelDetail/styling/ChannelDetail.scss @@ -0,0 +1,20 @@ +.str-chat__channel-detail { + width: min(800px, calc(100vw - (2 * var(--str-chat__spacing-lg, 24px)))); + max-width: 100%; + height: 100%; + + .str-chat__prompt__header__description { + display: none; + } +} + +.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); +} diff --git a/src/components/ChannelDetail/styling/ChannelManagementView.scss b/src/components/ChannelDetail/styling/ChannelManagementView.scss new file mode 100644 index 0000000000..7c41752836 --- /dev/null +++ b/src/components/ChannelDetail/styling/ChannelManagementView.scss @@ -0,0 +1,44 @@ +.str-chat__channel-detail__channel-management-view__body { + display: flex; + flex-direction: column; + gap: 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__channel-management-view__actions { + padding-block: var(--str-chat__spacing-xs); + padding-inline: 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); + } +} diff --git a/src/components/ChannelDetail/styling/index.scss b/src/components/ChannelDetail/styling/index.scss new file mode 100644 index 0000000000..ad191db63f --- /dev/null +++ b/src/components/ChannelDetail/styling/index.scss @@ -0,0 +1,2 @@ +@use 'ChannelDetail'; +@use 'ChannelManagementView'; diff --git a/src/components/ChannelHeader/AvatarWithChannelDetail.tsx b/src/components/ChannelHeader/AvatarWithChannelDetail.tsx new file mode 100644 index 0000000000..2937476027 --- /dev/null +++ b/src/components/ChannelHeader/AvatarWithChannelDetail.tsx @@ -0,0 +1,62 @@ +import clsx from 'clsx'; +import React, { useCallback, useState } from 'react'; + +import { useComponentContext, useTranslationContext } from '../../context'; +import { + type ChannelAvatarProps, + ChannelAvatar as DefaultChannelAvatar, +} from '../Avatar'; +import { ChannelDetail as DefaultChannelDetail } from '../ChannelDetail/ChannelDetail'; +import { GlobalModal } from '../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 { 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/components/ChannelHeader/hooks/useChannelHasMembersOnline.ts b/src/components/ChannelHeader/hooks/useChannelHasMembersOnline.ts new file mode 100644 index 0000000000..1d1bd9cd80 --- /dev/null +++ b/src/components/ChannelHeader/hooks/useChannelHasMembersOnline.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; +import type { ChannelState } from 'stream-chat'; + +import { useChannelStateContext } from '../../../context/ChannelStateContext'; + +export const useChannelHasMembersOnline = (enabled = true) => { + const { channel } = useChannelStateContext(); + 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..8cde31483a 100644 --- a/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts +++ b/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts @@ -1,9 +1,8 @@ -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'; /** * Returns the channel header online status text (e.g. "Online", "Offline", or "X members, Y online"). @@ -14,37 +13,17 @@ export function useChannelHeaderOnlineStatus(): string | null { const { client } = useChatContext(); const { channel, watcherCount = 0 } = useChannelStateContext(); 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(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/ChannelHeader/index.ts b/src/components/ChannelHeader/index.ts index a8a155add1..48041c412c 100644 --- a/src/components/ChannelHeader/index.ts +++ b/src/components/ChannelHeader/index.ts @@ -1 +1,2 @@ +export * from './AvatarWithChannelDetail'; export * from './ChannelHeader'; diff --git a/src/components/ChannelHeader/styling/AvatarWithChannelDetail.scss b/src/components/ChannelHeader/styling/AvatarWithChannelDetail.scss new file mode 100644 index 0000000000..6d5f72cb46 --- /dev/null +++ b/src/components/ChannelHeader/styling/AvatarWithChannelDetail.scss @@ -0,0 +1,3 @@ +.str-chat__channel-detail-modal { + height: 80%; +} diff --git a/src/components/ChannelHeader/styling/ChannelHeader.scss b/src/components/ChannelHeader/styling/ChannelHeader.scss index 1572a30103..5ed04d05cc 100644 --- a/src/components/ChannelHeader/styling/ChannelHeader.scss +++ b/src/components/ChannelHeader/styling/ChannelHeader.scss @@ -25,6 +25,17 @@ min-width: 0; } + .str-chat__channel-header__avatar-button { + appearance: none; + background: none; + border: 0; + border-radius: 50%; + color: inherit; + cursor: pointer; + display: flex; + padding: 0; + } + .str-chat__channel-header__data__title, .str-chat__channel-header__data__subtitle { @include utils.ellipsis-text; diff --git a/src/components/ChannelHeader/styling/index.scss b/src/components/ChannelHeader/styling/index.scss index 1385a7048d..c918871fcd 100644 --- a/src/components/ChannelHeader/styling/index.scss +++ b/src/components/ChannelHeader/styling/index.scss @@ -1 +1,2 @@ +@forward './AvatarWithChannelDetail'; @forward './ChannelHeader'; diff --git a/src/components/Dialog/components/Prompt.tsx b/src/components/Dialog/components/Prompt.tsx index 902949d023..bed6c432ce 100644 --- a/src/components/Dialog/components/Prompt.tsx +++ b/src/components/Dialog/components/Prompt.tsx @@ -57,7 +57,7 @@ const PromptHeader = ({ circular className='str-chat__prompt__header__close-button' onClick={close} - size='sm' + size='md' variant='secondary' > diff --git a/src/components/Dialog/styling/Prompt.scss b/src/components/Dialog/styling/Prompt.scss index 744db79d27..7a0527afdf 100644 --- a/src/components/Dialog/styling/Prompt.scss +++ b/src/components/Dialog/styling/Prompt.scss @@ -15,6 +15,7 @@ display: flex; flex-direction: column; gap: var(--str-chat__spacing-xxs); + padding-block: var(--str-chat__spacing-xs); flex: 1; min-width: 0; } @@ -35,8 +36,8 @@ flex-shrink: 0; color: var(--str-chat__text-primary); .str-chat__icon { - width: var(--str-chat__icon-size-md); - height: var(--str-chat__icon-size-md); + width: var(--str-chat__icon-size-sm); + height: var(--str-chat__icon-size-sm); } } } diff --git a/src/components/Form/SwitchField.tsx b/src/components/Form/SwitchField.tsx index 522091e774..d492ce2bf2 100644 --- a/src/components/Form/SwitchField.tsx +++ b/src/components/Form/SwitchField.tsx @@ -2,13 +2,15 @@ import clsx from 'clsx'; import type { ChangeEventHandler, ComponentProps, + ComponentType, KeyboardEventHandler, MouseEventHandler, PropsWithChildren, ReactNode, } from 'react'; -import React, { isValidElement, useRef, useState } from 'react'; +import React, { isValidElement, useCallback, useMemo, useRef, useState } from 'react'; import { useStableId } from '../UtilityComponents/useStableId'; +import { ListItemLayout } from '../ListItemLayout'; export type SwitchFieldProps = Omit< PropsWithChildren>, @@ -20,14 +22,22 @@ export type SwitchFieldProps = Omit< description?: string; /** Class applied to the root div element of the SwitchField component */ fieldClassName?: string; + /** Optional decorative icon rendered before the label content */ + Icon?: ComponentType; /** Optional title line */ title?: string; }; +export type SwitchFieldIconProps = { + className?: string; + decorative?: boolean; +}; + export const SwitchField = ({ children, description, fieldClassName, + Icon, title, ...props }: SwitchFieldProps) => { @@ -52,26 +62,35 @@ export const SwitchField = ({ const isOn = isControlled ? checked : uncontrolledChecked; const isReadOnly = isControlled && onChange === undefined; - const handleChange: ChangeEventHandler = (event) => { - if (!isControlled) { - setUncontrolledChecked(event.target.checked); - } + const handleChange: ChangeEventHandler = useCallback( + (event) => { + if (!isControlled) { + setUncontrolledChecked(event.target.checked); + } - onChange?.(event); - }; + onChange?.(event); + }, + [isControlled, onChange], + ); - const handleKeyDown: KeyboardEventHandler = (event) => { - onKeyDown?.(event); - if (event.defaultPrevented || event.key !== ' ') return; + const handleKeyDown: KeyboardEventHandler = useCallback( + (event) => { + onKeyDown?.(event); + if (event.defaultPrevented || event.key !== ' ') return; - event.preventDefault(); - event.currentTarget.click(); - }; + event.preventDefault(); + event.currentTarget.click(); + }, + [onKeyDown], + ); - const handleSwitchClick: MouseEventHandler = (event) => { - if (disabled || event.target === inputRef.current) return; - inputRef.current?.click(); - }; + const handleSwitchClick: MouseEventHandler = useCallback( + (event) => { + if (disabled || event.target === inputRef.current) return; + inputRef.current?.click(); + }, + [disabled], + ); // When no title/aria-label is provided, SwitchField can still be named by a caller-supplied // child element id via aria-labelledby. @@ -84,6 +103,78 @@ export const SwitchField = ({ // 4) caller-supplied child id (children path) const resolvedAriaLabelledBy = ariaLabelledBy ?? (!ariaLabel ? (title ? switchLabelId : childLabelId) : undefined); + const LeadingIcon = useMemo(() => { + if (!Icon) return undefined; + + const LeadingIcon = Icon; + + function SwitchFieldLeadingIcon() { + return ; + } + + return SwitchFieldLeadingIcon; + }, [Icon]); + const rootProps = useMemo( + () => ({ + className: clsx( + 'str-chat__form__switch-field', + fieldClassName, + disabled && 'str-chat__form__switch-field--disabled', + ), + }), + [disabled, fieldClassName], + ); + const TrailingSlot = useMemo(() => { + function SwitchFieldTrailingSlot() { + return ( + + ); + } + + return SwitchFieldTrailingSlot; + }, [ + ariaLabel, + disabled, + handleChange, + handleKeyDown, + handleSwitchClick, + isOn, + isReadOnly, + resolvedAriaLabelledBy, + rest, + switchId, + ]); + + if (title) { + return ( + + } + TrailingSlot={TrailingSlot} + /> + ); + } return (
- {title ? ( - - ) : ( - children - )} - + {Icon && } + {children} +
); }; diff --git a/src/components/Form/__tests__/SwitchField.test.tsx b/src/components/Form/__tests__/SwitchField.test.tsx index c1960cd96a..2edd7476cf 100644 --- a/src/components/Form/__tests__/SwitchField.test.tsx +++ b/src/components/Form/__tests__/SwitchField.test.tsx @@ -4,6 +4,10 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { SwitchField } from '../SwitchField'; import { axe } from '../../../../axe-helper'; +const TestIcon = ({ className }: { className?: string; decorative?: boolean }) => ( + +); + describe('SwitchField', () => { it('renders a single switch control with switch semantics', () => { render( @@ -74,6 +78,22 @@ describe('SwitchField', () => { expect(results).toHaveNoViolations(); }); + it('renders an optional decorative icon without changing the switch name', () => { + render( + , + ); + + expect(screen.getByTestId('switch-field-icon')).toHaveClass( + 'str-chat__form__switch-field__icon', + ); + expect(screen.getByRole('switch', { name: 'Mute chat' })).toBeInTheDocument(); + }); + it('uses caller-provided child id for aria-labelledby when title is not provided', () => { render( diff --git a/src/components/Form/styling/SwitchField.scss b/src/components/Form/styling/SwitchField.scss index 508b1035cf..b921943805 100644 --- a/src/components/Form/styling/SwitchField.scss +++ b/src/components/Form/styling/SwitchField.scss @@ -23,7 +23,6 @@ $switch-field-track-radius: var(--str-chat__button-radius-full, 9999px); align-items: center; gap: var(--str-chat__spacing-sm); width: 100%; - padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-md); background-color: var(--str-chat__switch-field-background-color); border-radius: var(--str-chat__switch-field-border-radius); box-sizing: border-box; @@ -36,6 +35,10 @@ $switch-field-track-radius: var(--str-chat__button-radius-full, 9999px); cursor: pointer; } + .str-chat__form__switch-field__layout { + flex: 1; + } + .str-chat__form__switch-field__input { position: absolute; inset: 0; @@ -47,6 +50,13 @@ $switch-field-track-radius: var(--str-chat__button-radius-full, 9999px); cursor: pointer; } + .str-chat__form__switch-field__icon { + width: 16px; + height: 16px; + flex-shrink: 0; + color: var(--str-chat__text-secondary); + } + .str-chat__form__switch-field__switch { position: relative; display: flex; diff --git a/src/components/Icons/icons.tsx b/src/components/Icons/icons.tsx index 2c6cc5267a..cd38d690bf 100644 --- a/src/components/Icons/icons.tsx +++ b/src/components/Icons/icons.tsx @@ -623,6 +623,24 @@ export const IconImage = createIcon( />, ); +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..83c81cff21 --- /dev/null +++ b/src/components/ListItemLayout/ListItemLayout.tsx @@ -0,0 +1,107 @@ +import clsx from 'clsx'; +import type { ComponentProps, ComponentType, HTMLAttributes, ReactNode } from 'react'; +import React from 'react'; + +export type ListItemLayoutRootElement = Extract< + keyof React.JSX.IntrinsicElements, + keyof HTMLElementTagNameMap +>; + +export type ListItemLayoutBaseProps = { + description?: ReactNode; + destructive?: boolean; + LeadingIcon?: ComponentType; + LeadingSlot?: ComponentType; + selected?: boolean; + subtitle?: ReactNode; + textClassName?: string; + title: ReactNode; + TrailingIcon?: ComponentType; + TrailingSlot?: ComponentType; +}; + +export type ListItemLayoutProps = + ListItemLayoutBaseProps & { + RootElement?: RootElement; + rootProps?: Omit, 'children'>; + }; + +export const ListItemLayout = ({ + description, + destructive, + LeadingIcon, + LeadingSlot, + RootElement, + rootProps, + selected, + subtitle, + textClassName, + title, + TrailingIcon, + TrailingSlot, +}: ListItemLayoutProps) => { + const RootComponent = RootElement ?? 'div'; + const resolvedRootProps = { + ...rootProps, + className: clsx( + 'str-chat__list-item-layout', + rootProps?.className, + destructive && 'str-chat__list-item-layout--destructive', + selected && 'str-chat__list-item-layout--selected', + ), + } as HTMLAttributes; + + // JSX cannot type-check a generic intrinsic element with generic root props here. + // Call sites still get RootElement-specific rootProps; createElement keeps rendering simple internally. + return React.createElement( + RootComponent, + resolvedRootProps, + LeadingIcon && ( + + + + ), + LeadingSlot && , + , + TrailingIcon && ( + + + + ), + TrailingSlot && , + ); +}; + +export type ListItemLayoutTextProps = Omit, 'title'> & { + description?: ReactNode; + subtitle?: ReactNode; + title: ReactNode; +}; + +export const ListItemLayoutText = ({ + className, + description, + subtitle, + title, + ...props +}: ListItemLayoutTextProps) => ( +
+ {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..c6ac11f77d --- /dev/null +++ b/src/components/ListItemLayout/styling/ListItemLayout.scss @@ -0,0 +1,121 @@ +@use '../../../styling/utils'; + +.str-chat__list-item-layout { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-sm); + text-align: start; + padding: var(--str-chat__spacing-xs); + width: 100%; + min-width: 0; + border-radius: var(--str-chat__radius-md); + + &.str-chat__list-item-layout--selected { + background-color: var(--str-chat__background-utility-selected); + } + + &.str-chat__list-item-layout--destructive { + color: var(--str-chat__accent-error); + + .str-chat__list-item-layout__title, + .str-chat__list-item-layout__subtitle, + .str-chat__list-item-layout__description { + color: var(--str-chat__accent-error); + } + } + + &: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); + } + } + + &:is(button) { + @include utils.button-reset; + padding: var(--str-chat__spacing-xs); + 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__text { + 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__text--subtitled { + 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; + width: var(--str-chat__icon-size-sm); + height: var(--str-chat__icon-size-sm); + + svg { + stroke: currentColor; + width: 100%; + height: 100%; + } + } + + .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; + } +} 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/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 03fde62ade..8afb6defa7 100644 --- a/src/components/Modal/GlobalModal.tsx +++ b/src/components/Modal/GlobalModal.tsx @@ -35,6 +35,11 @@ export type ModalProps = { open: boolean; /** Custom class to be applied to the modal root div */ className?: 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. */ @@ -58,6 +63,7 @@ export const GlobalModal = ({ children, className, CloseButtonOnOverlay, + dialogRootProps, onClose, onCloseAttempt, open, @@ -69,6 +75,11 @@ export const GlobalModal = ({ const closeButtonRef = useRef(null); const closingRef = useRef(false); const { theme } = useChatContext(); + const { + className: dialogRootClassName, + onKeyDown: dialogRootOnKeyDown, + ...dialogRootPropsRest + } = dialogRootProps ?? {}; const dialogLabelingBaseId = dialog.id; const resolvedModalAriaProps = useResolvedModalAriaProps({ ariaDescribedby, @@ -109,6 +120,7 @@ export const GlobalModal = ({ }; const handleDialogKeyDown = (event: React.KeyboardEvent) => { + dialogRootOnKeyDown?.(event); if (event.defaultPrevented || event.key !== 'Escape') return; maybeClose('escape', event); }; @@ -141,14 +153,15 @@ export const GlobalModal = ({ >
{children}
diff --git a/src/components/Modal/__tests__/GlobalModal.test.tsx b/src/components/Modal/__tests__/GlobalModal.test.tsx index 118d360c64..2c15ff758f 100644 --- a/src/components/Modal/__tests__/GlobalModal.test.tsx +++ b/src/components/Modal/__tests__/GlobalModal.test.tsx @@ -234,6 +234,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: { 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 ( -
+
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; +}; + +export type SectionNavigatorProps = HTMLAttributes & { + sections: SectionNavigatorSection[]; + createLayoutObserver?: SectionNavigatorLayoutObserverFactory; + defaultLayout?: SectionNavigatorLayout; + initialHistory?: SectionNavigatorRoute[]; + layout?: SectionNavigatorLayout; + 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 = { + history: [], + historyPop: () => undefined, + historyPush: () => undefined, + layout: SECTION_NAVIGATOR_LAYOUT.tabs, +}; + +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, + 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 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 showNavigation = !isInlineLayout || !currentSection; + + 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)); + }, []); + + 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( + () => ({ + history, + historyPop, + historyPush, + layout, + }), + [history, historyPop, historyPush, layout], + ); + + const Content = activeSection?.SectionContent; + + return ( + +
+ {showNavigation && ( +
+ {sections.map((section) => { + const NavButton = section.NavButton; + const selected = activeSection?.id === section.id; + + return ( +
+ historyPush({ id: section.id })} + selected={selected} + /> +
+ ); + })} +
+ )} + {Content && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/components/SectionNavigator/__tests__/SectionNavigator.test.tsx b/src/components/SectionNavigator/__tests__/SectionNavigator.test.tsx new file mode 100644 index 0000000000..0930b87161 --- /dev/null +++ b/src/components/SectionNavigator/__tests__/SectionNavigator.test.tsx @@ -0,0 +1,220 @@ +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 sections: SectionNavigatorSection[] = [ + { + id: 'media', + NavButton: createNavButton('Media nav'), + SectionContent: createContent('Media'), + }, + { + id: 'files', + NavButton: createNavButton('Files nav'), + SectionContent: createContent('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', () => { + render(); + + expect(screen.queryByText('Media nav')).not.toBeInTheDocument(); + expect(screen.queryByText('Files nav')).not.toBeInTheDocument(); + 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 { 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(screen.queryByText('Media nav')).not.toBeInTheDocument(); + expect(screen.queryByText('Files nav')).not.toBeInTheDocument(); + expect(screen.queryByText('Files content inline')).not.toBeInTheDocument(); + expect(screen.getByText('Media content inline')).toBeInTheDocument(); + }); + + it('lets a custom layout observer set the layout', () => { + const createLayoutObserver = vi.fn(({ setLayout }) => { + setLayout('inline'); + }); + + render( + , + ); + + expect(createLayoutObserver).toHaveBeenCalledWith( + expect.objectContaining({ tabsLayoutMinWidth: 720 }), + ); + expect(screen.queryByText('Media nav')).not.toBeInTheDocument(); + 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', () => { + render( +
+ +
, + ); + + expect(screen.getByText('Media nav')).toBeInTheDocument(); + + act(() => { + resizeObserverCallback?.( + [{ contentRect: { width: 0 } as DOMRectReadOnly } as ResizeObserverEntry], + {} as ResizeObserver, + ); + }); + + expect(screen.getByText('Media nav')).toBeInTheDocument(); + + act(() => { + resizeObserverCallback?.( + [{ contentRect: { width: 320 } as DOMRectReadOnly } as ResizeObserverEntry], + {} as ResizeObserver, + ); + }); + + expect(screen.queryByText('Media nav')).not.toBeInTheDocument(); + + act(() => { + resizeObserverCallback?.( + [{ contentRect: { width: 800 } as DOMRectReadOnly } as ResizeObserverEntry], + {} as ResizeObserver, + ); + }); + + expect(screen.getByText('Media nav')).toBeInTheDocument(); + }); + + it('uses tabsLayoutMinWidth to resolve the default observer layout', () => { + render( +
+ +
, + ); + + act(() => { + resizeObserverCallback?.( + [{ contentRect: { width: 640 } as DOMRectReadOnly } as ResizeObserverEntry], + {} as ResizeObserver, + ); + }); + + expect(screen.queryByText('Media nav')).not.toBeInTheDocument(); + + act(() => { + resizeObserverCallback?.( + [{ contentRect: { width: 960 } as DOMRectReadOnly } as ResizeObserverEntry], + {} as ResizeObserver, + ); + }); + + expect(screen.getByText('Media nav')).toBeInTheDocument(); + }); +}); diff --git a/src/components/SectionNavigator/index.ts b/src/components/SectionNavigator/index.ts new file mode 100644 index 0000000000..17cfcc9828 --- /dev/null +++ b/src/components/SectionNavigator/index.ts @@ -0,0 +1 @@ +export * from './SectionNavigator'; diff --git a/src/components/SectionNavigator/styling/SectionNavigator.scss b/src/components/SectionNavigator/styling/SectionNavigator.scss new file mode 100644 index 0000000000..e49589c03d --- /dev/null +++ b/src/components/SectionNavigator/styling/SectionNavigator.scss @@ -0,0 +1,35 @@ +@use '../../../styling/utils'; + +.str-chat__section-navigator { + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + width: 100%; + height: 100%; +} + +.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-xxs); +} + +.str-chat__section-navigator__content { + flex: 1; + min-height: 0; + min-width: 0; +} 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..4907c44e25 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,6 +7,7 @@ export * from './Badge'; export * from './BaseImage'; export * from './Button'; export * from './Channel'; +export * from './ChannelDetail'; export * from './ChannelHeader'; export * from './ChannelList'; export * from './ChannelListItem'; @@ -23,6 +24,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 +39,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/i18n/de.json b/src/i18n/de.json index b7fd83e70a..dc5e205ad9 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -65,6 +65,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,6 +105,7 @@ "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 Menu": "Menรผ รถffnen", "aria/Open Message Actions Menu": "Nachrichtenaktionsmenรผ รถffnen", "aria/Open Reaction Selector": "Reaktionsauswahl รถffnen", @@ -151,6 +153,7 @@ "Back": "Back", "ban-command-args": "[@Benutzername] [Text]", "ban-command-description": "Einen Benutzer verbannen", + "Block user": "Benutzer blockieren", "Block User": "Benutzer blockieren", "Cancel": "Abbrechen", "Cannot seek in the recording": "In der Aufnahme kann nicht gesucht werden", @@ -173,12 +176,14 @@ "Commands": "Befehle", "Commands matching": "รœbereinstimmende Befehle", "Connection failure, reconnecting now...": "Verbindungsfehler, Wiederherstellung der Verbindung...", + "Contact info": "Kontaktinfo", "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", @@ -206,16 +211,21 @@ "Enforce unique vote is enabled": "Eindeutige Abstimmung ist aktiviert", "Error": "Fehler", "Error adding flag": "Fehler beim Hinzufรผgen des Flags", + "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 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 pinning message": "Fehler beim Pinnen der Nachricht", "Error removing message pin": "Fehler beim Entfernen der gepinnten Nachricht", "Error reproducing the recording": "Fehler bei der Wiedergabe der Aufnahme", "Error starting recording": "Fehler beim Starten der Aufnahme", "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", @@ -248,6 +258,7 @@ "Generating...": "Generieren...", "giphy-command-args": "[Text]", "giphy-command-description": "Poste ein zufรคlliges Gif in den Kanal", + "Group info": "Gruppeninfo", "Hide who voted": "Verbergen, wer abgestimmt hat", "Image": "Bild", "imageCount_one": "Bild", @@ -326,6 +337,7 @@ "Location": "Standort", "Location sharing ended": "Standortfreigabe beendet", "Location: {{ coordinates }}": "Standort: {{ coordinates }}", + "Manage channel": "Kanal 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", @@ -341,6 +353,8 @@ "Missing permissions to upload the attachment": "Fehlende Berechtigungen zum Hochladen des Anhangs", "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", @@ -488,6 +502,8 @@ "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", @@ -502,7 +518,9 @@ "Upload failed": "Upload fehlgeschlagen", "Upload type: \"{{ type }}\" is not allowed": "Upload-Typ: \"{{ type }}\" ist nicht erlaubt", "User blocked": "Benutzer blockiert", + "User muted": "Benutzer stummgeschaltet", "User unblocked": "Blockierung des Benutzers aufgehoben", + "User unmuted": "Benutzer-Stummschaltung aufgehoben", "User uploaded content": "Vom Benutzer hochgeladener Inhalt", "Video": "Video", "videoCount_one": "Video", diff --git a/src/i18n/en.json b/src/i18n/en.json index 3d1e3b0d38..14f36e936c 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -65,6 +65,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,6 +105,7 @@ "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 Menu": "Open Menu", "aria/Open Message Actions Menu": "Open Message Actions Menu", "aria/Open Reaction Selector": "Open Reaction Selector", @@ -151,6 +153,7 @@ "Back": "Back", "ban-command-args": "[@username] [text]", "ban-command-description": "Ban a user", + "Block user": "Block user", "Block User": "Block User", "Cancel": "Cancel", "Cannot seek in the recording": "Cannot seek in the recording", @@ -173,12 +176,14 @@ "Commands": "Commands", "Commands matching": "Commands matching", "Connection failure, reconnecting now...": "Connection failure, reconnecting now...", + "Contact info": "Contact info", "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", @@ -206,16 +211,21 @@ "Enforce unique vote is enabled": "Enforce unique vote is enabled", "Error": "Error", "Error adding flag": "Error adding flag", + "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 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 pinning message": "Error pinning message", "Error removing message pin": "Error removing message pin", "Error reproducing the recording": "Error reproducing the recording", "Error starting recording": "Error starting recording", "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", @@ -248,6 +258,7 @@ "Generating...": "Generating...", "giphy-command-args": "[text]", "giphy-command-description": "Post a random gif to the channel", + "Group info": "Group info", "Hide who voted": "Hide Who Voted", "Image": "Image", "imageCount_one": "Image", @@ -326,6 +337,7 @@ "Location": "Location", "Location sharing ended": "Location sharing ended", "Location: {{ coordinates }}": "Location: {{ coordinates }}", + "Manage channel": "Manage channel", "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", @@ -341,6 +353,8 @@ "Missing permissions to upload the attachment": "Missing permissions to upload the attachment", "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", @@ -488,6 +502,8 @@ "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", @@ -502,7 +518,9 @@ "Upload failed": "Upload failed", "Upload type: \"{{ type }}\" is not allowed": "Upload type: \"{{ type }}\" is not allowed", "User blocked": "User blocked", + "User muted": "User muted", "User unblocked": "User unblocked", + "User unmuted": "User unmuted", "User uploaded content": "User uploaded content", "Video": "Video", "videoCount_one": "Video", diff --git a/src/i18n/es.json b/src/i18n/es.json index 566ac477e6..8d985bbe99 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -73,6 +73,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,6 +113,7 @@ "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 Menu": "Abrir menรบ", "aria/Open Message Actions Menu": "Abrir menรบ de acciones de mensaje", "aria/Open Reaction Selector": "Abrir selector de reacciones", @@ -159,6 +161,7 @@ "Back": "Atrรกs", "ban-command-args": "[@usuario] [texto]", "ban-command-description": "Prohibir a un usuario", + "Block user": "Bloquear usuario", "Block User": "Bloquear usuario", "Cancel": "Cancelar", "Cannot seek in the recording": "No se puede buscar en la grabaciรณn", @@ -181,12 +184,14 @@ "Commands": "Comandos", "Commands matching": "Coincidencia de comandos", "Connection failure, reconnecting now...": "Fallo de conexiรณn, reconectando ahora...", + "Contact info": "Informaciรณn de 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", @@ -214,16 +219,21 @@ "Enforce unique vote is enabled": "El voto รบnico estรก habilitado", "Error": "Error", "Error adding flag": "Error al agregar la bandera", + "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 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 pinning message": "Error al fijar el mensaje", "Error removing message pin": "Error al quitar el pin del mensaje", "Error reproducing the recording": "Error al reproducir la grabaciรณn", "Error starting recording": "Error al iniciar la grabaciรณn", "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", @@ -257,6 +267,7 @@ "Generating...": "Generando...", "giphy-command-args": "[texto]", "giphy-command-description": "Publicar un gif aleatorio en el canal", + "Group info": "Informaciรณn del grupo", "Hide who voted": "Ocultar quiรฉn votรณ", "Image": "Imagen", "imageCount_one": "Imagen", @@ -337,6 +348,7 @@ "Location": "Ubicaciรณn", "Location sharing ended": "Compartir ubicaciรณn terminado", "Location: {{ coordinates }}": "Ubicaciรณn: {{ coordinates }}", + "Manage channel": "Gestionar canal", "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", @@ -352,6 +364,8 @@ "Missing permissions to upload the attachment": "Faltan permisos para subir el archivo adjunto", "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", @@ -504,6 +518,8 @@ "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", @@ -518,7 +534,9 @@ "Upload failed": "Carga fallida", "Upload type: \"{{ type }}\" is not allowed": "Tipo de carga: \"{{ type }}\" no estรก permitido", "User blocked": "Usuario bloqueado", + "User muted": "Usuario silenciado", "User unblocked": "Usuario desbloqueado", + "User unmuted": "Usuario con silencio desactivado", "User uploaded content": "Contenido subido por el usuario", "Video": "Vรญdeo", "videoCount_one": "Video", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 2b3d13b7da..2919c0c7e9 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -73,6 +73,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,6 +113,7 @@ "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 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", @@ -159,6 +161,7 @@ "Back": "Retour", "ban-command-args": "[@nomdutilisateur] [texte]", "ban-command-description": "Bannir un utilisateur", + "Block user": "Bloquer l'utilisateur", "Block User": "Bloquer l'utilisateur", "Cancel": "Annuler", "Cannot seek in the recording": "Impossible de rechercher dans l'enregistrement", @@ -181,12 +184,14 @@ "Commands": "Commandes", "Commands matching": "Correspondance des commandes", "Connection failure, reconnecting now...": "ร‰chec de la connexion, reconnexion en cours...", + "Contact info": "Informations 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รฉ", @@ -214,16 +219,21 @@ "Enforce unique vote is enabled": "Le vote unique est activรฉ", "Error": "Erreur", "Error adding flag": "Erreur lors de l'ajout du signalement", + "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 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 pinning message": "Erreur lors de l'รฉpinglage du message", "Error removing message pin": "Erreur lors du retrait de l'รฉpinglage du message", "Error reproducing the recording": "Erreur lors de la reproduction de l'enregistrement", "Error starting recording": "Erreur lors du dรฉmarrage de l'enregistrement", "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", @@ -257,6 +267,7 @@ "Generating...": "Gรฉnรฉration...", "giphy-command-args": "[texte]", "giphy-command-description": "Poster un GIF alรฉatoire dans le canal", + "Group info": "Informations du groupe", "Hide who voted": "Masquer qui a votรฉ", "Image": "Image", "imageCount_one": "Photo", @@ -337,6 +348,7 @@ "Location": "Emplacement", "Location sharing ended": "Partage d'emplacement terminรฉ", "Location: {{ coordinates }}": "Emplacement : {{ coordinates }}", + "Manage channel": "Gรฉrer le canal", "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", @@ -352,6 +364,8 @@ "Missing permissions to upload the attachment": "Autorisations manquantes pour tรฉlรฉcharger la piรจce jointe", "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", @@ -504,6 +518,8 @@ "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", @@ -518,7 +534,9 @@ "Upload failed": "ร‰chec du tรฉlรฉversement", "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 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", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 194f35c756..5b80b4eafb 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -65,6 +65,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,6 +105,7 @@ "aria/Notifications": "เคธเฅ‚เคšเคจเคพเคเค‚", "aria/Open Attachment Selector": "เค…เคŸเฅˆเคšเคฎเฅ‡เค‚เคŸ เคšเคฏเคจเค•เคฐเฅเคคเคพ เค–เฅ‹เคฒเฅ‡เค‚", "aria/Open Channel Actions Menu": "เคšเฅˆเคจเคฒ เค•เฅเคฐเคฟเคฏเคพเคเค เคฎเฅ‡เคจเฅ‚ เค–เฅ‹เคฒเฅ‡เค‚", + "aria/Open channel details": "เคšเฅˆเคจเคฒ เคตเคฟเคตเคฐเคฃ เค–เฅ‹เคฒเฅ‡เค‚", "aria/Open Menu": "เคฎเฅ‡เคจเฅเคฏเฅ‚ เค–เฅ‹เคฒเฅ‡เค‚", "aria/Open Message Actions Menu": "เคธเค‚เคฆเฅ‡เคถ เค•เฅเคฐเคฟเคฏเคพ เคฎเฅ‡เคจเฅเคฏเฅ‚ เค–เฅ‹เคฒเฅ‡เค‚", "aria/Open Reaction Selector": "เคชเฅเคฐเคคเคฟเค•เฅเคฐเคฟเคฏเคพ เคšเคฏเคจเค•เคฐเฅเคคเคพ เค–เฅ‹เคฒเฅ‡เค‚", @@ -151,6 +153,7 @@ "Back": "เคตเคพเคชเคธ", "ban-command-args": "[@เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคจเคพเคฎ] [เคชเคพเค ]", "ban-command-description": "เคเค• เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค•เฅ‹ เคชเฅเคฐเคคเคฟเคทเฅ‡เคงเคฟเคค เค•เคฐเฅ‡เค‚", + "Block user": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค•เฅ‹ เคฌเฅเคฒเฅ‰เค• เค•เคฐเฅ‡เค‚", "Block User": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค•เฅ‹ เคฌเฅเคฒเฅ‰เค• เค•เคฐเฅ‡เค‚", "Cancel": "เคฐเคฆเฅเคฆ เค•เคฐเฅ‡เค‚", "Cannot seek in the recording": "เคฐเฅ‡เค•เฅ‰เคฐเฅเคกเคฟเค‚เค— เคฎเฅ‡เค‚ เค–เฅ‹เคœ เคจเคนเฅ€เค‚ เค•เฅ€ เคœเคพ เคธเค•เคคเฅ€", @@ -173,12 +176,14 @@ "Commands": "เค•เคฎเคพเค‚เคก", "Commands matching": "เคฎเฅ‡เคฒ เค–เคพเคคเฅ€ เคนเฅˆ", "Connection failure, reconnecting now...": "เค•เคจเฅ‡เค•เฅเคถเคจ เคตเคฟเคซเคฒ เคฐเคนเคพ, เค…เคฌ เคชเฅเคจเคƒ เค•เคจเฅ‡เค•เฅเคŸ เคนเฅ‹ เคฐเคนเคพ เคนเฅˆ ...", + "Contact info": "เคธเค‚เคชเคฐเฅเค• เคœเคพเคจเค•เคพเคฐเฅ€", "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": "เคชเคนเฅเค‚เคš เค—เคฏเคพ", @@ -206,17 +211,22 @@ "Enforce unique vote is enabled": "เค…เคจเฅ‹เค–เคพ เคตเฅ‹เคŸ เคธเค•เฅเคทเคฎ เคนเฅˆ", "Error": "เคคเฅเคฐเฅเคŸเคฟ", "Error adding flag": "เคงเฅเคตเคœ เคœเฅ‹เคกเคผเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", + "Error blocking user": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค•เฅ‹ เคฌเฅเคฒเฅ‰เค• เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", "Error connecting to chat, refresh the page to try again.": "เคšเฅˆเคŸ เคธเฅ‡ เค•เคจเฅ‡เค•เฅเคŸ เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ, เคชเฅ‡เคœ เค•เฅ‹ เคฐเคฟเคซเฅเคฐเฅ‡เคถ เค•เคฐเฅ‡เค‚", "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 pinning message": "เคธเค‚เคฆเฅ‡เคถ เค•เฅ‹ เคชเคฟเคจ เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", "Error removing message pin": "เคธเค‚เคฆเฅ‡เคถ เคชเคฟเคจ เคจเคฟเค•เคพเคฒเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", "Error reproducing the recording": "เคฐเคฟเค•เฅ‰เคฐเฅเคกเคฟเค‚เค— เคชเฅเคจ: เค‰เคคเฅเคชเคจเฅเคจ เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", "Error starting recording": "เคฐเฅ‡เค•เฅ‰เคฐเฅเคกเคฟเค‚เค— เคถเฅเคฐเฅ‚ เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", "Error unmuting a user ...": "เคฏเฅ‚เคœเคฐ เค•เฅ‹ เค…เคจเคฎเฅเคฏเฅ‚เคŸ เค•เคฐเคจเฅ‡ เค•เคพ เคชเฅเคฐเคฏเคพเคธ เคซเฅ‡เคฒ เคนเฅเค†", + "Error unmuting channel": "เคšเฅˆเคจเคฒ เค•เฅ‹ เค…เคจเคฎเฅเคฏเฅ‚เคŸ เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", + "Error unmuting user": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค•เฅ‹ เค…เคจเคฎเฅเคฏเฅ‚เคŸ เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", "Error uploading attachment": "เค…เคŸเฅˆเคšเคฎเฅ‡เค‚เคŸ เค…เคชเคฒเฅ‹เคก เค•เคฐเคคเฅ‡ เคธเคฎเคฏ เคคเฅเคฐเฅเคŸเคฟ", "Error uploading file": "เคซเคผเคพเค‡เคฒ เค…เคชเคฒเฅ‹เคก เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", "Error uploading image": "เค›เคตเคฟ เค…เคชเคฒเฅ‹เคก เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", @@ -249,6 +259,7 @@ "Generating...": "เคฌเคจเคพ เคฐเคนเคพ เคนเฅˆ...", "giphy-command-args": "[เคชเคพเค ]", "giphy-command-description": "เคšเฅˆเคจเคฒ เคชเคฐ เคเค• เค•เฅเคฐเฅ‰เคซเคฟเคฒ เคœเฅ€เค†เค‡เคเคซ เคชเฅ‹เคธเฅเคŸ เค•เคฐเฅ‡เค‚", + "Group info": "เคธเคฎเฅ‚เคน เคœเคพเคจเค•เคพเคฐเฅ€", "Hide who voted": "เค•เคฟเคธเคจเฅ‡ เคตเฅ‹เคŸ เคฆเคฟเคฏเคพ เค›เคฟเคชเคพเคเค‚", "Image": "เค›เคตเคฟ", "imageCount_one": "1 เค›เคตเคฟ", @@ -327,6 +338,7 @@ "Location": "เคธเฅเคฅเคพเคจ", "Location sharing ended": "เคธเฅเคฅเคพเคจ เคธเคพเคเคพ เค•เคฐเคจเคพ เคธเคฎเคพเคชเฅเคค", "Location: {{ coordinates }}": "เคธเฅเคฅเคพเคจ: {{ coordinates }}", + "Manage channel": "เคšเฅˆเคจเคฒ เคชเฅเคฐเคฌเค‚เคงเคฟเคค เค•เคฐเฅ‡เค‚", "Mark as unread": "เค…เคชเค เคฟเคค เคšเคฟเคนเฅเคจเคฟเคค เค•เคฐเฅ‡เค‚", "Maximum number of votes (from 2 to 10)": "เค…เคงเคฟเค•เคคเคฎ เคตเฅ‹เคŸเฅ‹เค‚ เค•เฅ€ เคธเค‚เค–เฅเคฏเคพ (2 เคธเฅ‡ 10)", "Maximum votes per person": "เคชเฅเคฐเคคเคฟ เคตเฅเคฏเค•เฅเคคเคฟ เค…เคงเคฟเค•เคคเคฎ เคตเฅ‹เคŸ", @@ -342,6 +354,8 @@ "Missing permissions to upload the attachment": "เค…เคŸเฅˆเคšเคฎเฅ‡เค‚เคŸ เค…เคชเคฒเฅ‹เคก เค•เคฐเคจเฅ‡ เค•เฅ‡ เคฒเคฟเค เค…เคจเฅเคฎเคคเคฟเคฏเคพเค‚ เค—เคพเคฏเคฌ", "Multiple votes": "เค•เคˆ เคตเฅ‹เคŸ", "Mute": "เคฎเฅเคฏเฅ‚เคŸ เค•เคฐเฅ‡", + "Mute chat": "เคšเฅˆเคŸ เคฎเฅเคฏเฅ‚เคŸ เค•เคฐเฅ‡เค‚", + "Mute user": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค•เฅ‹ เคฎเฅเคฏเฅ‚เคŸ เค•เคฐเฅ‡เค‚", "mute-command-args": "[@เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคจเคพเคฎ]", "mute-command-description": "เคเค• เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค•เฅ‹ เคฎเฅเคฏเฅ‚เคŸ เค•เคฐเฅ‡เค‚", "network error": "เคจเฅ‡เคŸเคตเคฐเฅเค• เคคเฅเคฐเฅเคŸเคฟ", @@ -489,6 +503,8 @@ "Unblock User": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค…เคจเคฌเฅเคฒเฅ‰เค• เค•เคฐเฅ‡เค‚", "unknown error": "เค…เคœเฅเคžเคพเคค เคคเฅเคฐเฅเคŸเคฟ", "Unmute": "เค…เคจเคฎเฅเคฏเฅ‚เคŸ", + "Unmute chat": "เคšเฅˆเคŸ เค…เคจเคฎเฅเคฏเฅ‚เคŸ เค•เคฐเฅ‡เค‚", + "Unmute user": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค•เฅ‹ เค…เคจเคฎเฅเคฏเฅ‚เคŸ เค•เคฐเฅ‡เค‚", "unmute-command-args": "[@เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคจเคพเคฎ]", "unmute-command-description": "เคเค• เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค•เฅ‹ เค…เคจเคฎเฅเคฏเฅ‚เคŸ เค•เคฐเฅ‡เค‚", "Unpin": "เค…เคจเคชเคฟเคจ", @@ -503,7 +519,9 @@ "Upload failed": "เค…เคชเคฒเฅ‹เคก เคตเคฟเคซเคฒ", "Upload type: \"{{ type }}\" is not allowed": "เค…เคชเคฒเฅ‹เคก เคชเฅเคฐเค•เคพเคฐ: \"{{ type }}\" เค•เฅ€ เค…เคจเฅเคฎเคคเคฟ เคจเคนเฅ€เค‚ เคนเฅˆ", "User blocked": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค…เคตเคฐเฅเคฆเฅเคง เค•เคฟเคฏเคพ เค—เคฏเคพ", + "User muted": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เคฎเฅเคฏเฅ‚เคŸ เค•เคฟเคฏเคพ เค—เคฏเคพ", "User unblocked": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค…เคจเคฌเฅเคฒเฅ‰เค• เค•เคฟเคฏเคพ เค—เคฏเคพ", + "User unmuted": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค…เคจเคฎเฅเคฏเฅ‚เคŸ เค•เคฟเคฏเคพ เค—เคฏเคพ", "User uploaded content": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค…เคชเคฒเฅ‹เคก เค•เฅ€ เค—เคˆ เคธเคพเคฎเค—เฅเคฐเฅ€", "Video": "เคตเฅ€เคกเคฟเคฏเฅ‹", "videoCount_one": "1 เคตเฅ€เคกเคฟเคฏเฅ‹", diff --git a/src/i18n/it.json b/src/i18n/it.json index e0946a2305..9eec64e7ac 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -73,6 +73,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,6 +113,7 @@ "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 Menu": "Apri menu", "aria/Open Message Actions Menu": "Apri il menu delle azioni di messaggio", "aria/Open Reaction Selector": "Apri il selettore di reazione", @@ -159,6 +161,7 @@ "Back": "Indietro", "ban-command-args": "[@nomeutente] [testo]", "ban-command-description": "Vietare un utente", + "Block user": "Blocca utente", "Block User": "Blocca utente", "Cancel": "Annulla", "Cannot seek in the recording": "Impossibile cercare nella registrazione", @@ -181,12 +184,14 @@ "Commands": "Comandi", "Commands matching": "Comandi corrispondenti", "Connection failure, reconnecting now...": "Errore di connessione, riconnessione in corso...", + "Contact info": "Informazioni 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", @@ -214,16 +219,21 @@ "Enforce unique vote is enabled": "Il voto unico รจ abilitato", "Error": "Errore", "Error adding flag": "Errore durante l'aggiunta del flag", + "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 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 pinning message": "Errore durante il blocco del messaggio", "Error removing message pin": "Errore durante la rimozione del PIN del messaggio", "Error reproducing the recording": "Errore durante la riproduzione della registrazione", "Error starting recording": "Errore durante l'avvio della registrazione", "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", @@ -257,6 +267,7 @@ "Generating...": "Generando...", "giphy-command-args": "[testo]", "giphy-command-description": "Pubblica un gif casuale sul canale", + "Group info": "Informazioni gruppo", "Hide who voted": "Nascondi chi ha votato", "Image": "Immagine", "imageCount_one": "Immagine", @@ -337,6 +348,7 @@ "Location": "Posizione", "Location sharing ended": "Condivisione posizione terminata", "Location: {{ coordinates }}": "Posizione: {{ coordinates }}", + "Manage channel": "Gestisci canale", "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", @@ -352,6 +364,8 @@ "Missing permissions to upload the attachment": "Autorizzazioni mancanti per caricare l'allegato", "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", @@ -504,6 +518,8 @@ "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", @@ -518,7 +534,9 @@ "Upload failed": "Caricamento non riuscito", "Upload type: \"{{ type }}\" is not allowed": "Tipo di caricamento: \"{{ type }}\" non รจ consentito", "User blocked": "Utente bloccato", + "User muted": "Utente silenziato", "User unblocked": "Utente sbloccato", + "User unmuted": "Utente riattivato", "User uploaded content": "Contenuto caricato dall'utente", "Video": "Video", "videoCount_one": "Video", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index c4c95114b5..3432de852b 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -64,6 +64,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,6 +104,7 @@ "aria/Notifications": "้€š็Ÿฅ", "aria/Open Attachment Selector": "ๆทปไป˜ใƒ•ใ‚กใ‚คใƒซ้ธๆŠžใ‚’้–‹ใ", "aria/Open Channel Actions Menu": "ใƒใƒฃใƒณใƒใƒซใ‚ขใ‚ฏใ‚ทใƒงใƒณใƒกใƒ‹ใƒฅใƒผใ‚’้–‹ใ", + "aria/Open channel details": "ใƒใƒฃใƒณใƒใƒซ่ฉณ็ดฐใ‚’้–‹ใ", "aria/Open Menu": "ใƒกใƒ‹ใƒฅใƒผใ‚’้–‹ใ", "aria/Open Message Actions Menu": "ใƒกใƒƒใ‚ปใƒผใ‚ธใ‚ขใ‚ฏใ‚ทใƒงใƒณใƒกใƒ‹ใƒฅใƒผใ‚’้–‹ใ", "aria/Open Reaction Selector": "ใƒชใ‚ขใ‚ฏใ‚ทใƒงใƒณใ‚ปใƒฌใ‚ฏใ‚ฟใƒผใ‚’้–‹ใ", @@ -150,6 +152,7 @@ "Back": "ๆˆปใ‚‹", "ban-command-args": "[@ใƒฆใƒผใ‚ถๅ] [ใƒ†ใ‚ญใ‚นใƒˆ]", "ban-command-description": "ใƒฆใƒผใ‚ถใƒผใ‚’็ฆๆญขใ™ใ‚‹", + "Block user": "ใƒฆใƒผใ‚ถใƒผใ‚’ใƒ–ใƒญใƒƒใ‚ฏ", "Block User": "ใƒฆใƒผใ‚ถใƒผใ‚’ใƒ–ใƒญใƒƒใ‚ฏ", "Cancel": "ใ‚ญใƒฃใƒณใ‚ปใƒซ", "Cannot seek in the recording": "้Œฒ้Ÿณไธญใซใ‚ทใƒผใ‚ฏใงใใพใ›ใ‚“", @@ -172,12 +175,14 @@ "Commands": "ใ‚ณใƒžใƒณใƒ‰", "Commands matching": "ไธ€่‡ดใ™ใ‚‹ใ‚ณใƒžใƒณใƒ‰", "Connection failure, reconnecting now...": "ๆŽฅ็ถšใŒๅคฑๆ•—ใ—ใพใ—ใŸใ€‚ๅ†ๆŽฅ็ถšไธญ...", + "Contact info": "้€ฃ็ตกๅ…ˆๆƒ…ๅ ฑ", "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": "้…ไฟกใ—ใพใ—ใŸ", @@ -205,16 +210,21 @@ "Enforce unique vote is enabled": "ไธ€ๆ„ใฎๆŠ•็ฅจใŒๆœ‰ๅŠนใซใชใฃใฆใ„ใพใ™", "Error": "ใ‚จใƒฉใƒผ", "Error adding flag": "ใƒ•ใƒฉใ‚ฐใ‚’่ฟฝๅŠ ใฎใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", + "Error blocking user": "ใƒฆใƒผใ‚ถใƒผใฎใƒ–ใƒญใƒƒใ‚ฏไธญใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", "Error connecting to chat, refresh the page to try again.": "ใƒใƒฃใƒƒใƒˆใธใฎๆŽฅ็ถšใŒใงใใพใ›ใ‚“ใงใ—ใŸใ€‚ใƒšใƒผใ‚ธใ‚’ๆ›ดๆ–ฐใ—ใฆใใ ใ•ใ„ใ€‚", "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 pinning message": "ใƒกใƒƒใ‚ปใƒผใ‚ธใ‚’ใƒ”ใƒณใฎใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", "Error removing message pin": "ใƒกใƒƒใ‚ปใƒผใ‚ธใฎใƒ”ใƒณใ‚’ๅ‰Š้™คใฎใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", "Error reproducing the recording": "้Œฒ้Ÿณใฎๅ†็”Ÿไธญใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", "Error starting recording": "้Œฒ้Ÿณใฎ้–‹ๅง‹ๆ™‚ใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", "Error unmuting a user ...": "ใƒฆใƒผใ‚ถใƒผใฎ็„ก้Ÿณ่งฃ้™คใฎใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ...", + "Error unmuting channel": "ใƒใƒฃใƒณใƒใƒซใฎใƒŸใƒฅใƒผใƒˆ่งฃ้™คไธญใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", + "Error unmuting user": "ใƒฆใƒผใ‚ถใƒผใฎใƒŸใƒฅใƒผใƒˆ่งฃ้™คไธญใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", "Error uploading attachment": "ๆทปไป˜ใƒ•ใ‚กใ‚คใƒซใฎใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ไธญใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", "Error uploading file": "ใƒ•ใ‚กใ‚คใƒซใ‚’ใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ใฎใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", "Error uploading image": "็”ปๅƒใ‚’ใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ใฎใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", @@ -246,6 +256,7 @@ "Generating...": "็”Ÿๆˆไธญ...", "giphy-command-args": "[ใƒ†ใ‚ญใ‚นใƒˆ]", "giphy-command-description": "ใƒใƒฃใƒณใƒใƒซใซใƒฉใƒณใƒ€ใƒ ใชGIFใ‚’ๆŠ•็จฟใ™ใ‚‹", + "Group info": "ใ‚ฐใƒซใƒผใƒ—ๆƒ…ๅ ฑ", "Hide who voted": "่ชฐใŒๆŠ•็ฅจใ—ใŸใ‹ใ‚’้ž่กจ็คบใซใ™ใ‚‹", "Image": "็”ปๅƒ", "imageCount_other": "{{ count }}ไปถใฎ็”ปๅƒ", @@ -322,6 +333,7 @@ "Location": "ไฝ็ฝฎๆƒ…ๅ ฑ", "Location sharing ended": "ไฝ็ฝฎๆƒ…ๅ ฑใฎๅ…ฑๆœ‰ใŒ็ต‚ไบ†ใ—ใพใ—ใŸ", "Location: {{ coordinates }}": "ไฝ็ฝฎ: {{ coordinates }}", + "Manage channel": "ใƒใƒฃใƒณใƒใƒซใ‚’็ฎก็†", "Mark as unread": "ๆœช่ชญใจใ—ใฆใƒžใƒผใ‚ฏ", "Maximum number of votes (from 2 to 10)": "ๆœ€ๅคงๆŠ•็ฅจๆ•ฐ๏ผˆ2ใ‹ใ‚‰10ใพใง๏ผ‰", "Maximum votes per person": "1ไบบใ‚ใŸใ‚Šใฎๆœ€ๅคงๆŠ•็ฅจๆ•ฐ", @@ -337,6 +349,8 @@ "Missing permissions to upload the attachment": "ๆทปไป˜ใƒ•ใ‚กใ‚คใƒซใ‚’ใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ใ™ใ‚‹ใŸใ‚ใฎ่จฑๅฏใŒใ‚ใ‚Šใพใ›ใ‚“", "Multiple votes": "่ค‡ๆ•ฐๆŠ•็ฅจ", "Mute": "็„ก้Ÿณ", + "Mute chat": "ใƒใƒฃใƒƒใƒˆใ‚’ใƒŸใƒฅใƒผใƒˆ", + "Mute user": "ใƒฆใƒผใ‚ถใƒผใ‚’ใƒŸใƒฅใƒผใƒˆ", "mute-command-args": "[@ใƒฆใƒผใ‚ถๅ]", "mute-command-description": "ใƒฆใƒผใ‚ถใƒผใ‚’ใƒŸใƒฅใƒผใƒˆใ™ใ‚‹", "network error": "ใƒใƒƒใƒˆใƒฏใƒผใ‚ฏใ‚จใƒฉใƒผ", @@ -482,6 +496,8 @@ "Unblock User": "ใƒฆใƒผใ‚ถใƒผใฎใƒ–ใƒญใƒƒใ‚ฏใ‚’่งฃ้™ค", "unknown error": "ไธๆ˜Žใชใ‚จใƒฉใƒผ", "Unmute": "็„ก้Ÿณใ‚’่งฃ้™คใ™ใ‚‹", + "Unmute chat": "ใƒใƒฃใƒƒใƒˆใฎใƒŸใƒฅใƒผใƒˆใ‚’่งฃ้™ค", + "Unmute user": "ใƒฆใƒผใ‚ถใƒผใฎใƒŸใƒฅใƒผใƒˆใ‚’่งฃ้™ค", "unmute-command-args": "[@ใƒฆใƒผใ‚ถๅ]", "unmute-command-description": "ใƒฆใƒผใ‚ถใƒผใฎใƒŸใƒฅใƒผใƒˆใ‚’่งฃ้™คใ™ใ‚‹", "Unpin": "ใƒ”ใƒณใ‚’่งฃ้™คใ™ใ‚‹", @@ -496,7 +512,9 @@ "Upload failed": "ใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ใซๅคฑๆ•—ใ—ใพใ—ใŸ", "Upload type: \"{{ type }}\" is not allowed": "ใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ใ‚ฟใ‚คใƒ—๏ผš\"{{ type }}\"ใฏ่จฑๅฏใ•ใ‚Œใฆใ„ใพใ›ใ‚“", "User blocked": "ใƒฆใƒผใ‚ถใƒผใ‚’ใƒ–ใƒญใƒƒใ‚ฏใ—ใพใ—ใŸ", + "User muted": "ใƒฆใƒผใ‚ถใƒผใ‚’ใƒŸใƒฅใƒผใƒˆใ—ใพใ—ใŸ", "User unblocked": "ใƒฆใƒผใ‚ถใƒผใฎใƒ–ใƒญใƒƒใ‚ฏใ‚’่งฃ้™คใ—ใพใ—ใŸ", + "User unmuted": "ใƒฆใƒผใ‚ถใƒผใฎใƒŸใƒฅใƒผใƒˆใ‚’่งฃ้™คใ—ใพใ—ใŸ", "User uploaded content": "ใƒฆใƒผใ‚ถใƒผใŒใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ใ—ใŸใ‚ณใƒณใƒ†ใƒณใƒ„", "Video": "ๅ‹•็”ป", "videoCount_other": "{{ count }}ไปถใฎๅ‹•็”ป", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index e62f036769..9a94a13507 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -64,6 +64,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,6 +104,7 @@ "aria/Notifications": "์•Œ๋ฆผ", "aria/Open Attachment Selector": "์ฒจ๋ถ€ ํŒŒ์ผ ์„ ํƒ๊ธฐ ์—ด๊ธฐ", "aria/Open Channel Actions Menu": "์ฑ„๋„ ์ž‘์—… ๋ฉ”๋‰ด ์—ด๊ธฐ", + "aria/Open channel details": "์ฑ„๋„ ์„ธ๋ถ€ ์ •๋ณด ์—ด๊ธฐ", "aria/Open Menu": "๋ฉ”๋‰ด ์—ด๊ธฐ", "aria/Open Message Actions Menu": "๋ฉ”์‹œ์ง€ ์•ก์…˜ ๋ฉ”๋‰ด ์—ด๊ธฐ", "aria/Open Reaction Selector": "๋ฐ˜์‘ ์„ ํƒ๊ธฐ ์—ด๊ธฐ", @@ -150,6 +152,7 @@ "Back": "๋’ค๋กœ", "ban-command-args": "[@์‚ฌ์šฉ์ž์ด๋ฆ„] [ํ…์ŠคํŠธ]", "ban-command-description": "์‚ฌ์šฉ์ž๋ฅผ ์ฐจ๋‹จ", + "Block user": "์‚ฌ์šฉ์ž ์ฐจ๋‹จ", "Block User": "์‚ฌ์šฉ์ž ์ฐจ๋‹จ", "Cancel": "์ทจ์†Œ", "Cannot seek in the recording": "๋…น์Œ์—์„œ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", @@ -172,12 +175,14 @@ "Commands": "๋ช…๋ น์–ด", "Commands matching": "์ผ์น˜ํ•˜๋Š” ๋ช…๋ น", "Connection failure, reconnecting now...": "์—ฐ๊ฒฐ ์‹คํŒจ, ์ง€๊ธˆ ๋‹ค์‹œ ์—ฐ๊ฒฐ ์ค‘...", + "Contact info": "์—ฐ๋ฝ์ฒ˜ ์ •๋ณด", "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": "๋ฐฐ๋‹ฌ๋จ", @@ -205,16 +210,21 @@ "Enforce unique vote is enabled": "๊ณ ์œ  ํˆฌํ‘œ๊ฐ€ ํ™œ์„ฑํ™”๋˜์—ˆ์Šต๋‹ˆ๋‹ค", "Error": "์˜ค๋ฅ˜", "Error adding flag": "ํ”Œ๋ž˜๊ทธ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๋™์•ˆ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", + "Error blocking user": "์‚ฌ์šฉ์ž ์ฐจ๋‹จ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", "Error connecting to chat, refresh the page to try again.": "์ฑ„ํŒ…์— ์—ฐ๊ฒฐํ•˜๋Š” ๋™์•ˆ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ํŽ˜์ด์ง€๋ฅผ ์ƒˆ๋กœ๊ณ ์นจํ•˜์—ฌ ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”.", "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 pinning message": "๋ฉ”์‹œ์ง€๋ฅผ ํ•€ํ•˜๋Š” ์ค‘์— ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", "Error removing message pin": "๋ฉ”์‹œ์ง€ ํ•€์„ ์ œ๊ฑฐํ•˜๋Š” ์ค‘์— ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", "Error reproducing the recording": "๋…น์Œ ์žฌ์ƒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", "Error starting recording": "๋…น์Œ ์‹œ์ž‘ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค", "Error unmuting a user ...": "์‚ฌ์šฉ์ž ์Œ์†Œ๊ฑฐ ํ•ด์ œ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ...", + "Error unmuting channel": "์ฑ„๋„ ์Œ์†Œ๊ฑฐ ํ•ด์ œ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", + "Error unmuting user": "์‚ฌ์šฉ์ž ์Œ์†Œ๊ฑฐ ํ•ด์ œ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", "Error uploading attachment": "์ฒจ๋ถ€ ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค", "Error uploading file": "ํŒŒ์ผ ์—…๋กœ๋“œ ์˜ค๋ฅ˜", "Error uploading image": "์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•˜๋Š” ๋™์•ˆ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", @@ -246,6 +256,7 @@ "Generating...": "์ƒ์„ฑ ์ค‘...", "giphy-command-args": "[ํ…์ŠคํŠธ]", "giphy-command-description": "์ฑ„๋„์— ๋ฌด์ž‘์œ„ GIF ๊ฒŒ์‹œ", + "Group info": "๊ทธ๋ฃน ์ •๋ณด", "Hide who voted": "๋ˆ„๊ฐ€ ํˆฌํ‘œํ–ˆ๋Š”์ง€ ์ˆจ๊ธฐ๊ธฐ", "Image": "์ด๋ฏธ์ง€", "imageCount_other": "์ด๋ฏธ์ง€ {{ count }}๊ฐœ", @@ -322,6 +333,7 @@ "Location": "์œ„์น˜", "Location sharing ended": "์œ„์น˜ ๊ณต์œ ๊ฐ€ ์ข…๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", "Location: {{ coordinates }}": "์œ„์น˜: {{ coordinates }}", + "Manage channel": "์ฑ„๋„ ๊ด€๋ฆฌ", "Mark as unread": "์ฝ์ง€ ์•Š์Œ์œผ๋กœ ํ‘œ์‹œ", "Maximum number of votes (from 2 to 10)": "์ตœ๋Œ€ ํˆฌํ‘œ ์ˆ˜ (2์—์„œ 10๊นŒ์ง€)", "Maximum votes per person": "1์ธ๋‹น ์ตœ๋Œ€ ํˆฌํ‘œ ์ˆ˜", @@ -337,6 +349,8 @@ "Missing permissions to upload the attachment": "์ฒจ๋ถ€ ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜๋ ค๋ฉด ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค", "Multiple votes": "๋ณต์ˆ˜ ํˆฌํ‘œ", "Mute": "๋ฌด์Œ", + "Mute chat": "์ฑ„ํŒ… ์Œ์†Œ๊ฑฐ", + "Mute user": "์‚ฌ์šฉ์ž ์Œ์†Œ๊ฑฐ", "mute-command-args": "[@์‚ฌ์šฉ์ž์ด๋ฆ„]", "mute-command-description": "์‚ฌ์šฉ์ž ์Œ์†Œ๊ฑฐ", "network error": "๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜", @@ -482,6 +496,8 @@ "Unblock User": "์‚ฌ์šฉ์ž ์ฐจ๋‹จ ํ•ด์ œ", "unknown error": "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜", "Unmute": "์Œ์†Œ๊ฑฐ ํ•ด์ œ", + "Unmute chat": "์ฑ„ํŒ… ์Œ์†Œ๊ฑฐ ํ•ด์ œ", + "Unmute user": "์‚ฌ์šฉ์ž ์Œ์†Œ๊ฑฐ ํ•ด์ œ", "unmute-command-args": "[@์‚ฌ์šฉ์ž์ด๋ฆ„]", "unmute-command-description": "์‚ฌ์šฉ์ž ์Œ์†Œ๊ฑฐ ํ•ด์ œ", "Unpin": "ํ•€ ํ•ด์ œ", @@ -496,7 +512,9 @@ "Upload failed": "์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", "Upload type: \"{{ type }}\" is not allowed": "์—…๋กœ๋“œ ์œ ํ˜•: \"{{ type }}\"์€(๋Š”) ํ—ˆ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", "User blocked": "์‚ฌ์šฉ์ž๊ฐ€ ์ฐจ๋‹จ๋จ", + "User muted": "์‚ฌ์šฉ์ž๊ฐ€ ์Œ์†Œ๊ฑฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", "User unblocked": "์‚ฌ์šฉ์ž ์ฐจ๋‹จ์ด ํ•ด์ œ๋จ", + "User unmuted": "์‚ฌ์šฉ์ž ์Œ์†Œ๊ฑฐ๊ฐ€ ํ•ด์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", "User uploaded content": "์‚ฌ์šฉ์ž ์—…๋กœ๋“œ ์ฝ˜ํ…์ธ ", "Video": "๋™์˜์ƒ", "videoCount_other": "๋™์˜์ƒ {{ count }}๊ฐœ", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 5d1b16af1f..fecd448186 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -65,6 +65,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,6 +105,7 @@ "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 Menu": "Menu openen", "aria/Open Message Actions Menu": "Menu voor berichtacties openen", "aria/Open Reaction Selector": "Reactiekiezer openen", @@ -151,6 +153,7 @@ "Back": "Terug", "ban-command-args": "[@gebruikersnaam] [tekst]", "ban-command-description": "Een gebruiker verbannen", + "Block user": "Gebruiker blokkeren", "Block User": "Gebruiker blokkeren", "Cancel": "Annuleer", "Cannot seek in the recording": "Kan niet zoeken in de opname", @@ -173,12 +176,14 @@ "Commands": "Commando's", "Commands matching": "Bijpassende opdrachten", "Connection failure, reconnecting now...": "Verbindingsfout, opnieuw verbinden...", + "Contact info": "Contactgegevens", "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", @@ -206,16 +211,21 @@ "Enforce unique vote is enabled": "Unieke stem is ingeschakeld", "Error": "Fout", "Error adding flag": "Fout bij toevoegen van vlag", + "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 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 pinning message": "Fout bij vastzetten van bericht", "Error removing message pin": "Fout bij verwijderen van berichtpin", "Error reproducing the recording": "Fout bij het afspelen van de opname", "Error starting recording": "Fout bij het starten van de opname", "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", @@ -248,6 +258,7 @@ "Generating...": "Genereren...", "giphy-command-args": "[tekst]", "giphy-command-description": "Plaats een willekeurige gif in het kanaal", + "Group info": "Groepsinformatie", "Hide who voted": "Verberg wie heeft gestemd", "Image": "Afbeelding", "imageCount_one": "Afbeelding", @@ -326,6 +337,7 @@ "Location": "Locatie", "Location sharing ended": "Locatie delen beรซindigd", "Location: {{ coordinates }}": "Locatie: {{ coordinates }}", + "Manage channel": "Kanaal 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", @@ -341,6 +353,8 @@ "Missing permissions to upload the attachment": "Missende toestemmingen om de bijlage te uploaden", "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", @@ -490,6 +504,8 @@ "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", @@ -504,7 +520,9 @@ "Upload failed": "Upload mislukt", "Upload type: \"{{ type }}\" is not allowed": "Uploadtype: \"{{ type }}\" is niet toegestaan", "User blocked": "Gebruiker geblokkeerd", + "User muted": "Gebruiker gedempt", "User unblocked": "Gebruiker gedeblokkeerd", + "User unmuted": "Gebruiker niet meer gedempt", "User uploaded content": "Gebruikersgeรผploade inhoud", "Video": "Video", "videoCount_one": "Video", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 6b44bac3b2..11a84dffd4 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -73,6 +73,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,6 +113,7 @@ "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 Menu": "Abrir menu", "aria/Open Message Actions Menu": "Abrir menu de aรงรตes de mensagem", "aria/Open Reaction Selector": "Abrir seletor de reaรงรตes", @@ -159,6 +161,7 @@ "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", "Cancel": "Cancelar", "Cannot seek in the recording": "Nรฃo รฉ possรญvel buscar na gravaรงรฃo", @@ -181,12 +184,14 @@ "Commands": "Comandos", "Commands matching": "Comandos correspondentes", "Connection failure, reconnecting now...": "Falha de conexรฃo, reconectando agora...", + "Contact info": "Informaรงรตes 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", @@ -214,16 +219,21 @@ "Enforce unique vote is enabled": "Voto รบnico estรก habilitado", "Error": "Erro", "Error adding flag": "Erro ao reportar", + "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 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 pinning message": "Erro ao fixar mensagem", "Error removing message pin": "Erro ao remover o PIN da mensagem", "Error reproducing the recording": "Erro ao reproduzir a gravaรงรฃo", "Error starting recording": "Erro ao iniciar a gravaรงรฃo", "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", @@ -257,6 +267,7 @@ "Generating...": "Gerando...", "giphy-command-args": "[texto]", "giphy-command-description": "Postar um gif aleatรณrio no canal", + "Group info": "Informaรงรตes do grupo", "Hide who voted": "Ocultar quem votou", "Image": "Imagem", "imageCount_one": "Imagem", @@ -337,6 +348,7 @@ "Location": "Localizaรงรฃo", "Location sharing ended": "Compartilhamento de localizaรงรฃo encerrado", "Location: {{ coordinates }}": "Localizaรงรฃo: {{ coordinates }}", + "Manage channel": "Gerenciar canal", "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", @@ -352,6 +364,8 @@ "Missing permissions to upload the attachment": "Faltando permissรตes para enviar o anexo", "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", @@ -504,6 +518,8 @@ "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", @@ -518,7 +534,9 @@ "Upload failed": "Falha no envio", "Upload type: \"{{ type }}\" is not allowed": "Tipo de upload: \"{{ type }}\" nรฃo รฉ permitido", "User blocked": "Usuรกrio bloqueado", + "User muted": "Usuรกrio silenciado", "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", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 830068fb96..e50409e696 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -82,6 +82,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,6 +122,7 @@ "aria/Notifications": "ะฃะฒะตะดะพะผะปะตะฝะธั", "aria/Open Attachment Selector": "ะžั‚ะบั€ั‹ั‚ัŒ ะฒั‹ะฑะพั€ ะฒะปะพะถะตะฝะธะน", "aria/Open Channel Actions Menu": "ะžั‚ะบั€ั‹ั‚ัŒ ะผะตะฝัŽ ะดะตะนัั‚ะฒะธะน ะบะฐะฝะฐะปะฐ", + "aria/Open channel details": "ะžั‚ะบั€ั‹ั‚ัŒ ัะฒะตะดะตะฝะธั ะพ ะบะฐะฝะฐะปะต", "aria/Open Menu": "ะžั‚ะบั€ั‹ั‚ัŒ ะผะตะฝัŽ", "aria/Open Message Actions Menu": "ะžั‚ะบั€ั‹ั‚ัŒ ะผะตะฝัŽ ะดะตะนัั‚ะฒะธะน ั ัะพะพะฑั‰ะตะฝะธัะผะธ", "aria/Open Reaction Selector": "ะžั‚ะบั€ั‹ั‚ัŒ ัะตะปะตะบั‚ะพั€ ั€ะตะฐะบั†ะธะน", @@ -168,6 +170,7 @@ "Back": "ะะฐะทะฐะด", "ban-command-args": "[@ะธะผัะฟะพะปัŒะทะพะฒะฐั‚ะตะปั] [ั‚ะตะบัั‚]", "ban-command-description": "ะ—ะฐะฑะปะพะบะธั€ะพะฒะฐั‚ัŒ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", + "Block user": "ะ—ะฐะฑะปะพะบะธั€ะพะฒะฐั‚ัŒ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", "Block User": "ะ—ะฐะฑะปะพะบะธั€ะพะฒะฐั‚ัŒ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", "Cancel": "ะžั‚ะผะตะฝะฐ", "Cannot seek in the recording": "ะะตะฒะพะทะผะพะถะฝะพ ะพััƒั‰ะตัั‚ะฒะธั‚ัŒ ะฟะพะธัะบ ะฒ ะทะฐะฟะธัะธ", @@ -190,12 +193,14 @@ "Commands": "ะšะพะผะฐะฝะดั‹", "Commands matching": "ะกะพะพั‚ะฒะตั‚ัั‚ะฒะธะต ะบะพะผะฐะฝะด", "Connection failure, reconnecting now...": "ะžัˆะธะฑะบะฐ ัะพะตะดะธะฝะตะฝะธั, ะฟะตั€ะตะฟะพะดะบะปัŽั‡ะตะฝะธะต...", + "Contact info": "ะ˜ะฝั„ะพั€ะผะฐั†ะธั ะพ ะบะพะฝั‚ะฐะบั‚ะต", "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": "ะžั‚ะฟั€ะฐะฒะปะตะฝะพ", @@ -223,16 +228,21 @@ "Enforce unique vote is enabled": "ะฃะฝะธะบะฐะปัŒะฝะพะต ะณะพะปะพัะพะฒะฐะฝะธะต ะฒะบะปัŽั‡ะตะฝะพ", "Error": "ะžัˆะธะฑะบะฐ", "Error adding flag": "ะžัˆะธะฑะบะฐ ะดะพะฑะฐะฒะปะตะฝะธั ั„ะปะฐะณะฐ", + "Error blocking user": "ะžัˆะธะฑะบะฐ ะฟั€ะธ ะฑะปะพะบะธั€ะพะฒะบะต ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", "Error connecting to chat, refresh the page to try again.": "ะžัˆะธะฑะบะฐ ะฟะพะดะบะปัŽั‡ะตะฝะธั ะบ ั‡ะฐั‚ัƒ, ะพะฑะฝะพะฒะธั‚ะต ัั‚ั€ะฐะฝะธั†ัƒ ั‡ั‚ะพะฑั‹ ะฟะพะฟั€ะพะฑะพะฒะฐั‚ัŒ ัะฝะพะฒะฐ.", "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 pinning message": "ะกะพะพะฑั‰ะตะฝะธะต ะพะฑ ะพัˆะธะฑะบะต ะฟั€ะธ ะทะฐะบั€ะตะฟะปะตะฝะธะธ", "Error removing message pin": "ะžัˆะธะฑะบะฐ ะฟั€ะธ ัƒะดะฐะปะตะฝะธะธ ะฑัƒะปะฐะฒะบะธ ัะพะพะฑั‰ะตะฝะธั", "Error reproducing the recording": "ะžัˆะธะฑะบะฐ ะฒะพัะฟั€ะพะธะทะฒะตะดะตะฝะธั ะทะฐะฟะธัะธ", "Error starting recording": "ะžัˆะธะฑะบะฐ ะฟั€ะธ ะทะฐะฟัƒัะบะต ะทะฐะฟะธัะธ", "Error unmuting a user ...": "ะžัˆะธะฑะบะฐ ะฒะบะปัŽั‡ะตะฝะธั ัƒะฒะตะดะพะผะปะตะฝะธะน...", + "Error unmuting channel": "ะžัˆะธะฑะบะฐ ะฟั€ะธ ะฒะบะปัŽั‡ะตะฝะธะธ ัƒะฒะตะดะพะผะปะตะฝะธะน ะบะฐะฝะฐะปะฐ", + "Error unmuting user": "ะžัˆะธะฑะบะฐ ะฟั€ะธ ะฒะบะปัŽั‡ะตะฝะธะธ ัƒะฒะตะดะพะผะปะตะฝะธะน ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", "Error uploading attachment": "ะžัˆะธะฑะบะฐ ะฟั€ะธ ะทะฐะณั€ัƒะทะบะต ะฒะปะพะถะตะฝะธั", "Error uploading file": "ะžัˆะธะฑะบะฐ ะฟั€ะธ ะทะฐะณั€ัƒะทะบะต ั„ะฐะนะปะฐ", "Error uploading image": "ะžัˆะธะฑะบะฐ ะทะฐะณั€ัƒะทะบะธ ะธะทะพะฑั€ะฐะถะตะฝะธั", @@ -270,6 +280,7 @@ "Generating...": "ะ“ะตะฝะตั€ะธั€ัƒัŽ...", "giphy-command-args": "[ั‚ะตะบัั‚]", "giphy-command-description": "ะžะฟัƒะฑะปะธะบะพะฒะฐั‚ัŒ ัะปัƒั‡ะฐะนะฝัƒัŽ GIF-ะฐะฝะธะผะฐั†ะธัŽ ะฒ ะบะฐะฝะฐะปะต", + "Group info": "ะ˜ะฝั„ะพั€ะผะฐั†ะธั ะพ ะณั€ัƒะฟะฟะต", "Hide who voted": "ะกะบั€ั‹ั‚ัŒ, ะบั‚ะพ ะณะพะปะพัะพะฒะฐะป", "Image": "ะ˜ะทะพะฑั€ะฐะถะตะฝะธะต", "imageCount_one": "{{ count }} ะธะทะพะฑั€ะฐะถะตะฝะธะต", @@ -352,6 +363,7 @@ "Location": "ะœะตัั‚ะพะฟะพะปะพะถะตะฝะธะต", "Location sharing ended": "ะžะฑะผะตะฝ ะผะตัั‚ะพะฟะพะปะพะถะตะฝะธะตะผ ะทะฐะฒะตั€ัˆะตะฝ", "Location: {{ coordinates }}": "ะœะตัั‚ะพะฟะพะปะพะถะตะฝะธะต: {{ coordinates }}", + "Manage channel": "ะฃะฟั€ะฐะฒะปัั‚ัŒ ะบะฐะฝะฐะปะพะผ", "Mark as unread": "ะžั‚ะผะตั‚ะธั‚ัŒ ะบะฐะบ ะฝะตะฟั€ะพั‡ะธั‚ะฐะฝะฝะพะต", "Maximum number of votes (from 2 to 10)": "ะœะฐะบัะธะผะฐะปัŒะฝะพะต ะบะพะปะธั‡ะตัั‚ะฒะพ ะณะพะปะพัะพะฒ (ะพั‚ 2 ะดะพ 10)", "Maximum votes per person": "ะœะฐะบัะธะผัƒะผ ะณะพะปะพัะพะฒ ะฝะฐ ั‡ะตะปะพะฒะตะบะฐ", @@ -367,6 +379,8 @@ "Missing permissions to upload the attachment": "ะžั‚ััƒั‚ัั‚ะฒัƒัŽั‚ ั€ะฐะทั€ะตัˆะตะฝะธั ะดะปั ะทะฐะณั€ัƒะทะบะธ ะฒะปะพะถะตะฝะธั", "Multiple votes": "ะะตัะบะพะปัŒะบะพ ะณะพะปะพัะพะฒ", "Mute": "ะžั‚ะบะปัŽั‡ะธั‚ัŒ ัƒะฒะตะดะพะผะปะตะฝะธั", + "Mute chat": "ะžั‚ะบะปัŽั‡ะธั‚ัŒ ัƒะฒะตะดะพะผะปะตะฝะธั ั‡ะฐั‚ะฐ", + "Mute user": "ะžั‚ะบะปัŽั‡ะธั‚ัŒ ัƒะฒะตะดะพะผะปะตะฝะธั ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", "mute-command-args": "[@ะธะผัะฟะพะปัŒะทะพะฒะฐั‚ะตะปั]", "mute-command-description": "ะ’ั‹ะบะปัŽั‡ะธั‚ัŒ ะผะธะบั€ะพั„ะพะฝ ัƒ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", "network error": "ะพัˆะธะฑะบะฐ ัะตั‚ะธ", @@ -524,6 +538,8 @@ "Unblock User": "ะ ะฐะทะฑะปะพะบะธั€ะพะฒะฐั‚ัŒ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", "unknown error": "ะฝะตะธะทะฒะตัั‚ะฝะฐั ะพัˆะธะฑะบะฐ", "Unmute": "ะ’ะบะปัŽั‡ะธั‚ัŒ ัƒะฒะตะดะพะผะปะตะฝะธั", + "Unmute chat": "ะ’ะบะปัŽั‡ะธั‚ัŒ ัƒะฒะตะดะพะผะปะตะฝะธั ั‡ะฐั‚ะฐ", + "Unmute user": "ะ’ะบะปัŽั‡ะธั‚ัŒ ัƒะฒะตะดะพะผะปะตะฝะธั ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", "unmute-command-args": "[@ะธะผัะฟะพะปัŒะทะพะฒะฐั‚ะตะปั]", "unmute-command-description": "ะ’ะบะปัŽั‡ะธั‚ัŒ ะผะธะบั€ะพั„ะพะฝ ัƒ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", "Unpin": "ะžั‚ะบั€ะตะฟะธั‚ัŒ", @@ -538,7 +554,9 @@ "Upload failed": "ะ—ะฐะณั€ัƒะทะบะฐ ะฝะต ัƒะดะฐะปะฐััŒ", "Upload type: \"{{ type }}\" is not allowed": "ะขะธะฟ ะทะฐะณั€ัƒะทะบะธ: \"{{ type }}\" ะฝะต ั€ะฐะทั€ะตัˆะตะฝ", "User blocked": "ะŸะพะปัŒะทะพะฒะฐั‚ะตะปัŒ ะทะฐะฑะปะพะบะธั€ะพะฒะฐะฝ", + "User muted": "ะฃะฒะตะดะพะผะปะตะฝะธั ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั ะพั‚ะบะปัŽั‡ะตะฝั‹", "User unblocked": "ะŸะพะปัŒะทะพะฒะฐั‚ะตะปัŒ ั€ะฐะทะฑะปะพะบะธั€ะพะฒะฐะฝ", + "User unmuted": "ะฃะฒะตะดะพะผะปะตะฝะธั ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั ะฒะบะปัŽั‡ะตะฝั‹", "User uploaded content": "ะŸะพะปัŒะทะพะฒะฐั‚ะตะปัŒ ะทะฐะณั€ัƒะทะธะป ะบะพะฝั‚ะตะฝั‚", "Video": "ะ’ะธะดะตะพ", "videoCount_one": "{{ count }} ะฒะธะดะตะพ", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 42758610e2..d49f9e93ea 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -65,6 +65,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,6 +105,7 @@ "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 Menu": "Menรผyรผ Aรง", "aria/Open Message Actions Menu": "Mesaj ฤฐลŸlemleri Menรผsรผnรผ Aรง", "aria/Open Reaction Selector": "Tepki Seรงiciyi Aรง", @@ -151,6 +153,7 @@ "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", "Cancel": "ฤฐptal", "Cannot seek in the recording": "Kayฤฑtta arama yapฤฑlamฤฑyor", @@ -173,12 +176,14 @@ "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", "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", @@ -206,16 +211,21 @@ "Enforce unique vote is enabled": "Benzersiz oy etkinleลŸtirildi", "Error": "Hata", "Error adding flag": "Bayrak eklenirken hata oluลŸtu", + "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 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 pinning message": "Mesaj sabitlenirken hata oluลŸtu", "Error removing message pin": "Mesaj PIN'i 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 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", @@ -248,6 +258,7 @@ "Generating...": "OluลŸturuluyor...", "giphy-command-args": "[metin]", "giphy-command-description": "Rastgele bir gif'i kanala gรถnder", + "Group info": "Grup bilgileri", "Hide who voted": "Kimin oy verdiฤŸini gizle", "Image": "Gรถrsel", "imageCount_one": "Gรถrsel", @@ -326,6 +337,7 @@ "Location": "Konum", "Location sharing ended": "Konum paylaลŸฤฑmฤฑ sona erdi", "Location: {{ coordinates }}": "Konum: {{ coordinates }}", + "Manage channel": "Kanalฤฑ 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", @@ -341,6 +353,8 @@ "Missing permissions to upload the attachment": "Ek yรผklemek iรงin izinler eksik", "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ฤฑ", @@ -488,6 +502,8 @@ "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", @@ -502,7 +518,9 @@ "Upload failed": "Yรผkleme baลŸarฤฑsฤฑz oldu", "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 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", 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..72ba436e3b 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -25,6 +25,7 @@ @use '../components/Avatar/styling/AvatarStack' as AvatarStack; @use '../components/Avatar/styling/GroupAvatar' as GroupAvatar; @use '../components/Channel/styling' as Channel; +@use '../components/ChannelDetail/styling' as ChannelDetail; @use '../components/ChannelHeader/styling' as ChannelHeader; @use '../components/ChannelList/styling' as ChannelList; @use '../components/ChannelListItem/styling' as ChannelListItem; @@ -34,6 +35,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 +48,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..9d6d387c08 --- /dev/null +++ b/src/utils/__tests__/isDmChannel.test.ts @@ -0,0 +1,41 @@ +import { fromPartial } from '@total-typescript/shoehorn'; +import type { ChannelState } from 'stream-chat'; +import { describe, expect, it } from 'vitest'; +import { isDmChannel } from '../isDmChannel'; + +describe('isDmChannel', () => { + it('returns true for one-member channels', () => { + expect(isDmChannel({ memberCount: 1 })).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({ + memberCount: 2, + members, + userId: '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({ + memberCount: 3, + members, + userId: 'user-1', + }), + ).toBe(false); + }); +}); diff --git a/src/utils/index.ts b/src/utils/index.ts index a6f6751bfb..f661a691a7 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './getChannel'; export * from './getTextareaCaretRect'; export * from './getWholeChar'; +export * from './isDmChannel'; 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)) + ); +}; From cc8443809bf91dfd183f065456e6baa2bbb8de96 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 2 Jun 2026 11:17:49 +0200 Subject: [PATCH 02/21] refactor: use ListItemLayout for switch fields in ChannelInfoActions.defaults.tsx --- src/components/Button/ListItemButton.tsx | 78 ------- src/components/Button/index.ts | 1 - .../ChannelDetail/ChannelDetail.tsx | 40 +++- .../Views/ChannelInfoActions.defaults.tsx | 209 ++++++++++++++---- .../ChannelInfoActions.defaults.test.tsx | 164 ++++++++++++++ .../styling/ChannelManagementView.scss | 7 + src/components/Form/SwitchField.tsx | 186 ++++++---------- .../Form/__tests__/SwitchField.test.tsx | 20 -- src/components/Form/styling/SwitchField.scss | 12 +- .../ListItemLayout/ListItemLayout.tsx | 110 +++++---- .../styling/ListItemLayout.scss | 17 +- src/utils/__tests__/isDmChannel.test.ts | 25 ++- 12 files changed, 523 insertions(+), 346 deletions(-) delete mode 100644 src/components/Button/ListItemButton.tsx create mode 100644 src/components/ChannelDetail/__tests__/ChannelInfoActions.defaults.test.tsx diff --git a/src/components/Button/ListItemButton.tsx b/src/components/Button/ListItemButton.tsx deleted file mode 100644 index f799f7892a..0000000000 --- a/src/components/Button/ListItemButton.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type { ComponentProps, ComponentType } from 'react'; -import React, { useMemo } from 'react'; -import clsx from 'clsx'; -import { ListItemLayout, type ListItemLayoutBaseProps } from '../ListItemLayout'; - -export type ListItemButtonProps = Omit, 'children' | 'title'> & - Omit & { - LeadingIcon?: ComponentType>; - TrailingIcon?: ComponentType>; - }; - -export const ListItemButton = ({ - 'aria-current': ariaCurrent, - 'aria-label': ariaLabel, - className, - description, - destructive, - disabled, - LeadingIcon, - LeadingSlot, - onClick, - selected, - subtitle, - title, - TrailingIcon, - TrailingSlot, - type, -}: ListItemButtonProps) => { - const LayoutLeadingIcon = useMemo(() => { - if (!LeadingIcon) return undefined; - - const Icon = LeadingIcon; - - function ListItemButtonLeadingIcon() { - return ; - } - - return ListItemButtonLeadingIcon; - }, [LeadingIcon]); - const LayoutTrailingIcon = useMemo(() => { - if (!TrailingIcon) return undefined; - - const Icon = TrailingIcon; - - function ListItemButtonTrailingIcon() { - return ; - } - - return ListItemButtonTrailingIcon; - }, [TrailingIcon]); - const rootProps = useMemo( - () => ({ - 'aria-current': ariaCurrent, - 'aria-label': ariaLabel, - className: clsx('str-chat__list-item-button', className), - disabled, - onClick, - type: type ?? 'button', - }), - [ariaCurrent, ariaLabel, className, disabled, onClick, type], - ); - - return ( - - ); -}; diff --git a/src/components/Button/index.ts b/src/components/Button/index.ts index 89931bf475..c8179d9bf5 100644 --- a/src/components/Button/index.ts +++ b/src/components/Button/index.ts @@ -1,3 +1,2 @@ export * from './Button'; -export * from './ListItemButton'; export * from './PlayButton'; diff --git a/src/components/ChannelDetail/ChannelDetail.tsx b/src/components/ChannelDetail/ChannelDetail.tsx index 5fb9883fc4..f3cc5fae72 100644 --- a/src/components/ChannelDetail/ChannelDetail.tsx +++ b/src/components/ChannelDetail/ChannelDetail.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import React from 'react'; +import React, { useMemo } from 'react'; import { SectionNavigator, @@ -9,24 +9,40 @@ import { } from '../SectionNavigator'; import { ChannelManagementView } from './Views/ChannelManagementView'; import { Prompt } from '../Dialog'; -import { ListItemButton } from '../Button'; import { IconInfo } from '../Icons'; +import { ListItemLayout } from '../ListItemLayout'; const ChannelDetailNavButtonClassName = 'str-chat__channel-detail__nav-button'; +const ChannelInfoNavButtonIcon = () => ( + +); + +const ChannelInfoNavButton = ({ select, selected }: SectionNavigatorNavButtonProps) => { + const rootProps = useMemo( + () => ({ + 'aria-current': selected ? ('page' as const) : undefined, + className: ChannelDetailNavButtonClassName, + onClick: select, + }), + [select, selected], + ); + + return ( + + ); +}; + const defaultSections: SectionNavigatorSection[] = [ { id: 'channel-info', - NavButton: ({ select, selected }: SectionNavigatorNavButtonProps) => ( - - ), + NavButton: ChannelInfoNavButton, SectionContent: ChannelManagementView, }, ]; diff --git a/src/components/ChannelDetail/Views/ChannelInfoActions.defaults.tsx b/src/components/ChannelDetail/Views/ChannelInfoActions.defaults.tsx index c91a415179..c1ea08c866 100644 --- a/src/components/ChannelDetail/Views/ChannelInfoActions.defaults.tsx +++ b/src/components/ChannelDetail/Views/ChannelInfoActions.defaults.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import debounce from 'lodash.debounce'; import { @@ -8,10 +8,10 @@ import { useTranslationContext, } from '../../../context'; import { isDmChannel } from '../../../utils'; -import { ListItemButton } from '../../Button'; import { useIsChannelMuted } from '../../ChannelListItem/hooks/useIsChannelMuted'; -import { SwitchField } from '../../Form'; +import { Switch } from '../../Form'; import { IconAudio, IconDelete, IconLeave, IconMute, IconNoSign } from '../../Icons'; +import { ListItemLayout } from '../../ListItemLayout'; import { useNotificationApi } from '../../Notifications'; export type ChannelInfoActionType = @@ -30,6 +30,22 @@ export type ChannelInfoActionItem = { const toError = (error: unknown) => error instanceof Error ? error : new Error('An unknown error occurred'); +const BlockUserActionIcon = () => ( + +); +const DeleteChatActionIcon = () => ( + +); +const MuteActionIcon = () => ( + +); +const MutedActionIcon = () => ( + +); +const LeaveChannelActionIcon = () => ( + +); + const useOtherMember = () => { const { client } = useChatContext(); const { channel } = useChannelStateContext(); @@ -96,11 +112,16 @@ const ChannelMuteAction = () => { const { addNotification } = useNotificationApi(); const { t } = useTranslationContext(); const { muted: channelMuted } = useIsChannelMuted(channel); + const [optimisticChannelMuted, setOptimisticChannelMuted] = useState(channelMuted); + + useEffect(() => { + setOptimisticChannelMuted(channelMuted); + }, [channelMuted]); const toggleChannelMute = useMemo( () => - debounce(() => { - if (channelMuted) { + debounce((nextMuted: boolean) => { + if (!nextMuted) { return channel .unmute() .then(() => @@ -112,16 +133,18 @@ const ChannelMuteAction = () => { type: 'api:channel:unmute:success', }), ) - .catch((error) => - addNotification({ + .catch((error) => { + setOptimisticChannelMuted(true); + + return addNotification({ context: { channel }, emitter: 'ChannelManagementView', error: toError(error), message: t('Error unmuting channel'), severity: 'error', type: 'api:channel:unmute:failed', - }), - ); + }); + }); } return channel @@ -135,27 +158,59 @@ const ChannelMuteAction = () => { type: 'api:channel:mute:success', }), ) - .catch((error) => - addNotification({ + .catch((error) => { + setOptimisticChannelMuted(false); + + return addNotification({ context: { channel }, emitter: 'ChannelManagementView', error: toError(error), message: t('Error muting channel'), severity: 'error', type: 'api:channel:mute:failed', - }), - ); + }); + }); }, 1000), - [addNotification, channel, channelMuted, t], + [addNotification, channel, t], + ); + + useEffect( + () => () => { + toggleChannelMute.cancel(); + }, + [toggleChannelMute], ); + const toggleOptimisticChannelMute = useCallback(() => { + const nextMuted = !optimisticChannelMuted; + + setOptimisticChannelMuted(nextMuted); + toggleChannelMute(nextMuted); + }, [optimisticChannelMuted, toggleChannelMute]); + + const rootProps = useMemo( + () => ({ + 'aria-pressed': optimisticChannelMuted, + className: 'str-chat__form__switch-field', + onClick: toggleOptimisticChannelMute, + }), + [optimisticChannelMuted, toggleOptimisticChannelMute], + ); + const TrailingSlot = useMemo(() => { + function ChannelMuteSwitch() { + return ; + } + + return ChannelMuteSwitch; + }, [optimisticChannelMuted]); + return ( - ); }; @@ -167,13 +222,18 @@ const UserMuteAction = () => { 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 toggleUserMute = useMemo( () => - debounce(() => { + debounce((nextMuted: boolean) => { if (!otherMember?.user?.id) return; - if (userMuted) { + if (!nextMuted) { return client .unmuteUser(otherMember.user.id) .then(() => @@ -185,16 +245,18 @@ const UserMuteAction = () => { type: 'api:user:unmute:success', }), ) - .catch((error) => - addNotification({ + .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 @@ -208,27 +270,59 @@ const UserMuteAction = () => { type: 'api:user:mute:success', }), ) - .catch((error) => - addNotification({ + .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', - }), - ); + }); + }); }, 1000), - [addNotification, channel, client, otherMember, t, userMuted], + [addNotification, channel, client, otherMember, t], + ); + + useEffect( + () => () => { + toggleUserMute.cancel(); + }, + [toggleUserMute], + ); + + const toggleOptimisticUserMute = useCallback(() => { + const nextMuted = !optimisticUserMuted; + + setOptimisticUserMuted(nextMuted); + toggleUserMute(nextMuted); + }, [optimisticUserMuted, toggleUserMute]); + + const rootProps = useMemo( + () => ({ + 'aria-pressed': optimisticUserMuted, + className: 'str-chat__form__switch-field', + onClick: toggleOptimisticUserMute, + }), + [optimisticUserMuted, toggleOptimisticUserMute], ); + const TrailingSlot = useMemo(() => { + function UserMuteSwitch() { + return ; + } + + return UserMuteSwitch; + }, [optimisticUserMuted]); return ( - ); }; @@ -268,12 +362,20 @@ const BlockUserAction = () => { } }, [addNotification, channel, client, otherMember, t]); + const rootProps = useMemo( + () => ({ + disabled: userBlockInProgress, + onClick: blockUser, + }), + [blockUser, userBlockInProgress], + ); + return ( - ); @@ -319,12 +421,20 @@ const LeaveChannelAction = () => { [addNotification, channel, client.userID, close, t], ); + const rootProps = useMemo( + () => ({ + disabled: leaveChannelInProgress, + onClick: leaveChannel, + }), + [leaveChannel, leaveChannelInProgress], + ); + return ( - ); @@ -333,7 +443,14 @@ const LeaveChannelAction = () => { const DeleteChatAction = () => { const { t } = useTranslationContext(); - return ; + return ( + + ); }; export const DefaultChannelInfoActions = { diff --git a/src/components/ChannelDetail/__tests__/ChannelInfoActions.defaults.test.tsx b/src/components/ChannelDetail/__tests__/ChannelInfoActions.defaults.test.tsx new file mode 100644 index 0000000000..75cf2adc66 --- /dev/null +++ b/src/components/ChannelDetail/__tests__/ChannelInfoActions.defaults.test.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, vi } from 'vitest'; + +import { DefaultChannelInfoActions } from '../Views/ChannelInfoActions.defaults'; + +const mocks = vi.hoisted(() => { + const addNotification = vi.fn(); + const blockUser = vi.fn(); + const close = vi.fn(); + const mute = vi.fn(); + const muteUser = vi.fn(); + const removeMembers = vi.fn(); + const t = vi.fn((key: string) => key); + const unmute = vi.fn(); + const unmuteUser = vi.fn(); + + const channel = { + data: { + members: [{ user: { id: 'own-user' } }, { user: { id: 'other-user' } }], + own_capabilities: ['ban-channel-members', 'leave-channel', 'mute-channel'], + }, + mute, + removeMembers, + unmute, + }; + + const client = { + blockUser, + muteUser, + unmuteUser, + user: { id: 'own-user' }, + userID: 'own-user', + }; + + return { + addNotification, + blockUser, + channel, + channelMuted: false, + client, + close, + mute, + mutes: [] as Array<{ target: { id: string } }>, + muteUser, + removeMembers, + t, + unmute, + unmuteUser, + }; +}); + +vi.mock('../../../context', () => ({ + useChannelStateContext: () => ({ channel: mocks.channel }), + useChatContext: () => ({ + client: mocks.client, + mutes: mocks.mutes, + }), + useModalContext: () => ({ close: mocks.close }), + useTranslationContext: () => ({ t: mocks.t }), +})); + +vi.mock('../../Notifications', () => ({ + useNotificationApi: () => ({ + addNotification: mocks.addNotification, + }), +})); + +vi.mock('../../ChannelListItem/hooks/useIsChannelMuted', () => ({ + useIsChannelMuted: () => ({ muted: mocks.channelMuted }), +})); + +const advanceDebounce = async () => { + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); +}; + +describe('DefaultChannelInfoActions', () => { + beforeEach(() => { + vi.useFakeTimers(); + mocks.addNotification.mockReset(); + mocks.blockUser.mockReset(); + mocks.close.mockReset(); + mocks.mute.mockReset(); + mocks.muteUser.mockReset(); + mocks.removeMembers.mockReset(); + mocks.t.mockClear(); + mocks.unmute.mockReset(); + mocks.unmuteUser.mockReset(); + mocks.channelMuted = false; + 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')); + + render(); + + 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')); + + render(); + + 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', + }), + ); + }); +}); diff --git a/src/components/ChannelDetail/styling/ChannelManagementView.scss b/src/components/ChannelDetail/styling/ChannelManagementView.scss index 7c41752836..ae1c9bc753 100644 --- a/src/components/ChannelDetail/styling/ChannelManagementView.scss +++ b/src/components/ChannelDetail/styling/ChannelManagementView.scss @@ -42,3 +42,10 @@ font: var(--str-chat__font-caption-default); } } + +.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); +} diff --git a/src/components/Form/SwitchField.tsx b/src/components/Form/SwitchField.tsx index d492ce2bf2..9594242c7f 100644 --- a/src/components/Form/SwitchField.tsx +++ b/src/components/Form/SwitchField.tsx @@ -2,15 +2,13 @@ import clsx from 'clsx'; import type { ChangeEventHandler, ComponentProps, - ComponentType, KeyboardEventHandler, MouseEventHandler, PropsWithChildren, ReactNode, } from 'react'; -import React, { isValidElement, useCallback, useMemo, useRef, useState } from 'react'; +import React, { isValidElement, useRef, useState } from 'react'; import { useStableId } from '../UtilityComponents/useStableId'; -import { ListItemLayout } from '../ListItemLayout'; export type SwitchFieldProps = Omit< PropsWithChildren>, @@ -22,22 +20,14 @@ export type SwitchFieldProps = Omit< description?: string; /** Class applied to the root div element of the SwitchField component */ fieldClassName?: string; - /** Optional decorative icon rendered before the label content */ - Icon?: ComponentType; /** Optional title line */ title?: string; }; -export type SwitchFieldIconProps = { - className?: string; - decorative?: boolean; -}; - export const SwitchField = ({ children, description, fieldClassName, - Icon, title, ...props }: SwitchFieldProps) => { @@ -62,35 +52,26 @@ export const SwitchField = ({ const isOn = isControlled ? checked : uncontrolledChecked; const isReadOnly = isControlled && onChange === undefined; - const handleChange: ChangeEventHandler = useCallback( - (event) => { - if (!isControlled) { - setUncontrolledChecked(event.target.checked); - } + const handleChange: ChangeEventHandler = (event) => { + if (!isControlled) { + setUncontrolledChecked(event.target.checked); + } - onChange?.(event); - }, - [isControlled, onChange], - ); + onChange?.(event); + }; - const handleKeyDown: KeyboardEventHandler = useCallback( - (event) => { - onKeyDown?.(event); - if (event.defaultPrevented || event.key !== ' ') return; + const handleKeyDown: KeyboardEventHandler = (event) => { + onKeyDown?.(event); + if (event.defaultPrevented || event.key !== ' ') return; - event.preventDefault(); - event.currentTarget.click(); - }, - [onKeyDown], - ); + event.preventDefault(); + event.currentTarget.click(); + }; - const handleSwitchClick: MouseEventHandler = useCallback( - (event) => { - if (disabled || event.target === inputRef.current) return; - inputRef.current?.click(); - }, - [disabled], - ); + const handleSwitchClick: MouseEventHandler = (event) => { + if (disabled || event.target === inputRef.current) return; + inputRef.current?.click(); + }; // When no title/aria-label is provided, SwitchField can still be named by a caller-supplied // child element id via aria-labelledby. @@ -103,78 +84,6 @@ export const SwitchField = ({ // 4) caller-supplied child id (children path) const resolvedAriaLabelledBy = ariaLabelledBy ?? (!ariaLabel ? (title ? switchLabelId : childLabelId) : undefined); - const LeadingIcon = useMemo(() => { - if (!Icon) return undefined; - - const LeadingIcon = Icon; - - function SwitchFieldLeadingIcon() { - return ; - } - - return SwitchFieldLeadingIcon; - }, [Icon]); - const rootProps = useMemo( - () => ({ - className: clsx( - 'str-chat__form__switch-field', - fieldClassName, - disabled && 'str-chat__form__switch-field--disabled', - ), - }), - [disabled, fieldClassName], - ); - const TrailingSlot = useMemo(() => { - function SwitchFieldTrailingSlot() { - return ( - - ); - } - - return SwitchFieldTrailingSlot; - }, [ - ariaLabel, - disabled, - handleChange, - handleKeyDown, - handleSwitchClick, - isOn, - isReadOnly, - resolvedAriaLabelledBy, - rest, - switchId, - ]); - - if (title) { - return ( - - } - TrailingSlot={TrailingSlot} - /> - ); - } return (
- {Icon && } - {children} - + {title ? ( + + ) : ( + children + )} +
); }; - 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/__tests__/SwitchField.test.tsx b/src/components/Form/__tests__/SwitchField.test.tsx index 2edd7476cf..c1960cd96a 100644 --- a/src/components/Form/__tests__/SwitchField.test.tsx +++ b/src/components/Form/__tests__/SwitchField.test.tsx @@ -4,10 +4,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { SwitchField } from '../SwitchField'; import { axe } from '../../../../axe-helper'; -const TestIcon = ({ className }: { className?: string; decorative?: boolean }) => ( - -); - describe('SwitchField', () => { it('renders a single switch control with switch semantics', () => { render( @@ -78,22 +74,6 @@ describe('SwitchField', () => { expect(results).toHaveNoViolations(); }); - it('renders an optional decorative icon without changing the switch name', () => { - render( - , - ); - - expect(screen.getByTestId('switch-field-icon')).toHaveClass( - 'str-chat__form__switch-field__icon', - ); - expect(screen.getByRole('switch', { name: 'Mute chat' })).toBeInTheDocument(); - }); - it('uses caller-provided child id for aria-labelledby when title is not provided', () => { render( diff --git a/src/components/Form/styling/SwitchField.scss b/src/components/Form/styling/SwitchField.scss index b921943805..508b1035cf 100644 --- a/src/components/Form/styling/SwitchField.scss +++ b/src/components/Form/styling/SwitchField.scss @@ -23,6 +23,7 @@ $switch-field-track-radius: var(--str-chat__button-radius-full, 9999px); align-items: center; gap: var(--str-chat__spacing-sm); width: 100%; + padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-md); background-color: var(--str-chat__switch-field-background-color); border-radius: var(--str-chat__switch-field-border-radius); box-sizing: border-box; @@ -35,10 +36,6 @@ $switch-field-track-radius: var(--str-chat__button-radius-full, 9999px); cursor: pointer; } - .str-chat__form__switch-field__layout { - flex: 1; - } - .str-chat__form__switch-field__input { position: absolute; inset: 0; @@ -50,13 +47,6 @@ $switch-field-track-radius: var(--str-chat__button-radius-full, 9999px); cursor: pointer; } - .str-chat__form__switch-field__icon { - width: 16px; - height: 16px; - flex-shrink: 0; - color: var(--str-chat__text-secondary); - } - .str-chat__form__switch-field__switch { position: relative; display: flex; diff --git a/src/components/ListItemLayout/ListItemLayout.tsx b/src/components/ListItemLayout/ListItemLayout.tsx index 83c81cff21..12998eab4c 100644 --- a/src/components/ListItemLayout/ListItemLayout.tsx +++ b/src/components/ListItemLayout/ListItemLayout.tsx @@ -1,5 +1,11 @@ import clsx from 'clsx'; -import type { ComponentProps, ComponentType, HTMLAttributes, ReactNode } from 'react'; +import type { + ComponentProps, + ComponentType, + ElementType, + HTMLAttributes, + ReactNode, +} from 'react'; import React from 'react'; export type ListItemLayoutRootElement = Extract< @@ -8,14 +14,18 @@ export type ListItemLayoutRootElement = Extract< >; export type ListItemLayoutBaseProps = { + ContentSlot?: ComponentType; + contentClassName?: string; description?: ReactNode; + descriptionClassName?: string; destructive?: boolean; LeadingIcon?: ComponentType; LeadingSlot?: ComponentType; selected?: boolean; subtitle?: ReactNode; - textClassName?: string; + subtitleClassName?: string; title: ReactNode; + titleClassName?: string; TrailingIcon?: ComponentType; TrailingSlot?: ComponentType; }; @@ -27,7 +37,10 @@ export type ListItemLayoutProps({ + ContentSlot = ListItemLayoutContent, + contentClassName, description, + descriptionClassName, destructive, LeadingIcon, LeadingSlot, @@ -35,13 +48,17 @@ export const ListItemLayout = + >; const resolvedRootProps = { + ...(RootComponent === 'button' ? { type: 'button' } : undefined), ...rootProps, className: clsx( 'str-chat__list-item-layout', @@ -51,57 +68,76 @@ export const ListItemLayout = - - - ), - LeadingSlot && , - , - TrailingIcon && ( - - - - ), - TrailingSlot && , + return ( + + {LeadingIcon && ( +
+ +
+ )} + {LeadingSlot && } + + {TrailingIcon && ( +
+ +
+ )} + {TrailingSlot && } +
); }; -export type ListItemLayoutTextProps = Omit, 'title'> & { +export type ListItemLayoutContentProps = Omit, 'title'> & { description?: ReactNode; + descriptionClassName?: string; subtitle?: ReactNode; + subtitleClassName?: string; title: ReactNode; + titleClassName?: string; }; -export const ListItemLayoutText = ({ +export const ListItemLayoutContent = ({ className, description, + descriptionClassName, subtitle, + subtitleClassName, title, + titleClassName, ...props -}: ListItemLayoutTextProps) => ( +}: ListItemLayoutContentProps) => (
- {title &&
{title}
} - {subtitle &&
{subtitle}
} + {title && ( +
+ {title} +
+ )} + {subtitle && ( +
+ {subtitle} +
+ )} {description && ( -
{description}
+
+ {description} +
)}
); diff --git a/src/components/ListItemLayout/styling/ListItemLayout.scss b/src/components/ListItemLayout/styling/ListItemLayout.scss index c6ac11f77d..4a7dc983eb 100644 --- a/src/components/ListItemLayout/styling/ListItemLayout.scss +++ b/src/components/ListItemLayout/styling/ListItemLayout.scss @@ -1,11 +1,12 @@ @use '../../../styling/utils'; .str-chat__list-item-layout { + --list-item-padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm); display: flex; align-items: center; gap: var(--str-chat__spacing-sm); text-align: start; - padding: var(--str-chat__spacing-xs); + padding: var(--list-item-padding); width: 100%; min-width: 0; border-radius: var(--str-chat__radius-md); @@ -36,7 +37,7 @@ &:is(button) { @include utils.button-reset; - padding: var(--str-chat__spacing-xs); + padding: var(--list-item-padding); cursor: pointer; &:hover:not(:disabled) { @@ -52,7 +53,7 @@ } } - .str-chat__list-item-layout__text { + .str-chat__list-item-layout__content { flex: 1; display: grid; align-items: start; @@ -64,7 +65,7 @@ min-width: 0; } - .str-chat__list-item-layout__text--subtitled { + .str-chat__list-item-layout__content--withSubtitle { grid-template-areas: 'title description' 'subtitle description'; @@ -76,14 +77,6 @@ .str-chat__list-item-layout__trailing-icon { display: flex; flex-shrink: 0; - width: var(--str-chat__icon-size-sm); - height: var(--str-chat__icon-size-sm); - - svg { - stroke: currentColor; - width: 100%; - height: 100%; - } } .str-chat__list-item-layout__description, diff --git a/src/utils/__tests__/isDmChannel.test.ts b/src/utils/__tests__/isDmChannel.test.ts index 9d6d387c08..ab7d5b7460 100644 --- a/src/utils/__tests__/isDmChannel.test.ts +++ b/src/utils/__tests__/isDmChannel.test.ts @@ -1,11 +1,16 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { ChannelState } from 'stream-chat'; +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', () => { - expect(isDmChannel({ memberCount: 1 })).toBe(true); + 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', () => { @@ -16,9 +21,11 @@ describe('isDmChannel', () => { expect( isDmChannel({ - memberCount: 2, - members, - userId: 'user-1', + channel: fromPartial({ + data: { member_count: 2 }, + state: { members }, + }), + ownUserId: 'user-1', }), ).toBe(true); }); @@ -32,9 +39,11 @@ describe('isDmChannel', () => { expect( isDmChannel({ - memberCount: 3, - members, - userId: 'user-1', + channel: fromPartial({ + data: { member_count: 3 }, + state: { members }, + }), + ownUserId: 'user-1', }), ).toBe(false); }); From c0c9fc271c4409b747968d91cef6f746c861025d Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 4 Jun 2026 11:59:11 +0200 Subject: [PATCH 03/21] feat(ChannelManagementView): invoke confirmation dialogs before invoking destructive action --- .../ChannelDetail/ChannelDetail.tsx | 23 +- .../ChannelDetail/ChannelDetailContext.tsx | 40 + .../Views/ChannelInfoActions.defaults.tsx | 485 ------------ .../ChannelManagementActions.defaults.tsx | 694 ++++++++++++++++++ .../Views/ChannelManagementView.tsx | 22 +- .../__tests__/ChannelDetail.test.tsx | 11 +- .../ChannelInfoActions.defaults.test.tsx | 164 ----- ...ChannelManagementActions.defaults.test.tsx | 407 ++++++++++ src/components/ChannelDetail/index.ts | 3 +- .../styling/ChannelManagementView.scss | 9 + .../ChannelHeader/AvatarWithChannelDetail.tsx | 16 +- .../hooks/useChannelHasMembersOnline.ts | 15 +- .../hooks/useChannelHeaderOnlineStatus.ts | 21 +- .../Dialog/__tests__/DialogsManager.test.ts | 38 + .../Dialog/service/DialogManager.ts | 7 + src/components/Modal/GlobalModal.tsx | 8 +- .../Modal/__tests__/GlobalModal.test.tsx | 105 ++- src/i18n/de.json | 6 + src/i18n/en.json | 6 + src/i18n/es.json | 6 + src/i18n/fr.json | 6 + src/i18n/hi.json | 6 + src/i18n/it.json | 6 + src/i18n/ja.json | 6 + src/i18n/ko.json | 6 + src/i18n/nl.json | 6 + src/i18n/pt.json | 6 + src/i18n/ru.json | 10 +- src/i18n/tr.json | 6 + src/utils/index.ts | 1 + 30 files changed, 1461 insertions(+), 684 deletions(-) create mode 100644 src/components/ChannelDetail/ChannelDetailContext.tsx delete mode 100644 src/components/ChannelDetail/Views/ChannelInfoActions.defaults.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx delete mode 100644 src/components/ChannelDetail/__tests__/ChannelInfoActions.defaults.test.tsx create mode 100644 src/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx diff --git a/src/components/ChannelDetail/ChannelDetail.tsx b/src/components/ChannelDetail/ChannelDetail.tsx index f3cc5fae72..7961ed443f 100644 --- a/src/components/ChannelDetail/ChannelDetail.tsx +++ b/src/components/ChannelDetail/ChannelDetail.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx'; import React, { useMemo } from 'react'; +import type { Channel } from 'stream-chat'; import { SectionNavigator, @@ -7,6 +8,7 @@ import { type SectionNavigatorProps, type SectionNavigatorSection, } from '../SectionNavigator'; +import { ChannelDetailProvider } from './ChannelDetailContext'; import { ChannelManagementView } from './Views/ChannelManagementView'; import { Prompt } from '../Dialog'; import { IconInfo } from '../Icons'; @@ -14,11 +16,14 @@ import { ListItemLayout } from '../ListItemLayout'; const ChannelDetailNavButtonClassName = 'str-chat__channel-detail__nav-button'; -const ChannelInfoNavButtonIcon = () => ( +const ChannelManagementNavButtonIcon = () => ( ); -const ChannelInfoNavButton = ({ select, selected }: SectionNavigatorNavButtonProps) => { +const ChannelManagementNavButton = ({ + select, + selected, +}: SectionNavigatorNavButtonProps) => { const rootProps = useMemo( () => ({ 'aria-current': selected ? ('page' as const) : undefined, @@ -30,7 +35,7 @@ const ChannelInfoNavButton = ({ select, selected }: SectionNavigatorNavButtonPro return ( & { + channel: Channel; sections?: SectionNavigatorSection[]; }; export const ChannelDetail = ({ + channel, className, sections = defaultSections, ...props }: ChannelDetailProps) => ( - - - + + + + + ); diff --git a/src/components/ChannelDetail/ChannelDetailContext.tsx b/src/components/ChannelDetail/ChannelDetailContext.tsx new file mode 100644 index 0000000000..da0f7bdd4e --- /dev/null +++ b/src/components/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/components/ChannelDetail/Views/ChannelInfoActions.defaults.tsx b/src/components/ChannelDetail/Views/ChannelInfoActions.defaults.tsx deleted file mode 100644 index c1ea08c866..0000000000 --- a/src/components/ChannelDetail/Views/ChannelInfoActions.defaults.tsx +++ /dev/null @@ -1,485 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import debounce from 'lodash.debounce'; - -import { - useChannelStateContext, - useChatContext, - useModalContext, - useTranslationContext, -} from '../../../context'; -import { isDmChannel } from '../../../utils'; -import { useIsChannelMuted } from '../../ChannelListItem/hooks/useIsChannelMuted'; -import { Switch } from '../../Form'; -import { IconAudio, IconDelete, IconLeave, IconMute, IconNoSign } from '../../Icons'; -import { ListItemLayout } from '../../ListItemLayout'; -import { useNotificationApi } from '../../Notifications'; - -export type ChannelInfoActionType = - | 'blockUser' - | 'deleteChat' - | 'leaveChannel' - | 'muteChannel' - | 'muteUser' - | (string & {}); - -export type ChannelInfoActionItem = { - Component: React.ComponentType; - type: ChannelInfoActionType; -}; - -const toError = (error: unknown) => - error instanceof Error ? error : new Error('An unknown error occurred'); - -const BlockUserActionIcon = () => ( - -); -const DeleteChatActionIcon = () => ( - -); -const MuteActionIcon = () => ( - -); -const MutedActionIcon = () => ( - -); -const LeaveChannelActionIcon = () => ( - -); - -const useOtherMember = () => { - const { client } = useChatContext(); - const { channel } = useChannelStateContext(); - - return useMemo( - () => - channel.data?.members?.find( - (member) => member.user?.id && member.user.id !== client.user?.id, - ), - [channel, client.user?.id], - ); -}; - -const useChannelInfoActionFilterState = () => { - const { client } = useChatContext(); - const { channel } = useChannelStateContext(); - 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 useBaseChannelInfoActionSetFilter = ( - channelInfoActionSet: ChannelInfoActionItem[], -) => { - const { canBlockUser, canLeaveChannel, canMuteChannel, canMuteUser } = - useChannelInfoActionFilterState(); - - return useMemo( - () => - channelInfoActionSet.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, channelInfoActionSet], - ); -}; - -const ChannelMuteAction = () => { - const { channel } = useChannelStateContext(); - const { addNotification } = useNotificationApi(); - const { t } = useTranslationContext(); - const { muted: channelMuted } = useIsChannelMuted(channel); - const [optimisticChannelMuted, setOptimisticChannelMuted] = useState(channelMuted); - - useEffect(() => { - setOptimisticChannelMuted(channelMuted); - }, [channelMuted]); - - const toggleChannelMute = useMemo( - () => - debounce((nextMuted: boolean) => { - if (!nextMuted) { - return channel - .unmute() - .then(() => - addNotification({ - context: { channel }, - emitter: 'ChannelManagementView', - message: t('Channel unmuted'), - severity: 'success', - type: 'api:channel:unmute:success', - }), - ) - .catch((error) => { - setOptimisticChannelMuted(true); - - return addNotification({ - context: { channel }, - emitter: 'ChannelManagementView', - error: toError(error), - message: t('Error unmuting channel'), - severity: 'error', - type: 'api:channel:unmute:failed', - }); - }); - } - - return channel - .mute() - .then(() => - addNotification({ - context: { channel }, - emitter: 'ChannelManagementView', - message: t('Channel muted'), - severity: 'success', - type: 'api:channel:mute:success', - }), - ) - .catch((error) => { - setOptimisticChannelMuted(false); - - return addNotification({ - context: { channel }, - emitter: 'ChannelManagementView', - error: toError(error), - message: t('Error muting channel'), - severity: 'error', - type: 'api:channel:mute:failed', - }); - }); - }, 1000), - [addNotification, channel, t], - ); - - useEffect( - () => () => { - toggleChannelMute.cancel(); - }, - [toggleChannelMute], - ); - - const toggleOptimisticChannelMute = useCallback(() => { - const nextMuted = !optimisticChannelMuted; - - setOptimisticChannelMuted(nextMuted); - toggleChannelMute(nextMuted); - }, [optimisticChannelMuted, toggleChannelMute]); - - const rootProps = useMemo( - () => ({ - 'aria-pressed': optimisticChannelMuted, - className: 'str-chat__form__switch-field', - onClick: toggleOptimisticChannelMute, - }), - [optimisticChannelMuted, toggleOptimisticChannelMute], - ); - const TrailingSlot = useMemo(() => { - function ChannelMuteSwitch() { - return ; - } - - return ChannelMuteSwitch; - }, [optimisticChannelMuted]); - - return ( - - ); -}; - -const UserMuteAction = () => { - const { client, mutes } = useChatContext(); - const { channel } = useChannelStateContext(); - 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 toggleUserMute = useMemo( - () => - debounce((nextMuted: boolean) => { - if (!otherMember?.user?.id) return; - - if (!nextMuted) { - return client - .unmuteUser(otherMember.user.id) - .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(otherMember.user.id) - .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', - }); - }); - }, 1000), - [addNotification, channel, client, otherMember, t], - ); - - useEffect( - () => () => { - toggleUserMute.cancel(); - }, - [toggleUserMute], - ); - - const toggleOptimisticUserMute = useCallback(() => { - const nextMuted = !optimisticUserMuted; - - setOptimisticUserMuted(nextMuted); - toggleUserMute(nextMuted); - }, [optimisticUserMuted, toggleUserMute]); - - const rootProps = useMemo( - () => ({ - 'aria-pressed': optimisticUserMuted, - className: 'str-chat__form__switch-field', - onClick: toggleOptimisticUserMute, - }), - [optimisticUserMuted, toggleOptimisticUserMute], - ); - const TrailingSlot = useMemo(() => { - function UserMuteSwitch() { - return ; - } - - return UserMuteSwitch; - }, [optimisticUserMuted]); - - return ( - - ); -}; - -const BlockUserAction = () => { - const { client } = useChatContext(); - const { channel } = useChannelStateContext(); - const { addNotification } = useNotificationApi(); - const { t } = useTranslationContext(); - const otherMember = useOtherMember(); - const [userBlockInProgress, setUserBlockInProgress] = useState(false); - - const blockUser = useCallback(async () => { - if (!otherMember?.user?.id) return; - - try { - setUserBlockInProgress(true); - await client.blockUser(otherMember.user.id); - 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 { - setUserBlockInProgress(false); - } - }, [addNotification, channel, client, otherMember, t]); - - const rootProps = useMemo( - () => ({ - disabled: userBlockInProgress, - onClick: blockUser, - }), - [blockUser, userBlockInProgress], - ); - - return ( - - ); -}; - -const LeaveChannelAction = () => { - const { client } = useChatContext(); - const { channel } = useChannelStateContext(); - const { close } = useModalContext(); - const { addNotification } = useNotificationApi(); - const { t } = useTranslationContext(); - const [leaveChannelInProgress, setLeaveChannelInProgress] = useState(false); - - const leaveChannel = useCallback( - async (event: React.MouseEvent) => { - event.stopPropagation(); - 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', - }); - 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( - () => ({ - disabled: leaveChannelInProgress, - onClick: leaveChannel, - }), - [leaveChannel, leaveChannelInProgress], - ); - - return ( - - ); -}; - -const DeleteChatAction = () => { - const { t } = useTranslationContext(); - - return ( - - ); -}; - -export const DefaultChannelInfoActions = { - BlockUser: BlockUserAction, - DeleteChat: DeleteChatAction, - LeaveChannel: LeaveChannelAction, - MuteChannel: ChannelMuteAction, - MuteUser: UserMuteAction, -}; - -export const defaultChannelInfoActionSet: ChannelInfoActionItem[] = [ - { - Component: DefaultChannelInfoActions.MuteChannel, - type: 'muteChannel', - }, - { - Component: DefaultChannelInfoActions.MuteUser, - type: 'muteUser', - }, - { - Component: DefaultChannelInfoActions.BlockUser, - type: 'blockUser', - }, - { - Component: DefaultChannelInfoActions.LeaveChannel, - type: 'leaveChannel', - }, - { - Component: DefaultChannelInfoActions.DeleteChat, - type: 'deleteChat', - }, -]; diff --git a/src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx b/src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx new file mode 100644 index 0000000000..f3f108b2aa --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx @@ -0,0 +1,694 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import debounce from 'lodash.debounce'; +import type { Channel } from 'stream-chat'; + +import { + useChatContext, + useComponentContext, + useModalContext, + useTranslationContext, +} from '../../../context'; +import { isDmChannel, useStableCallback } from '../../../utils'; +import { useIsChannelMuted } from '../../ChannelListItem/hooks/useIsChannelMuted'; +import { Alert } from '../../Dialog'; +import { Button } from '../../Button'; +import { Switch } from '../../Form'; +import { IconAudio, IconDelete, IconLeave, IconMute, IconNoSign } from '../../Icons'; +import { ListItemLayout } from '../../ListItemLayout'; +import { GlobalModal } from '../../Modal'; +import { useNotificationApi } from '../../Notifications'; +import { useChannelDetailContext } from '../ChannelDetailContext'; +import clsx from 'clsx'; + +export type ChannelManagementActionType = + | 'blockUser' + | 'deleteChat' + | 'leaveChannel' + | 'muteChannel' + | 'muteUser' + | (string & {}); + +export type ChannelManagementActionItem = { + Component: React.ComponentType; + type: ChannelManagementActionType; +}; + +const toError = (error: unknown) => + error instanceof Error ? error : new Error('An unknown error occurred'); + +const getDisplayName = (name?: string, fallback?: string) => name || fallback || ''; + +const BlockUserActionIcon = () => ( + +); +const DeleteChatActionIcon = () => ( + +); +const MuteActionIcon = () => ( + +); +const MutedActionIcon = () => ( + +); +const LeaveChannelActionIcon = () => ( + +); + +const channelManagementViewActionClassName = 'str-chat__channel-management-view-action'; + +type ChannelManagementConfirmationAlertProps = { + action: 'blockUser' | 'deleteChat' | 'leaveChannel'; + cancelLabel: string; + confirmLabel: string; + description: string; + isSubmitting?: boolean; + onCancel: () => void; + onConfirm: () => void; + testId: string; + title: string; +}; + +const ChannelManagementConfirmationAlert = ({ + action, + cancelLabel, + confirmLabel, + description, + isSubmitting, + onCancel, + onConfirm, + testId, + title, +}: ChannelManagementConfirmationAlertProps) => ( + + + + + + + +); + +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 [alertOpen, setAlertOpen] = useState(false); + const [userBlockInProgress, setUserBlockInProgress] = useState(false); + + const closeBlockUserAlert = useCallback(() => { + setAlertOpen(false); + }, []); + + const openBlockUserAlert = useCallback(() => { + setAlertOpen(true); + }, []); + + const blockUser = useCallback(async () => { + if (!otherMember?.user?.id) return; + + try { + setUserBlockInProgress(true); + await client.blockUser(otherMember.user.id); + 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, otherMember, 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/components/ChannelDetail/Views/ChannelManagementView.tsx b/src/components/ChannelDetail/Views/ChannelManagementView.tsx index bd41a3a06c..110c785973 100644 --- a/src/components/ChannelDetail/Views/ChannelManagementView.tsx +++ b/src/components/ChannelDetail/Views/ChannelManagementView.tsx @@ -1,5 +1,4 @@ import { - useChannelStateContext, useChatContext, useComponentContext, useModalContext, @@ -16,33 +15,34 @@ import { useIsChannelMuted } from '../../ChannelListItem/hooks/useIsChannelMuted import { useChannelHasMembersOnline } from '../../ChannelHeader/hooks/useChannelHasMembersOnline'; import { Prompt } from '../../Dialog'; import { - type ChannelInfoActionItem, - defaultChannelInfoActionSet, - useBaseChannelInfoActionSetFilter, -} from './ChannelInfoActions.defaults'; + type ChannelManagementActionItem, + defaultChannelManagementActionSet, + useBaseChannelManagementActionSetFilter, +} from './ChannelManagementActions.defaults'; import { useChannelHeaderOnlineStatus } from '../../ChannelHeader/hooks/useChannelHeaderOnlineStatus'; +import { useChannelDetailContext } from '../ChannelDetailContext'; export type ChannelManagementViewProps = SectionNavigatorSectionContentProps & { - channelInfoActionSet?: ChannelInfoActionItem[]; + channelManagementActionSet?: ChannelManagementActionItem[]; }; export const ChannelManagementView = ({ - channelInfoActionSet = defaultChannelInfoActionSet, + channelManagementActionSet = defaultChannelManagementActionSet, }: ChannelManagementViewProps) => { const { t } = useTranslationContext(); const { client } = useChatContext(); - const { channel } = useChannelStateContext(); + const { channel } = useChannelDetailContext(); const { close } = useModalContext(); const { Avatar = DefaultChannelAvatar } = useComponentContext(); const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ channel, }); - const isOnline = useChannelHasMembersOnline(); + const isOnline = useChannelHasMembersOnline({ channel }); const { muted: channelMuted } = useIsChannelMuted(channel); const userMuted = false; const membership = useChannelMembershipState(channel); - const actions = useBaseChannelInfoActionSetFilter(channelInfoActionSet); - const onlineStatusText = useChannelHeaderOnlineStatus(); + const actions = useBaseChannelManagementActionSetFilter(channelManagementActionSet); + const onlineStatusText = useChannelHeaderOnlineStatus({ channel }); const pinned = !!membership.pinned_at; const resolvedIsDmChannel = isDmChannel({ diff --git a/src/components/ChannelDetail/__tests__/ChannelDetail.test.tsx b/src/components/ChannelDetail/__tests__/ChannelDetail.test.tsx index 2000a100ef..2cc4d75fad 100644 --- a/src/components/ChannelDetail/__tests__/ChannelDetail.test.tsx +++ b/src/components/ChannelDetail/__tests__/ChannelDetail.test.tsx @@ -1,6 +1,7 @@ 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 '../../SectionNavigator'; @@ -13,6 +14,10 @@ const sections: SectionNavigatorSection[] = [ }, ]; +const channel = { + cid: 'messaging:test-channel', +} as Channel; + describe('ChannelDetail', () => { const OriginalResizeObserver = globalThis.ResizeObserver; @@ -30,7 +35,11 @@ describe('ChannelDetail', () => { it('applies the channel-detail width class to the prompt wrapper', () => { const { container } = render( - , + , ); const prompt = container.querySelector('.str-chat__prompt'); diff --git a/src/components/ChannelDetail/__tests__/ChannelInfoActions.defaults.test.tsx b/src/components/ChannelDetail/__tests__/ChannelInfoActions.defaults.test.tsx deleted file mode 100644 index 75cf2adc66..0000000000 --- a/src/components/ChannelDetail/__tests__/ChannelInfoActions.defaults.test.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import React from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, beforeEach, vi } from 'vitest'; - -import { DefaultChannelInfoActions } from '../Views/ChannelInfoActions.defaults'; - -const mocks = vi.hoisted(() => { - const addNotification = vi.fn(); - const blockUser = vi.fn(); - const close = vi.fn(); - const mute = vi.fn(); - const muteUser = vi.fn(); - const removeMembers = vi.fn(); - const t = vi.fn((key: string) => key); - const unmute = vi.fn(); - const unmuteUser = vi.fn(); - - const channel = { - data: { - members: [{ user: { id: 'own-user' } }, { user: { id: 'other-user' } }], - own_capabilities: ['ban-channel-members', 'leave-channel', 'mute-channel'], - }, - mute, - removeMembers, - unmute, - }; - - const client = { - blockUser, - muteUser, - unmuteUser, - user: { id: 'own-user' }, - userID: 'own-user', - }; - - return { - addNotification, - blockUser, - channel, - channelMuted: false, - client, - close, - mute, - mutes: [] as Array<{ target: { id: string } }>, - muteUser, - removeMembers, - t, - unmute, - unmuteUser, - }; -}); - -vi.mock('../../../context', () => ({ - useChannelStateContext: () => ({ channel: mocks.channel }), - useChatContext: () => ({ - client: mocks.client, - mutes: mocks.mutes, - }), - useModalContext: () => ({ close: mocks.close }), - useTranslationContext: () => ({ t: mocks.t }), -})); - -vi.mock('../../Notifications', () => ({ - useNotificationApi: () => ({ - addNotification: mocks.addNotification, - }), -})); - -vi.mock('../../ChannelListItem/hooks/useIsChannelMuted', () => ({ - useIsChannelMuted: () => ({ muted: mocks.channelMuted }), -})); - -const advanceDebounce = async () => { - await act(async () => { - await vi.runOnlyPendingTimersAsync(); - }); -}; - -describe('DefaultChannelInfoActions', () => { - beforeEach(() => { - vi.useFakeTimers(); - mocks.addNotification.mockReset(); - mocks.blockUser.mockReset(); - mocks.close.mockReset(); - mocks.mute.mockReset(); - mocks.muteUser.mockReset(); - mocks.removeMembers.mockReset(); - mocks.t.mockClear(); - mocks.unmute.mockReset(); - mocks.unmuteUser.mockReset(); - mocks.channelMuted = false; - 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')); - - render(); - - 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')); - - render(); - - 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', - }), - ); - }); -}); diff --git a/src/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx b/src/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx new file mode 100644 index 0000000000..7a4fdbf8ea --- /dev/null +++ b/src/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx @@ -0,0 +1,407 @@ +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/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 unmute = vi.fn(); + const unmuteUser = vi.fn(); + + 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 = { + blockUser, + muteUser, + 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, + 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('../../Notifications', () => ({ + useNotificationApi: () => ({ + addNotification: mocks.addNotification, + }), +})); + +vi.mock('../../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.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.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 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/components/ChannelDetail/index.ts b/src/components/ChannelDetail/index.ts index c0d57b031a..75fd489718 100644 --- a/src/components/ChannelDetail/index.ts +++ b/src/components/ChannelDetail/index.ts @@ -1,3 +1,4 @@ export * from './ChannelDetail'; +export * from './ChannelDetailContext'; export * from './Views/ChannelManagementView'; -export * from './Views/ChannelInfoActions.defaults'; +export * from './Views/ChannelManagementActions.defaults'; diff --git a/src/components/ChannelDetail/styling/ChannelManagementView.scss b/src/components/ChannelDetail/styling/ChannelManagementView.scss index ae1c9bc753..4e2b7ca31e 100644 --- a/src/components/ChannelDetail/styling/ChannelManagementView.scss +++ b/src/components/ChannelDetail/styling/ChannelManagementView.scss @@ -43,9 +43,18 @@ } } +.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)); +} diff --git a/src/components/ChannelHeader/AvatarWithChannelDetail.tsx b/src/components/ChannelHeader/AvatarWithChannelDetail.tsx index 2937476027..9f3b11601a 100644 --- a/src/components/ChannelHeader/AvatarWithChannelDetail.tsx +++ b/src/components/ChannelHeader/AvatarWithChannelDetail.tsx @@ -1,17 +1,24 @@ import clsx from 'clsx'; import React, { useCallback, useState } from 'react'; -import { useComponentContext, useTranslationContext } from '../../context'; +import { + useChannelStateContext, + useComponentContext, + useTranslationContext, +} from '../../context'; import { type ChannelAvatarProps, ChannelAvatar as DefaultChannelAvatar, } from '../Avatar'; -import { ChannelDetail as DefaultChannelDetail } from '../ChannelDetail/ChannelDetail'; +import { + type ChannelDetailProps, + ChannelDetail as DefaultChannelDetail, +} from '../ChannelDetail/ChannelDetail'; import { GlobalModal } from '../Modal'; export type AvatarWithChannelDetailProps = ChannelAvatarProps & { Avatar?: React.ComponentType; - ChannelDetail?: React.ComponentType; + ChannelDetail?: React.ComponentType; }; const avatarWithChannelDetailDialogRootProps = { @@ -25,6 +32,7 @@ export const AvatarWithChannelDetail = ({ ...avatarProps }: AvatarWithChannelDetailProps) => { const { t } = useTranslationContext(); + const { channel } = useChannelStateContext(); const { Avatar: ContextAvatar, Modal = GlobalModal } = useComponentContext(); const [isModalOpen, setIsModalOpen] = useState(false); @@ -55,7 +63,7 @@ export const AvatarWithChannelDetail = ({ onClose={closeModal} open={isModalOpen} > - + ); diff --git a/src/components/ChannelHeader/hooks/useChannelHasMembersOnline.ts b/src/components/ChannelHeader/hooks/useChannelHasMembersOnline.ts index 1d1bd9cd80..3044994328 100644 --- a/src/components/ChannelHeader/hooks/useChannelHasMembersOnline.ts +++ b/src/components/ChannelHeader/hooks/useChannelHasMembersOnline.ts @@ -1,10 +1,19 @@ import { useEffect, useState } from 'react'; -import type { ChannelState } from 'stream-chat'; +import type { Channel, ChannelState } from 'stream-chat'; import { useChannelStateContext } from '../../../context/ChannelStateContext'; -export const useChannelHasMembersOnline = (enabled = true) => { - const { channel } = useChannelStateContext(); +export type UseChannelHasMembersOnlineParams = { + channel?: Channel; + enabled?: boolean; +}; + +export const useChannelHasMembersOnline = ({ + channel: channelOverride, + enabled = true, +}: UseChannelHasMembersOnlineParams = {}) => { + const { channel: contextChannel } = useChannelStateContext(); + const channel = channelOverride ?? contextChannel; const [watchers, setWatchers] = useState(() => Object.assign({}, channel?.state?.watchers ?? {}), ); diff --git a/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts b/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts index 8cde31483a..b6fa4fafa4 100644 --- a/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts +++ b/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts @@ -3,21 +3,36 @@ 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 || {}; const isDirectMessagingChannel = isDmChannel({ channel, ownUserId: client.user?.id, }); - const hasMembersOnline = useChannelHasMembersOnline(isDirectMessagingChannel); + const hasMembersOnline = useChannelHasMembersOnline({ + channel, + enabled: isDirectMessagingChannel, + }); if (!memberCount) return null; diff --git a/src/components/Dialog/__tests__/DialogsManager.test.ts b/src/components/Dialog/__tests__/DialogsManager.test.ts index 9a872d2848..ead8b887f5 100644 --- a/src/components/Dialog/__tests__/DialogsManager.test.ts +++ b/src/components/Dialog/__tests__/DialogsManager.test.ts @@ -222,6 +222,22 @@ describe('DialogManager', () => { expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(0); }); + it('removes a dialog from the open stack as soon as it is marked for removal', () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + const dialogManager = new DialogManager(); + dialogManager.open({ id: 'underlying-dialog' }); + dialogManager.open({ id: dialogId }); + dialogManager.markForRemoval(dialogId); + + expect(dialogManager.state.getLatestValue().openedDialogIds).toEqual([ + 'underlying-dialog', + ]); + expect(dialogManager.openDialogCount).toBe(1); + + vi.runAllTimers(); + }); + it('cancels dialog removal if it is referenced again quickly', () => { vi.useFakeTimers({ shouldAdvanceTime: true }); @@ -236,4 +252,26 @@ describe('DialogManager', () => { expect(dialogManager.openDialogCount).toBe(1); expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(1); }); + + it('restores an open dialog to the stack when pending removal is cancelled', () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + const dialogManager = new DialogManager(); + dialogManager.open({ id: 'underlying-dialog' }); + dialogManager.open({ id: dialogId }); + dialogManager.markForRemoval(dialogId); + + expect(dialogManager.state.getLatestValue().openedDialogIds).toEqual([ + 'underlying-dialog', + ]); + + dialogManager.getOrCreate({ id: dialogId }); + + expect(dialogManager.state.getLatestValue().openedDialogIds).toEqual([ + 'underlying-dialog', + dialogId, + ]); + + vi.runAllTimers(); + }); }); diff --git a/src/components/Dialog/service/DialogManager.ts b/src/components/Dialog/service/DialogManager.ts index 0d984821d9..8157d6725c 100644 --- a/src/components/Dialog/service/DialogManager.ts +++ b/src/components/Dialog/service/DialogManager.ts @@ -112,6 +112,9 @@ export class DialogManager { removalTimeout: undefined, }, }, + openedDialogIds: current.dialogsById[id]?.isOpen + ? [...current.openedDialogIds.filter((dialogId) => dialogId !== id), id] + : current.openedDialogIds, })); } @@ -200,6 +203,7 @@ export class DialogManager { }, 16), }, }, + openedDialogIds: current.openedDialogIds.filter((dialogId) => dialogId !== id), })); } @@ -221,6 +225,9 @@ export class DialogManager { removalTimeout: undefined, }, }, + openedDialogIds: current.dialogsById[id]?.isOpen + ? [...current.openedDialogIds.filter((dialogId) => dialogId !== id), id] + : current.openedDialogIds, })); } } diff --git a/src/components/Modal/GlobalModal.tsx b/src/components/Modal/GlobalModal.tsx index e978e8792d..95beef57e9 100644 --- a/src/components/Modal/GlobalModal.tsx +++ b/src/components/Modal/GlobalModal.tsx @@ -138,11 +138,15 @@ export const GlobalModal = ({ maybeClose('escape', event); }; - // Sync open prop โ†’ dialog open. Don't close here (dialog ref changes after close โ†’ effect loop). + // Sync open prop โ†’ dialog state. // closingRef blocks re-open when we just closed and parent hasn't set open=false yet. useEffect(() => { if (!open) { closingRef.current = false; + if (isOpen) { + dialog.close(); + } + return; } if (open && !isOpen && !closingRef.current) { dialog.open(); @@ -166,7 +170,6 @@ export const GlobalModal = ({ >
{children}
diff --git a/src/components/Modal/__tests__/GlobalModal.test.tsx b/src/components/Modal/__tests__/GlobalModal.test.tsx index 2cf7d439f0..d559f79d0d 100644 --- a/src/components/Modal/__tests__/GlobalModal.test.tsx +++ b/src/components/Modal/__tests__/GlobalModal.test.tsx @@ -75,6 +75,54 @@ const renderStackedModals = ({ , ); +const RemovableChildModalFixture = () => { + const [showChild, setShowChild] = React.useState(true); + + return ( + + + + + + {showChild && ( + + + + + + )} + + + + + ); +}; + +const CloseChildModalFixture = () => { + const [childOpen, setChildOpen] = React.useState(true); + + return ( + + + + + + + + + + + + + + + ); +}; + const OverlayCloseButton = React.forwardRef< HTMLButtonElement, React.ComponentProps<'button'> @@ -302,7 +350,7 @@ describe('GlobalModal', () => { const dialog = screen.getByRole('dialog'); expect(dialog).toHaveClass('str-chat__modal__dialog'); expect(dialog).toHaveAttribute('aria-modal', 'true'); - expect(dialog).toHaveAttribute('tabindex', '-1'); + expect(dialog).toHaveAttribute('tabindex', '0'); expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title'); expect(dialog).toHaveAttribute('aria-describedby', 'modal-description'); }); @@ -434,9 +482,11 @@ describe('GlobalModal', () => { expect(parentModal).toBeInTheDocument(); expect(parentModal).not.toHaveAttribute('aria-modal'); expect(parentModal).toHaveAttribute('inert'); + expect(parentModal).toHaveAttribute('tabindex', '-1'); expect(childModal).toBeInTheDocument(); expect(childModal).toHaveAttribute('aria-modal', 'true'); expect(childModal).not.toHaveAttribute('inert'); + expect(childModal).toHaveAttribute('tabindex', '0'); }); it('only closes the topmost modal on Escape', () => { @@ -457,6 +507,59 @@ describe('GlobalModal', () => { expect(parentOnClose).not.toHaveBeenCalled(); }); + it('restores interactivity to the underlying modal after the topmost modal closes', () => { + const childOnClose = vi.fn(); + const parentOnClose = vi.fn(); + + renderStackedModals({ childOnClose, parentOnClose }); + + const parentModal = screen.getByRole('dialog', { name: 'Parent modal' }); + const childModal = screen.getByRole('alertdialog', { name: 'Child modal' }); + + fireEvent.keyDown(childModal, { key: 'Escape' }); + + expect(childOnClose).toHaveBeenCalledTimes(1); + expect(parentModal).toHaveAttribute('aria-modal', 'true'); + expect(parentModal).not.toHaveAttribute('inert'); + expect(parentModal).toHaveAttribute('tabindex', '0'); + + fireEvent.keyDown(parentModal, { key: 'Escape' }); + + expect(parentOnClose).toHaveBeenCalledTimes(1); + }); + + it('restores topmost state to the underlying modal after the topmost modal is removed', () => { + render(); + + const parentModal = screen.getByRole('dialog', { name: 'Parent modal' }); + expect(parentModal).toHaveAttribute('tabindex', '-1'); + + fireEvent.click(screen.getByRole('button', { name: 'Remove child modal' })); + + expect( + screen.queryByRole('alertdialog', { name: 'Child modal' }), + ).not.toBeInTheDocument(); + expect(parentModal).toHaveAttribute('aria-modal', 'true'); + expect(parentModal).not.toHaveAttribute('inert'); + expect(parentModal).toHaveAttribute('tabindex', '0'); + }); + + it('restores topmost state to the underlying modal after the topmost modal open prop becomes false', () => { + render(); + + const parentModal = screen.getByRole('dialog', { name: 'Parent modal' }); + expect(parentModal).toHaveAttribute('tabindex', '-1'); + + fireEvent.click(screen.getByRole('button', { name: 'Close child modal' })); + + expect( + screen.queryByRole('alertdialog', { name: 'Child modal' }), + ).not.toBeInTheDocument(); + expect(parentModal).toHaveAttribute('aria-modal', 'true'); + expect(parentModal).not.toHaveAttribute('inert'); + expect(parentModal).toHaveAttribute('tabindex', '0'); + }); + it('forwards alertdialog role when explicitly provided', () => { renderComponent({ props: { diff --git a/src/i18n/de.json b/src/i18n/de.json index dc5e205ad9..ca09d0b8fc 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -55,6 +55,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 }}", @@ -165,6 +166,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", @@ -213,6 +215,7 @@ "Error adding flag": "Fehler beim Hinzufรผgen des Flags", "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.", @@ -322,6 +325,7 @@ "language/zh": "Chinesisch (Vereinfacht)", "language/zh-TW": "Chinesisch (Traditionell)", "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", @@ -468,6 +472,8 @@ "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 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", diff --git a/src/i18n/en.json b/src/i18n/en.json index 14f36e936c..0a88dd121d 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -55,6 +55,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 }}", @@ -165,6 +166,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", @@ -213,6 +215,7 @@ "Error adding flag": "Error adding flag", "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.", @@ -322,6 +325,7 @@ "language/zh": "Chinese (Simplified)", "language/zh-TW": "Chinese (Traditional)", "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", @@ -468,6 +472,8 @@ "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 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", diff --git a/src/i18n/es.json b/src/i18n/es.json index 8d985bbe99..968b503083 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -63,6 +63,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 }}", @@ -173,6 +174,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", @@ -221,6 +223,7 @@ "Error adding flag": "Error al agregar la bandera", "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.", @@ -332,6 +335,7 @@ "language/zh": "Chino (simplificado)", "language/zh-TW": "Chino (tradicional)", "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", @@ -482,6 +486,8 @@ "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 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", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 2919c0c7e9..bddf766d99 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -63,6 +63,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 }}", @@ -173,6 +174,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", @@ -221,6 +223,7 @@ "Error adding flag": "Erreur lors de l'ajout du signalement", "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.", @@ -332,6 +335,7 @@ "language/zh": "Chinois (simplifiรฉ)", "language/zh-TW": "Chinois (traditionnel)", "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", @@ -482,6 +486,8 @@ "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 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", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 5b80b4eafb..afc757b23a 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -55,6 +55,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 }}", @@ -165,6 +166,7 @@ "Channel unmuted": "เคšเฅˆเคจเคฒ เค…เคจเคฎเฅเคฏเฅ‚เคŸ เค•เคฟเคฏเคพ เค—เคฏเคพ", "Channel unpinned": "เคšเฅˆเคจเคฒ เค…เคจเคชเคฟเคจ เค•เคฟเคฏเคพ เค—เคฏเคพ", "Channels": "เคšเฅˆเคจเคฒ", + "Chat deleted": "Chat deleted", "Chats": "เคšเฅˆเคŸ", "Choose between 2 to 10 options": "2 เคธเฅ‡ 10 เคตเคฟเค•เคฒเฅเคช เคšเฅเคจเฅ‡เค‚", "Close": "เคฌเค‚เคฆ เค•เคฐเฅ‡", @@ -213,6 +215,7 @@ "Error adding flag": "เคงเฅเคตเคœ เคœเฅ‹เคกเคผเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", "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": "เคธเค‚เคฆเฅ‡เคถ เค•เฅ‹ เค…เคชเค เคฟเคค เคšเคฟเคนเฅเคจเคฟเคค เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", @@ -323,6 +326,7 @@ "language/zh": "เคšเฅ€เคจเฅ€ (เคธเคฐเคฒเฅ€เค•เฅƒเคค)", "language/zh-TW": "เคšเฅ€เคจเฅ€ (เคชเคพเคฐเค‚เคชเคฐเคฟเค•)", "Leave Channel": "เคšเฅˆเคจเคฒ เค›เฅ‹เคกเคผเฅ‡เค‚", + "Leave chat": "เคšเฅˆเคจเคฒ เค›เฅ‹เคกเคผเฅ‡เค‚", "Left channel": "เคšเฅˆเคจเคฒ เค›เฅ‹เคกเคผ เคฆเคฟเคฏเคพ เค—เคฏเคพ", "Let others add options": "เคฆเฅ‚เคธเคฐเฅ‹เค‚ เค•เฅ‹ เคตเคฟเค•เคฒเฅเคช เคœเฅ‹เคกเคผเคจเฅ‡ เคฆเฅ‡เค‚", "Limit votes per person": "เคชเฅเคฐเคคเคฟ เคตเฅเคฏเค•เฅเคคเคฟ เคตเฅ‹เคŸ เคธเฅ€เคฎเคฟเคค เค•เคฐเฅ‡เค‚", @@ -469,6 +473,8 @@ "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 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": "เคฅเฅเคฐเฅ‡เคก เคฎเฅ‡เค‚ เค‰เคคเฅเคคเคฐ", diff --git a/src/i18n/it.json b/src/i18n/it.json index 9eec64e7ac..5df06da965 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -63,6 +63,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 }}", @@ -173,6 +174,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", @@ -221,6 +223,7 @@ "Error adding flag": "Errore durante l'aggiunta del flag", "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.", @@ -332,6 +335,7 @@ "language/zh": "Cinese (semplificato)", "language/zh-TW": "Cinese (tradizionale)", "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", @@ -482,6 +486,8 @@ "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 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", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 3432de852b..19aa275faa 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -54,6 +54,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 }}", @@ -164,6 +165,7 @@ "Channel unmuted": "ใƒใƒฃใƒณใƒใƒซใฎใƒŸใƒฅใƒผใƒˆใ‚’่งฃ้™คใ—ใพใ—ใŸ", "Channel unpinned": "ใƒใƒฃใƒณใƒใƒซใฎใƒ”ใƒณ็•™ใ‚ใ‚’่งฃ้™คใ—ใพใ—ใŸ", "Channels": "ใƒใƒฃใƒณใƒใƒซ", + "Chat deleted": "Chat deleted", "Chats": "ใƒใƒฃใƒƒใƒˆ", "Choose between 2 to 10 options": "2ใ€œ10ใฎ้ธๆŠž่‚ขใ‹ใ‚‰้ธใถ", "Close": "้–‰ใ‚ใ‚‹", @@ -212,6 +214,7 @@ "Error adding flag": "ใƒ•ใƒฉใ‚ฐใ‚’่ฟฝๅŠ ใฎใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", "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ไปถใฎใƒใƒฃใƒณใƒใƒซใƒกใƒƒใ‚ปใƒผใ‚ธใ‚ˆใ‚Šๅคใ„ๆœช่ชญใƒกใƒƒใ‚ปใƒผใ‚ธใฏใƒžใƒผใ‚ฏใงใใพใ›ใ‚“ใ€‚", @@ -319,6 +322,7 @@ "language/zh": "ไธญๅ›ฝ่ชž๏ผˆ็ฐกไฝ“ๅญ—๏ผ‰", "language/zh-TW": "ไธญๅ›ฝ่ชž๏ผˆ็นไฝ“ๅญ—๏ผ‰", "Leave Channel": "ใƒใƒฃใƒณใƒใƒซใ‚’้€€ๅ‡บ", + "Leave chat": "ใƒใƒฃใƒณใƒใƒซใ‚’้€€ๅ‡บ", "Left channel": "ใƒใƒฃใƒณใƒใƒซใ‚’้€€ๅ‡บใ—ใพใ—ใŸ", "Let others add options": "ไป–ใฎไบบใŒ้ธๆŠž่‚ขใ‚’่ฟฝๅŠ ใงใใ‚‹ใ‚ˆใ†ใซใ™ใ‚‹", "Limit votes per person": "1ไบบใ‚ใŸใ‚ŠใฎๆŠ•็ฅจๆ•ฐใ‚’ๅˆถ้™ใ™ใ‚‹", @@ -464,6 +468,8 @@ "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 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": "ใ‚นใƒฌใƒƒใƒ‰ใฎ่ฟ”ไฟก", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 9a94a13507..badb95166e 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -54,6 +54,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 }}", @@ -164,6 +165,7 @@ "Channel unmuted": "์ฑ„๋„ ์Œ์†Œ๊ฑฐ๊ฐ€ ํ•ด์ œ๋จ", "Channel unpinned": "์ฑ„๋„ ๊ณ ์ •์ด ํ•ด์ œ๋จ", "Channels": "์ฑ„๋„", + "Chat deleted": "Chat deleted", "Chats": "์ฑ„ํŒ…", "Choose between 2 to 10 options": "2~10๊ฐœ์˜ ์„ ํƒ์ง€ ์ค‘์—์„œ ์„ ํƒ", "Close": "๋‹ซ๊ธฐ", @@ -212,6 +214,7 @@ "Error adding flag": "ํ”Œ๋ž˜๊ทธ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๋™์•ˆ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", "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๊ฐœ์˜ ์ฑ„๋„ ๋ฉ”์‹œ์ง€๋ณด๋‹ค ์˜ค๋ž˜๋œ ์ฝ์ง€ ์•Š์€ ๋ฉ”์‹œ์ง€๋Š” ํ‘œ์‹œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", @@ -319,6 +322,7 @@ "language/zh": "์ค‘๊ตญ์–ด(๊ฐ„์ฒด)", "language/zh-TW": "์ค‘๊ตญ์–ด(๋ฒˆ์ฒด)", "Leave Channel": "์ฑ„๋„ ๋‚˜๊ฐ€๊ธฐ", + "Leave chat": "์ฑ„๋„ ๋‚˜๊ฐ€๊ธฐ", "Left channel": "์ฑ„๋„์„ ๋‚˜๊ฐ”์Šต๋‹ˆ๋‹ค", "Let others add options": "๋‹ค๋ฅธ ์‚ฌ๋žŒ์ด ์„ ํƒ์ง€๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ—ˆ์šฉ", "Limit votes per person": "1์ธ๋‹น ํˆฌํ‘œ ์ˆ˜ ์ œํ•œ", @@ -464,6 +468,8 @@ "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 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": "์Šค๋ ˆ๋“œ ๋‹ต์žฅ", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index fecd448186..87fc6a4c9b 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -55,6 +55,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 }}", @@ -165,6 +166,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", @@ -213,6 +215,7 @@ "Error adding flag": "Fout bij toevoegen van vlag", "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.", @@ -322,6 +325,7 @@ "language/zh": "Chinees (vereenvoudigd)", "language/zh-TW": "Chinees (traditioneel)", "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", @@ -470,6 +474,8 @@ "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 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", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 11a84dffd4..672e642726 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -63,6 +63,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 }}", @@ -173,6 +174,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", @@ -221,6 +223,7 @@ "Error adding flag": "Erro ao reportar", "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.", @@ -332,6 +335,7 @@ "language/zh": "Chinรชs (simplificado)", "language/zh-TW": "Chinรชs (tradicional)", "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", @@ -482,6 +486,8 @@ "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 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", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index e50409e696..ca28597306 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -72,6 +72,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 }}", @@ -182,6 +183,7 @@ "Channel unmuted": "ะ—ะฐะณะปัƒัˆะตะฝะธะต ะบะฐะฝะฐะปะฐ ัะฝัั‚ะพ", "Channel unpinned": "ะšะฐะฝะฐะป ะพั‚ะบั€ะตะฟะปั‘ะฝ", "Channels": "ะšะฐะฝะฐะปั‹", + "Chat deleted": "Chat deleted", "Chats": "ะงะฐั‚ั‹", "Choose between 2 to 10 options": "ะ’ั‹ะฑะตั€ะธั‚ะต ะพั‚ 2 ะดะพ 10 ะฒะฐั€ะธะฐะฝั‚ะพะฒ", "Close": "ะ—ะฐะบั€ั‹ั‚ัŒ", @@ -230,6 +232,7 @@ "Error adding flag": "ะžัˆะธะฑะบะฐ ะดะพะฑะฐะฒะปะตะฝะธั ั„ะปะฐะณะฐ", "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 ัะพะพะฑั‰ะตะฝะธะน ะฒ ะบะฐะฝะฐะปะต.", @@ -269,13 +272,13 @@ "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 }} ั„ะฐะนะปะฐ", "Flag": "ะŸะพะถะฐะปะพะฒะฐั‚ัŒัั", "Generating...": "ะ“ะตะฝะตั€ะธั€ัƒัŽ...", "giphy-command-args": "[ั‚ะตะบัั‚]", @@ -346,6 +349,7 @@ "language/zh": "ะšะธั‚ะฐะนัะบะธะน (ัƒะฟั€ะพั‰ั‘ะฝะฝั‹ะน)", "language/zh-TW": "ะšะธั‚ะฐะนัะบะธะน (ั‚ั€ะฐะดะธั†ะธะพะฝะฝั‹ะน)", "Leave Channel": "ะŸะพะบะธะฝัƒั‚ัŒ ะบะฐะฝะฐะป", + "Leave chat": "ะŸะพะบะธะฝัƒั‚ัŒ ะบะฐะฝะฐะป", "Left channel": "ะšะฐะฝะฐะป ะฟะพะบะธะฝัƒั‚", "Let others add options": "ะ ะฐะทั€ะตัˆะธั‚ัŒ ะดั€ัƒะณะธะผ ะดะพะฑะฐะฒะปัั‚ัŒ ะฒะฐั€ะธะฐะฝั‚ั‹", "Limit votes per person": "ะžะณั€ะฐะฝะธั‡ะธั‚ัŒ ะณะพะปะพัะฐ ะฝะฐ ั‡ะตะปะพะฒะตะบะฐ", @@ -500,6 +504,8 @@ "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 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": "ะžั‚ะฒะตั‚ ะฒ ะฒะตั‚ะบะต", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index d49f9e93ea..756a2e7667 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -55,6 +55,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 }}", @@ -165,6 +166,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", @@ -213,6 +215,7 @@ "Error adding flag": "Bayrak eklenirken hata oluลŸtu", "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.", @@ -322,6 +325,7 @@ "language/zh": "ร‡ince (basitleลŸtirilmiลŸ)", "language/zh-TW": "ร‡ince (geleneksel)", "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ฤฑ", @@ -468,6 +472,8 @@ "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 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ฤฑ", diff --git a/src/utils/index.ts b/src/utils/index.ts index f661a691a7..6d1fbdbc09 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,3 +2,4 @@ export * from './getChannel'; export * from './getTextareaCaretRect'; export * from './getWholeChar'; export * from './isDmChannel'; +export * from './useStableCallback'; From 7054821b96a84dfb4ac84263eaa33c9ff4c6495c Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 4 Jun 2026 14:39:31 +0200 Subject: [PATCH 04/21] feat: add useIsUserMuted hook --- .../Views/ChannelManagementView.tsx | 27 +++- .../__tests__/ChannelManagementView.test.tsx | 137 ++++++++++++++++++ src/components/ChannelListItem/hooks/index.ts | 1 + .../ChannelListItem/hooks/useIsUserMuted.ts | 12 ++ 4 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 src/components/ChannelDetail/__tests__/ChannelManagementView.test.tsx create mode 100644 src/components/ChannelListItem/hooks/useIsUserMuted.ts diff --git a/src/components/ChannelDetail/Views/ChannelManagementView.tsx b/src/components/ChannelDetail/Views/ChannelManagementView.tsx index 110c785973..978fb9862f 100644 --- a/src/components/ChannelDetail/Views/ChannelManagementView.tsx +++ b/src/components/ChannelDetail/Views/ChannelManagementView.tsx @@ -7,9 +7,9 @@ import { import { isDmChannel } from '../../../utils'; import type { SectionNavigatorSectionContentProps } from '../../SectionNavigator'; import { ChannelAvatar as DefaultChannelAvatar } from '../../Avatar'; -import { useChannelPreviewInfo } from '../../ChannelListItem'; +import { useChannelPreviewInfo, useIsUserMuted } from '../../ChannelListItem'; import { IconMute, IconPin } from '../../Icons'; -import React from 'react'; +import React, { useMemo } from 'react'; import { useChannelMembershipState } from '../../ChannelList'; import { useIsChannelMuted } from '../../ChannelListItem/hooks/useIsChannelMuted'; import { useChannelHasMembersOnline } from '../../ChannelHeader/hooks/useChannelHasMembersOnline'; @@ -37,19 +37,30 @@ export const ChannelManagementView = ({ 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.data?.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 = false; + const userMuted = useIsUserMuted(otherMemberUserId); const membership = useChannelMembershipState(channel); const actions = useBaseChannelManagementActionSetFilter(channelManagementActionSet); const onlineStatusText = useChannelHeaderOnlineStatus({ channel }); const pinned = !!membership.pinned_at; - const resolvedIsDmChannel = isDmChannel({ - channel, - ownUserId: client.user?.id, - }); - return (
({ + channel: { + data: { + member_count: 2, + name: 'Test channel', + }, + state: { + members: { + 'other-user': { user: { id: 'other-user' } }, + 'own-user': { user: { id: 'own-user' } }, + }, + membership: {}, + }, + }, + close: vi.fn(), + 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('../../ChannelList', () => ({ + useChannelMembershipState: () => mocks.channel.state.membership, +})); + +vi.mock('../../ChannelListItem', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useChannelPreviewInfo: () => ({ + displayImage: undefined, + displayTitle: 'Other user', + groupChannelDisplayInfo: { members: [] }, + }), + }; +}); + +vi.mock('../../ChannelListItem/hooks/useIsChannelMuted', () => ({ + useIsChannelMuted: () => ({ muted: false }), +})); + +vi.mock('../../ChannelHeader/hooks/useChannelHasMembersOnline', () => ({ + useChannelHasMembersOnline: () => false, +})); + +vi.mock('../../ChannelHeader/hooks/useChannelHeaderOnlineStatus', () => ({ + useChannelHeaderOnlineStatus: () => undefined, +})); + +vi.mock('../../Dialog', () => ({ + Prompt: { + Body: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) =>
{children}
, + Header: ({ description, title }: { description: string; title: string }) => ( +
+

{title}

+

{description}

+
+ ), + }, +})); + +vi.mock('../../Icons', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + IconMute: () => , + IconPin: () => , + }; +}); + +const renderChannelManagementView = () => + render( + + + , + ); + +describe('ChannelManagementView', () => { + beforeEach(() => { + 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(); + }); +}); 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/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], + ); +}; From 6e87676c73d251075882c8bf54f217bda92b28b6 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 4 Jun 2026 14:52:34 +0200 Subject: [PATCH 05/21] fix: route notifications through generated modal dialogs --- src/components/Chat/Chat.tsx | 22 ++++--- src/components/Chat/__tests__/Chat.test.tsx | 35 +++++++++++ .../__tests__/useNotificationApi.test.tsx | 58 +++++++++++++++++++ .../Notifications/hooks/useNotificationApi.ts | 25 ++++---- 4 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 src/components/Notifications/hooks/__tests__/useNotificationApi.test.tsx 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/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( From d2ad28f0ea4b2dd8e675ac105b7d181369e06c6a Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 4 Jun 2026 16:33:53 +0200 Subject: [PATCH 06/21] feat: allow to unblock user from ChannelManagementView --- .../ChannelManagementActions.defaults.tsx | 74 +++++++++++++++---- ...ChannelManagementActions.defaults.test.tsx | 57 ++++++++++++++ .../styling/ChannelManagementView.scss | 6 ++ src/i18n/de.json | 2 + src/i18n/en.json | 2 + src/i18n/es.json | 2 + src/i18n/fr.json | 2 + src/i18n/hi.json | 2 + src/i18n/it.json | 2 + src/i18n/ja.json | 2 + src/i18n/ko.json | 2 + src/i18n/nl.json | 2 + src/i18n/pt.json | 2 + src/i18n/ru.json | 2 + src/i18n/tr.json | 2 + 15 files changed, 146 insertions(+), 15 deletions(-) diff --git a/src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx b/src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx index f3f108b2aa..a588bdd932 100644 --- a/src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx +++ b/src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx @@ -10,6 +10,7 @@ import { } from '../../../context'; import { isDmChannel, useStableCallback } from '../../../utils'; import { useIsChannelMuted } from '../../ChannelListItem/hooks/useIsChannelMuted'; +import { useStateStore } from '../../../store'; import { Alert } from '../../Dialog'; import { Button } from '../../Button'; import { Switch } from '../../Form'; @@ -39,23 +40,25 @@ const toError = (error: unknown) => const getDisplayName = (name?: string, fallback?: string) => name || fallback || ''; const BlockUserActionIcon = () => ( - + ); const DeleteChatActionIcon = () => ( - + ); const MuteActionIcon = () => ( - + ); const MutedActionIcon = () => ( - + ); const LeaveChannelActionIcon = () => ( - + ); const channelManagementViewActionClassName = 'str-chat__channel-management-view-action'; +const blockedUsersSelector = ({ userIds }: { userIds: string[] }) => ({ userIds }); + type ChannelManagementConfirmationAlertProps = { action: 'blockUser' | 'deleteChat' | 'leaveChannel'; cancelLabel: string; @@ -420,6 +423,15 @@ const BlockUserAction = () => { 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); @@ -431,12 +443,40 @@ const BlockUserAction = () => { 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 (!otherMember?.user?.id) return; + if (!targetUserId) return; try { setUserBlockInProgress(true); - await client.blockUser(otherMember.user.id); + await client.blockUser(targetUserId); addNotification({ context: { channel }, emitter: 'ChannelManagementView', @@ -457,7 +497,7 @@ const BlockUserAction = () => { setAlertOpen(false); setUserBlockInProgress(false); } - }, [addNotification, channel, client, otherMember, t]); + }, [addNotification, channel, client, targetUserId, t]); const rootProps = useMemo( () => ({ @@ -475,21 +515,25 @@ const BlockUserAction = () => { LeadingIcon={BlockUserActionIcon} RootElement='button' rootProps={rootProps} - title={t('Block user')} + title={isBlocked ? t('Unblock User') : t('Block user')} /> diff --git a/src/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx b/src/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx index 7a4fdbf8ea..0d00eef287 100644 --- a/src/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx +++ b/src/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx @@ -20,8 +20,31 @@ const mocks = vi.hoisted(() => { 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: { @@ -42,8 +65,10 @@ const mocks = vi.hoisted(() => { }; const client = { + blockedUsers, blockUser, muteUser, + unBlockUser, unmuteUser, user: { id: 'own-user' }, userID: 'own-user', @@ -62,6 +87,7 @@ const mocks = vi.hoisted(() => { muteUser, removeMembers, t, + unBlockUser, unmute, unmuteUser, useStableTranslationFunction: true, @@ -154,6 +180,7 @@ describe('DefaultChannelManagementActions', () => { mocks.muteUser.mockReset(); mocks.removeMembers.mockReset(); mocks.t.mockClear(); + mocks.unBlockUser.mockReset(); mocks.unmute.mockReset(); mocks.unmuteUser.mockReset(); mocks.useStableTranslationFunction = true; @@ -172,6 +199,7 @@ describe('DefaultChannelManagementActions', () => { 'other-user': { user: { id: 'other-user' } }, 'own-user': { user: { id: 'own-user' } }, }; + mocks.client.blockedUsers.next({ userIds: [] }); mocks.mutes = []; }); @@ -303,6 +331,35 @@ describe('DefaultChannelManagementActions', () => { ); }); + 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 User' })); + + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Unblock User' })).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); diff --git a/src/components/ChannelDetail/styling/ChannelManagementView.scss b/src/components/ChannelDetail/styling/ChannelManagementView.scss index 4e2b7ca31e..ae78aa30a2 100644 --- a/src/components/ChannelDetail/styling/ChannelManagementView.scss +++ b/src/components/ChannelDetail/styling/ChannelManagementView.scss @@ -54,6 +54,12 @@ color: var(--str-chat__text-secondary); } +.str-chat__channel-detail__action-icon--block-user, +.str-chat__channel-detail__action-icon--delete-chat, +.str-chat__channel-detail__action-icon--leave-channel { + color: var(--str-chat__accent-error); +} + .str-chat__channel-management-confirmation-alert { min-width: min(304px, calc(100vw - 32px)); max-width: min(304px, calc(100vw - 32px)); diff --git a/src/i18n/de.json b/src/i18n/de.json index ca09d0b8fc..b00a0f1c85 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -226,6 +226,7 @@ "Error removing message pin": "Fehler beim Entfernen der gepinnten Nachricht", "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", @@ -473,6 +474,7 @@ "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", diff --git a/src/i18n/en.json b/src/i18n/en.json index 0a88dd121d..2b41a5d643 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -226,6 +226,7 @@ "Error removing message pin": "Error removing message pin", "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", @@ -473,6 +474,7 @@ "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", diff --git a/src/i18n/es.json b/src/i18n/es.json index 968b503083..3faa5194a0 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -234,6 +234,7 @@ "Error removing message pin": "Error al quitar el pin del mensaje", "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", @@ -487,6 +488,7 @@ "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", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index bddf766d99..d5c5c57628 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -234,6 +234,7 @@ "Error removing message pin": "Erreur lors du retrait de l'รฉpinglage du message", "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", @@ -487,6 +488,7 @@ "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รฉ", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index afc757b23a..834c6071b9 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -227,6 +227,7 @@ "Error removing message pin": "เคธเค‚เคฆเฅ‡เคถ เคชเคฟเคจ เคจเคฟเค•เคพเคฒเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", "Error reproducing the recording": "เคฐเคฟเค•เฅ‰เคฐเฅเคกเคฟเค‚เค— เคชเฅเคจ: เค‰เคคเฅเคชเคจเฅเคจ เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", "Error starting recording": "เคฐเฅ‡เค•เฅ‰เคฐเฅเคกเคฟเค‚เค— เคถเฅเคฐเฅ‚ เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", + "Error unblocking user": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค•เฅ‹ เค…เคจเคฌเฅเคฒเฅ‰เค• เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", "Error unmuting a user ...": "เคฏเฅ‚เคœเคฐ เค•เฅ‹ เค…เคจเคฎเฅเคฏเฅ‚เคŸ เค•เคฐเคจเฅ‡ เค•เคพ เคชเฅเคฐเคฏเคพเคธ เคซเฅ‡เคฒ เคนเฅเค†", "Error unmuting channel": "เคšเฅˆเคจเคฒ เค•เฅ‹ เค…เคจเคฎเฅเคฏเฅ‚เคŸ เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", "Error unmuting user": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค•เฅ‹ เค…เคจเคฎเฅเคฏเฅ‚เคŸ เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคคเฅเคฐเฅเคŸเคฟ", @@ -474,6 +475,7 @@ "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": "เคฅเฅเคฐเฅ‡เคก เคจเคนเฅ€เค‚ เคฎเคฟเคฒเคพ", diff --git a/src/i18n/it.json b/src/i18n/it.json index 5df06da965..3becc2d660 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -234,6 +234,7 @@ "Error removing message pin": "Errore durante la rimozione del PIN del messaggio", "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", @@ -487,6 +488,7 @@ "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", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 19aa275faa..634e6ce27c 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -225,6 +225,7 @@ "Error removing message pin": "ใƒกใƒƒใ‚ปใƒผใ‚ธใฎใƒ”ใƒณใ‚’ๅ‰Š้™คใฎใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", "Error reproducing the recording": "้Œฒ้Ÿณใฎๅ†็”Ÿไธญใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", "Error starting recording": "้Œฒ้Ÿณใฎ้–‹ๅง‹ๆ™‚ใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", + "Error unblocking user": "ใƒฆใƒผใ‚ถใƒผใฎใƒ–ใƒญใƒƒใ‚ฏ่งฃ้™คไธญใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", "Error unmuting a user ...": "ใƒฆใƒผใ‚ถใƒผใฎ็„ก้Ÿณ่งฃ้™คใฎใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ...", "Error unmuting channel": "ใƒใƒฃใƒณใƒใƒซใฎใƒŸใƒฅใƒผใƒˆ่งฃ้™คไธญใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", "Error unmuting user": "ใƒฆใƒผใ‚ถใƒผใฎใƒŸใƒฅใƒผใƒˆ่งฃ้™คไธญใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", @@ -469,6 +470,7 @@ "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": "ใ‚นใƒฌใƒƒใƒ‰ใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“ใงใ—ใŸ", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index badb95166e..06ddc0c6f1 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -225,6 +225,7 @@ "Error removing message pin": "๋ฉ”์‹œ์ง€ ํ•€์„ ์ œ๊ฑฐํ•˜๋Š” ์ค‘์— ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", "Error reproducing the recording": "๋…น์Œ ์žฌ์ƒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", "Error starting recording": "๋…น์Œ ์‹œ์ž‘ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค", + "Error unblocking user": "์‚ฌ์šฉ์ž ์ฐจ๋‹จ ํ•ด์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค", "Error unmuting a user ...": "์‚ฌ์šฉ์ž ์Œ์†Œ๊ฑฐ ํ•ด์ œ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ...", "Error unmuting channel": "์ฑ„๋„ ์Œ์†Œ๊ฑฐ ํ•ด์ œ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", "Error unmuting user": "์‚ฌ์šฉ์ž ์Œ์†Œ๊ฑฐ ํ•ด์ œ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", @@ -469,6 +470,7 @@ "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": "์Šค๋ ˆ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 87fc6a4c9b..0f6457e58c 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -226,6 +226,7 @@ "Error removing message pin": "Fout bij verwijderen van berichtpin", "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", @@ -475,6 +476,7 @@ "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", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 672e642726..d851bd963a 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -234,6 +234,7 @@ "Error removing message pin": "Erro ao remover o PIN da mensagem", "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", @@ -487,6 +488,7 @@ "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", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index ca28597306..9c3f0cc2fe 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -243,6 +243,7 @@ "Error removing message pin": "ะžัˆะธะฑะบะฐ ะฟั€ะธ ัƒะดะฐะปะตะฝะธะธ ะฑัƒะปะฐะฒะบะธ ัะพะพะฑั‰ะตะฝะธั", "Error reproducing the recording": "ะžัˆะธะฑะบะฐ ะฒะพัะฟั€ะพะธะทะฒะตะดะตะฝะธั ะทะฐะฟะธัะธ", "Error starting recording": "ะžัˆะธะฑะบะฐ ะฟั€ะธ ะทะฐะฟัƒัะบะต ะทะฐะฟะธัะธ", + "Error unblocking user": "ะžัˆะธะฑะบะฐ ะฟั€ะธ ั€ะฐะทะฑะปะพะบะธั€ะพะฒะบะต ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", "Error unmuting a user ...": "ะžัˆะธะฑะบะฐ ะฒะบะปัŽั‡ะตะฝะธั ัƒะฒะตะดะพะผะปะตะฝะธะน...", "Error unmuting channel": "ะžัˆะธะฑะบะฐ ะฟั€ะธ ะฒะบะปัŽั‡ะตะฝะธะธ ัƒะฒะตะดะพะผะปะตะฝะธะน ะบะฐะฝะฐะปะฐ", "Error unmuting user": "ะžัˆะธะฑะบะฐ ะฟั€ะธ ะฒะบะปัŽั‡ะตะฝะธะธ ัƒะฒะตะดะพะผะปะตะฝะธะน ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", @@ -505,6 +506,7 @@ "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": "ะ’ะตั‚ะบะฐ ะฝะต ะฝะฐะนะดะตะฝะฐ", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 756a2e7667..af33699e52 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -226,6 +226,7 @@ "Error removing message pin": "Mesaj PIN'i 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", @@ -473,6 +474,7 @@ "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ฤฑ", From 679fd6b1d88cb541851e03e7c2d57a272fba0823 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 5 Jun 2026 10:25:06 +0200 Subject: [PATCH 07/21] feat: add edit mode to ChannelManagementView --- .../ChannelManagementActions.defaults.tsx | 6 +- .../Views/ChannelManagementView.tsx | 429 ++++++++++++++++-- ...ChannelManagementActions.defaults.test.tsx | 4 +- .../__tests__/ChannelManagementView.test.tsx | 264 ++++++++++- .../ChannelDetail/styling/ChannelDetail.scss | 13 + .../styling/ChannelManagementView.scss | 34 ++ ...nelListItemActionButtons.defaults.test.tsx | 2 +- src/components/Dialog/components/Prompt.tsx | 67 ++- src/components/Dialog/styling/Prompt.scss | 29 +- .../MessageActions.defaults.tsx | 4 +- src/i18n/de.json | 4 + src/i18n/en.json | 4 + src/i18n/es.json | 4 + src/i18n/fr.json | 4 + src/i18n/hi.json | 4 + src/i18n/it.json | 4 + src/i18n/ja.json | 4 + src/i18n/ko.json | 4 + src/i18n/nl.json | 4 + src/i18n/pt.json | 4 + src/i18n/ru.json | 4 + src/i18n/tr.json | 4 + 22 files changed, 829 insertions(+), 71 deletions(-) diff --git a/src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx b/src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx index a588bdd932..0d37500b7b 100644 --- a/src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx +++ b/src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx @@ -515,13 +515,13 @@ const BlockUserAction = () => { LeadingIcon={BlockUserActionIcon} RootElement='button' rootProps={rootProps} - title={isBlocked ? t('Unblock User') : t('Block user')} + title={isBlocked ? t('Unblock') : t('Block user')} /> { onCancel={closeBlockUserAlert} onConfirm={isBlocked ? unblockUser : blockUser} testId='channel-detail-block-user-alert' - title={isBlocked ? t('Unblock User') : t('Block User')} + title={isBlocked ? t('Unblock') : t('Block User')} /> diff --git a/src/components/ChannelDetail/Views/ChannelManagementView.tsx b/src/components/ChannelDetail/Views/ChannelManagementView.tsx index 978fb9862f..5b4b75da2d 100644 --- a/src/components/ChannelDetail/Views/ChannelManagementView.tsx +++ b/src/components/ChannelDetail/Views/ChannelManagementView.tsx @@ -1,3 +1,12 @@ +import React, { + type SyntheticEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + import { useChatContext, useComponentContext, @@ -8,8 +17,7 @@ import { isDmChannel } from '../../../utils'; import type { SectionNavigatorSectionContentProps } from '../../SectionNavigator'; import { ChannelAvatar as DefaultChannelAvatar } from '../../Avatar'; import { useChannelPreviewInfo, useIsUserMuted } from '../../ChannelListItem'; -import { IconMute, IconPin } from '../../Icons'; -import React, { useMemo } from 'react'; +import { IconCheckmark, IconMute, IconPin } from '../../Icons'; import { useChannelMembershipState } from '../../ChannelList'; import { useIsChannelMuted } from '../../ChannelListItem/hooks/useIsChannelMuted'; import { useChannelHasMembersOnline } from '../../ChannelHeader/hooks/useChannelHasMembersOnline'; @@ -21,18 +29,28 @@ import { } from './ChannelManagementActions.defaults'; import { useChannelHeaderOnlineStatus } from '../../ChannelHeader/hooks/useChannelHeaderOnlineStatus'; import { useChannelDetailContext } from '../ChannelDetailContext'; +import { Button } from '../../Button'; +import { TextInput } from '../../Form'; +import { useNotificationApi } from '../../Notifications/hooks/useNotificationApi'; export type ChannelManagementViewProps = SectionNavigatorSectionContentProps & { channelManagementActionSet?: ChannelManagementActionItem[]; + EditModeComponent?: React.ComponentType; + uploadImage?: ChannelManagementImageUpload; + ViewModeComponent?: React.ComponentType; }; -export const ChannelManagementView = ({ - channelManagementActionSet = defaultChannelManagementActionSet, -}: ChannelManagementViewProps) => { - const { t } = useTranslationContext(); +export type ChannelManagementImageUpload = (file: File) => Promise | string; + +export type ChannelManagementInfoBodyProps = { + actions: ChannelManagementActionItem[]; +}; + +export const ChannelManagementInfoBody = ({ + actions, +}: ChannelManagementInfoBodyProps) => { const { client } = useChatContext(); const { channel } = useChannelDetailContext(); - const { close } = useModalContext(); const { Avatar = DefaultChannelAvatar } = useComponentContext(); const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ channel, @@ -57,49 +75,384 @@ export const ChannelManagementView = ({ const { muted: channelMuted } = useIsChannelMuted(channel); const userMuted = useIsUserMuted(otherMemberUserId); const membership = useChannelMembershipState(channel); - const actions = useBaseChannelManagementActionSetFilter(channelManagementActionSet); 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 } = useChannelPreviewInfo({ channel }); + const { addNotification } = useNotificationApi(); + + const resolvedIsDmChannel = isDmChannel({ channel, ownUserId: client.user?.id }); + 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, + handleDeleteImage, + handleFileChange, + handleOpenFilePicker, + handleSubmit, + hasAvatarImage: !!previewImageUrl, + name, + nameLabel, + previewImageUrl, + setName, + t, + trimmedName, + }; +}; + +export const ChannelManagementEditBody = (props: ChannelManagementEditBodyProps) => { + const { Avatar = DefaultChannelAvatar } = useComponentContext(); + const { + canSubmit, + displayTitle, + fileInputRef, + handleDeleteImage, + handleFileChange, + handleOpenFilePicker, + handleSubmit, + hasAvatarImage, + name, + nameLabel, + previewImageUrl, + setName, + t, + trimmedName, + } = useChannelManagementEditForm(props); + + return ( +
-
+
-
-
- {displayTitle && {displayTitle}} - {pinned && } - {(resolvedIsDmChannel && userMuted) || - (!resolvedIsDmChannel && channelMuted) ? ( - - ) : null} -
- {onlineStatusText && ( -
- {onlineStatusText} -
+
+ + {hasAvatarImage && ( + )} +
-
- {actions.map(({ Component, type }) => ( - - ))} -
+ setName(event.target.value)} + placeholder={nameLabel} + value={name} + /> + + + + + + {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/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx b/src/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx index 0d00eef287..0ee16f0482 100644 --- a/src/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx +++ b/src/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx @@ -337,10 +337,10 @@ describe('DefaultChannelManagementActions', () => { renderAction(); - fireEvent.click(screen.getByRole('button', { name: 'Unblock User' })); + fireEvent.click(screen.getByRole('button', { name: 'Unblock' })); expect(screen.getByRole('alertdialog')).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Unblock User' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Unblock' })).toBeInTheDocument(); expect(mocks.unBlockUser).not.toHaveBeenCalled(); await act(async () => { diff --git a/src/components/ChannelDetail/__tests__/ChannelManagementView.test.tsx b/src/components/ChannelDetail/__tests__/ChannelManagementView.test.tsx index f0f5c6e563..dd5a1e8b62 100644 --- a/src/components/ChannelDetail/__tests__/ChannelManagementView.test.tsx +++ b/src/components/ChannelDetail/__tests__/ChannelManagementView.test.tsx @@ -1,16 +1,19 @@ import React from 'react'; -import { render, screen } from '@testing-library/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'; 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' } }, @@ -18,8 +21,10 @@ const mocks = vi.hoisted(() => ({ }, membership: {}, }, + updatePartial: vi.fn(), }, close: vi.fn(), + displayImage: undefined as string | undefined, mutes: [] as Mute[], })); @@ -56,7 +61,7 @@ vi.mock('../../ChannelListItem', async (importOriginal) => { return { ...actual, useChannelPreviewInfo: () => ({ - displayImage: undefined, + displayImage: mocks.displayImage, displayTitle: 'Other user', groupChannelDisplayInfo: { members: [] }, }), @@ -84,10 +89,43 @@ vi.mock('../../Dialog', () => ({ children: React.ReactNode; className?: string; }) =>
{children}
, - Header: ({ description, title }: { description: string; title: string }) => ( + Footer: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) =>
{children}
, + FooterControls: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) =>
{children}
, + FooterControlsButtonPrimary: ( + props: React.ButtonHTMLAttributes, + ) => + )} + {TrailingContent && } ), }, @@ -103,15 +141,37 @@ vi.mock('../../Icons', async (importOriginal) => { }; }); -const renderChannelManagementView = () => +vi.mock('../../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 = []; }); @@ -134,4 +194,196 @@ describe('ChannelManagementView', () => { 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('keeps the save button disabled until something changes', () => { + renderChannelManagementView(); + + enterEditMode(); + + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + + setName('Renamed channel'); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); + }); + }); }); diff --git a/src/components/ChannelDetail/styling/ChannelDetail.scss b/src/components/ChannelDetail/styling/ChannelDetail.scss index bf9e774fbc..b8a7531793 100644 --- a/src/components/ChannelDetail/styling/ChannelDetail.scss +++ b/src/components/ChannelDetail/styling/ChannelDetail.scss @@ -1,7 +1,20 @@ .str-chat__channel-detail { + display: flex; + flex-direction: column; width: min(800px, calc(100vw - (2 * var(--str-chat__spacing-lg, 24px)))); max-width: 100%; height: 100%; + min-height: 0; + + .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; diff --git a/src/components/ChannelDetail/styling/ChannelManagementView.scss b/src/components/ChannelDetail/styling/ChannelManagementView.scss index ae78aa30a2..b1aed3b170 100644 --- a/src/components/ChannelDetail/styling/ChannelManagementView.scss +++ b/src/components/ChannelDetail/styling/ChannelManagementView.scss @@ -64,3 +64,37 @@ min-width: min(304px, calc(100vw - 32px)); max-width: min(304px, calc(100vw - 32px)); } + +.str-chat__channel-detail__channel-management-view__form { + display: contents; +} + +.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/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/Dialog/components/Prompt.tsx b/src/components/Dialog/components/Prompt.tsx index bed6c432ce..4f2ce050f4 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(); @@ -36,8 +42,26 @@ const PromptHeader = ({ const resolvedDescriptionId = descriptionId ?? derivedDescriptionId; return ( -
+
+ {LeadingContent && }
+ {goBack && ( + + )}

{title}

@@ -47,21 +71,28 @@ const PromptHeader = ({

)}
- {close && ( - + {(close || TrailingContent) && ( +
+ {TrailingContent && } + {close && ( + + )} +
)}
); diff --git a/src/components/Dialog/styling/Prompt.scss b/src/components/Dialog/styling/Prompt.scss index 7a0527afdf..eb8e51266b 100644 --- a/src/components/Dialog/styling/Prompt.scss +++ b/src/components/Dialog/styling/Prompt.scss @@ -4,6 +4,27 @@ @include utils.modal; width: 100%; + .str-chat__prompt__header.str-chat__prompt__header--withGoBack { + .str-chat__prompt__header__title-group { + display: grid; + grid-template-areas: + 'goBack title' + '. description'; + + .str-chat__prompt__header__go-back-button { + grid-area: goBack; + } + + .str-chat__prompt__header__title { + grid-area: title; + } + + .str-chat__prompt__header__description { + grid-area: description; + } + } + } + .str-chat__prompt__header { display: flex; align-items: center; @@ -31,8 +52,14 @@ color: var(--str-chat__text-secondary); } + .str-chat__prompt__header__leading-content, + .str-chat__prompt__header__trailing-content { + display: flex; + gap: var(--str-chat__spacing-xs); + align-items: center; + } + .str-chat__prompt__header__close-button { - align-self: flex-start; flex-shrink: 0; color: var(--str-chat__text-primary); .str-chat__icon { 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/i18n/de.json b/src/i18n/de.json index b00a0f1c85..020ba508e5 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -201,6 +201,8 @@ "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 Message": "Nachricht bearbeiten", "Edit message request failed": "Anfrage zum Bearbeiten der Nachricht fehlgeschlagen", "Edited": "Bearbeitet", @@ -262,6 +264,7 @@ "Generating...": "Generieren...", "giphy-command-args": "[Text]", "giphy-command-description": "Poste ein zufรคlliges Gif in den Kanal", + "Go back": "Zurรผck", "Group info": "Gruppeninfo", "Hide who voted": "Verbergen, wer abgestimmt hat", "Image": "Bild", @@ -507,6 +510,7 @@ "Unarchive": "Archivierung aufheben", "unban-command-args": "[@Benutzername]", "unban-command-description": "Einen Benutzer entbannen", + "Unblock": "Entsperren", "Unblock User": "Benutzer entsperren", "unknown error": "Unbekannter Fehler", "Unmute": "Stummschaltung aufheben", diff --git a/src/i18n/en.json b/src/i18n/en.json index 2b41a5d643..0d476b811a 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -201,6 +201,8 @@ "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 Message": "Edit Message", "Edit message request failed": "Edit message request failed", "Edited": "Edited", @@ -262,6 +264,7 @@ "Generating...": "Generating...", "giphy-command-args": "[text]", "giphy-command-description": "Post a random gif to the channel", + "Go back": "Go back", "Group info": "Group info", "Hide who voted": "Hide Who Voted", "Image": "Image", @@ -507,6 +510,7 @@ "Unarchive": "Unarchive", "unban-command-args": "[@username]", "unban-command-description": "Unban a user", + "Unblock": "Unblock", "Unblock User": "Unblock User", "unknown error": "unknown error", "Unmute": "Unmute", diff --git a/src/i18n/es.json b/src/i18n/es.json index 3faa5194a0..10c4d239e6 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -209,6 +209,8 @@ "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 Message": "Editar mensaje", "Edit message request failed": "Error al editar la solicitud de mensaje", "Edited": "Editado", @@ -271,6 +273,7 @@ "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", "Hide who voted": "Ocultar quiรฉn votรณ", "Image": "Imagen", @@ -523,6 +526,7 @@ "Unarchive": "Desarchivar", "unban-command-args": "[@usuario]", "unban-command-description": "Quitar la prohibiciรณn a un usuario", + "Unblock": "Desbloquear", "Unblock User": "Desbloquear usuario", "unknown error": "error desconocido", "Unmute": "Activar sonido", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index d5c5c57628..e16dc45ba0 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -209,6 +209,8 @@ "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 Message": "ร‰diter un message", "Edit message request failed": "ร‰chec de la demande de modification du message", "Edited": "Modifiรฉ", @@ -271,6 +273,7 @@ "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", "Hide who voted": "Masquer qui a votรฉ", "Image": "Image", @@ -523,6 +526,7 @@ "Unarchive": "Dรฉsarchiver", "unban-command-args": "[@nomdutilisateur]", "unban-command-description": "Dรฉbannir un utilisateur", + "Unblock": "Dรฉbloquer", "Unblock User": "Dรฉbloquer l'utilisateur", "unknown error": "erreur inconnue", "Unmute": "Dรฉsactiver muet", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 834c6071b9..6a7bfd33ce 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -201,6 +201,8 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "เคธเค‚เคชเคพเคฆเคฟเคค เค•เคฐเฅ‡เค‚", + "Edit chat data": "เคšเฅˆเคŸ เคกเฅ‡เคŸเคพ เคธเค‚เคชเคพเคฆเคฟเคค เค•เคฐเฅ‡เค‚", "Edit Message": "เคฎเฅˆเคธเฅ‡เคœ เคฎเฅ‡เค‚ เคฌเคฆเคฒเคพเคต เค•เคฐเฅ‡", "Edit message request failed": "เคธเค‚เคฆเฅ‡เคถ เคธเค‚เคชเคพเคฆเคฟเคค เค•เคฐเคจเฅ‡ เค•เคพ เค…เคจเฅเคฐเฅ‹เคง เคตเคฟเคซเคฒ เคฐเคนเคพ", "Edited": "เคธเค‚เคชเคพเคฆเคฟเคค", @@ -263,6 +265,7 @@ "Generating...": "เคฌเคจเคพ เคฐเคนเคพ เคนเฅˆ...", "giphy-command-args": "[เคชเคพเค ]", "giphy-command-description": "เคšเฅˆเคจเคฒ เคชเคฐ เคเค• เค•เฅเคฐเฅ‰เคซเคฟเคฒ เคœเฅ€เค†เค‡เคเคซ เคชเฅ‹เคธเฅเคŸ เค•เคฐเฅ‡เค‚", + "Go back": "เคตเคพเคชเคธ เคœเคพเคเค‚", "Group info": "เคธเคฎเฅ‚เคน เคœเคพเคจเค•เคพเคฐเฅ€", "Hide who voted": "เค•เคฟเคธเคจเฅ‡ เคตเฅ‹เคŸ เคฆเคฟเคฏเคพ เค›เคฟเคชเคพเคเค‚", "Image": "เค›เคตเคฟ", @@ -508,6 +511,7 @@ "Unarchive": "เค…เคจเค†เคฐเฅเค•เคพเค‡เคต", "unban-command-args": "[@เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคจเคพเคฎ]", "unban-command-description": "เคเค• เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค•เฅ‹ เคชเฅเคฐเคคเคฟเคทเฅ‡เคง เคธเฅ‡ เคฎเฅเค•เฅเคค เค•เคฐเฅ‡เค‚", + "Unblock": "เค…เคจเคฌเฅเคฒเฅ‰เค• เค•เคฐเฅ‡เค‚", "Unblock User": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค…เคจเคฌเฅเคฒเฅ‰เค• เค•เคฐเฅ‡เค‚", "unknown error": "เค…เคœเฅเคžเคพเคค เคคเฅเคฐเฅเคŸเคฟ", "Unmute": "เค…เคจเคฎเฅเคฏเฅ‚เคŸ", diff --git a/src/i18n/it.json b/src/i18n/it.json index 3becc2d660..65028ded9a 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -209,6 +209,8 @@ "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 Message": "Modifica messaggio", "Edit message request failed": "Richiesta di modifica del messaggio non riuscita", "Edited": "Modificato", @@ -271,6 +273,7 @@ "Generating...": "Generando...", "giphy-command-args": "[testo]", "giphy-command-description": "Pubblica un gif casuale sul canale", + "Go back": "Indietro", "Group info": "Informazioni gruppo", "Hide who voted": "Nascondi chi ha votato", "Image": "Immagine", @@ -523,6 +526,7 @@ "Unarchive": "Ripristina", "unban-command-args": "[@nomeutente]", "unban-command-description": "Togliere il divieto a un utente", + "Unblock": "Sblocca", "Unblock User": "Sblocca utente", "unknown error": "errore sconosciuto", "Unmute": "Riattiva il notifiche", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 634e6ce27c..6cafdbe3c6 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -200,6 +200,8 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "็ทจ้›†", + "Edit chat data": "ใƒใƒฃใƒƒใƒˆใƒ‡ใƒผใ‚ฟใ‚’็ทจ้›†", "Edit Message": "ใƒกใƒƒใ‚ปใƒผใ‚ธใ‚’็ทจ้›†", "Edit message request failed": "ใƒกใƒƒใ‚ปใƒผใ‚ธใฎ็ทจ้›†่ฆๆฑ‚ใŒๅคฑๆ•—ใ—ใพใ—ใŸ", "Edited": "็ทจ้›†ๆธˆใฟ", @@ -260,6 +262,7 @@ "Generating...": "็”Ÿๆˆไธญ...", "giphy-command-args": "[ใƒ†ใ‚ญใ‚นใƒˆ]", "giphy-command-description": "ใƒใƒฃใƒณใƒใƒซใซใƒฉใƒณใƒ€ใƒ ใชGIFใ‚’ๆŠ•็จฟใ™ใ‚‹", + "Go back": "ๆˆปใ‚‹", "Group info": "ใ‚ฐใƒซใƒผใƒ—ๆƒ…ๅ ฑ", "Hide who voted": "่ชฐใŒๆŠ•็ฅจใ—ใŸใ‹ใ‚’้ž่กจ็คบใซใ™ใ‚‹", "Image": "็”ปๅƒ", @@ -501,6 +504,7 @@ "Unarchive": "ใ‚ขใƒผใ‚ซใ‚คใƒ–่งฃ้™ค", "unban-command-args": "[@ใƒฆใƒผใ‚ถๅ]", "unban-command-description": "ใƒฆใƒผใ‚ถใƒผใฎ็ฆๆญขใ‚’่งฃ้™คใ™ใ‚‹", + "Unblock": "ใƒ–ใƒญใƒƒใ‚ฏ่งฃ้™ค", "Unblock User": "ใƒฆใƒผใ‚ถใƒผใฎใƒ–ใƒญใƒƒใ‚ฏใ‚’่งฃ้™ค", "unknown error": "ไธๆ˜Žใชใ‚จใƒฉใƒผ", "Unmute": "็„ก้Ÿณใ‚’่งฃ้™คใ™ใ‚‹", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 06ddc0c6f1..a035a2fcf1 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -200,6 +200,8 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "์ˆ˜์ •", + "Edit chat data": "์ฑ„ํŒ… ๋ฐ์ดํ„ฐ ์ˆ˜์ •", "Edit Message": "๋ฉ”์‹œ์ง€ ์ˆ˜์ •", "Edit message request failed": "๋ฉ”์‹œ์ง€ ์ˆ˜์ • ์š”์ฒญ ์‹คํŒจ", "Edited": "ํŽธ์ง‘๋จ", @@ -260,6 +262,7 @@ "Generating...": "์ƒ์„ฑ ์ค‘...", "giphy-command-args": "[ํ…์ŠคํŠธ]", "giphy-command-description": "์ฑ„๋„์— ๋ฌด์ž‘์œ„ GIF ๊ฒŒ์‹œ", + "Go back": "๋’ค๋กœ ๊ฐ€๊ธฐ", "Group info": "๊ทธ๋ฃน ์ •๋ณด", "Hide who voted": "๋ˆ„๊ฐ€ ํˆฌํ‘œํ–ˆ๋Š”์ง€ ์ˆจ๊ธฐ๊ธฐ", "Image": "์ด๋ฏธ์ง€", @@ -501,6 +504,7 @@ "Unarchive": "์•„์นด์ด๋ธŒ ํ•ด์ œ", "unban-command-args": "[@์‚ฌ์šฉ์ž์ด๋ฆ„]", "unban-command-description": "์‚ฌ์šฉ์ž ์ฐจ๋‹จ ํ•ด์ œ", + "Unblock": "์ฐจ๋‹จ ํ•ด์ œ", "Unblock User": "์‚ฌ์šฉ์ž ์ฐจ๋‹จ ํ•ด์ œ", "unknown error": "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜", "Unmute": "์Œ์†Œ๊ฑฐ ํ•ด์ œ", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 0f6457e58c..2f4cbc6a73 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -201,6 +201,8 @@ "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 Message": "Bericht bewerken", "Edit message request failed": "Verzoek om bericht bewerken mislukt", "Edited": "Bewerkt", @@ -262,6 +264,7 @@ "Generating...": "Genereren...", "giphy-command-args": "[tekst]", "giphy-command-description": "Plaats een willekeurige gif in het kanaal", + "Go back": "Terug", "Group info": "Groepsinformatie", "Hide who voted": "Verberg wie heeft gestemd", "Image": "Afbeelding", @@ -509,6 +512,7 @@ "Unarchive": "Uit archief halen", "unban-command-args": "[@gebruikersnaam]", "unban-command-description": "Een gebruiker debannen", + "Unblock": "Deblokkeren", "Unblock User": "Gebruiker deblokkeren", "unknown error": "onbekende fout", "Unmute": "Dempen opheffen", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index d851bd963a..d60d484049 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -209,6 +209,8 @@ "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 Message": "Editar Mensagem", "Edit message request failed": "O pedido de ediรงรฃo da mensagem falhou", "Edited": "Editada", @@ -271,6 +273,7 @@ "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", "Hide who voted": "Ocultar quem votou", "Image": "Imagem", @@ -523,6 +526,7 @@ "Unarchive": "Desarquivar", "unban-command-args": "[@nomedeusuรกrio]", "unban-command-description": "Desbanir um usuรกrio", + "Unblock": "Desbloquear", "Unblock User": "Desbloquear usuรกrio", "unknown error": "erro desconhecido", "Unmute": "Ativar som", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 9c3f0cc2fe..85a0a8c6b4 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -218,6 +218,8 @@ "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Share Location": "{{ milliseconds | durationFormatter }}", + "Edit": "ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ", + "Edit chat data": "ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ ะดะฐะฝะฝั‹ะต ั‡ะฐั‚ะฐ", "Edit Message": "ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ ัะพะพะฑั‰ะตะฝะธะต", "Edit message request failed": "ะะต ัƒะดะฐะปะพััŒ ะธะทะผะตะฝะธั‚ัŒ ะทะฐะฟั€ะพั ัะพะพะฑั‰ะตะฝะธั", "Edited": "ะžั‚ั€ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะพ", @@ -284,6 +286,7 @@ "Generating...": "ะ“ะตะฝะตั€ะธั€ัƒัŽ...", "giphy-command-args": "[ั‚ะตะบัั‚]", "giphy-command-description": "ะžะฟัƒะฑะปะธะบะพะฒะฐั‚ัŒ ัะปัƒั‡ะฐะนะฝัƒัŽ GIF-ะฐะฝะธะผะฐั†ะธัŽ ะฒ ะบะฐะฝะฐะปะต", + "Go back": "ะะฐะทะฐะด", "Group info": "ะ˜ะฝั„ะพั€ะผะฐั†ะธั ะพ ะณั€ัƒะฟะฟะต", "Hide who voted": "ะกะบั€ั‹ั‚ัŒ, ะบั‚ะพ ะณะพะปะพัะพะฒะฐะป", "Image": "ะ˜ะทะพะฑั€ะฐะถะตะฝะธะต", @@ -543,6 +546,7 @@ "Unarchive": "ะฃะดะฐะปะธั‚ัŒ ะธะท ะฐั€ั…ะธะฒะฐ", "unban-command-args": "[@ะธะผัะฟะพะปัŒะทะพะฒะฐั‚ะตะปั]", "unban-command-description": "ะ ะฐะทะฑะปะพะบะธั€ะพะฒะฐั‚ัŒ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", + "Unblock": "ะ ะฐะทะฑะปะพะบะธั€ะพะฒะฐั‚ัŒ", "Unblock User": "ะ ะฐะทะฑะปะพะบะธั€ะพะฒะฐั‚ัŒ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", "unknown error": "ะฝะตะธะทะฒะตัั‚ะฝะฐั ะพัˆะธะฑะบะฐ", "Unmute": "ะ’ะบะปัŽั‡ะธั‚ัŒ ัƒะฒะตะดะพะผะปะตะฝะธั", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index af33699e52..bc123428ca 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -201,6 +201,8 @@ "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 Message": "Mesajฤฑ Dรผzenle", "Edit message request failed": "Mesaj dรผzenleme isteฤŸi baลŸarฤฑsฤฑz oldu", "Edited": "Dรผzenlendi", @@ -262,6 +264,7 @@ "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", "Hide who voted": "Kimin oy verdiฤŸini gizle", "Image": "Gรถrsel", @@ -507,6 +510,7 @@ "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", "unknown error": "bilinmeyen hata", "Unmute": "Sesini aรง", From f226e6120bf9b1839fd4be9b9129b9749b428d41 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 9 Jun 2026 11:53:49 +0200 Subject: [PATCH 08/21] feat: add ChannelMembersView --- examples/vite/src/index.scss | 3 +- .../ChannelDetail/ChannelDetail.tsx | 36 +- .../ChannelManagementActions.defaults.tsx | 30 +- .../ChannelManagementView.tsx | 30 +- .../Views/ChannelManagementView/index.ts | 2 + .../ChannelMemberActions.defaults.tsx | 616 ++++++++++++++++++ .../ChannelMemberDetail.tsx | 154 +++++ .../__tests__/ChannelMemberDetail.test.tsx | 146 +++++ .../Views/ChannelMemberDetailView/index.ts | 1 + .../ChannelMembersHeaderActions.defaults.tsx | 275 ++++++++ .../ChannelMembersView/ChannelMembersView.tsx | 159 +++++ .../ChannelMembersView.utils.ts | 17 + .../ChannelMembersViewList.tsx | 298 +++++++++ .../ChannelMembersViewSearch.tsx | 229 +++++++ ...nnelMembersHeaderActions.defaults.test.tsx | 198 ++++++ .../__tests__/ChannelMembersView.test.tsx | 356 ++++++++++ .../ChannelMembersView.utils.test.ts | 99 +++ .../__tests__/ChannelMembersViewList.test.tsx | 184 ++++++ .../ChannelMembersViewSearch.test.tsx | 146 +++++ .../__tests__/testUtils.tsx | 72 ++ .../Views/ChannelMembersView/index.ts | 2 + ...ChannelManagementActions.defaults.test.tsx | 2 +- .../__tests__/ChannelManagementView.test.tsx | 2 +- src/components/ChannelDetail/index.ts | 5 +- .../styling/ChannelManagementView.scss | 6 - .../styling/ChannelMemberDetailView.scss | 63 ++ .../styling/ChannelMembersView.scss | 89 +++ .../ChannelDetail/styling/index.scss | 2 + src/components/Dialog/components/Prompt.tsx | 15 +- .../Dialog/service/DialogPortal.tsx | 14 +- src/components/Dialog/styling/Dialog.scss | 12 +- src/components/Dialog/styling/Prompt.scss | 27 +- src/components/Form/Checkbox.tsx | 19 + src/components/Form/index.ts | 1 + src/components/Form/styling/Checkbox.scss | 21 + src/components/Form/styling/index.scss | 1 + .../styling/ListItemLayout.scss | 5 + src/components/Poll/PollOptionSelector.tsx | 11 +- .../Poll/styling/PollOptionList.scss | 21 - src/i18n/de.json | 46 ++ src/i18n/en.json | 46 ++ src/i18n/es.json | 50 ++ src/i18n/fr.json | 50 ++ src/i18n/hi.json | 46 ++ src/i18n/it.json | 50 ++ src/i18n/ja.json | 42 ++ src/i18n/ko.json | 42 ++ src/i18n/nl.json | 46 ++ src/i18n/pt.json | 50 ++ src/i18n/ru.json | 54 ++ src/i18n/tr.json | 46 ++ 51 files changed, 3842 insertions(+), 95 deletions(-) rename src/components/ChannelDetail/Views/{ => ChannelManagementView}/ChannelManagementActions.defaults.tsx (95%) rename src/components/ChannelDetail/Views/{ => ChannelManagementView}/ChannelManagementView.tsx (93%) create mode 100644 src/components/ChannelDetail/Views/ChannelManagementView/index.ts create mode 100644 src/components/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMemberDetailView/index.ts create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewSearch.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.utils.test.ts create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewSearch.test.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/__tests__/testUtils.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/index.ts create mode 100644 src/components/ChannelDetail/styling/ChannelMemberDetailView.scss create mode 100644 src/components/ChannelDetail/styling/ChannelMembersView.scss create mode 100644 src/components/Form/Checkbox.tsx create mode 100644 src/components/Form/styling/Checkbox.scss diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index a7ec39b74d..082ff5cd67 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -229,8 +229,7 @@ body { z-index: 2; } - .str-chat__notification-list, - .str-chat__dialog-overlay { + .str-chat__notification-list { z-index: 4; } diff --git a/src/components/ChannelDetail/ChannelDetail.tsx b/src/components/ChannelDetail/ChannelDetail.tsx index 7961ed443f..51832a8284 100644 --- a/src/components/ChannelDetail/ChannelDetail.tsx +++ b/src/components/ChannelDetail/ChannelDetail.tsx @@ -10,8 +10,9 @@ import { } from '../SectionNavigator'; import { ChannelDetailProvider } from './ChannelDetailContext'; import { ChannelManagementView } from './Views/ChannelManagementView'; +import { ChannelMembersView } from './Views/ChannelMembersView'; import { Prompt } from '../Dialog'; -import { IconInfo } from '../Icons'; +import { IconInfo, IconUser } from '../Icons'; import { ListItemLayout } from '../ListItemLayout'; const ChannelDetailNavButtonClassName = 'str-chat__channel-detail__nav-button'; @@ -20,6 +21,10 @@ const ChannelManagementNavButtonIcon = () => ( ); +const ChannelMembersNavButtonIcon = () => ( + +); + const ChannelManagementNavButton = ({ select, selected, @@ -44,12 +49,41 @@ const ChannelManagementNavButton = ({ ); }; +const ChannelMembersNavButton = ({ + select, + selected, +}: SectionNavigatorNavButtonProps) => { + const rootProps = useMemo( + () => ({ + 'aria-current': selected ? ('page' as const) : undefined, + className: ChannelDetailNavButtonClassName, + onClick: select, + }), + [select, selected], + ); + + return ( + + ); +}; + const defaultSections: SectionNavigatorSection[] = [ { id: 'channel-info', NavButton: ChannelManagementNavButton, SectionContent: ChannelManagementView, }, + { + id: 'channel-members', + NavButton: ChannelMembersNavButton, + SectionContent: ChannelMembersView, + }, ]; export type ChannelDetailProps = Omit & { diff --git a/src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx b/src/components/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx similarity index 95% rename from src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx rename to src/components/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx index 0d37500b7b..4e1cc593b9 100644 --- a/src/components/ChannelDetail/Views/ChannelManagementActions.defaults.tsx +++ b/src/components/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx @@ -7,18 +7,18 @@ import { useComponentContext, useModalContext, useTranslationContext, -} from '../../../context'; -import { isDmChannel, useStableCallback } from '../../../utils'; -import { useIsChannelMuted } from '../../ChannelListItem/hooks/useIsChannelMuted'; -import { useStateStore } from '../../../store'; -import { Alert } from '../../Dialog'; -import { Button } from '../../Button'; -import { Switch } from '../../Form'; -import { IconAudio, IconDelete, IconLeave, IconMute, IconNoSign } from '../../Icons'; -import { ListItemLayout } from '../../ListItemLayout'; -import { GlobalModal } from '../../Modal'; -import { useNotificationApi } from '../../Notifications'; -import { useChannelDetailContext } from '../ChannelDetailContext'; +} from '../../../../context'; +import { isDmChannel, useStableCallback } from '../../../../utils'; +import { useIsChannelMuted } from '../../../ChannelListItem/hooks/useIsChannelMuted'; +import { useStateStore } from '../../../../store'; +import { Alert } from '../../../Dialog'; +import { Button } from '../../../Button'; +import { Switch } from '../../../Form'; +import { IconAudio, IconDelete, IconLeave, IconMute, IconNoSign } from '../../../Icons'; +import { ListItemLayout } from '../../../ListItemLayout'; +import { GlobalModal } from '../../../Modal'; +import { useNotificationApi } from '../../../Notifications'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; import clsx from 'clsx'; export type ChannelManagementActionType = @@ -40,10 +40,10 @@ const toError = (error: unknown) => const getDisplayName = (name?: string, fallback?: string) => name || fallback || ''; const BlockUserActionIcon = () => ( - + ); const DeleteChatActionIcon = () => ( - + ); const MuteActionIcon = () => ( @@ -52,7 +52,7 @@ const MutedActionIcon = () => ( ); const LeaveChannelActionIcon = () => ( - + ); const channelManagementViewActionClassName = 'str-chat__channel-management-view-action'; diff --git a/src/components/ChannelDetail/Views/ChannelManagementView.tsx b/src/components/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx similarity index 93% rename from src/components/ChannelDetail/Views/ChannelManagementView.tsx rename to src/components/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx index 5b4b75da2d..8d14e81c8b 100644 --- a/src/components/ChannelDetail/Views/ChannelManagementView.tsx +++ b/src/components/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx @@ -12,26 +12,26 @@ import { useComponentContext, useModalContext, useTranslationContext, -} from '../../../context'; -import { isDmChannel } from '../../../utils'; -import type { SectionNavigatorSectionContentProps } from '../../SectionNavigator'; -import { ChannelAvatar as DefaultChannelAvatar } from '../../Avatar'; -import { useChannelPreviewInfo, useIsUserMuted } from '../../ChannelListItem'; -import { IconCheckmark, IconMute, IconPin } from '../../Icons'; -import { useChannelMembershipState } from '../../ChannelList'; -import { useIsChannelMuted } from '../../ChannelListItem/hooks/useIsChannelMuted'; -import { useChannelHasMembersOnline } from '../../ChannelHeader/hooks/useChannelHasMembersOnline'; -import { Prompt } from '../../Dialog'; +} from '../../../../context'; +import { isDmChannel } from '../../../../utils'; +import type { SectionNavigatorSectionContentProps } from '../../../SectionNavigator'; +import { ChannelAvatar as DefaultChannelAvatar } from '../../../Avatar'; +import { useChannelPreviewInfo, useIsUserMuted } from '../../../ChannelListItem'; +import { IconCheckmark, IconMute, IconPin } from '../../../Icons'; +import { useChannelMembershipState } from '../../../ChannelList'; +import { useIsChannelMuted } from '../../../ChannelListItem/hooks/useIsChannelMuted'; +import { useChannelHasMembersOnline } from '../../../ChannelHeader/hooks/useChannelHasMembersOnline'; +import { Prompt } from '../../../Dialog'; import { type ChannelManagementActionItem, defaultChannelManagementActionSet, useBaseChannelManagementActionSetFilter, } from './ChannelManagementActions.defaults'; -import { useChannelHeaderOnlineStatus } from '../../ChannelHeader/hooks/useChannelHeaderOnlineStatus'; -import { useChannelDetailContext } from '../ChannelDetailContext'; -import { Button } from '../../Button'; -import { TextInput } from '../../Form'; -import { useNotificationApi } from '../../Notifications/hooks/useNotificationApi'; +import { useChannelHeaderOnlineStatus } from '../../../ChannelHeader/hooks/useChannelHeaderOnlineStatus'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { Button } from '../../../Button'; +import { TextInput } from '../../../Form'; +import { useNotificationApi } from '../../../Notifications/hooks/useNotificationApi'; export type ChannelManagementViewProps = SectionNavigatorSectionContentProps & { channelManagementActionSet?: ChannelManagementActionItem[]; diff --git a/src/components/ChannelDetail/Views/ChannelManagementView/index.ts b/src/components/ChannelDetail/Views/ChannelManagementView/index.ts new file mode 100644 index 0000000000..54cd581422 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelManagementView/index.ts @@ -0,0 +1,2 @@ +export * from './ChannelManagementView'; +export * from './ChannelManagementActions.defaults'; diff --git a/src/components/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/components/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx new file mode 100644 index 0000000000..78ab131f00 --- /dev/null +++ b/src/components/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 '../../../Dialog'; +import { Button } from '../../../Button'; +import { Switch } from '../../../Form'; +import { + IconAudio, + IconMessageBubble, + IconMute, + IconNoSign, + IconUserRemove, +} from '../../../Icons'; +import { ListItemLayout } from '../../../ListItemLayout'; +import { GlobalModal } from '../../../Modal'; +import { useNotificationApi } from '../../../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/components/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx b/src/components/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx new file mode 100644 index 0000000000..3573c489f0 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx @@ -0,0 +1,154 @@ +import React, { useMemo } from 'react'; +import type { ChannelMemberResponse } from 'stream-chat'; + +import { + useChatContext, + useComponentContext, + useModalContext, + useTranslationContext, +} from '../../../../context'; +import type { SectionNavigatorSectionContentProps } from '../../../SectionNavigator'; +import { ChannelAvatar as DefaultChannelAvatar } from '../../../Avatar'; +import { Prompt } from '../../../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/components/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx b/src/components/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx new file mode 100644 index 0000000000..00016c43bf --- /dev/null +++ b/src/components/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('../../../../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/components/ChannelDetail/Views/ChannelMemberDetailView/index.ts b/src/components/ChannelDetail/Views/ChannelMemberDetailView/index.ts new file mode 100644 index 0000000000..a2197b2eac --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMemberDetailView/index.ts @@ -0,0 +1 @@ +export * from './ChannelMemberDetail'; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx new file mode 100644 index 0000000000..a4fdc1eca6 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx @@ -0,0 +1,275 @@ +import React, { useMemo, useState } from 'react'; + +import { + modalDialogManagerId, + useComponentContext, + useModalContext, + useTranslationContext, +} from '../../../../context'; +import { Button } from '../../../Button'; +import { + ContextMenu, + ContextMenuButton, + useDialog, + useDialogIsOpen, +} from '../../../Dialog'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { canUpdateChannelMembers } from './ChannelMembersView.utils'; +import type { + ChannelMembersHeaderActionsProps, + ChannelMembersViewController, +} from './ChannelMembersView'; + +export type ChannelMembersHeaderActionType = + | 'addMembers' + | 'manageMembers' + | (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 'manageMembers': + 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 ManageMembersHeaderAction = ({ + controller, +}: ChannelMembersHeaderActionComponentProps) => { + const { t } = useTranslationContext(); + + if (controller.mode !== 'browse') return null; + + return ( + + ); +}; + +const ManageMembersMenuAction = ({ + closeMenu, + controller, +}: ChannelMembersHeaderActionComponentProps) => { + const { t } = useTranslationContext(); + + if (controller.mode !== 'browse') return null; + + return ( + { + controller.setMode('manage'); + closeMenu?.(); + }} + > + {t('Manage')} + + ); +}; + +export const DefaultChannelMembersHeaderActions = { + AddMembers: AddMembersHeaderAction, + AddMembersMenu: AddMembersMenuAction, + ManageMembers: ManageMembersHeaderAction, + ManageMembersMenu: ManageMembersMenuAction, +}; + +export const defaultChannelMembersHeaderActionSet: ChannelMembersHeaderActionItem[] = [ + { + menu: DefaultChannelMembersHeaderActions.AddMembers, + type: 'addMembers', + }, + { + quick: DefaultChannelMembersHeaderActions.ManageMembers, + type: 'manageMembers', + }, +]; + +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 modalContext = useModalContext(); + const dialogManagerId = modalContext?.dialogId ? modalDialogManagerId : undefined; + const dialog = useDialog({ dialogManagerId, id: dialogId }); + 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/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx new file mode 100644 index 0000000000..f569c5d075 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx @@ -0,0 +1,159 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ChannelMemberResponse } from 'stream-chat'; + +import { useModalContext, useTranslationContext } from '../../../../context'; +import { Prompt } from '../../../Dialog'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { ChannelMemberDetail } from '../ChannelMemberDetailView'; +import { + type ChannelMembersHeaderActionItem, + type ChannelMembersHeaderActionsMenuTriggerProps, + defaultChannelMembersHeaderActionSet, + DefaultHeaderActions, +} from './ChannelMembersHeaderActions.defaults'; +import { ChannelMembersViewList } from './ChannelMembersViewList'; +import { ChannelMembersViewSearch } from './ChannelMembersViewSearch'; +import type { SectionNavigatorSectionContentProps } from '../../../SectionNavigator'; + +export type ChannelMembersHeaderActionsProps = { + controller: ChannelMembersViewController; + HeaderActionsMenuTrigger?: React.ComponentType; + headerActionSet: ChannelMembersHeaderActionItem[]; +}; + +export type ChannelMembersViewMode = 'add' | 'browse' | 'manage' | '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 === 'manage'; + const isViewingMemberDetail = !!selectedMember; + 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 controller = useMemo( + () => ({ + mode, + setMode: setViewMode, + }), + [mode, setViewMode], + ); + + const HeaderTrailingActions = useMemo( + () => + function HeaderTrailingActions() { + return ( + + ); + }, + [HeaderActions, HeaderActionsMenuTrigger, controller, headerActionSet], + ); + + const headerTitle = isAddingMember + ? t('Add members') + : isManagingMembers + ? t('Manage members') + : t('{{ count }} members', { count: memberCount }); + + if (isViewingMemberDetail && selectedMember) { + return ( + setViewMode('browse')} + /> + ); + } + + return ( +
+ { + setViewMode('browse'); + } + : isViewingMemberDetail + ? () => { + setViewMode('browse'); + } + : isManagingMembers + ? () => setViewMode('browse') + : undefined + } + title={headerTitle} + TrailingContent={HeaderTrailingActions} + /> + {isAddingMember ? ( + { + setMemberCount((currentCount) => currentCount + count); + setMembersAddedCount(count); + setMembersRefreshKey((currentKey) => currentKey + 1); + setViewMode('browse'); + }} + /> + ) : ( + { + setSelectedMember(member); + setViewMode('memberDetail'); + }} + onMembersRemoved={(count) => { + setMemberCount((currentCount) => currentCount - count); + }} + /> + )} +
+ ); +}; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts new file mode 100644 index 0000000000..91c8bfb1cb --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts @@ -0,0 +1,17 @@ +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 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/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx new file mode 100644 index 0000000000..9485ff22f2 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx @@ -0,0 +1,298 @@ +import { + type ChannelMemberResponse, + ChannelMembersPaginator, + type PaginatorState, +} from 'stream-chat'; +import debounce from 'lodash.debounce'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useChatContext, useTranslationContext } from '../../../../context'; +import { useStateStore } from '../../../../store'; +import { Avatar } from '../../../Avatar'; +import { Checkbox, TextInput } from '../../../Form'; +import { IconMute, IconSearch } from '../../../Icons'; +import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; +import { ListItemLayout } from '../../../ListItemLayout'; +import { Prompt } from '../../../Dialog'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { + canUpdateChannelMembers, + CHANNEL_MEMBERS_QUERY_LIMIT, + getMemberDisplayName, + getUserDisplayName, +} from './ChannelMembersView.utils'; + +const getMemberRoleTranslationKey = (member: ChannelMemberResponse) => { + const role = member.channel_role || member.role; + + if (role === 'admin') return 'Admin'; + if (role === 'channel_moderator' || role === 'moderator') return 'Moderator'; + if (role === 'owner') return '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'); +}; + +const membersPaginatorStateSelector = (state: PaginatorState) => ({ + isLoading: state.isLoading, + members: state.items, +}); + +const MEMBERS_SEARCH_DEBOUNCE_MS = 300; + +export type ChannelMembersViewListProps = { + manageMembers?: boolean; + onMemberSelect?: (member: ChannelMemberResponse) => void; + onMembersRemoved?: (memberCount: number) => void; +}; + +const getMemberUserId = (member: ChannelMemberResponse) => + member.user?.id || member.user_id; + +export const ChannelMembersViewList = ({ + manageMembers = false, + onMemberSelect, + onMembersRemoved, +}: ChannelMembersViewListProps) => { + const { mutes } = useChatContext(); + const { t } = useTranslationContext(); + const { channel } = useChannelDetailContext(); + const canManageChannelMembers = canUpdateChannelMembers(channel); + const isManageMode = manageMembers && canManageChannelMembers; + const fallbackMembers = useMemo( + () => Object.values(channel.state?.members ?? {}), + [channel], + ); + const membersPaginator = useMemo( + () => + new ChannelMembersPaginator(channel, { + pageSize: CHANNEL_MEMBERS_QUERY_LIMIT, + }), + [channel], + ); + const searchMembers = useMemo( + () => + debounce((query: string) => { + const trimmedQuery = query.trim(); + membersPaginator.filters = trimmedQuery + ? { name: { $autocomplete: trimmedQuery } } + : undefined; + membersPaginator.next(); + }, MEMBERS_SEARCH_DEBOUNCE_MS), + [membersPaginator], + ); + const { isLoading, members } = useStateStore( + membersPaginator.state, + membersPaginatorStateSelector, + ); + const [searchInput, setSearchInput] = useState(''); + const [isRemoving, setIsRemoving] = useState(false); + const [selectedMemberUserIds, setSelectedMemberUserIds] = useState([]); + const wasManagingMembersRef = useRef(manageMembers); + + const resetMembersSearch = useCallback(() => { + searchMembers.cancel(); + membersPaginator.cancelScheduledQuery(); + setSearchInput(''); + membersPaginator.filters = undefined; + void membersPaginator.next(); + }, [membersPaginator, searchMembers]); + + const displayedMembers = members ?? fallbackMembers; + const selectedMemberUserIdSet = useMemo( + () => new Set(selectedMemberUserIds), + [selectedMemberUserIds], + ); + const mutedUserIdSet = useMemo( + () => new Set(mutes.map((mute) => mute.target.id)), + [mutes], + ); + + useEffect(() => { + if (!isManageMode) { + setSelectedMemberUserIds([]); + setIsRemoving(false); + } + }, [isManageMode]); + + useEffect(() => { + if (wasManagingMembersRef.current && !manageMembers) { + resetMembersSearch(); + setSelectedMemberUserIds([]); + setIsRemoving(false); + } + + wasManagingMembersRef.current = manageMembers; + }, [manageMembers, resetMembersSearch]); + + useEffect(() => { + membersPaginator.next(); + }, [membersPaginator]); + + useEffect( + () => () => { + searchMembers.cancel(); + membersPaginator.cancelScheduledQuery(); + }, + [membersPaginator, searchMembers], + ); + + const handleSearchChange = useCallback( + (event: React.ChangeEvent) => { + const { value } = event.target; + setSearchInput(value); + searchMembers(value); + }, + [searchMembers], + ); + + const toggleSelectedMember = useCallback((memberUserId: string) => { + setSelectedMemberUserIds((currentSelectedMemberUserIds) => + currentSelectedMemberUserIds.includes(memberUserId) + ? currentSelectedMemberUserIds.filter((id) => id !== memberUserId) + : [...currentSelectedMemberUserIds, memberUserId], + ); + }, []); + + const handleRemove = async () => { + if (!isManageMode || !selectedMemberUserIds.length || isRemoving) return; + + setIsRemoving(true); + const memberCount = selectedMemberUserIds.length; + + try { + await channel.removeMembers(selectedMemberUserIds); + setSelectedMemberUserIds([]); + resetMembersSearch(); + onMembersRemoved?.(memberCount); + } finally { + setIsRemoving(false); + } + }; + + const emptyStateText = isLoading ? t('Searching...') : t('No user found'); + + return ( + <> + + } + onChange={handleSearchChange} + placeholder={t('Search')} + type='search' + value={searchInput} + /> + + {displayedMembers.length > 0 ? ( + displayedMembers.map((member) => { + const memberUserId = getMemberUserId(member); + if (!memberUserId) return null; + + const user = member.user; + const displayName = getMemberDisplayName(member); + const roleTranslationKey = getMemberRoleTranslationKey(member); + const isMuted = mutedUserIdSet.has(memberUserId); + const avatar = ( + + ); + + if (isManageMode) { + const selected = selectedMemberUserIdSet.has(memberUserId); + + return ( + avatar} + 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={() => } + /> + ); + } + + return ( + avatar} + 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={() => ( + + {roleTranslationKey ? ( + + {t(roleTranslationKey)} + + ) : null} + {isMuted ? ( + + ) : null} + + )} + /> + ); + }) + ) : ( +
+ + {emptyStateText} +
+ )} +
+
+ {isManageMode && selectedMemberUserIds.length > 0 && ( + + + + {t('Remove {{ count }} members', { count: selectedMemberUserIds.length })} + + + + )} + + ); +}; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewSearch.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewSearch.tsx new file mode 100644 index 0000000000..4820f4bc46 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewSearch.tsx @@ -0,0 +1,229 @@ +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 '../../../Avatar'; +import { Checkbox, TextInput } from '../../../Form'; +import { IconMute, IconSearch } from '../../../Icons'; +import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; +import { ListItemLayout } from '../../../ListItemLayout'; +import { Prompt } from '../../../Dialog'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { + canUpdateChannelMembers, + getChannelMemberUserIds, + getUserDisplayName, +} from './ChannelMembersView.utils'; +import { useNotificationApi } from '../../../Notifications'; + +export type ChannelMembersViewSearchProps = { + onMembersAdded: (memberCount: number) => void; + searchSource?: UserSearchSource; +}; + +const USER_SEARCH_PAGE_SIZE = 30; + +const searchSourceStateSelector = (state: SearchSourceState) => ({ + isLoading: state.isLoading, + users: state.items, +}); + +export const ChannelMembersViewSearch = ({ + onMembersAdded, + searchSource, +}: ChannelMembersViewSearchProps) => { + 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, + }); + + source.activate(); + return source; + }, [client, searchSource]); + + const { isLoading, users: searchUsers } = useStateStore( + userSearchSource.state, + searchSourceStateSelector, + ); + + const users = useMemo( + () => searchUsers?.filter((user) => !excludedMemberIds.has(user.id)), + [excludedMemberIds, searchUsers], + ); + + const [isSaving, setIsSaving] = useState(false); + const [searchInput, setSearchInput] = useState(''); + 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( + (event: React.ChangeEvent) => { + const { value } = event.target; + setSearchInput(value); + userSearchSource.search(value); + }, + [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', + }); + } + }; + + const emptyStateText = isLoading ? t('Searching...') : t('No user found'); + + return ( + <> + + } + onChange={handleSearchChange} + placeholder={t('Search')} + type='search' + value={searchInput} + /> + + {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 + } + /> + ); + }) + ) : ( +
+ + {emptyStateText} +
+ )} +
+
+ {canManageChannelMembers && selectedUserIds.length > 0 && ( + + + + {t('Add {{ count }} members', { count: selectedUserIds.length })} + + + + )} + + ); +}; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx new file mode 100644 index 0000000000..3f7bbe9be6 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx @@ -0,0 +1,198 @@ +import { render, screen } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import React from 'react'; +import type { Channel } from 'stream-chat'; + +import { + useComponentContext, + useModalContext, + useTranslationContext, +} from '../../../../../context'; +import { ChannelDetailProvider } from '../../../ChannelDetailContext'; +import { + type ChannelMembersHeaderActionItem, + DefaultHeaderActions, +} from '../ChannelMembersHeaderActions.defaults'; +import type { + ChannelMembersViewController, + ChannelMembersViewMode, +} from '../ChannelMembersView'; + +vi.mock('../../../../../context'); + +vi.mock('../../../../Dialog', () => ({ + ContextMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ContextMenuButton: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => ( + + ), + useDialog: ({ id }: { id: string }) => ({ + close: vi.fn(), + id, + toggle: vi.fn(), + }), + useDialogIsOpen: () => false, +})); + +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: 'manageMembers', + }, + ]; + + 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/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx new file mode 100644 index 0000000000..5110bfe2bb --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx @@ -0,0 +1,356 @@ +import { fireEvent, screen } from '@testing-library/react'; +import React from 'react'; + +import { + useComponentContext, + useModalContext, + useTranslationContext, +} from '../../../../../context'; +import { ChannelMembersView } from '../ChannelMembersView'; +import type { ChannelMembersHeaderActionItem } from '../ChannelMembersHeaderActions.defaults'; +import { createChannel, renderWithChannel } from './testUtils'; + +vi.mock('../../../../../context'); + +vi.mock('../ChannelMembersViewSearch', () => ({ + ChannelMembersViewSearch: ({ + onMembersAdded, + }: { + onMembersAdded: (count: number) => void; + }) => ( +
+ +
+ ), +})); + +vi.mock('../ChannelMembersViewList', () => ({ + ChannelMembersViewList: ({ + manageMembers, + onMembersRemoved, + }: { + manageMembers?: boolean; + onMembersRemoved?: (count: number) => void; + }) => ( +
+ {manageMembers && ( + + )} +
+ ), +})); + +vi.mock('../../../../Dialog', () => ({ + ContextMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ContextMenuButton: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => ( + + ), + 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, +})); + +describe('ChannelMembersView', () => { + const close = vi.fn(); + const customHeaderActionSet: ChannelMembersHeaderActionItem[] = [ + { + menu: () => null, + type: 'manageMembers', + }, + { + quick: () => null, + type: 'addMembers', + }, + ]; + const CustomHeaderActions = ({ + controller, + headerActionSet, + }: { + controller: { + mode: 'add' | 'browse' | 'manage' | 'memberDetail'; + setMode: (mode: 'add' | 'browse' | 'manage' | 'memberDetail') => void; + }; + headerActionSet: ChannelMembersHeaderActionItem[]; + }) => { + if (controller.mode !== 'browse') return null; + + const hasManageAction = headerActionSet.some( + (action) => action.type === 'manageMembers', + ); + 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: 'Manage channel members' }), + ).not.toBeInTheDocument(); + expect(screen.getByTestId('channel-members-view-list')).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-view-list')).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-view-search')).toBeInTheDocument(); + expect(screen.queryByTestId('channel-members-view-list')).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Add members' })).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-view-list')).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: '{{ count }} members:3' }), + ).toBeInTheDocument(); + }); + + it('switches to manage-members mode via custom HeaderActions', () => { + renderWithChannel( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Manage channel members' })); + + expect(screen.getByTestId('channel-members-view-list')).toHaveAttribute( + 'data-manage-members', + 'true', + ); + 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: 'Manage channel members' }), + ).not.toBeInTheDocument(); + }); + + it('returns to browse mode from manage mode via go back', () => { + renderWithChannel( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Manage channel members' })); + fireEvent.click(screen.getByRole('button', { name: 'Go back' })); + + expect(screen.getByTestId('channel-members-view-list')).toHaveAttribute( + 'data-manage-members', + 'false', + ); + expect( + screen.getByRole('heading', { name: '{{ count }} members:2' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Manage 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: 'Manage channel members' })); + fireEvent.click(screen.getByRole('button', { name: 'Mock remove members' })); + + expect(screen.getByTestId('channel-members-view-list')).toHaveAttribute( + 'data-manage-members', + 'true', + ); + 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: 'manageMembers', + }, + ]; + + 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/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.utils.test.ts b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.utils.test.ts new file mode 100644 index 0000000000..2d1792bf6f --- /dev/null +++ b/src/components/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/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx new file mode 100644 index 0000000000..e0e5547b1d --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx @@ -0,0 +1,184 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import type { ChannelMemberResponse } from 'stream-chat'; + +import { useTranslationContext } from '../../../../../context'; +import { useStateStore } from '../../../../../store'; +import { ChannelMembersViewList } from '../ChannelMembersViewList'; +import { createChannel, getSelectableMemberButton, renderWithChannel } from './testUtils'; + +const mocks = vi.hoisted(() => ({ + paginatorCancelScheduledQuery: vi.fn(), + paginatorNext: vi.fn(), +})); + +vi.mock('stream-chat', async (importOriginal) => { + const actual = await importOriginal(); + + class ChannelMembersPaginator { + filters: unknown; + state = {}; + + next = mocks.paginatorNext; + + cancelScheduledQuery = mocks.paginatorCancelScheduledQuery; + } + + return { + ...actual, + ChannelMembersPaginator, + }; +}); + +vi.mock('lodash.debounce', () => ({ + default: (fn: (...args: unknown[]) => unknown) => { + const debounced = (...args: unknown[]) => fn(...args); + vi.spyOn(debounced, 'cancel').mockImplementation(); + return debounced; + }, +})); + +vi.mock('../../../../../context'); +vi.mock('../../../../../store'); + +vi.mock('../../../../InfiniteScrollPaginator/InfiniteScrollPaginator', () => ({ + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../../../Dialog', () => ({ + Prompt: { + Body: ({ children }: { children: React.ReactNode }) =>
{children}
, + Footer: ({ children }: { children: React.ReactNode }) =>
{children}
, + FooterControls: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + FooterControlsButtonPrimary: ( + props: React.ButtonHTMLAttributes, + ) => ); }; -const ManageMembersMenuAction = ({ +const RemoveMembersMenuAction = ({ closeMenu, controller, }: ChannelMembersHeaderActionComponentProps) => { @@ -137,13 +134,14 @@ const ManageMembersMenuAction = ({ return ( { - controller.setMode('manage'); + controller.setMode('remove'); closeMenu?.(); }} > - {t('Manage')} + {t('Remove')} ); }; @@ -151,18 +149,18 @@ const ManageMembersMenuAction = ({ export const DefaultChannelMembersHeaderActions = { AddMembers: AddMembersHeaderAction, AddMembersMenu: AddMembersMenuAction, - ManageMembers: ManageMembersHeaderAction, - ManageMembersMenu: ManageMembersMenuAction, + RemoveMembers: RemoveMembersHeaderAction, + RemoveMembersMenu: RemoveMembersMenuAction, }; export const defaultChannelMembersHeaderActionSet: ChannelMembersHeaderActionItem[] = [ { - menu: DefaultChannelMembersHeaderActions.AddMembers, + menu: DefaultChannelMembersHeaderActions.AddMembersMenu, type: 'addMembers', }, { - quick: DefaultChannelMembersHeaderActions.ManageMembers, - type: 'manageMembers', + menu: DefaultChannelMembersHeaderActions.RemoveMembersMenu, + type: 'removeMembers', }, ]; @@ -208,9 +206,8 @@ export const DefaultHeaderActions = ({ null, ); const dialogId = getHeaderActionsDialogId(channel.id); - const modalContext = useModalContext(); - const dialogManagerId = modalContext?.dialogId ? modalDialogManagerId : undefined; - const dialog = useDialog({ dialogManagerId, id: dialogId }); + const { dialog, dialogManager } = useDialogOnNearestManager({ id: dialogId }); + const dialogManagerId = dialogManager?.id; const dialogIsOpen = useDialogIsOpen(dialogId, dialogManagerId); if (!actions.length) return null; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx index f569c5d075..01f6920e44 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx @@ -21,7 +21,7 @@ export type ChannelMembersHeaderActionsProps = { headerActionSet: ChannelMembersHeaderActionItem[]; }; -export type ChannelMembersViewMode = 'add' | 'browse' | 'manage' | 'memberDetail'; +export type ChannelMembersViewMode = 'add' | 'browse' | 'remove' | 'memberDetail'; export type ChannelMembersViewController = { mode: ChannelMembersViewMode; @@ -50,7 +50,7 @@ export const ChannelMembersView = ({ const [membersAddedCount, setMembersAddedCount] = useState(0); const isAddingMember = mode === 'add'; - const isManagingMembers = mode === 'manage'; + const isManagingMembers = mode === 'remove'; const isViewingMemberDetail = !!selectedMember; const isAlternateMode = isAddingMember || isManagingMembers || isViewingMemberDetail; @@ -84,6 +84,7 @@ export const ChannelMembersView = ({ const HeaderTrailingActions = useMemo( () => function HeaderTrailingActions() { + if (mode !== 'browse') return null; return ( ); }, - [HeaderActions, HeaderActionsMenuTrigger, controller, headerActionSet], + [HeaderActions, HeaderActionsMenuTrigger, controller, headerActionSet, mode], ); const headerTitle = isAddingMember @@ -144,7 +145,6 @@ export const ChannelMembersView = ({ ) : ( { setSelectedMember(member); setViewMode('memberDetail'); @@ -152,6 +152,7 @@ export const ChannelMembersView = ({ onMembersRemoved={(count) => { setMemberCount((currentCount) => currentCount - count); }} + removeMembers={isManagingMembers} /> )}
diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx index 9485ff22f2..f46e8fb9a5 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx @@ -57,24 +57,24 @@ const membersPaginatorStateSelector = (state: PaginatorState void; onMembersRemoved?: (memberCount: number) => void; + removeMembers?: boolean; }; const getMemberUserId = (member: ChannelMemberResponse) => member.user?.id || member.user_id; export const ChannelMembersViewList = ({ - manageMembers = false, onMemberSelect, onMembersRemoved, + removeMembers = false, }: ChannelMembersViewListProps) => { const { mutes } = useChatContext(); const { t } = useTranslationContext(); const { channel } = useChannelDetailContext(); const canManageChannelMembers = canUpdateChannelMembers(channel); - const isManageMode = manageMembers && canManageChannelMembers; + const isRemoveMode = removeMembers && canManageChannelMembers; const fallbackMembers = useMemo( () => Object.values(channel.state?.members ?? {}), [channel], @@ -104,7 +104,7 @@ export const ChannelMembersViewList = ({ const [searchInput, setSearchInput] = useState(''); const [isRemoving, setIsRemoving] = useState(false); const [selectedMemberUserIds, setSelectedMemberUserIds] = useState([]); - const wasManagingMembersRef = useRef(manageMembers); + const wasManagingMembersRef = useRef(removeMembers); const resetMembersSearch = useCallback(() => { searchMembers.cancel(); @@ -125,21 +125,21 @@ export const ChannelMembersViewList = ({ ); useEffect(() => { - if (!isManageMode) { + if (!isRemoveMode) { setSelectedMemberUserIds([]); setIsRemoving(false); } - }, [isManageMode]); + }, [isRemoveMode]); useEffect(() => { - if (wasManagingMembersRef.current && !manageMembers) { + if (wasManagingMembersRef.current && !removeMembers) { resetMembersSearch(); setSelectedMemberUserIds([]); setIsRemoving(false); } - wasManagingMembersRef.current = manageMembers; - }, [manageMembers, resetMembersSearch]); + wasManagingMembersRef.current = removeMembers; + }, [removeMembers, resetMembersSearch]); useEffect(() => { membersPaginator.next(); @@ -171,7 +171,7 @@ export const ChannelMembersViewList = ({ }, []); const handleRemove = async () => { - if (!isManageMode || !selectedMemberUserIds.length || isRemoving) return; + if (!isRemoveMode || !selectedMemberUserIds.length || isRemoving) return; setIsRemoving(true); const memberCount = selectedMemberUserIds.length; @@ -222,7 +222,7 @@ export const ChannelMembersViewList = ({ /> ); - if (isManageMode) { + if (isRemoveMode) { const selected = selectedMemberUserIdSet.has(memberUserId); return ( @@ -281,7 +281,7 @@ export const ChannelMembersViewList = ({ )} - {isManageMode && selectedMemberUserIds.length > 0 && ( + {isRemoveMode && selectedMemberUserIds.length > 0 && ( ({ 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']) => @@ -124,7 +132,7 @@ describe('ChannelMembersHeaderActions.defaults', () => { }, { menu: () => Menu Manage, - type: 'manageMembers', + type: 'removeMembers', }, ]; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx index 5110bfe2bb..515960f967 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx @@ -28,14 +28,14 @@ vi.mock('../ChannelMembersViewSearch', () => ({ vi.mock('../ChannelMembersViewList', () => ({ ChannelMembersViewList: ({ - manageMembers, onMembersRemoved, + removeMembers, }: { - manageMembers?: boolean; onMembersRemoved?: (count: number) => void; + removeMembers?: boolean; }) => ( -
- {manageMembers && ( +
+ {removeMembers && ( @@ -50,12 +50,16 @@ vi.mock('../../../../Dialog', () => ({ ), ContextMenuButton: ({ children, + Icon, onClick, + ...props }: { children: React.ReactNode; + Icon?: React.ComponentType; onClick?: () => void; - }) => ( - ), @@ -89,6 +93,14 @@ vi.mock('../../../../Dialog', () => ({ toggle: vi.fn(), }), useDialogIsOpen: () => false, + useDialogOnNearestManager: ({ id }: { id: string }) => ({ + dialog: { + close: vi.fn(), + id, + toggle: vi.fn(), + }, + dialogManager: { id: 'nearest-dialog-manager' }, + }), })); describe('ChannelMembersView', () => { @@ -96,7 +108,7 @@ describe('ChannelMembersView', () => { const customHeaderActionSet: ChannelMembersHeaderActionItem[] = [ { menu: () => null, - type: 'manageMembers', + type: 'removeMembers', }, { quick: () => null, @@ -108,15 +120,15 @@ describe('ChannelMembersView', () => { headerActionSet, }: { controller: { - mode: 'add' | 'browse' | 'manage' | 'memberDetail'; - setMode: (mode: 'add' | 'browse' | 'manage' | 'memberDetail') => void; + 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 === 'manageMembers', + (action) => action.type === 'removeMembers', ); const hasAddAction = headerActionSet.some((action) => action.type === 'addMembers'); @@ -124,11 +136,11 @@ describe('ChannelMembersView', () => {
{hasManageAction && ( )} {hasAddAction && ( @@ -160,15 +172,15 @@ describe('ChannelMembersView', () => { ); }); - it('shows only Add button by default when update-channel-members capability is granted', () => { + it('shows member action buttons by default when update-channel-members capability is granted', () => { renderWithChannel(); expect( screen.getByRole('button', { name: 'Add channel members' }), ).toBeInTheDocument(); expect( - screen.queryByRole('button', { name: 'Manage channel members' }), - ).not.toBeInTheDocument(); + screen.getByRole('button', { name: 'Remove channel members' }), + ).toBeInTheDocument(); expect(screen.getByTestId('channel-members-view-list')).toBeInTheDocument(); }); @@ -191,6 +203,35 @@ describe('ChannelMembersView', () => { 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( , @@ -214,7 +255,7 @@ describe('ChannelMembersView', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: 'Manage channel members' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove channel members' })); expect(screen.getByTestId('channel-members-view-list')).toHaveAttribute( 'data-manage-members', @@ -226,7 +267,7 @@ describe('ChannelMembersView', () => { screen.queryByRole('button', { name: 'Add channel members' }), ).not.toBeInTheDocument(); expect( - screen.queryByRole('button', { name: 'Manage channel members' }), + screen.queryByRole('button', { name: 'Remove channel members' }), ).not.toBeInTheDocument(); }); @@ -238,7 +279,7 @@ describe('ChannelMembersView', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: 'Manage channel members' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove channel members' })); fireEvent.click(screen.getByRole('button', { name: 'Go back' })); expect(screen.getByTestId('channel-members-view-list')).toHaveAttribute( @@ -249,7 +290,7 @@ describe('ChannelMembersView', () => { screen.getByRole('heading', { name: '{{ count }} members:2' }), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'Manage channel members' }), + screen.getByRole('button', { name: 'Remove channel members' }), ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Add channel members' }), @@ -264,7 +305,7 @@ describe('ChannelMembersView', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: 'Manage channel members' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove channel members' })); fireEvent.click(screen.getByRole('button', { name: 'Mock remove members' })); expect(screen.getByTestId('channel-members-view-list')).toHaveAttribute( @@ -303,7 +344,7 @@ describe('ChannelMembersView', () => { }, { menu: () => Menu manage action, - type: 'manageMembers', + type: 'removeMembers', }, ]; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx index e0e5547b1d..b7f0a5b5c6 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx @@ -96,7 +96,7 @@ describe('ChannelMembersViewList', () => { }); }); - it('renders browse-only rows when manageMembers is disabled', () => { + it('renders browse-only rows when removeMembers is disabled', () => { renderWithChannel(); expect(screen.getByText('Alice')).toBeInTheDocument(); @@ -108,11 +108,11 @@ describe('ChannelMembersViewList', () => { ).not.toBeInTheDocument(); }); - it('shows selectable rows and remove footer when manageMembers and permission are granted', async () => { + it('shows selectable rows and remove footer when removeMembers and permission are granted', async () => { const channel = createChannel({ ownCapabilities: ['update-channel-members'] }); renderWithChannel( - , + , channel, ); @@ -134,7 +134,7 @@ describe('ChannelMembersViewList', () => { const channel = createChannel({ ownCapabilities: ['update-channel-members'] }); renderWithChannel( - , + , channel, ); @@ -155,9 +155,9 @@ describe('ChannelMembersViewList', () => { }); }); - it('does not show selection UI when manageMembers is enabled without permission', () => { + it('does not show selection UI when removeMembers is enabled without permission', () => { renderWithChannel( - , + , createChannel({ ownCapabilities: [] }), ); diff --git a/src/components/Dialog/service/DialogPortal.tsx b/src/components/Dialog/service/DialogPortal.tsx index 509159548c..685a16d889 100644 --- a/src/components/Dialog/service/DialogPortal.tsx +++ b/src/components/Dialog/service/DialogPortal.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import type { PropsWithChildren } from 'react'; import React, { useCallback, useEffect, useState } from 'react'; import { useDialogIsOpen, useOpenedDialogCount } from '../hooks'; @@ -16,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); @@ -65,11 +74,14 @@ export const DialogPortalDestination = () => { return (
- {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(), @@ -575,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/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 349483b44b..6de5b78bc2 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -371,9 +371,7 @@ "Location": "Standort", "Location sharing ended": "Standortfreigabe beendet", "Location: {{ coordinates }}": "Standort: {{ coordinates }}", - "Manage": "Verwalten", "Manage channel": "Kanal verwalten", - "Manage channel members": "Kanalmitglieder 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)", @@ -448,9 +446,11 @@ "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", diff --git a/src/i18n/en.json b/src/i18n/en.json index ca1a1ab500..118eac3f36 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -371,9 +371,7 @@ "Location": "Location", "Location sharing ended": "Location sharing ended", "Location: {{ coordinates }}": "Location: {{ coordinates }}", - "Manage": "Manage", "Manage channel": "Manage channel", - "Manage channel members": "Manage channel members", "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)", @@ -448,9 +446,11 @@ "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", diff --git a/src/i18n/es.json b/src/i18n/es.json index b07b0ec2d8..b1eaa94e50 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -385,9 +385,7 @@ "Location": "Ubicaciรณn", "Location sharing ended": "Compartir ubicaciรณn terminado", "Location: {{ coordinates }}": "Ubicaciรณn: {{ coordinates }}", - "Manage": "Gestionar", "Manage channel": "Gestionar canal", - "Manage channel members": "Gestionar miembros del 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)", @@ -462,10 +460,12 @@ "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", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index c466fa92e3..4c7fe859e9 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -385,9 +385,7 @@ "Location": "Emplacement", "Location sharing ended": "Partage d'emplacement terminรฉ", "Location: {{ coordinates }}": "Emplacement : {{ coordinates }}", - "Manage": "Gรฉrer", "Manage channel": "Gรฉrer le canal", - "Manage channel members": "Gรฉrer les membres du 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)", @@ -462,10 +460,12 @@ "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", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 3d1cb3397c..988e1e9e66 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -372,9 +372,7 @@ "Location": "เคธเฅเคฅเคพเคจ", "Location sharing ended": "เคธเฅเคฅเคพเคจ เคธเคพเคเคพ เค•เคฐเคจเคพ เคธเคฎเคพเคชเฅเคค", "Location: {{ coordinates }}": "เคธเฅเคฅเคพเคจ: {{ coordinates }}", - "Manage": "เคชเฅเคฐเคฌเค‚เคงเคฟเคค เค•เคฐเฅ‡เค‚", "Manage channel": "เคšเฅˆเคจเคฒ เคชเฅเคฐเคฌเค‚เคงเคฟเคค เค•เคฐเฅ‡เค‚", - "Manage channel members": "เคšเฅˆเคจเคฒ เคธเคฆเคธเฅเคฏ เคชเฅเคฐเคฌเค‚เคงเคฟเคค เค•เคฐเฅ‡เค‚", "Manage members": "เคธเคฆเคธเฅเคฏ เคชเฅเคฐเคฌเค‚เคงเคฟเคค เค•เคฐเฅ‡เค‚", "Mark as unread": "เค…เคชเค เคฟเคค เคšเคฟเคนเฅเคจเคฟเคค เค•เคฐเฅ‡เค‚", "Maximum number of votes (from 2 to 10)": "เค…เคงเคฟเค•เคคเคฎ เคตเฅ‹เคŸเฅ‹เค‚ เค•เฅ€ เคธเค‚เค–เฅเคฏเคพ (2 เคธเฅ‡ 10)", @@ -449,9 +447,11 @@ "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": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เคนเคŸเคพเคเค‚", diff --git a/src/i18n/it.json b/src/i18n/it.json index c65edef1b9..ede8fc91fd 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -385,9 +385,7 @@ "Location": "Posizione", "Location sharing ended": "Condivisione posizione terminata", "Location: {{ coordinates }}": "Posizione: {{ coordinates }}", - "Manage": "Gestisci", "Manage channel": "Gestisci canale", - "Manage channel members": "Gestisci membri del 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)", @@ -462,10 +460,12 @@ "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", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index d17b8dcc1e..7e792caf2c 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -364,9 +364,7 @@ "Location": "ไฝ็ฝฎๆƒ…ๅ ฑ", "Location sharing ended": "ไฝ็ฝฎๆƒ…ๅ ฑใฎๅ…ฑๆœ‰ใŒ็ต‚ไบ†ใ—ใพใ—ใŸ", "Location: {{ coordinates }}": "ไฝ็ฝฎ: {{ coordinates }}", - "Manage": "็ฎก็†", "Manage channel": "ใƒใƒฃใƒณใƒใƒซใ‚’็ฎก็†", - "Manage channel members": "ใƒใƒฃใƒณใƒใƒซใƒกใƒณใƒใƒผใ‚’็ฎก็†", "Manage members": "ใƒกใƒณใƒใƒผใ‚’็ฎก็†", "Mark as unread": "ๆœช่ชญใจใ—ใฆใƒžใƒผใ‚ฏ", "Maximum number of votes (from 2 to 10)": "ๆœ€ๅคงๆŠ•็ฅจๆ•ฐ๏ผˆ2ใ‹ใ‚‰10ใพใง๏ผ‰", @@ -441,8 +439,10 @@ "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": "ใƒฆใƒผใ‚ถใƒผใ‚’ๅ‰Š้™ค", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index be4f8d21ce..ef30e62e3a 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -364,9 +364,7 @@ "Location": "์œ„์น˜", "Location sharing ended": "์œ„์น˜ ๊ณต์œ ๊ฐ€ ์ข…๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", "Location: {{ coordinates }}": "์œ„์น˜: {{ coordinates }}", - "Manage": "๊ด€๋ฆฌ", "Manage channel": "์ฑ„๋„ ๊ด€๋ฆฌ", - "Manage channel members": "์ฑ„๋„ ๋ฉค๋ฒ„ ๊ด€๋ฆฌ", "Manage members": "๋ฉค๋ฒ„ ๊ด€๋ฆฌ", "Mark as unread": "์ฝ์ง€ ์•Š์Œ์œผ๋กœ ํ‘œ์‹œ", "Maximum number of votes (from 2 to 10)": "์ตœ๋Œ€ ํˆฌํ‘œ ์ˆ˜ (2์—์„œ 10๊นŒ์ง€)", @@ -441,8 +439,10 @@ "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": "์‚ฌ์šฉ์ž ์ œ๊ฑฐ", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index edcc049599..04f7d38a5f 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -371,9 +371,7 @@ "Location": "Locatie", "Location sharing ended": "Locatie delen beรซindigd", "Location: {{ coordinates }}": "Locatie: {{ coordinates }}", - "Manage": "Beheren", "Manage channel": "Kanaal beheren", - "Manage channel members": "Kanaalleden 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)", @@ -448,9 +446,11 @@ "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", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index ccfd0c24a1..2f2cb3e647 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -385,9 +385,7 @@ "Location": "Localizaรงรฃo", "Location sharing ended": "Compartilhamento de localizaรงรฃo encerrado", "Location: {{ coordinates }}": "Localizaรงรฃo: {{ coordinates }}", - "Manage": "Gerenciar", "Manage channel": "Gerenciar canal", - "Manage channel members": "Gerenciar membros do 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)", @@ -462,10 +460,12 @@ "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", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 0012c32e50..70af298398 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -403,9 +403,7 @@ "Location": "ะœะตัั‚ะพะฟะพะปะพะถะตะฝะธะต", "Location sharing ended": "ะžะฑะผะตะฝ ะผะตัั‚ะพะฟะพะปะพะถะตะฝะธะตะผ ะทะฐะฒะตั€ัˆะตะฝ", "Location: {{ coordinates }}": "ะœะตัั‚ะพะฟะพะปะพะถะตะฝะธะต: {{ coordinates }}", - "Manage": "ะฃะฟั€ะฐะฒะปะตะฝะธะต", "Manage channel": "ะฃะฟั€ะฐะฒะปัั‚ัŒ ะบะฐะฝะฐะปะพะผ", - "Manage channel members": "ะฃะฟั€ะฐะฒะปัั‚ัŒ ัƒั‡ะฐัั‚ะฝะธะบะฐะผะธ ะบะฐะฝะฐะปะฐ", "Manage members": "ะฃะฟั€ะฐะฒะปัั‚ัŒ ัƒั‡ะฐัั‚ะฝะธะบะฐะผะธ", "Mark as unread": "ะžั‚ะผะตั‚ะธั‚ัŒ ะบะฐะบ ะฝะตะฟั€ะพั‡ะธั‚ะฐะฝะฝะพะต", "Maximum number of votes (from 2 to 10)": "ะœะฐะบัะธะผะฐะปัŒะฝะพะต ะบะพะปะธั‡ะตัั‚ะฒะพ ะณะพะปะพัะพะฒ (ะพั‚ 2 ะดะพ 10)", @@ -480,11 +478,13 @@ "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": "ะฃะดะฐะปะธั‚ัŒ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 661ff7f194..2096b48e76 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -371,9 +371,7 @@ "Location": "Konum", "Location sharing ended": "Konum paylaลŸฤฑmฤฑ sona erdi", "Location: {{ coordinates }}": "Konum: {{ coordinates }}", - "Manage": "Yรถnet", "Manage channel": "Kanalฤฑ yรถnet", - "Manage channel members": "Kanal รผyelerini 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ฤฑ)", @@ -448,9 +446,11 @@ "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", From d133b38b590baf1338ffc4030744351f3bf0a814 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 9 Jun 2026 13:23:16 +0200 Subject: [PATCH 10/21] chore(demo): allow to configure channel detail actions --- .../vite/src/AppSettings/AppSettings.scss | 16 +++ examples/vite/src/AppSettings/AppSettings.tsx | 12 +- examples/vite/src/AppSettings/state.ts | 34 ++++++ .../tabs/ChannelDetail/ChannelDetailTab.tsx | 111 ++++++++++++++++++ .../ChannelDetail/channelDetailSettings.ts | 63 ++++++++++ .../AppSettings/tabs/ChannelDetail/index.ts | 2 + .../ChatLayout/ConfiguredChannelDetail.tsx | 46 ++++++++ examples/vite/src/ChatLayout/Panels.tsx | 4 +- examples/vite/src/index.scss | 3 +- .../ChannelDetail/ChannelDetail.tsx | 4 +- .../ChannelMembersHeaderActions.defaults.tsx | 6 +- .../__tests__/ChannelMembersView.test.tsx | 6 +- 12 files changed, 293 insertions(+), 14 deletions(-) create mode 100644 examples/vite/src/AppSettings/tabs/ChannelDetail/ChannelDetailTab.tsx create mode 100644 examples/vite/src/AppSettings/tabs/ChannelDetail/channelDetailSettings.ts create mode 100644 examples/vite/src/AppSettings/tabs/ChannelDetail/index.ts create mode 100644 examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx diff --git a/examples/vite/src/AppSettings/AppSettings.scss b/examples/vite/src/AppSettings/AppSettings.scss index 80965713a5..7236172fd7 100644 --- a/examples/vite/src/AppSettings/AppSettings.scss +++ b/examples/vite/src/AppSettings/AppSettings.scss @@ -1059,6 +1059,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); diff --git a/examples/vite/src/AppSettings/AppSettings.tsx b/examples/vite/src/AppSettings/AppSettings.tsx index 1b0fca31eb..8a03053e7e 100644 --- a/examples/vite/src/AppSettings/AppSettings.tsx +++ b/examples/vite/src/AppSettings/AppSettings.tsx @@ -6,9 +6,11 @@ import { IconBell, IconEmoji, IconMessageBubble, + IconMessageBubbles, } 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'; @@ -23,10 +25,17 @@ import { IconTextDirection, } from '../icons.tsx'; -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: IconMessageBubbles, id: 'channelDetail', title: 'Channel Detail' }, { Icon: IconMessageBubble, id: 'messageActions', title: 'Message Actions' }, { Icon: IconBell, id: 'notifications', title: 'Notifications' }, { Icon: IconSidebar, id: 'sidebar', title: 'Sidebar' }, @@ -136,6 +145,7 @@ export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => { id={`${activeTab}-content`} role='tabpanel' > + {activeTab === 'channelDetail' && } {activeTab === 'general' && } {activeTab === 'messageActions' && } {activeTab === 'notifications' && } 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..48306e301f --- /dev/null +++ b/examples/vite/src/AppSettings/tabs/ChannelDetail/ChannelDetailTab.tsx @@ -0,0 +1,111 @@ +import { Button, SwitchField } from 'stream-chat-react'; + +import { + appSettingsStore, + type ChannelMembersHeaderActionForm, + type ChannelMembersHeaderActionId, + useAppSettingsState, +} from '../../state'; +import { channelMembersHeaderActionLabels } from './channelDetailSettings'; + +const channelMembersHeaderActionIds: ChannelMembersHeaderActionId[] = [ + 'addMembers', + 'removeMembers', +]; + +const channelMembersHeaderActionForms: ChannelMembersHeaderActionForm[] = [ + 'quick', + 'menu', +]; + +const getChannelMembersHeaderActionFormLabel = (form: ChannelMembersHeaderActionForm) => + form === 'quick' ? 'Quick' : 'Menu'; + +export const ChannelDetailTab = () => { + 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 +
+
+ Configure which default header actions are available in the ChannelDetail modal + and how each action is rendered. +
+ +
+ {channelMembersHeaderActionIds.map((type) => { + const action = headerActions[type]; + + return ( +
+ + updateChannelMembersHeaderAction(type, { + enabled: event.target.checked, + }) + } + > + {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..879bf5ad97 --- /dev/null +++ b/examples/vite/src/AppSettings/tabs/ChannelDetail/channelDetailSettings.ts @@ -0,0 +1,63 @@ +import { + type ChannelMembersHeaderActionItem, + DefaultChannelMembersHeaderActions, +} from 'stream-chat-react'; + +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/ChatLayout/ConfiguredChannelDetail.tsx b/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx new file mode 100644 index 0000000000..fb75e84d7b --- /dev/null +++ b/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx @@ -0,0 +1,46 @@ +import { useMemo } from 'react'; +import { + AvatarWithChannelDetail, + type AvatarWithChannelDetailProps, + ChannelDetail, + type ChannelDetailProps, + ChannelManagementNavButton, + ChannelManagementView, + ChannelMembersNavButton, + ChannelMembersView, + type SectionNavigatorSection, +} from 'stream-chat-react'; + +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( + () => [ + { + id: 'channel-info', + NavButton: ChannelManagementNavButton, + SectionContent: ChannelManagementView, + }, + { + id: 'channel-members', + NavButton: ChannelMembersNavButton, + SectionContent: (sectionProps) => ( + + ), + }, + ], + [headerActionSet], + ); + + return ; +}; + +export const ConfiguredAvatarWithChannelDetail = ( + props: AvatarWithChannelDetailProps, +) => ; diff --git a/examples/vite/src/ChatLayout/Panels.tsx b/examples/vite/src/ChatLayout/Panels.tsx index 64bee4f507..f1f479d679 100644 --- a/examples/vite/src/ChatLayout/Panels.tsx +++ b/examples/vite/src/ChatLayout/Panels.tsx @@ -3,7 +3,6 @@ import type { ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat'; import { useEffect, useRef } from 'react'; import { AIStateIndicator, - AvatarWithChannelDetail, Channel, ChannelAvatar, ChannelHeader, @@ -25,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'; @@ -76,7 +76,7 @@ const ResponsiveChannelPanels = () => { > - +
{messageListType === 'virtualized' ? ( diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index 082ff5cd67..a7ec39b74d 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -229,7 +229,8 @@ body { z-index: 2; } - .str-chat__notification-list { + .str-chat__notification-list, + .str-chat__dialog-overlay { z-index: 4; } diff --git a/src/components/ChannelDetail/ChannelDetail.tsx b/src/components/ChannelDetail/ChannelDetail.tsx index 51832a8284..8dcabcb9a5 100644 --- a/src/components/ChannelDetail/ChannelDetail.tsx +++ b/src/components/ChannelDetail/ChannelDetail.tsx @@ -25,7 +25,7 @@ const ChannelMembersNavButtonIcon = () => ( ); -const ChannelManagementNavButton = ({ +export const ChannelManagementNavButton = ({ select, selected, }: SectionNavigatorNavButtonProps) => { @@ -49,7 +49,7 @@ const ChannelManagementNavButton = ({ ); }; -const ChannelMembersNavButton = ({ +export const ChannelMembersNavButton = ({ select, selected, }: SectionNavigatorNavButtonProps) => { diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx index b6f4a5d3f2..d603e381d3 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx @@ -155,13 +155,9 @@ export const DefaultChannelMembersHeaderActions = { export const defaultChannelMembersHeaderActionSet: ChannelMembersHeaderActionItem[] = [ { - menu: DefaultChannelMembersHeaderActions.AddMembersMenu, + quick: DefaultChannelMembersHeaderActions.AddMembers, type: 'addMembers', }, - { - menu: DefaultChannelMembersHeaderActions.RemoveMembersMenu, - type: 'removeMembers', - }, ]; export type ChannelMembersHeaderActionsMenuTriggerProps = { diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx index 515960f967..80045ac9f5 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx @@ -172,15 +172,15 @@ describe('ChannelMembersView', () => { ); }); - it('shows member action buttons by default when update-channel-members capability is granted', () => { + 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.getByRole('button', { name: 'Remove channel members' }), - ).toBeInTheDocument(); + screen.queryByRole('button', { name: 'Remove channel members' }), + ).not.toBeInTheDocument(); expect(screen.getByTestId('channel-members-view-list')).toBeInTheDocument(); }); From 4cc1aaa0d96f8adaf1db8093a91c3e8abc979fe0 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 9 Jun 2026 15:08:22 +0200 Subject: [PATCH 11/21] chore(demo): migrate app settings modal to SectionNavigator --- .../vite/src/AppSettings/AppSettings.scss | 69 ++++-- examples/vite/src/AppSettings/AppSettings.tsx | 140 +++++++---- .../tabs/ChannelDetail/ChannelDetailTab.tsx | 100 ++++---- .../AppSettings/tabs/General/GeneralTab.tsx | 122 +++++----- .../tabs/MessageActions/MessageActionsTab.tsx | 142 +++++++----- .../tabs/Notifications/NotificationsTab.tsx | 70 +++--- .../tabs/Reactions/ReactionsTab.tsx | 218 ++++++++++-------- .../tabs/SettingsTabLayoutComponents.tsx | 26 +++ .../AppSettings/tabs/Sidebar/SidebarTab.tsx | 69 +++--- src/components/Dialog/styling/Prompt.scss | 138 +++++------ 10 files changed, 638 insertions(+), 456 deletions(-) create mode 100644 examples/vite/src/AppSettings/tabs/SettingsTabLayoutComponents.tsx diff --git a/examples/vite/src/AppSettings/AppSettings.scss b/examples/vite/src/AppSettings/AppSettings.scss index 7236172fd7..6039f31b07 100644 --- a/examples/vite/src/AppSettings/AppSettings.scss +++ b/examples/vite/src/AppSettings/AppSettings.scss @@ -972,21 +972,6 @@ 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; - } - } - .app__settings-modal__body { display: grid; grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); @@ -994,11 +979,16 @@ 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 +1008,52 @@ 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); + padding-inline: 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 { @@ -1141,8 +1161,7 @@ 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; diff --git a/examples/vite/src/AppSettings/AppSettings.tsx b/examples/vite/src/AppSettings/AppSettings.tsx index 8a03053e7e..a801fe51f9 100644 --- a/examples/vite/src/AppSettings/AppSettings.tsx +++ b/examples/vite/src/AppSettings/AppSettings.tsx @@ -1,4 +1,4 @@ -import React, { type ComponentType, useState } from 'react'; +import { type ComponentType, useCallback, useMemo, useState } from 'react'; import { Button, ChatViewSelectorButton, @@ -7,6 +7,9 @@ import { IconEmoji, IconMessageBubble, IconMessageBubbles, + SectionNavigator, + type SectionNavigatorNavButtonProps, + type SectionNavigatorSection, } from 'stream-chat-react'; import { ActionsMenu } from './ActionsMenu'; @@ -33,15 +36,84 @@ type TabId = | 'reactions' | 'sidebar'; -const tabConfig: { Icon: ComponentType; id: TabId; title: string }[] = [ - { Icon: IconGear, id: 'general', title: 'General' }, - { Icon: IconMessageBubbles, id: 'channelDetail', title: 'Channel Detail' }, - { 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, @@ -97,8 +169,12 @@ 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], + ); return (
@@ -112,47 +188,13 @@ export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => { onClick={() => setOpen(true)} text='Settings' /> - setOpen(false)} open={open}> +
-
- - Settings -
-
- -
- {activeTab === 'channelDetail' && } - {activeTab === 'general' && } - {activeTab === 'messageActions' && } - {activeTab === 'notifications' && } - {activeTab === 'sidebar' && } - {activeTab === 'reactions' && } -
-
+
diff --git a/examples/vite/src/AppSettings/tabs/ChannelDetail/ChannelDetailTab.tsx b/examples/vite/src/AppSettings/tabs/ChannelDetail/ChannelDetailTab.tsx index 48306e301f..3b181d8957 100644 --- a/examples/vite/src/AppSettings/tabs/ChannelDetail/ChannelDetailTab.tsx +++ b/examples/vite/src/AppSettings/tabs/ChannelDetail/ChannelDetailTab.tsx @@ -6,6 +6,10 @@ import { type ChannelMembersHeaderActionId, useAppSettingsState, } from '../../state'; +import { + SettingsTabBody, + SettingsTabLayoutHeader, +} from '../SettingsTabLayoutComponents.tsx'; import { channelMembersHeaderActionLabels } from './channelDetailSettings'; const channelMembersHeaderActionIds: ChannelMembersHeaderActionId[] = [ @@ -21,7 +25,11 @@ const channelMembersHeaderActionForms: ChannelMembersHeaderActionForm[] = [ const getChannelMembersHeaderActionFormLabel = (form: ChannelMembersHeaderActionForm) => form === 'quick' ? 'Quick' : 'Menu'; -export const ChannelDetailTab = () => { +type ChannelDetailTabProps = { + close: () => void; +}; + +export const ChannelDetailTab = ({ close }: ChannelDetailTabProps) => { const { channelDetail, channelDetail: { @@ -57,55 +65,57 @@ export const ChannelDetailTab = () => { return (
-
-
- Channel members view actions -
-
- Configure which default header actions are available in the ChannelDetail modal - and how each action is rendered. -
+ + +
+
+ Channel members view actions +
-
- {channelMembersHeaderActionIds.map((type) => { - const action = headerActions[type]; +
+ {channelMembersHeaderActionIds.map((type) => { + const action = headerActions[type]; - return ( -
- - updateChannelMembersHeaderAction(type, { - enabled: event.target.checked, - }) - } - > - {channelMembersHeaderActionLabels[type]} - + return ( +
+ + updateChannelMembersHeaderAction(type, { + enabled: event.target.checked, + }) + } + title={channelMembersHeaderActionLabels[type]} + > -
- {channelMembersHeaderActionForms.map((form) => ( - - ))} +
+ {channelMembersHeaderActionForms.map((form) => ( + + ))} +
-
- ); - })} + ); + })} +
-
+
); }; 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..56bdb9f917 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,83 @@ 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”. + }) + } + > + 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 - -
+ }) + } + > + 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 - -
+ }) + } + > + 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..8093f50c79 --- /dev/null +++ b/examples/vite/src/AppSettings/tabs/SettingsTabLayoutComponents.tsx @@ -0,0 +1,26 @@ +import { Prompt } 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/src/components/Dialog/styling/Prompt.scss b/src/components/Dialog/styling/Prompt.scss index 28e7a841fd..42ada22d04 100644 --- a/src/components/Dialog/styling/Prompt.scss +++ b/src/components/Dialog/styling/Prompt.scss @@ -3,92 +3,98 @@ .str-chat__prompt { @include utils.modal; width: 100%; +} - .str-chat__prompt__header { - display: flex; - align-items: center; - gap: var(--str-chat__spacing-xs) var(--str-chat__spacing-md); - width: 100%; - padding: var(--str-chat__spacing-xl); - - &.str-chat__prompt__header--withGoBack - .str-chat__prompt__header__title-group.str-chat__prompt__header__title-group--withDescription { - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - grid-template-areas: - 'goBack title' - '. description'; +.str-chat__prompt__header { + display: flex; + align-items: baseline; + gap: var(--str-chat__spacing-xs) var(--str-chat__spacing-md); + width: 100%; + padding: var(--str-chat__spacing-xl); - .str-chat__prompt__header__go-back-button { - grid-area: goBack; - justify-self: start; - align-self: center; - } + .str-chat__prompt__header__title-group { + display: flex; + gap: var(--str-chat__spacing-xxs); + padding-block: var(--str-chat__spacing-xs); + flex: 1; + min-width: 0; + } - .str-chat__prompt__header__title { - grid-area: title; - } + .str-chat__prompt__header__title-group.str-chat__prompt__header__title-group--withDescription { + flex-direction: column; + } + &.str-chat__prompt__header--withGoBack .str-chat__prompt__header__title-group { + align-items: center; + } - .str-chat__prompt__header__description { - grid-area: description; - } - } + &.str-chat__prompt__header--withGoBack + .str-chat__prompt__header__title-group.str-chat__prompt__header__title-group--withDescription { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + grid-template-areas: + 'goBack title' + '. description'; - .str-chat__prompt__header__title-group { - display: flex; - align-items: center; - gap: var(--str-chat__spacing-xxs); - padding-block: var(--str-chat__spacing-xs); - flex: 1; - min-width: 0; + .str-chat__prompt__header__go-back-button { + grid-area: goBack; + justify-self: start; + align-self: center; } .str-chat__prompt__header__title { - margin: 0; - font: var(--str-chat__font-heading-sm); - color: var(--str-chat__text-primary); + grid-area: title; } .str-chat__prompt__header__description { - font: var(--str-chat__font-caption-default); - color: var(--str-chat__text-secondary); - } - - .str-chat__prompt__header__leading-content, - .str-chat__prompt__header__trailing-content { - display: flex; - gap: var(--str-chat__spacing-xs); - align-items: center; + grid-area: description; } + } - .str-chat__prompt__header__close-button { - flex-shrink: 0; - color: var(--str-chat__text-primary); - .str-chat__icon { - width: var(--str-chat__icon-size-sm); - height: var(--str-chat__icon-size-sm); - } - } + .str-chat__prompt__header__title { + margin: 0; + font: var(--str-chat__font-heading-sm); + color: var(--str-chat__text-primary); } - .str-chat__prompt__body { - /* Vertical padding so focus rings (e.g. TextInput wrapper box-shadow) are not clipped by scrollable-y */ - padding: var(--str-chat__spacing-xxs) var(--str-chat__spacing-xl); - @include utils.scrollable-y; + .str-chat__prompt__header__description { + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-secondary); } - .str-chat__prompt__footer { + .str-chat__prompt__header__leading-content, + .str-chat__prompt__header__trailing-content { display: flex; + gap: var(--str-chat__spacing-xs); align-items: center; - justify-content: flex-end; - width: 100%; - padding: var(--str-chat__spacing-xl); + } - .str-chat__prompt__footer__controls { - display: flex; - align-items: center; - gap: var(--str-chat__spacing-xs); + .str-chat__prompt__header__close-button { + flex-shrink: 0; + color: var(--str-chat__text-primary); + .str-chat__icon { + width: var(--str-chat__icon-size-sm); + height: var(--str-chat__icon-size-sm); } } } + +.str-chat__prompt__body { + /* Vertical padding so focus rings (e.g. TextInput wrapper box-shadow) are not clipped by scrollable-y */ + padding: var(--str-chat__spacing-xxs) var(--str-chat__spacing-xl); + @include utils.scrollable-y; +} + +.str-chat__prompt__footer { + display: flex; + align-items: center; + justify-content: flex-end; + width: 100%; + padding: var(--str-chat__spacing-xl); + + .str-chat__prompt__footer__controls { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xs); + } +} From e84e32c90da9cc3ca23ed7e3ce027dc85909d95e Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 9 Jun 2026 15:45:02 +0200 Subject: [PATCH 12/21] fix: extract role correctly --- .../ChannelMembersViewList.tsx | 24 ++++++++++--------- .../__tests__/ChannelMembersViewList.test.tsx | 12 ++++++++-- .../ChannelMembersViewSearch.test.tsx | 7 ++++++ .../styling/ChannelMembersView.scss | 6 +++++ 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx index f46e8fb9a5..ee36a25c97 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx @@ -22,12 +22,14 @@ import { getUserDisplayName, } from './ChannelMembersView.utils'; -const getMemberRoleTranslationKey = (member: ChannelMemberResponse) => { - const role = member.channel_role || member.role; - - if (role === 'admin') return 'Admin'; - if (role === 'channel_moderator' || role === 'moderator') return 'Moderator'; - if (role === 'owner') return 'Owner'; +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; }; @@ -211,7 +213,7 @@ export const ChannelMembersViewList = ({ const user = member.user; const displayName = getMemberDisplayName(member); - const roleTranslationKey = getMemberRoleTranslationKey(member); + const roleTranslation = getMemberRoleTranslation(member, t); const isMuted = mutedUserIdSet.has(memberUserId); const avatar = ( ( - - {roleTranslationKey ? ( +
    + {roleTranslation ? ( - {t(roleTranslationKey)} + {roleTranslation} ) : null} {isMuted ? ( ) : null} - +
    )} /> ); diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx index b7f0a5b5c6..340e87231b 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; import type { ChannelMemberResponse } from 'stream-chat'; -import { useTranslationContext } from '../../../../../context'; +import { useChatContext, useTranslationContext } from '../../../../../context'; import { useStateStore } from '../../../../../store'; import { ChannelMembersViewList } from '../ChannelMembersViewList'; import { createChannel, getSelectableMemberButton, renderWithChannel } from './testUtils'; @@ -32,7 +32,12 @@ vi.mock('stream-chat', async (importOriginal) => { vi.mock('lodash.debounce', () => ({ default: (fn: (...args: unknown[]) => unknown) => { - const debounced = (...args: unknown[]) => fn(...args); + const debounced = Object.assign( + vi.fn((...args: unknown[]) => fn(...args)), + { + cancel: () => undefined, + }, + ); vi.spyOn(debounced, 'cancel').mockImplementation(); return debounced; }, @@ -89,6 +94,9 @@ describe('ChannelMembersViewList', () => { return key; }, } as ReturnType); + vi.mocked(useChatContext).mockReturnValue({ + mutes: [], + } as ReturnType); vi.mocked(useStateStore).mockReturnValue({ isLoading: false, diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewSearch.test.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewSearch.test.tsx index db29016593..72f200a6dd 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewSearch.test.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewSearch.test.tsx @@ -16,6 +16,12 @@ import { vi.mock('../../../../../context'); vi.mock('../../../../../store'); +vi.mock('../../../../Notifications', () => ({ + useNotificationApi: () => ({ + addNotification: vi.fn(), + }), +})); + vi.mock('../../../../InfiniteScrollPaginator/InfiniteScrollPaginator', () => ({ InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => (
    {children}
    @@ -53,6 +59,7 @@ describe('ChannelMembersViewSearch', () => { vi.mocked(useChatContext).mockReturnValue({ client: { user: { id: 'user-1' } }, + mutes: [], } as ReturnType); vi.mocked(useStateStore).mockReturnValue({ diff --git a/src/components/ChannelDetail/styling/ChannelMembersView.scss b/src/components/ChannelDetail/styling/ChannelMembersView.scss index 6639b70e1c..b0773f3eae 100644 --- a/src/components/ChannelDetail/styling/ChannelMembersView.scss +++ b/src/components/ChannelDetail/styling/ChannelMembersView.scss @@ -61,6 +61,12 @@ 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); From bd205e6d2d72668cc66110c6d63cdea8f22f6b05 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 10 Jun 2026 11:06:26 +0200 Subject: [PATCH 13/21] fix: prevent channel members views lists re-render on pagination --- .../ChannelMembersBrowseView.tsx | 132 ++++++++ .../ChannelMembersRemoveView.tsx | 160 ++++++++++ .../ChannelMembersView/ChannelMembersView.tsx | 45 ++- .../ChannelMembersView.utils.ts | 3 + .../ChannelMembersViewEmptyList.tsx | 12 + .../ChannelMembersViewList.tsx | 300 ------------------ .../ChannelMembersViewListFooter.tsx | 29 ++ .../ChannelMembersViewSearchInput.tsx | 46 +++ .../ChannelMembersBrowseView.test.tsx | 158 +++++++++ .../__tests__/ChannelMembersView.test.tsx | 108 +++++-- .../useChannelMembersSearch.ts | 76 +++++ .../styling/ChannelMembersView.scss | 5 + .../styling/ChannelMembersViewListFooter.scss | 6 + .../ChannelDetail/styling/index.scss | 1 + 14 files changed, 725 insertions(+), 356 deletions(-) create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewEmptyList.tsx delete mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewListFooter.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewSearchInput.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts create mode 100644 src/components/ChannelDetail/styling/ChannelMembersViewListFooter.scss diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx new file mode 100644 index 0000000000..7229841821 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx @@ -0,0 +1,132 @@ +import type { ChannelMemberResponse } from 'stream-chat'; +import React, { useMemo } from 'react'; + +import { useChatContext, useTranslationContext } from '../../../../context'; +import { Avatar } from '../../../Avatar'; +import { IconMute } from '../../../Icons'; +import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; +import { ListItemLayout } from '../../../ListItemLayout'; +import { Prompt } from '../../../Dialog'; +import { + getMemberDisplayName, + getMemberUserId, + getUserDisplayName, +} from './ChannelMembersView.utils'; +import { ChannelMembersViewEmptyList } from './ChannelMembersViewEmptyList'; +import { ChannelMembersViewListFooter } from './ChannelMembersViewListFooter'; +import { ChannelMembersViewSearchInput } from './ChannelMembersViewSearchInput'; +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, + membersSearchSource, + searchInputResetKey, + } = useChannelMembersSearch(); + const mutedUserIdSet = useMemo( + () => new Set(mutes.map((mute) => mute.target.id)), + [mutes], + ); + + return ( + + + + {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} +
    + )} + /> + ); + }) + ) : ( + + )} + +
    +
    + ); +}; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx new file mode 100644 index 0000000000..549bffb75b --- /dev/null +++ b/src/components/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 '../../../Avatar'; +import { Checkbox } from '../../../Form'; +import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; +import { ListItemLayout } from '../../../ListItemLayout'; +import { Prompt } from '../../../Dialog'; +import { useChannelDetailContext } from '../../ChannelDetailContext'; +import { + canUpdateChannelMembers, + getMemberDisplayName, + getMemberUserId, + getUserDisplayName, +} from './ChannelMembersView.utils'; +import { ChannelMembersBrowseView } from './ChannelMembersBrowseView'; +import { ChannelMembersViewEmptyList } from './ChannelMembersViewEmptyList'; +import { ChannelMembersViewListFooter } from './ChannelMembersViewListFooter'; +import { ChannelMembersViewSearchInput } from './ChannelMembersViewSearchInput'; +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={() => } + /> + ); + }) + ) : ( + + )} + + + + {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/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx index 01f6920e44..bda54551a9 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx @@ -11,8 +11,9 @@ import { defaultChannelMembersHeaderActionSet, DefaultHeaderActions, } from './ChannelMembersHeaderActions.defaults'; -import { ChannelMembersViewList } from './ChannelMembersViewList'; -import { ChannelMembersViewSearch } from './ChannelMembersViewSearch'; +import { ChannelMembersAddView } from './ChannelMembersAddView'; +import { ChannelMembersBrowseView } from './ChannelMembersBrowseView'; +import { ChannelMembersRemoveView } from './ChannelMembersRemoveView'; import type { SectionNavigatorSectionContentProps } from '../../../SectionNavigator'; export type ChannelMembersHeaderActionsProps = { @@ -51,7 +52,7 @@ export const ChannelMembersView = ({ const isAddingMember = mode === 'add'; const isManagingMembers = mode === 'remove'; - const isViewingMemberDetail = !!selectedMember; + const isViewingMemberDetail = mode === 'memberDetail'; const isAlternateMode = isAddingMember || isManagingMembers || isViewingMemberDetail; useEffect(() => { @@ -73,6 +74,8 @@ export const ChannelMembersView = ({ } }, []); + const goBack = useCallback(() => setViewMode('browse'), [setViewMode]); + const controller = useMemo( () => ({ mode, @@ -104,11 +107,7 @@ export const ChannelMembersView = ({ if (isViewingMemberDetail && selectedMember) { return ( - setViewMode('browse')} - /> + ); } @@ -118,41 +117,35 @@ export const ChannelMembersView = ({ close={close} description={isAlternateMode ? undefined : t('Browse channel members')} goBack={ - isAddingMember - ? () => { - setViewMode('browse'); - } - : isViewingMemberDetail - ? () => { - setViewMode('browse'); - } - : isManagingMembers - ? () => setViewMode('browse') - : undefined + isAddingMember || isViewingMemberDetail || isManagingMembers + ? goBack + : undefined } title={headerTitle} TrailingContent={HeaderTrailingActions} /> {isAddingMember ? ( - { setMemberCount((currentCount) => currentCount + count); setMembersAddedCount(count); setMembersRefreshKey((currentKey) => currentKey + 1); - setViewMode('browse'); + goBack(); + }} + /> + ) : isManagingMembers ? ( + { + setMemberCount((currentCount) => currentCount - count); }} /> ) : ( - { setSelectedMember(member); setViewMode('memberDetail'); }} - onMembersRemoved={(count) => { - setMemberCount((currentCount) => currentCount - count); - }} - removeMembers={isManagingMembers} /> )}
    diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts index 91c8bfb1cb..4a832e3102 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts @@ -5,6 +5,9 @@ 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 || ''; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewEmptyList.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewEmptyList.tsx new file mode 100644 index 0000000000..90078a9713 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewEmptyList.tsx @@ -0,0 +1,12 @@ +import { IconSearch } from '../../../Icons'; +import { useTranslationContext } from '../../../../context'; + +export const ChannelMembersViewEmptyList = () => { + const { t } = useTranslationContext(); + return ( +
    + + {t('No user found')} +
    + ); +}; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx deleted file mode 100644 index ee36a25c97..0000000000 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewList.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { - type ChannelMemberResponse, - ChannelMembersPaginator, - type PaginatorState, -} from 'stream-chat'; -import debounce from 'lodash.debounce'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import { useChatContext, useTranslationContext } from '../../../../context'; -import { useStateStore } from '../../../../store'; -import { Avatar } from '../../../Avatar'; -import { Checkbox, TextInput } from '../../../Form'; -import { IconMute, IconSearch } from '../../../Icons'; -import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; -import { ListItemLayout } from '../../../ListItemLayout'; -import { Prompt } from '../../../Dialog'; -import { useChannelDetailContext } from '../../ChannelDetailContext'; -import { - canUpdateChannelMembers, - CHANNEL_MEMBERS_QUERY_LIMIT, - getMemberDisplayName, - getUserDisplayName, -} from './ChannelMembersView.utils'; - -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'); -}; - -const membersPaginatorStateSelector = (state: PaginatorState) => ({ - isLoading: state.isLoading, - members: state.items, -}); - -const MEMBERS_SEARCH_DEBOUNCE_MS = 300; - -export type ChannelMembersViewListProps = { - onMemberSelect?: (member: ChannelMemberResponse) => void; - onMembersRemoved?: (memberCount: number) => void; - removeMembers?: boolean; -}; - -const getMemberUserId = (member: ChannelMemberResponse) => - member.user?.id || member.user_id; - -export const ChannelMembersViewList = ({ - onMemberSelect, - onMembersRemoved, - removeMembers = false, -}: ChannelMembersViewListProps) => { - const { mutes } = useChatContext(); - const { t } = useTranslationContext(); - const { channel } = useChannelDetailContext(); - const canManageChannelMembers = canUpdateChannelMembers(channel); - const isRemoveMode = removeMembers && canManageChannelMembers; - const fallbackMembers = useMemo( - () => Object.values(channel.state?.members ?? {}), - [channel], - ); - const membersPaginator = useMemo( - () => - new ChannelMembersPaginator(channel, { - pageSize: CHANNEL_MEMBERS_QUERY_LIMIT, - }), - [channel], - ); - const searchMembers = useMemo( - () => - debounce((query: string) => { - const trimmedQuery = query.trim(); - membersPaginator.filters = trimmedQuery - ? { name: { $autocomplete: trimmedQuery } } - : undefined; - membersPaginator.next(); - }, MEMBERS_SEARCH_DEBOUNCE_MS), - [membersPaginator], - ); - const { isLoading, members } = useStateStore( - membersPaginator.state, - membersPaginatorStateSelector, - ); - const [searchInput, setSearchInput] = useState(''); - const [isRemoving, setIsRemoving] = useState(false); - const [selectedMemberUserIds, setSelectedMemberUserIds] = useState([]); - const wasManagingMembersRef = useRef(removeMembers); - - const resetMembersSearch = useCallback(() => { - searchMembers.cancel(); - membersPaginator.cancelScheduledQuery(); - setSearchInput(''); - membersPaginator.filters = undefined; - void membersPaginator.next(); - }, [membersPaginator, searchMembers]); - - const displayedMembers = members ?? fallbackMembers; - const selectedMemberUserIdSet = useMemo( - () => new Set(selectedMemberUserIds), - [selectedMemberUserIds], - ); - const mutedUserIdSet = useMemo( - () => new Set(mutes.map((mute) => mute.target.id)), - [mutes], - ); - - useEffect(() => { - if (!isRemoveMode) { - setSelectedMemberUserIds([]); - setIsRemoving(false); - } - }, [isRemoveMode]); - - useEffect(() => { - if (wasManagingMembersRef.current && !removeMembers) { - resetMembersSearch(); - setSelectedMemberUserIds([]); - setIsRemoving(false); - } - - wasManagingMembersRef.current = removeMembers; - }, [removeMembers, resetMembersSearch]); - - useEffect(() => { - membersPaginator.next(); - }, [membersPaginator]); - - useEffect( - () => () => { - searchMembers.cancel(); - membersPaginator.cancelScheduledQuery(); - }, - [membersPaginator, searchMembers], - ); - - const handleSearchChange = useCallback( - (event: React.ChangeEvent) => { - const { value } = event.target; - setSearchInput(value); - searchMembers(value); - }, - [searchMembers], - ); - - const toggleSelectedMember = useCallback((memberUserId: string) => { - setSelectedMemberUserIds((currentSelectedMemberUserIds) => - currentSelectedMemberUserIds.includes(memberUserId) - ? currentSelectedMemberUserIds.filter((id) => id !== memberUserId) - : [...currentSelectedMemberUserIds, memberUserId], - ); - }, []); - - const handleRemove = async () => { - if (!isRemoveMode || !selectedMemberUserIds.length || isRemoving) return; - - setIsRemoving(true); - const memberCount = selectedMemberUserIds.length; - - try { - await channel.removeMembers(selectedMemberUserIds); - setSelectedMemberUserIds([]); - resetMembersSearch(); - onMembersRemoved?.(memberCount); - } finally { - setIsRemoving(false); - } - }; - - const emptyStateText = isLoading ? t('Searching...') : t('No user found'); - - return ( - <> - - } - onChange={handleSearchChange} - placeholder={t('Search')} - type='search' - value={searchInput} - /> - - {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); - const avatar = ( - - ); - - if (isRemoveMode) { - const selected = selectedMemberUserIdSet.has(memberUserId); - - return ( - avatar} - 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={() => } - /> - ); - } - - return ( - avatar} - 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} -
    - )} - /> - ); - }) - ) : ( -
    - - {emptyStateText} -
    - )} -
    -
    - {isRemoveMode && selectedMemberUserIds.length > 0 && ( - - - - {t('Remove {{ count }} members', { count: selectedMemberUserIds.length })} - - - - )} - - ); -}; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewListFooter.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewListFooter.tsx new file mode 100644 index 0000000000..ffff19f5e3 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewListFooter.tsx @@ -0,0 +1,29 @@ +import type { SearchSource, SearchSourceState } from 'stream-chat'; +import { useStateStore } from '../../../../store'; +import { LoadingIndicator } from '../../../Loading'; + +const searchSourceFooterStateSelector = (state: SearchSourceState) => ({ + hasNextPage: state.hasNext, + isLoading: state.isLoading, +}); + +export type ChannelMembersViewListFooterProps = { + searchSource: SearchSource; +}; + +export const ChannelMembersViewListFooter = ({ + searchSource, +}: ChannelMembersViewListFooterProps) => { + const { hasNextPage, isLoading } = useStateStore( + searchSource.state, + searchSourceFooterStateSelector, + ); + + if (!hasNextPage) return null; + + return ( +
    + {isLoading && } +
    + ); +}; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewSearchInput.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewSearchInput.tsx new file mode 100644 index 0000000000..a7f1e27489 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewSearchInput.tsx @@ -0,0 +1,46 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { useTranslationContext } from '../../../../context'; +import { TextInput } from '../../../Form'; +import { IconSearch } from '../../../Icons'; + +export type ChannelMembersViewSearchInputProps = { + autoFocus?: boolean; + onSearchChange: (query: string) => void; + resetKey?: number; +}; + +export const ChannelMembersViewSearchInput = React.memo( + ({ autoFocus, onSearchChange, resetKey }: ChannelMembersViewSearchInputProps) => { + const { t } = useTranslationContext(); + const [searchInput, setSearchInput] = useState(''); + + useEffect(() => { + setSearchInput(''); + }, [resetKey]); + + const handleSearchChange = useCallback( + (event: React.ChangeEvent) => { + const { value } = event.target; + setSearchInput(value); + onSearchChange(value); + }, + [onSearchChange], + ); + + return ( + } + onChange={handleSearchChange} + placeholder={t('Search')} + type='search' + value={searchInput} + /> + ); + }, +); + +ChannelMembersViewSearchInput.displayName = 'ChannelMembersViewSearchInput'; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx new file mode 100644 index 0000000000..80cbe15b25 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx @@ -0,0 +1,158 @@ +import { fireEvent, screen } from '@testing-library/react'; +import React from 'react'; +import type { ChannelMemberResponse } from 'stream-chat'; + +import { useChatContext, useTranslationContext } from '../../../../../context'; +import { useStateStore } from '../../../../../store'; +import { ChannelMembersBrowseView } from '../ChannelMembersBrowseView'; +import { renderWithChannel } from './testUtils'; + +const mocks = vi.hoisted(() => ({ + infiniteScrollPaginatorRenderCount: 0, + searchSourceActivate: vi.fn(), + searchSourceCancelScheduledQuery: vi.fn(), + searchSourceOptions: [] as unknown[], + searchSourceResetState: vi.fn(), + searchSourceSearch: vi.fn(), +})); + +vi.mock('stream-chat', async (importOriginal) => { + const actual = await importOriginal(); + + class ChannelMemberSearchSource { + state = {}; + + constructor(_channel: unknown, options: unknown) { + mocks.searchSourceOptions.push(options); + } + + activate = mocks.searchSourceActivate; + + search = mocks.searchSourceSearch; + + resetState = mocks.searchSourceResetState; + + cancelScheduledQuery = mocks.searchSourceCancelScheduledQuery; + } + + return { + ...actual, + ChannelMemberSearchSource, + }; +}); + +vi.mock('../../../../../context'); +vi.mock('../../../../../store'); + +vi.mock('../../../../InfiniteScrollPaginator/InfiniteScrollPaginator', () => ({ + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => { + mocks.infiniteScrollPaginatorRenderCount += 1; + return
    {children}
    ; + }, +})); + +vi.mock('../../../../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; }) => ( -
    +
    @@ -26,20 +43,42 @@ vi.mock('../ChannelMembersViewSearch', () => ({ ), })); -vi.mock('../ChannelMembersViewList', () => ({ - ChannelMembersViewList: ({ +vi.mock('../ChannelMembersBrowseView', () => ({ + ChannelMembersBrowseView: ({ + onMemberSelect, + }: { + onMemberSelect?: (member: { + user: { id: string; name: string }; + user_id: string; + }) => void; + }) => ( +
    + Mock browse members + +
    + ), +})); + +vi.mock('../ChannelMembersRemoveView', () => ({ + ChannelMembersRemoveView: ({ onMembersRemoved, - removeMembers, }: { onMembersRemoved?: (count: number) => void; - removeMembers?: boolean; }) => ( -
    - {removeMembers && ( - - )} +
    +
    ), })); @@ -181,7 +220,7 @@ describe('ChannelMembersView', () => { expect( screen.queryByRole('button', { name: 'Remove channel members' }), ).not.toBeInTheDocument(); - expect(screen.getByTestId('channel-members-view-list')).toBeInTheDocument(); + expect(screen.getByTestId('channel-members-browse-view')).toBeInTheDocument(); }); it('hides Add button without update-channel-members capability', () => { @@ -190,7 +229,7 @@ describe('ChannelMembersView', () => { expect( screen.queryByRole('button', { name: 'Add channel members' }), ).not.toBeInTheDocument(); - expect(screen.getByTestId('channel-members-view-list')).toBeInTheDocument(); + expect(screen.getByTestId('channel-members-browse-view')).toBeInTheDocument(); }); it('switches to add-member search mode from the header action', () => { @@ -198,8 +237,8 @@ describe('ChannelMembersView', () => { fireEvent.click(screen.getByRole('button', { name: 'Add channel members' })); - expect(screen.getByTestId('channel-members-view-search')).toBeInTheDocument(); - expect(screen.queryByTestId('channel-members-view-list')).not.toBeInTheDocument(); + 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(); }); @@ -241,12 +280,30 @@ describe('ChannelMembersView', () => { fireEvent.click(screen.getByRole('button', { name: 'Add channel members' })); fireEvent.click(screen.getByRole('button', { name: 'Mock add members' })); - expect(screen.getByTestId('channel-members-view-list')).toBeInTheDocument(); + 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-view-list')).toHaveAttribute( - 'data-manage-members', - 'true', - ); + 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( @@ -282,10 +336,7 @@ describe('ChannelMembersView', () => { fireEvent.click(screen.getByRole('button', { name: 'Remove channel members' })); fireEvent.click(screen.getByRole('button', { name: 'Go back' })); - expect(screen.getByTestId('channel-members-view-list')).toHaveAttribute( - 'data-manage-members', - 'false', - ); + expect(screen.getByTestId('channel-members-browse-view')).toBeInTheDocument(); expect( screen.getByRole('heading', { name: '{{ count }} members:2' }), ).toBeInTheDocument(); @@ -308,10 +359,7 @@ describe('ChannelMembersView', () => { fireEvent.click(screen.getByRole('button', { name: 'Remove channel members' })); fireEvent.click(screen.getByRole('button', { name: 'Mock remove members' })); - expect(screen.getByTestId('channel-members-view-list')).toHaveAttribute( - 'data-manage-members', - 'true', - ); + 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( diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts b/src/components/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts new file mode 100644 index 0000000000..18f4adbcb1 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts @@ -0,0 +1,76 @@ +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], + ); + const membersSearchSource = useMemo(() => { + const source = new ChannelMemberSearchSource(channel, { + allowEmptySearchString: true, + debounceMs: MEMBERS_SEARCH_DEBOUNCE_MS, + pageSize: CHANNEL_MEMBERS_QUERY_LIMIT, + resetOnNewSearchQuery: false, + }); + + source.activate(); + return source; + }, [channel]); + 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(() => { + void membersSearchSource.search(''); + }, [membersSearchSource]); + + useEffect( + () => () => { + membersSearchSource.cancelScheduledQuery(); + }, + [membersSearchSource], + ); + + return { + displayedMembers: members ?? fallbackMembers, + handleSearchChange, + membersSearchSource, + resetMembersSearch, + searchInputResetKey, + }; +}; diff --git a/src/components/ChannelDetail/styling/ChannelMembersView.scss b/src/components/ChannelDetail/styling/ChannelMembersView.scss index b0773f3eae..0999a6442c 100644 --- a/src/components/ChannelDetail/styling/ChannelMembersView.scss +++ b/src/components/ChannelDetail/styling/ChannelMembersView.scss @@ -82,6 +82,11 @@ 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 { diff --git a/src/components/ChannelDetail/styling/ChannelMembersViewListFooter.scss b/src/components/ChannelDetail/styling/ChannelMembersViewListFooter.scss new file mode 100644 index 0000000000..a4984db213 --- /dev/null +++ b/src/components/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/components/ChannelDetail/styling/index.scss b/src/components/ChannelDetail/styling/index.scss index 3017eef7b3..1a3c4de31e 100644 --- a/src/components/ChannelDetail/styling/index.scss +++ b/src/components/ChannelDetail/styling/index.scss @@ -2,3 +2,4 @@ @use 'ChannelMemberDetailView'; @use 'ChannelManagementView'; @use 'ChannelMembersView'; +@use 'ChannelMembersViewListFooter'; From 4f40ee2a5bade81eff909180018b2f03d93aee4b Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 10 Jun 2026 14:09:00 +0200 Subject: [PATCH 14/21] feat(ChannelDetail): add PinnedMessageView --- .../ChatLayout/ConfiguredChannelDetail.tsx | 27 +- .../ChannelDetail/ChannelDetail.tsx | 40 ++- .../ChannelDetail/ChannelDetailEmptyList.tsx | 9 + ... => ChannelDetailListLoadingIndicator.tsx} | 8 +- ...Input.tsx => ChannelDetailSearchInput.tsx} | 16 +- ...ewSearch.tsx => ChannelMembersAddView.tsx} | 47 ++-- .../ChannelMembersBrowseView.tsx | 12 +- .../ChannelMembersRemoveView.tsx | 12 +- .../ChannelMembersViewEmptyList.tsx | 12 - ...est.tsx => ChannelMembersAddView.test.tsx} | 40 ++- ....tsx => ChannelMembersRemoveView.test.tsx} | 75 ++---- .../PinnedMessagesEmptyList.tsx | 20 ++ .../PinnedMessagesView/PinnedMessagesView.tsx | 142 ++++++++++ .../__tests__/PinnedMessagesView.test.tsx | 254 ++++++++++++++++++ .../Views/PinnedMessagesView/index.ts | 1 + .../usePinnedMessagesSearch.ts | 101 +++++++ src/components/ChannelDetail/index.ts | 1 + .../ChannelDetail/styling/ChannelDetail.scss | 10 + .../styling/ChannelMembersView.scss | 10 - .../styling/PinnedMessagesView.scss | 94 +++++++ .../ChannelDetail/styling/index.scss | 1 + src/i18n/de.json | 7 + src/i18n/en.json | 7 + src/i18n/es.json | 7 + src/i18n/fr.json | 7 + src/i18n/hi.json | 7 + src/i18n/it.json | 7 + src/i18n/ja.json | 7 + src/i18n/ko.json | 7 + src/i18n/nl.json | 7 + src/i18n/pt.json | 7 + src/i18n/ru.json | 7 + src/i18n/tr.json | 7 + 33 files changed, 860 insertions(+), 156 deletions(-) create mode 100644 src/components/ChannelDetail/ChannelDetailEmptyList.tsx rename src/components/ChannelDetail/{Views/ChannelMembersView/ChannelMembersViewListFooter.tsx => ChannelDetailListLoadingIndicator.tsx} (75%) rename src/components/ChannelDetail/{Views/ChannelMembersView/ChannelMembersViewSearchInput.tsx => ChannelDetailSearchInput.tsx} (62%) rename src/components/ChannelDetail/Views/ChannelMembersView/{ChannelMembersViewSearch.tsx => ChannelMembersAddView.tsx} (83%) delete mode 100644 src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewEmptyList.tsx rename src/components/ChannelDetail/Views/ChannelMembersView/__tests__/{ChannelMembersViewSearch.test.tsx => ChannelMembersAddView.test.tsx} (81%) rename src/components/ChannelDetail/Views/ChannelMembersView/__tests__/{ChannelMembersViewList.test.tsx => ChannelMembersRemoveView.test.tsx} (66%) create mode 100644 src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx create mode 100644 src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx create mode 100644 src/components/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx create mode 100644 src/components/ChannelDetail/Views/PinnedMessagesView/index.ts create mode 100644 src/components/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts create mode 100644 src/components/ChannelDetail/styling/PinnedMessagesView.scss diff --git a/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx b/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx index fb75e84d7b..66052a9f81 100644 --- a/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx +++ b/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx @@ -4,11 +4,10 @@ import { type AvatarWithChannelDetailProps, ChannelDetail, type ChannelDetailProps, - ChannelManagementNavButton, - ChannelManagementView, - ChannelMembersNavButton, ChannelMembersView, + defaultChannelDetailSections, type SectionNavigatorSection, + type SectionNavigatorSectionContentProps, } from 'stream-chat-react'; import { useAppSettingsSelector } from '../AppSettings/state'; @@ -22,18 +21,16 @@ const ConfiguredChannelDetail = (props: ChannelDetailProps) => { ); const sections = useMemo( () => [ - { - id: 'channel-info', - NavButton: ChannelManagementNavButton, - SectionContent: ChannelManagementView, - }, - { - id: 'channel-members', - NavButton: ChannelMembersNavButton, - SectionContent: (sectionProps) => ( - - ), - }, + ...defaultChannelDetailSections.map((section) => + section.id !== 'channel-members' + ? section + : { + ...section, + SectionContent: (sectionProps: SectionNavigatorSectionContentProps) => ( + + ), + }, + ), ], [headerActionSet], ); diff --git a/src/components/ChannelDetail/ChannelDetail.tsx b/src/components/ChannelDetail/ChannelDetail.tsx index 8dcabcb9a5..3730d27ffb 100644 --- a/src/components/ChannelDetail/ChannelDetail.tsx +++ b/src/components/ChannelDetail/ChannelDetail.tsx @@ -11,8 +11,9 @@ import { import { ChannelDetailProvider } from './ChannelDetailContext'; import { ChannelManagementView } from './Views/ChannelManagementView'; import { ChannelMembersView } from './Views/ChannelMembersView'; +import { PinnedMessagesView } from './Views/PinnedMessagesView'; import { Prompt } from '../Dialog'; -import { IconInfo, IconUser } from '../Icons'; +import { IconInfo, IconPin, IconUser } from '../Icons'; import { ListItemLayout } from '../ListItemLayout'; const ChannelDetailNavButtonClassName = 'str-chat__channel-detail__nav-button'; @@ -25,6 +26,10 @@ const ChannelMembersNavButtonIcon = () => ( ); +const PinnedMessagesNavButtonIcon = () => ( + +); + export const ChannelManagementNavButton = ({ select, selected, @@ -73,7 +78,31 @@ export const ChannelMembersNavButton = ({ ); }; -const defaultSections: SectionNavigatorSection[] = [ +export const PinnedMessagesNavButton = ({ + select, + selected, +}: SectionNavigatorNavButtonProps) => { + const rootProps = useMemo( + () => ({ + 'aria-current': selected ? ('page' as const) : undefined, + className: ChannelDetailNavButtonClassName, + onClick: select, + }), + [select, selected], + ); + + return ( + + ); +}; + +export const defaultChannelDetailSections: SectionNavigatorSection[] = [ { id: 'channel-info', NavButton: ChannelManagementNavButton, @@ -84,6 +113,11 @@ const defaultSections: SectionNavigatorSection[] = [ NavButton: ChannelMembersNavButton, SectionContent: ChannelMembersView, }, + { + id: 'pinned-messages', + NavButton: PinnedMessagesNavButton, + SectionContent: PinnedMessagesView, + }, ]; export type ChannelDetailProps = Omit & { @@ -94,7 +128,7 @@ export type ChannelDetailProps = Omit & { export const ChannelDetail = ({ channel, className, - sections = defaultSections, + sections = defaultChannelDetailSections, ...props }: ChannelDetailProps) => ( diff --git a/src/components/ChannelDetail/ChannelDetailEmptyList.tsx b/src/components/ChannelDetail/ChannelDetailEmptyList.tsx new file mode 100644 index 0000000000..e1c4e10861 --- /dev/null +++ b/src/components/ChannelDetail/ChannelDetailEmptyList.tsx @@ -0,0 +1,9 @@ +import { IconSearch } from '../Icons'; +import type { PropsWithChildrenOnly } from '../../types/types'; + +export const ChannelDetailEmptyList = ({ children }: PropsWithChildrenOnly) => ( +
    + +
    {children}
    +
    +); diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewListFooter.tsx b/src/components/ChannelDetail/ChannelDetailListLoadingIndicator.tsx similarity index 75% rename from src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewListFooter.tsx rename to src/components/ChannelDetail/ChannelDetailListLoadingIndicator.tsx index ffff19f5e3..5db56bfc36 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewListFooter.tsx +++ b/src/components/ChannelDetail/ChannelDetailListLoadingIndicator.tsx @@ -1,6 +1,6 @@ import type { SearchSource, SearchSourceState } from 'stream-chat'; -import { useStateStore } from '../../../../store'; -import { LoadingIndicator } from '../../../Loading'; +import { useStateStore } from '../../store'; +import { LoadingIndicator } from '../Loading'; const searchSourceFooterStateSelector = (state: SearchSourceState) => ({ hasNextPage: state.hasNext, @@ -11,7 +11,7 @@ export type ChannelMembersViewListFooterProps = { searchSource: SearchSource; }; -export const ChannelMembersViewListFooter = ({ +export const ChannelDetailListLoadingIndicator = ({ searchSource, }: ChannelMembersViewListFooterProps) => { const { hasNextPage, isLoading } = useStateStore( @@ -19,7 +19,7 @@ export const ChannelMembersViewListFooter = ({ searchSourceFooterStateSelector, ); - if (!hasNextPage) return null; + if (!hasNextPage || !isLoading) return null; return (
    diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewSearchInput.tsx b/src/components/ChannelDetail/ChannelDetailSearchInput.tsx similarity index 62% rename from src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewSearchInput.tsx rename to src/components/ChannelDetail/ChannelDetailSearchInput.tsx index a7f1e27489..d514a87402 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewSearchInput.tsx +++ b/src/components/ChannelDetail/ChannelDetailSearchInput.tsx @@ -1,17 +1,17 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useTranslationContext } from '../../../../context'; -import { TextInput } from '../../../Form'; -import { IconSearch } from '../../../Icons'; +import { useTranslationContext } from '../../context'; +import { TextInput } from '../Form'; +import { IconSearch } from '../Icons'; -export type ChannelMembersViewSearchInputProps = { +export type ChannelDetailSearchInputProps = { autoFocus?: boolean; onSearchChange: (query: string) => void; resetKey?: number; }; -export const ChannelMembersViewSearchInput = React.memo( - ({ autoFocus, onSearchChange, resetKey }: ChannelMembersViewSearchInputProps) => { +export const ChannelDetailSearchInput = React.memo( + ({ autoFocus, onSearchChange, resetKey }: ChannelDetailSearchInputProps) => { const { t } = useTranslationContext(); const [searchInput, setSearchInput] = useState(''); @@ -32,7 +32,7 @@ export const ChannelMembersViewSearchInput = React.memo( } onChange={handleSearchChange} placeholder={t('Search')} @@ -43,4 +43,4 @@ export const ChannelMembersViewSearchInput = React.memo( }, ); -ChannelMembersViewSearchInput.displayName = 'ChannelMembersViewSearchInput'; +ChannelDetailSearchInput.displayName = 'ChannelDetailSearchInput'; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewSearch.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx similarity index 83% rename from src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewSearch.tsx rename to src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx index 4820f4bc46..039752fede 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewSearch.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx @@ -4,8 +4,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useChatContext, useTranslationContext } from '../../../../context'; import { useStateStore } from '../../../../store'; import { Avatar } from '../../../Avatar'; -import { Checkbox, TextInput } from '../../../Form'; -import { IconMute, IconSearch } from '../../../Icons'; +import { Checkbox } from '../../../Form'; +import { IconMute } from '../../../Icons'; import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; import { ListItemLayout } from '../../../ListItemLayout'; import { Prompt } from '../../../Dialog'; @@ -16,23 +16,25 @@ import { getUserDisplayName, } from './ChannelMembersView.utils'; import { useNotificationApi } from '../../../Notifications'; +import { ChannelDetailSearchInput } from '../../ChannelDetailSearchInput'; +import { ChannelDetailEmptyList } from '../../ChannelDetailEmptyList'; +import { ChannelDetailListLoadingIndicator } from '../../ChannelDetailListLoadingIndicator'; -export type ChannelMembersViewSearchProps = { +export type ChannelMembersAddViewProps = { onMembersAdded: (memberCount: number) => void; searchSource?: UserSearchSource; }; const USER_SEARCH_PAGE_SIZE = 30; -const searchSourceStateSelector = (state: SearchSourceState) => ({ - isLoading: state.isLoading, +const searchSourceItemsStateSelector = (state: SearchSourceState) => ({ users: state.items, }); -export const ChannelMembersViewSearch = ({ +export const ChannelMembersAddView = ({ onMembersAdded, searchSource, -}: ChannelMembersViewSearchProps) => { +}: ChannelMembersAddViewProps) => { const { client, mutes } = useChatContext(); const { t } = useTranslationContext(); const { channel } = useChannelDetailContext(); @@ -48,15 +50,16 @@ export const ChannelMembersViewSearch = ({ new UserSearchSource(client, { allowEmptySearchString: true, pageSize: USER_SEARCH_PAGE_SIZE, + resetOnNewSearchQuery: false, }); source.activate(); return source; }, [client, searchSource]); - const { isLoading, users: searchUsers } = useStateStore( + const { users: searchUsers } = useStateStore( userSearchSource.state, - searchSourceStateSelector, + searchSourceItemsStateSelector, ); const users = useMemo( @@ -65,7 +68,6 @@ export const ChannelMembersViewSearch = ({ ); const [isSaving, setIsSaving] = useState(false); - const [searchInput, setSearchInput] = useState(''); const [selectedUserIds, setSelectedUserIds] = useState([]); useEffect(() => () => userSearchSource.cancelScheduledQuery(), [userSearchSource]); @@ -90,10 +92,8 @@ export const ChannelMembersViewSearch = ({ ); const handleSearchChange = useCallback( - (event: React.ChangeEvent) => { - const { value } = event.target; - setSearchInput(value); - userSearchSource.search(value); + (query: string) => { + userSearchSource.search(query); }, [userSearchSource], ); @@ -135,21 +135,10 @@ export const ChannelMembersViewSearch = ({ } }; - const emptyStateText = isLoading ? t('Searching...') : t('No user found'); - return ( <> - } - onChange={handleSearchChange} - placeholder={t('Search')} - type='search' - value={searchInput} - /> + - - {emptyStateText} -
    + {t('No user found')} )} + {canManageChannelMembers && selectedUserIds.length > 0 && ( diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx index 7229841821..63339f133c 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx @@ -12,9 +12,9 @@ import { getMemberUserId, getUserDisplayName, } from './ChannelMembersView.utils'; -import { ChannelMembersViewEmptyList } from './ChannelMembersViewEmptyList'; -import { ChannelMembersViewListFooter } from './ChannelMembersViewListFooter'; -import { ChannelMembersViewSearchInput } from './ChannelMembersViewSearchInput'; +import { ChannelDetailEmptyList } from '../../ChannelDetailEmptyList'; +import { ChannelDetailListLoadingIndicator } from '../../ChannelDetailListLoadingIndicator'; +import { ChannelDetailSearchInput } from '../../ChannelDetailSearchInput'; import { useChannelMembersSearch } from './useChannelMembersSearch'; const getMemberRoleTranslation = ( @@ -68,7 +68,7 @@ export const ChannelMembersBrowseView = ({ return ( - @@ -123,9 +123,9 @@ export const ChannelMembersBrowseView = ({ ); }) ) : ( - + {t('No member found')} )} - + ); diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx index 549bffb75b..1f82734c45 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx @@ -15,9 +15,9 @@ import { getUserDisplayName, } from './ChannelMembersView.utils'; import { ChannelMembersBrowseView } from './ChannelMembersBrowseView'; -import { ChannelMembersViewEmptyList } from './ChannelMembersViewEmptyList'; -import { ChannelMembersViewListFooter } from './ChannelMembersViewListFooter'; -import { ChannelMembersViewSearchInput } from './ChannelMembersViewSearchInput'; +import { ChannelDetailEmptyList } from '../../ChannelDetailEmptyList'; +import { ChannelDetailListLoadingIndicator } from '../../ChannelDetailListLoadingIndicator'; +import { ChannelDetailSearchInput } from '../../ChannelDetailSearchInput'; import { useChannelMembersSearch } from './useChannelMembersSearch'; const getPresenceStatusText = ( @@ -87,7 +87,7 @@ const ChannelMembersRemoveList = ({ return ( <> - @@ -129,9 +129,9 @@ const ChannelMembersRemoveList = ({ ); }) ) : ( - + {t('No member found')} )} - + {selectedMemberUserIds.length > 0 && ( diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewEmptyList.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewEmptyList.tsx deleted file mode 100644 index 90078a9713..0000000000 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersViewEmptyList.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { IconSearch } from '../../../Icons'; -import { useTranslationContext } from '../../../../context'; - -export const ChannelMembersViewEmptyList = () => { - const { t } = useTranslationContext(); - return ( -
    - - {t('No user found')} -
    - ); -}; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewSearch.test.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx similarity index 81% rename from src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewSearch.test.tsx rename to src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx index 72f200a6dd..b66c77e06d 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewSearch.test.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx @@ -4,7 +4,7 @@ import type { UserResponse } from 'stream-chat'; import { useChatContext, useTranslationContext } from '../../../../../context'; import { useStateStore } from '../../../../../store'; -import { ChannelMembersViewSearch } from '../ChannelMembersViewSearch'; +import { ChannelMembersAddView } from '../ChannelMembersAddView'; import { createChannel, createUserSearchSource, @@ -13,6 +13,10 @@ import { renderWithChannel, } from './testUtils'; +const mocks = vi.hoisted(() => ({ + infiniteScrollPaginatorRenderCount: 0, +})); + vi.mock('../../../../../context'); vi.mock('../../../../../store'); @@ -23,9 +27,10 @@ vi.mock('../../../../Notifications', () => ({ })); vi.mock('../../../../InfiniteScrollPaginator/InfiniteScrollPaginator', () => ({ - InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => ( -
    {children}
    - ), + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => { + mocks.infiniteScrollPaginatorRenderCount += 1; + return
    {children}
    ; + }, })); vi.mock('../../../../Dialog', () => ({ @@ -46,11 +51,12 @@ const searchUsers: UserResponse[] = [ { id: 'user-3', name: 'Carol' }, ]; -describe('ChannelMembersViewSearch', () => { +describe('ChannelMembersAddView', () => { const onMembersAdded = vi.fn(); beforeEach(() => { vi.clearAllMocks(); + mocks.infiniteScrollPaginatorRenderCount = 0; vi.mocked(useTranslationContext).mockReturnValue({ t: (key: string, options?: { count?: number }) => @@ -72,7 +78,7 @@ describe('ChannelMembersViewSearch', () => { const { searchSource } = createUserSearchSource(); renderWithChannel( - , @@ -92,7 +98,7 @@ describe('ChannelMembersViewSearch', () => { const { searchSource } = createUserSearchSource(); renderWithChannel( - , @@ -119,7 +125,7 @@ describe('ChannelMembersViewSearch', () => { const { searchSource } = createUserSearchSource(); renderWithChannel( - , @@ -140,7 +146,7 @@ describe('ChannelMembersViewSearch', () => { const { search, searchSource } = createUserSearchSource(); renderWithChannel( - , @@ -150,4 +156,20 @@ describe('ChannelMembersViewSearch', () => { expect(search).toHaveBeenCalledWith('car'); }); + + it('does not re-render user results while typing before source state changes', () => { + const { searchSource } = createUserSearchSource(); + + renderWithChannel( + , + ); + + const renderCount = mocks.infiniteScrollPaginatorRenderCount; + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'car' } }); + + expect(mocks.infiniteScrollPaginatorRenderCount).toBe(renderCount); + }); }); diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx similarity index 66% rename from src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx rename to src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx index 340e87231b..fe1724b584 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersViewList.test.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx @@ -4,45 +4,37 @@ import type { ChannelMemberResponse } from 'stream-chat'; import { useChatContext, useTranslationContext } from '../../../../../context'; import { useStateStore } from '../../../../../store'; -import { ChannelMembersViewList } from '../ChannelMembersViewList'; +import { ChannelMembersRemoveView } from '../ChannelMembersRemoveView'; import { createChannel, getSelectableMemberButton, renderWithChannel } from './testUtils'; const mocks = vi.hoisted(() => ({ - paginatorCancelScheduledQuery: vi.fn(), - paginatorNext: vi.fn(), + searchSourceActivate: vi.fn(), + searchSourceCancelScheduledQuery: vi.fn(), + searchSourceResetState: vi.fn(), + searchSourceSearch: vi.fn(), })); vi.mock('stream-chat', async (importOriginal) => { const actual = await importOriginal(); - class ChannelMembersPaginator { - filters: unknown; + class ChannelMemberSearchSource { state = {}; - next = mocks.paginatorNext; + activate = mocks.searchSourceActivate; - cancelScheduledQuery = mocks.paginatorCancelScheduledQuery; + search = mocks.searchSourceSearch; + + resetState = mocks.searchSourceResetState; + + cancelScheduledQuery = mocks.searchSourceCancelScheduledQuery; } return { ...actual, - ChannelMembersPaginator, + ChannelMemberSearchSource, }; }); -vi.mock('lodash.debounce', () => ({ - default: (fn: (...args: unknown[]) => unknown) => { - const debounced = Object.assign( - vi.fn((...args: unknown[]) => fn(...args)), - { - cancel: () => undefined, - }, - ); - vi.spyOn(debounced, 'cancel').mockImplementation(); - return debounced; - }, -})); - vi.mock('../../../../../context'); vi.mock('../../../../../store'); @@ -73,7 +65,6 @@ const members: ChannelMemberResponse[] = [ user_id: 'user-1', }, { - channel_role: 'admin', created_at: '2026-01-01T00:00:00.000000000Z', updated_at: '2026-01-01T00:00:00.000000000Z', user: { id: 'user-2', name: 'Bob' }, @@ -81,7 +72,7 @@ const members: ChannelMemberResponse[] = [ }, ]; -describe('ChannelMembersViewList', () => { +describe('ChannelMembersRemoveView', () => { const onMembersRemoved = vi.fn(); beforeEach(() => { @@ -104,23 +95,11 @@ describe('ChannelMembersViewList', () => { }); }); - it('renders browse-only rows when removeMembers is disabled', () => { - renderWithChannel(); - - expect(screen.getByText('Alice')).toBeInTheDocument(); - expect(screen.getByText('Bob')).toBeInTheDocument(); - expect(screen.getByText('Admin')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Alice' })).not.toBeInTheDocument(); - expect( - document.querySelector('.str-chat__channel-detail__channel-members-view__checkbox'), - ).not.toBeInTheDocument(); - }); - - it('shows selectable rows and remove footer when removeMembers and permission are granted', async () => { + it('shows selectable rows and remove footer when permission is granted', async () => { const channel = createChannel({ ownCapabilities: ['update-channel-members'] }); renderWithChannel( - , + , channel, ); @@ -142,11 +121,11 @@ describe('ChannelMembersViewList', () => { const channel = createChannel({ ownCapabilities: ['update-channel-members'] }); renderWithChannel( - , + , channel, ); - mocks.paginatorNext.mockClear(); + mocks.searchSourceSearch.mockClear(); const searchInput = screen.getByRole('searchbox', { name: 'Search' }); fireEvent.change(searchInput, { target: { value: 'ali' } }); @@ -159,13 +138,14 @@ describe('ChannelMembersViewList', () => { await waitFor(() => { expect(channel.removeMembers).toHaveBeenCalledWith(['user-1']); expect(searchInput).toHaveValue(''); - expect(mocks.paginatorNext).toHaveBeenCalled(); + expect(mocks.searchSourceResetState).toHaveBeenCalled(); + expect(mocks.searchSourceSearch).toHaveBeenCalledWith(''); }); }); - it('does not show selection UI when removeMembers is enabled without permission', () => { + it('falls back to browse rows without permission', () => { renderWithChannel( - , + , createChannel({ ownCapabilities: [] }), ); @@ -178,15 +158,4 @@ describe('ChannelMembersViewList', () => { screen.queryByRole('button', { name: /Remove {{ count }} members/ }), ).not.toBeInTheDocument(); }); - - it('falls back to channel state members when paginator has no items', () => { - vi.mocked(useStateStore).mockReturnValue({ - isLoading: false, - members: null, - }); - - renderWithChannel(); - - expect(screen.getByText('Alice')).toBeInTheDocument(); - }); }); diff --git a/src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx b/src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx new file mode 100644 index 0000000000..b7be8cdc43 --- /dev/null +++ b/src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx @@ -0,0 +1,20 @@ +import { IconPin } from '../../../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/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx b/src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx new file mode 100644 index 0000000000..dfc1150054 --- /dev/null +++ b/src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx @@ -0,0 +1,142 @@ +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 '../../../Avatar'; +import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; +import { ListItemLayout } from '../../../ListItemLayout'; +import { Prompt } from '../../../Dialog'; +import type { SectionNavigatorSectionContentProps } from '../../../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/ChannelPreviewTimestamp', + }), + [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, + hasSearchResultsLoaded, + pinnedMessagesSearchSource, + } = usePinnedMessagesSearch(); + + return ( +
    + + + + + {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/components/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx b/src/components/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx new file mode 100644 index 0000000000..47cd86a6c2 --- /dev/null +++ b/src/components/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx @@ -0,0 +1,254 @@ +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('../../../../InfiniteScrollPaginator/InfiniteScrollPaginator', () => ({ + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => { + mocks.infiniteScrollPaginatorRenderCount += 1; + return
    {children}
    ; + }, +})); + +vi.mock('../../../../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/ChannelPreviewTimestamp') { + 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(); + }); +}); diff --git a/src/components/ChannelDetail/Views/PinnedMessagesView/index.ts b/src/components/ChannelDetail/Views/PinnedMessagesView/index.ts new file mode 100644 index 0000000000..af7960fe68 --- /dev/null +++ b/src/components/ChannelDetail/Views/PinnedMessagesView/index.ts @@ -0,0 +1 @@ +export * from './PinnedMessagesView'; diff --git a/src/components/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts b/src/components/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts new file mode 100644 index 0000000000..a36f4c7f7a --- /dev/null +++ b/src/components/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts @@ -0,0 +1,101 @@ +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], + ); + 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 }; + source.activate(); + + return source; + }, [channel.cid, client]); + 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, + hasSearchResultsLoaded: Array.isArray(messages), + pinnedMessagesSearchSource, + }; +}; diff --git a/src/components/ChannelDetail/index.ts b/src/components/ChannelDetail/index.ts index c5d067f848..dbf9df45ce 100644 --- a/src/components/ChannelDetail/index.ts +++ b/src/components/ChannelDetail/index.ts @@ -3,3 +3,4 @@ export * from './ChannelDetailContext'; export * from './Views/ChannelManagementView/ChannelManagementView'; export * from './Views/ChannelManagementView/ChannelManagementActions.defaults'; export * from './Views/ChannelMembersView'; +export * from './Views/PinnedMessagesView'; diff --git a/src/components/ChannelDetail/styling/ChannelDetail.scss b/src/components/ChannelDetail/styling/ChannelDetail.scss index b8a7531793..0c73ed2556 100644 --- a/src/components/ChannelDetail/styling/ChannelDetail.scss +++ b/src/components/ChannelDetail/styling/ChannelDetail.scss @@ -31,3 +31,13 @@ gap: var(--str-chat__spacing-md); padding: var(--str-chat__spacing-xl); } + +.str-chat__channel-detail__search-input { + flex-shrink: 0; + width: 100%; + margin-bottom: var(--str-chat__spacing-md); + + .str-chat__form-text-input__wrapper--outline { + border-radius: var(--str-chat__radius-max); + } +} diff --git a/src/components/ChannelDetail/styling/ChannelMembersView.scss b/src/components/ChannelDetail/styling/ChannelMembersView.scss index 0999a6442c..166f9c4938 100644 --- a/src/components/ChannelDetail/styling/ChannelMembersView.scss +++ b/src/components/ChannelDetail/styling/ChannelMembersView.scss @@ -20,16 +20,6 @@ min-height: 0; } -.str-chat__channel-detail__channel-members-view__search-input { - flex-shrink: 0; - width: 100%; - margin-bottom: var(--str-chat__spacing-md); - - .str-chat__form-text-input__wrapper--outline { - border-radius: var(--str-chat__radius-max); - } -} - .str-chat__channel-detail__channel-members-view__list { @include utils.hide-scrollbar; display: flex; diff --git a/src/components/ChannelDetail/styling/PinnedMessagesView.scss b/src/components/ChannelDetail/styling/PinnedMessagesView.scss new file mode 100644 index 0000000000..1175db832b --- /dev/null +++ b/src/components/ChannelDetail/styling/PinnedMessagesView.scss @@ -0,0 +1,94 @@ +@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: var(--str-chat__spacing-xxs); + } +} + +.str-chat__list-item-layout.str-chat__channel-detail__pinned-messages-view__list-item { + width: 100%; + text-align: start; +} + +.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/components/ChannelDetail/styling/index.scss b/src/components/ChannelDetail/styling/index.scss index 1a3c4de31e..8a0651e954 100644 --- a/src/components/ChannelDetail/styling/index.scss +++ b/src/components/ChannelDetail/styling/index.scss @@ -3,3 +3,4 @@ @use 'ChannelManagementView'; @use 'ChannelMembersView'; @use 'ChannelMembersViewListFooter'; +@use 'PinnedMessagesView'; diff --git a/src/i18n/de.json b/src/i18n/de.json index 6de5b78bc2..0d71dfb878 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -171,6 +171,7 @@ "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", @@ -403,6 +404,9 @@ "No chats here yetโ€ฆ": "Noch keine Chats hier...", "No conversations yet": "Noch keine Unterhaltungen", "No items exist": "Keine Elemente vorhanden", + "No member found": "Kein Mitglied gefunden", + "No messages found": "Keine Nachrichten gefunden", + "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.", @@ -425,8 +429,11 @@ "People matching": "Passende Personen", "Photo": "Foto", "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", diff --git a/src/i18n/en.json b/src/i18n/en.json index 118eac3f36..99c8a8b921 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -171,6 +171,7 @@ "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", @@ -403,6 +404,9 @@ "No chats here yetโ€ฆ": "No chats here yetโ€ฆ", "No conversations yet": "No conversations yet", "No items exist": "No items exist", + "No member found": "No member found", + "No messages found": "No messages found", + "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.", @@ -425,8 +429,11 @@ "People matching": "People matching", "Photo": "Photo", "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", diff --git a/src/i18n/es.json b/src/i18n/es.json index b1eaa94e50..5995e8bffd 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -182,6 +182,7 @@ "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", @@ -417,6 +418,9 @@ "No chats here yetโ€ฆ": "Aรบn no hay mensajes aquรญ...", "No conversations yet": "Aรบn no hay conversaciones", "No items exist": "No existen elementos", + "No member found": "No se encontrรณ ningรบn miembro", + "No messages found": "No se encontraron mensajes", + "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.", @@ -439,8 +443,11 @@ "People matching": "Personas que coinciden", "Photo": "Foto", "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", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 4c7fe859e9..10c2f7f1d1 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -182,6 +182,7 @@ "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", @@ -417,6 +418,9 @@ "No chats here yetโ€ฆ": "Pas encore de messages ici...", "No conversations yet": "Aucune conversation pour le moment", "No items exist": "Aucun รฉlรฉment", + "No member found": "Aucun membre trouvรฉ", + "No messages found": "Aucun message trouvรฉ", + "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.", @@ -439,8 +443,11 @@ "People matching": "Correspondance de personnes", "Photo": "Photo", "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", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 988e1e9e66..8d81013c91 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -171,6 +171,7 @@ "Block user": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค•เฅ‹ เคฌเฅเคฒเฅ‰เค• เค•เคฐเฅ‡เค‚", "Block User": "เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เค•เฅ‹ เคฌเฅเคฒเฅ‰เค• เค•เคฐเฅ‡เค‚", "Browse channel members": "เคšเฅˆเคจเคฒ เคธเคฆเคธเฅเคฏ เคฆเฅ‡เค–เฅ‡เค‚", + "Browse pinned messages": "เคชเคฟเคจ เค•เคฟเค เค—เค เคธเค‚เคฆเฅ‡เคถ เคฆเฅ‡เค–เฅ‡เค‚", "Cancel": "เคฐเคฆเฅเคฆ เค•เคฐเฅ‡เค‚", "Cannot seek in the recording": "เคฐเฅ‡เค•เฅ‰เคฐเฅเคกเคฟเค‚เค— เคฎเฅ‡เค‚ เค–เฅ‹เคœ เคจเคนเฅ€เค‚ เค•เฅ€ เคœเคพ เคธเค•เคคเฅ€", "Changes saved": "เคฌเคฆเคฒเคพเคต เคธเคนเฅ‡เคœเฅ‡ เค—เค", @@ -404,6 +405,9 @@ "No chats here yetโ€ฆ": "เคฏเคนเคพเค‚ เค…เคญเฅ€ เคคเค• เค•เฅ‹เคˆ เคšเฅˆเคŸ เคจเคนเฅ€เค‚...", "No conversations yet": "เค…เคญเฅ€ เคคเค• เค•เฅ‹เคˆ เคฌเคพเคคเคšเฅ€เคค เคจเคนเฅ€เค‚ เคนเฅˆ", "No items exist": "เค•เฅ‹เคˆ เค†เค‡เคŸเคฎ เคฎเฅŒเคœเฅ‚เคฆ เคจเคนเฅ€เค‚ เคนเฅˆ", + "No member found": "เค•เฅ‹เคˆ เคธเคฆเคธเฅเคฏ เคจเคนเฅ€เค‚ เคฎเคฟเคฒเคพ", + "No messages found": "เค•เฅ‹เคˆ เคธเค‚เคฆเฅ‡เคถ เคจเคนเฅ€เค‚ เคฎเคฟเคฒเคพ", + "No pinned messages": "เค•เฅ‹เคˆ เคชเคฟเคจ เค•เคฟเคฏเคพ เค—เคฏเคพ เคธเค‚เคฆเฅ‡เคถ เคจเคนเฅ€เค‚", "No results found": "เค•เฅ‹เคˆ เคชเคฐเคฟเคฃเคพเคฎ เคจเคนเฅ€เค‚ เคฎเคฟเคฒเคพ", "No user found": "เค•เฅ‹เคˆ เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เคจเคนเฅ€เค‚ เคฎเคฟเคฒเคพ", "Nobody will be able to vote in this poll anymore.": "เค…เคฌ เค•เฅ‹เคˆ เคญเฅ€ เค‡เคธ เคฎเคคเคฆเคพเคจ เคฎเฅ‡เค‚ เคฎเคคเคฆเคพเคจ เคจเคนเฅ€เค‚ เค•เคฐ เคธเค•เฅ‡เค—เคพเฅค", @@ -426,8 +430,11 @@ "People matching": "เคฎเฅ‡เคฒ เค–เคพเคคเฅ‡ เคฒเฅ‹เค—", "Photo": "เคซเคผเฅ‹เคŸเฅ‹", "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": "เคตเฅ€เคกเคฟเคฏเฅ‹ เคšเคฒเคพเคเค‚", diff --git a/src/i18n/it.json b/src/i18n/it.json index ede8fc91fd..d2923e11fb 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -182,6 +182,7 @@ "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", @@ -417,6 +418,9 @@ "No chats here yetโ€ฆ": "Non ci sono ancora messaggi qui...", "No conversations yet": "Ancora nessuna conversazione", "No items exist": "Nessun elemento presente", + "No member found": "Nessun membro trovato", + "No messages found": "Nessun messaggio trovato", + "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.", @@ -439,8 +443,11 @@ "People matching": "Persone che corrispondono", "Photo": "Foto", "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", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 7e792caf2c..2ae789b814 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -167,6 +167,7 @@ "Block user": "ใƒฆใƒผใ‚ถใƒผใ‚’ใƒ–ใƒญใƒƒใ‚ฏ", "Block User": "ใƒฆใƒผใ‚ถใƒผใ‚’ใƒ–ใƒญใƒƒใ‚ฏ", "Browse channel members": "ใƒใƒฃใƒณใƒใƒซใƒกใƒณใƒใƒผใ‚’่กจ็คบ", + "Browse pinned messages": "ใƒ”ใƒณ็•™ใ‚ใƒกใƒƒใ‚ปใƒผใ‚ธใ‚’่กจ็คบ", "Cancel": "ใ‚ญใƒฃใƒณใ‚ปใƒซ", "Cannot seek in the recording": "้Œฒ้Ÿณไธญใซใ‚ทใƒผใ‚ฏใงใใพใ›ใ‚“", "Changes saved": "ๅค‰ๆ›ดใ‚’ไฟๅญ˜ใ—ใพใ—ใŸ", @@ -396,6 +397,9 @@ "No chats here yetโ€ฆ": "ใ“ใ“ใซใฏใพใ ใƒใƒฃใƒƒใƒˆใฏใ‚ใ‚Šใพใ›ใ‚“โ€ฆ", "No conversations yet": "ใพใ ไผš่ฉฑใฏใ‚ใ‚Šใพใ›ใ‚“", "No items exist": "้ …็›ฎใŒใ‚ใ‚Šใพใ›ใ‚“", + "No member found": "ใƒกใƒณใƒใƒผใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“", + "No messages found": "ใƒกใƒƒใ‚ปใƒผใ‚ธใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“", + "No pinned messages": "ใƒ”ใƒณ็•™ใ‚ใƒกใƒƒใ‚ปใƒผใ‚ธใฏใ‚ใ‚Šใพใ›ใ‚“", "No results found": "็ตๆžœใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“", "No user found": "ใƒฆใƒผใ‚ถใƒผใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“", "Nobody will be able to vote in this poll anymore.": "ใ“ใฎๆŠ•็ฅจใงใฏใ€่ชฐใ‚‚ๆŠ•็ฅจใงใใชใใชใ‚Šใพใ™ใ€‚", @@ -418,8 +422,11 @@ "People matching": "ไธ€่‡ดใ™ใ‚‹ไบบ", "Photo": "ๅ†™็œŸ", "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": "ๅ‹•็”ปใ‚’ๅ†็”Ÿ", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index ef30e62e3a..4b9f61051e 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -167,6 +167,7 @@ "Block user": "์‚ฌ์šฉ์ž ์ฐจ๋‹จ", "Block User": "์‚ฌ์šฉ์ž ์ฐจ๋‹จ", "Browse channel members": "์ฑ„๋„ ๋ฉค๋ฒ„ ๋ณด๊ธฐ", + "Browse pinned messages": "๊ณ ์ •๋œ ๋ฉ”์‹œ์ง€ ๋ณด๊ธฐ", "Cancel": "์ทจ์†Œ", "Cannot seek in the recording": "๋…น์Œ์—์„œ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", "Changes saved": "๋ณ€๊ฒฝ ์‚ฌํ•ญ์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", @@ -396,6 +397,9 @@ "No chats here yetโ€ฆ": "์•„์ง ์ฑ„ํŒ…์ด ์—†์Šต๋‹ˆ๋‹ค...", "No conversations yet": "์•„์ง ๋Œ€ํ™”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", "No items exist": "ํ•ญ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค.", + "No member found": "๋ฉค๋ฒ„๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", + "No messages found": "๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", + "No pinned messages": "๊ณ ์ •๋œ ๋ฉ”์‹œ์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค", "No results found": "๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค", "No user found": "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", "Nobody will be able to vote in this poll anymore.": "์ด ํˆฌํ‘œ์— ๋” ์ด์ƒ ์•„๋ฌด๋„ ํˆฌํ‘œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", @@ -418,8 +422,11 @@ "People matching": "์ผ์น˜ํ•˜๋Š” ์‚ฌ๋žŒ", "Photo": "์‚ฌ์ง„", "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": "๋™์˜์ƒ ์žฌ์ƒ", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 04f7d38a5f..a905884079 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -171,6 +171,7 @@ "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", @@ -403,6 +404,9 @@ "No chats here yetโ€ฆ": "Nog geen chats hier...", "No conversations yet": "Nog geen gesprekken", "No items exist": "Er zijn geen items", + "No member found": "Geen lid gevonden", + "No messages found": "Geen berichten gevonden", + "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.", @@ -425,8 +429,11 @@ "People matching": "Mensen die matchen", "Photo": "Foto", "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", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 2f2cb3e647..caf6a64174 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -182,6 +182,7 @@ "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", @@ -417,6 +418,9 @@ "No chats here yetโ€ฆ": "Ainda nรฃo hรก conversas aqui...", "No conversations yet": "Ainda nรฃo hรก conversas", "No items exist": "Nรฃo existem itens", + "No member found": "Nenhum membro encontrado", + "No messages found": "Nenhuma mensagem encontrada", + "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.", @@ -439,8 +443,11 @@ "People matching": "Pessoas correspondentes", "Photo": "Foto", "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", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 70af298398..caf0a67954 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -194,6 +194,7 @@ "Block user": "ะ—ะฐะฑะปะพะบะธั€ะพะฒะฐั‚ัŒ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", "Block User": "ะ—ะฐะฑะปะพะบะธั€ะพะฒะฐั‚ัŒ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั", "Browse channel members": "ะŸั€ะพัะผะพั‚ั€ะตั‚ัŒ ัƒั‡ะฐัั‚ะฝะธะบะพะฒ ะบะฐะฝะฐะปะฐ", + "Browse pinned messages": "ะŸั€ะพัะผะพั‚ั€ะตั‚ัŒ ะทะฐะบั€ะตะฟะปะตะฝะฝั‹ะต ัะพะพะฑั‰ะตะฝะธั", "Cancel": "ะžั‚ะผะตะฝะฐ", "Cannot seek in the recording": "ะะตะฒะพะทะผะพะถะฝะพ ะพััƒั‰ะตัั‚ะฒะธั‚ัŒ ะฟะพะธัะบ ะฒ ะทะฐะฟะธัะธ", "Changes saved": "ะ˜ะทะผะตะฝะตะฝะธั ัะพั…ั€ะฐะฝะตะฝั‹", @@ -435,6 +436,9 @@ "No chats here yetโ€ฆ": "ะ—ะดะตััŒ ะตั‰ะต ะฝะตั‚ ั‡ะฐั‚ะพะฒ...", "No conversations yet": "ะŸะพะบะฐ ะฝะตั‚ ะฑะตัะตะด", "No items exist": "ะญะปะตะผะตะฝั‚ะพะฒ ะฝะตั‚", + "No member found": "ะฃั‡ะฐัั‚ะฝะธะบ ะฝะต ะฝะฐะนะดะตะฝ", + "No messages found": "ะกะพะพะฑั‰ะตะฝะธั ะฝะต ะฝะฐะนะดะตะฝั‹", + "No pinned messages": "ะะตั‚ ะทะฐะบั€ะตะฟะปะตะฝะฝั‹ั… ัะพะพะฑั‰ะตะฝะธะน", "No results found": "ะ ะตะทัƒะปัŒั‚ะฐั‚ั‹ ะฝะต ะฝะฐะนะดะตะฝั‹", "No user found": "ะŸะพะปัŒะทะพะฒะฐั‚ะตะปัŒ ะฝะต ะฝะฐะนะดะตะฝ", "Nobody will be able to vote in this poll anymore.": "ะะธะบั‚ะพ ะฑะพะปัŒัˆะต ะฝะต ัะผะพะถะตั‚ ะณะพะปะพัะพะฒะฐั‚ัŒ ะฒ ัั‚ะพะผ ะพะฟั€ะพัะต.", @@ -457,8 +461,11 @@ "People matching": "ะกะพะฒะฟะฐะดะฐัŽั‰ะธะต ะปัŽะดะธ", "Photo": "ะคะพั‚ะพ", "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": "ะ’ะพัะฟั€ะพะธะทะฒะตัั‚ะธ ะฒะธะดะตะพ", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 2096b48e76..af78bb3117 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -171,6 +171,7 @@ "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", @@ -403,6 +404,9 @@ "No chats here yetโ€ฆ": "Henรผz burada sohbet yok...", "No conversations yet": "Henรผz konuลŸma yok", "No items exist": "Hiรง รถฤŸe yok", + "No member found": "รœye bulunamadฤฑ", + "No messages found": "Mesaj bulunamadฤฑ", + "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.", @@ -425,8 +429,11 @@ "People matching": "EลŸleลŸen kiลŸiler", "Photo": "FotoฤŸraf", "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", From 5f111a3ac8792f6f4e0dbe538d0c8cb9f3f6c36c Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 11 Jun 2026 11:39:52 +0200 Subject: [PATCH 15/21] feat(ChannelDetail): add ChannelMediaView and ChannelFilesView --- .../AudioPlayback/components/index.ts | 1 + .../ChannelDetail/ChannelDetail.tsx | 70 +++++- .../ChannelFilesEmptyList.tsx | 20 ++ .../ChannelFilesView/ChannelFilesView.tsx | 120 ++++++++++ .../ChannelFilesView.utils.ts | 90 ++++++++ .../__tests__/ChannelFilesView.test.tsx | 216 ++++++++++++++++++ .../Views/ChannelFilesView/index.ts | 4 + .../ChannelFilesView/useChannelFilesSearch.ts | 66 ++++++ .../ChannelMediaEmptyList.tsx | 20 ++ .../ChannelMediaView/ChannelMediaView.tsx | 162 +++++++++++++ .../ChannelMediaView.utils.ts | 73 ++++++ .../__tests__/ChannelMediaView.test.tsx | 193 ++++++++++++++++ .../Views/ChannelMediaView/index.ts | 4 + .../ChannelMediaView/useChannelMediaSearch.ts | 66 ++++++ .../ChannelMembersBrowseView.tsx | 13 +- .../useChannelMembersSearch.ts | 12 +- .../PinnedMessagesView/PinnedMessagesView.tsx | 9 +- .../__tests__/PinnedMessagesView.test.tsx | 11 + .../usePinnedMessagesSearch.ts | 8 +- src/components/ChannelDetail/index.ts | 2 + .../styling/ChannelFilesView.scss | 115 ++++++++++ .../styling/ChannelMediaView.scss | 144 ++++++++++++ .../ChannelDetail/styling/index.scss | 2 + src/components/Icons/icons.tsx | 8 + .../styling/ListItemLayout.scss | 1 + src/i18n/de.json | 8 + src/i18n/en.json | 8 + src/i18n/es.json | 8 + src/i18n/fr.json | 8 + src/i18n/hi.json | 8 + src/i18n/it.json | 8 + src/i18n/ja.json | 8 + src/i18n/ko.json | 8 + src/i18n/nl.json | 8 + src/i18n/pt.json | 8 + src/i18n/ru.json | 8 + src/i18n/tr.json | 8 + 37 files changed, 1513 insertions(+), 13 deletions(-) create mode 100644 src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts create mode 100644 src/components/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelFilesView/index.ts create mode 100644 src/components/ChannelDetail/Views/ChannelFilesView/useChannelFilesSearch.ts create mode 100644 src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts create mode 100644 src/components/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx create mode 100644 src/components/ChannelDetail/Views/ChannelMediaView/index.ts create mode 100644 src/components/ChannelDetail/Views/ChannelMediaView/useChannelMediaSearch.ts create mode 100644 src/components/ChannelDetail/styling/ChannelFilesView.scss create mode 100644 src/components/ChannelDetail/styling/ChannelMediaView.scss 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/ChannelDetail/ChannelDetail.tsx b/src/components/ChannelDetail/ChannelDetail.tsx index 3730d27ffb..6152eceabd 100644 --- a/src/components/ChannelDetail/ChannelDetail.tsx +++ b/src/components/ChannelDetail/ChannelDetail.tsx @@ -9,11 +9,13 @@ import { type SectionNavigatorSection, } from '../SectionNavigator'; 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 '../Dialog'; -import { IconInfo, IconPin, IconUser } from '../Icons'; +import { IconFolder, IconImage, IconInfo, IconPin, IconUser } from '../Icons'; import { ListItemLayout } from '../ListItemLayout'; const ChannelDetailNavButtonClassName = 'str-chat__channel-detail__nav-button'; @@ -30,6 +32,14 @@ const PinnedMessagesNavButtonIcon = () => ( ); +const ChannelMediaNavButtonIcon = () => ( + +); + +const ChannelFilesNavButtonIcon = () => ( + +); + export const ChannelManagementNavButton = ({ select, selected, @@ -102,6 +112,54 @@ export const PinnedMessagesNavButton = ({ ); }; +export const ChannelMediaNavButton = ({ + select, + selected, +}: SectionNavigatorNavButtonProps) => { + const rootProps = useMemo( + () => ({ + 'aria-current': selected ? ('page' as const) : undefined, + className: ChannelDetailNavButtonClassName, + onClick: select, + }), + [select, selected], + ); + + return ( + + ); +}; + +export const ChannelFilesNavButton = ({ + select, + selected, +}: SectionNavigatorNavButtonProps) => { + const rootProps = useMemo( + () => ({ + 'aria-current': selected ? ('page' as const) : undefined, + className: ChannelDetailNavButtonClassName, + onClick: select, + }), + [select, selected], + ); + + return ( + + ); +}; + export const defaultChannelDetailSections: SectionNavigatorSection[] = [ { id: 'channel-info', @@ -118,6 +176,16 @@ export const defaultChannelDetailSections: SectionNavigatorSection[] = [ NavButton: PinnedMessagesNavButton, SectionContent: PinnedMessagesView, }, + { + id: 'channel-media', + NavButton: ChannelMediaNavButton, + SectionContent: ChannelMediaView, + }, + { + id: 'channel-files', + NavButton: ChannelFilesNavButton, + SectionContent: ChannelFilesView, + }, ]; export type ChannelDetailProps = Omit & { diff --git a/src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx b/src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx new file mode 100644 index 0000000000..822fac0faf --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx @@ -0,0 +1,20 @@ +import { useTranslationContext } from '../../../../context'; +import { IconFolder } from '../../../Icons'; + +export const ChannelFilesEmptyList = () => { + const { t } = useTranslationContext('ChannelFilesEmptyList'); + + return ( +
    + +
    +

    + {t('No files')} +

    +

    + {t('Share a file to see it here')} +

    +
    +
    + ); +}; diff --git a/src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx b/src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx new file mode 100644 index 0000000000..beadb2360b --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx @@ -0,0 +1,120 @@ +import React from 'react'; + +import { useModalContext, useTranslationContext } from '../../../../context'; +import { getDateString } from '../../../../i18n/utils'; +import { FileSizeIndicator } from '../../../Attachment/components/FileSizeIndicator'; +import { Prompt } from '../../../Dialog'; +import { FileIcon } from '../../../FileIcon'; +import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; +import { ListItemLayout } from '../../../ListItemLayout'; +import type { SectionNavigatorSectionContentProps } from '../../../SectionNavigator'; +import { ChannelDetailListLoadingIndicator } from '../../ChannelDetailListLoadingIndicator'; +import { ChannelFilesEmptyList } from './ChannelFilesEmptyList'; +import type { ChannelFileItem } from './ChannelFilesView.utils'; +import { useChannelFilesSearch } from './useChannelFilesSearch'; + +const ChannelFilesSectionHeader = ({ timestamp }: { timestamp?: string }) => { + const { t, tDateTimeParser } = useTranslationContext('ChannelFilesView'); + const label = getDateString({ + format: 'MMMM YYYY', + messageCreatedAt: timestamp, + t, + tDateTimeParser, + }); + + if (!label) return null; + + return ( +
    {label}
    + ); +}; + +const getAttachmentFileName = (attachment: ChannelFileItem['attachment']) => + attachment.title || attachment.fallback || ''; + +const ChannelFileListItem = ({ item }: { item: ChannelFileItem }) => { + const { attachment } = item; + const fileName = getAttachmentFileName(attachment); + const assetUrl = attachment.asset_url; + + const FileListItemIcon = () => ( + + ); + + const sharedProps = { + LeadingSlot: FileListItemIcon, + subtitle: , + subtitleClassName: 'str-chat__channel-detail__files-view__list-item__size', + title: fileName, + titleClassName: 'str-chat__channel-detail__files-view__list-item__name', + }; + + if (assetUrl) { + return ( + + ); + } + + return ( + + ); +}; + +export type ChannelFilesViewProps = SectionNavigatorSectionContentProps; + +export const ChannelFilesView: React.ComponentType = () => { + const { t } = useTranslationContext(); + const { close } = useModalContext(); + const { channelFilesSearchSource, fileGroups, hasResultsLoaded } = + useChannelFilesSearch(); + + return ( +
    + + + + {fileGroups.length > 0 ? ( + fileGroups.map((group) => ( +
    + +
    + {group.items.map((item) => ( + + ))} +
    +
    + )) + ) : hasResultsLoaded ? ( + + ) : null} + +
    +
    +
    + ); +}; diff --git a/src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts b/src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts new file mode 100644 index 0000000000..6df59635f3 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts @@ -0,0 +1,90 @@ +import { + type Attachment, + isAudioAttachment, + isFileAttachment, + isScrapedContent, + type LocalMessage, + type MessageResponse, +} from 'stream-chat'; + +import { isDate } from '../../../../i18n/utils'; + +/** Attachment types listed by the files view (everything that is not an image/video). */ +export const FILE_ATTACHMENT_TYPES = ['file', 'audio'] as const; + +export type ChannelFileAttachmentType = (typeof FILE_ATTACHMENT_TYPES)[number]; + +export type ChannelFileItem = { + /** Raw attachment to render (no transformation applied). */ + attachment: Attachment; + /** Stable identity (messageId + attachment index). */ + id: string; + /** ISO timestamp of the carrying message, used for the month sections. */ + createdAt?: string; +}; + +export type ChannelFileGroup = { + /** Items belonging to the same month/year. */ + items: ChannelFileItem[]; + /** Stable grouping key (`YYYY-MM` or `unknown`). */ + key: string; + /** Representative timestamp used to render the section header. */ + timestamp?: string; +}; + +const normalizeTimestamp = (timestamp?: string | Date) => { + if (!timestamp) return undefined; + return isDate(timestamp) ? timestamp.toISOString() : timestamp; +}; + +const isChannelFileAttachment = (attachment: Attachment) => + (!isScrapedContent(attachment) && isFileAttachment(attachment)) || + isAudioAttachment(attachment); + +const byCreatedAtDesc = (a: ChannelFileItem, b: ChannelFileItem) => + (b.createdAt ?? '').localeCompare(a.createdAt ?? ''); + +/** + * Flattens messages into file/audio attachment items grouped into descending + * month/year sections (newest first), in a single pass over the attachments. + * + * The raw attachment is kept untransformed; only the carrying message timestamp + * is captured for the month sections. + */ +export const toChannelFileGroups = ( + messages: Array, +): ChannelFileGroup[] => { + const groups: ChannelFileGroup[] = []; + const groupIndexByKey = new Map(); + + messages.forEach((message) => { + const createdAt = normalizeTimestamp(message.created_at); + const key = createdAt ? createdAt.slice(0, 7) : 'unknown'; + + message.attachments?.forEach((attachment, index) => { + if (!isChannelFileAttachment(attachment)) return; + + const item: ChannelFileItem = { + attachment, + createdAt, + id: `${message.id}-${index}`, + }; + const existingIndex = groupIndexByKey.get(key); + + if (existingIndex === undefined) { + groupIndexByKey.set(key, groups.length); + groups.push({ items: [item], key, timestamp: createdAt }); + } else { + groups[existingIndex].items.push(item); + } + }); + }); + + groups.forEach((group) => { + group.items.sort(byCreatedAtDesc); + group.timestamp = group.items[0]?.createdAt; + }); + groups.sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? '')); + + return groups; +}; diff --git a/src/components/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx b/src/components/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx new file mode 100644 index 0000000000..2e2403b0ec --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx @@ -0,0 +1,216 @@ +import Dayjs from 'dayjs'; +import { 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, + useModalContext, + useTranslationContext, +} from '../../../../../context'; +import { useStateStore } from '../../../../../store'; +import { ChannelDetailProvider } from '../../../ChannelDetailContext'; +import { ChannelFilesView } from '../ChannelFilesView'; + +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('../../../../InfiniteScrollPaginator/InfiniteScrollPaginator', () => ({ + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), +})); + +vi.mock('../../../../Dialog', () => ({ + Prompt: { + Body: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + Header: ({ title }: { title?: React.ReactNode }) => ( +
    +

    {title}

    +
    + ), + }, +})); + +const messages: MessageResponse[] = [ + { + attachments: [ + { + asset_url: 'https://cdn.test/financial-report-Q1-2026.pdf', + file_size: 4 * 1024 * 1024, + mime_type: 'application/pdf', + title: 'financial-report-Q1-2026.pdf', + type: 'file', + }, + { + image_url: 'https://cdn.test/screenshot.png', + title: 'screenshot', + type: 'image', + }, + { + og_scrape_url: 'https://getstream.io', + title: 'scraped-link-preview', + title_link: 'https://getstream.io', + type: 'file', + }, + ], + cid: 'messaging:test-channel', + created_at: '2026-03-10T15:53:00.000Z', + id: 'message-1', + type: 'regular', + updated_at: '2026-03-10T15:53:00.000Z', + user: { id: 'user-1', name: 'Alice' }, + }, + { + attachments: [ + { + asset_url: 'https://cdn.test/customer-feedback.wav', + file_size: 7 * 1024 * 1024, + mime_type: 'audio/wav', + title: 'customer-feedback.wav', + type: 'audio', + }, + ], + cid: 'messaging:test-channel', + created_at: '2026-02-05T15:53:00.000Z', + id: 'message-2', + type: 'regular', + updated_at: '2026-02-05T15:53:00.000Z', + user: { id: 'user-2', name: 'Bob' }, + }, + { + attachments: [ + { + asset_url: 'https://cdn.test/sales-report-may.xlsx', + file_size: 6 * 1024 * 1024, + mime_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + title: 'sales-report-may.xlsx', + type: 'file', + }, + ], + cid: 'messaging:test-channel', + created_at: '2026-02-01T15:53:00.000Z', + id: 'message-3', + type: 'regular', + updated_at: '2026-02-01T15:53:00.000Z', + user: { id: 'user-1', name: 'Alice' }, + }, +]; + +const channel = fromPartial({ cid: 'messaging:test-channel' }); + +const renderView = () => + render( + + + , + ); + +describe('ChannelFilesView', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.searchSourceInstances.length = 0; + mocks.searchSourceOptions.length = 0; + + vi.mocked(useTranslationContext).mockReturnValue({ + t: (key: string) => key, + tDateTimeParser: (input?: string | number | Date) => Dayjs(input), + } as unknown as ReturnType); + + vi.mocked(useChatContext).mockReturnValue({ + client: { userID: 'user-1' }, + } as ReturnType); + + vi.mocked(useModalContext).mockReturnValue({ + close: vi.fn(), + } as ReturnType); + + vi.mocked(useStateStore).mockReturnValue({ + isLoading: false, + messages, + }); + }); + + it('configures MessageSearchSource to paginate file and audio 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: ['file', 'audio'] } }, + }); + expect(mocks.searchSourceActivate).toHaveBeenCalled(); + }); + + it('renders a list item per file/audio attachment, ignoring image/video attachments', () => { + renderView(); + + expect(screen.getByRole('heading', { name: 'Files' })).toBeInTheDocument(); + + expect(screen.getAllByRole('link')).toHaveLength(3); + expect(screen.getByText('financial-report-Q1-2026.pdf')).toBeInTheDocument(); + expect(screen.getByText('customer-feedback.wav')).toBeInTheDocument(); + expect(screen.getByText('sales-report-may.xlsx')).toBeInTheDocument(); + expect(screen.queryByText('screenshot')).not.toBeInTheDocument(); + expect(screen.queryByText('scraped-link-preview')).not.toBeInTheDocument(); + }); + + it('groups attachments into descending month sections', () => { + renderView(); + + expect(screen.getByText('March 2026')).toBeInTheDocument(); + expect(screen.getByText('February 2026')).toBeInTheDocument(); + }); + + it('renders an empty state once results load with no files', () => { + vi.mocked(useStateStore).mockReturnValue({ + isLoading: false, + messages: [], + }); + + renderView(); + + expect(screen.getByText('No files')).toBeInTheDocument(); + expect(screen.getByText('Share a file to see it here')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ChannelDetail/Views/ChannelFilesView/index.ts b/src/components/ChannelDetail/Views/ChannelFilesView/index.ts new file mode 100644 index 0000000000..31fb7646b6 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelFilesView/index.ts @@ -0,0 +1,4 @@ +export * from './ChannelFilesEmptyList'; +export * from './ChannelFilesView'; +export * from './ChannelFilesView.utils'; +export * from './useChannelFilesSearch'; diff --git a/src/components/ChannelDetail/Views/ChannelFilesView/useChannelFilesSearch.ts b/src/components/ChannelDetail/Views/ChannelFilesView/useChannelFilesSearch.ts new file mode 100644 index 0000000000..12192847d9 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelFilesView/useChannelFilesSearch.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 { FILE_ATTACHMENT_TYPES, toChannelFileGroups } from './ChannelFilesView.utils'; + +const CHANNEL_FILES_SEARCH_PAGE_SIZE = 30; + +const channelFilesSearchSourceStateSelector = ( + state: SearchSourceState, +) => ({ + isLoading: state.isLoading, + messages: state.items, +}); + +export const useChannelFilesSearch = () => { + const { client } = useChatContext(); + const { channel } = useChannelDetailContext(); + + const channelFilesSearchSource = useMemo(() => { + const source = new MessageSearchSource(client, { + allowEmptySearchString: true, + pageSize: CHANNEL_FILES_SEARCH_PAGE_SIZE, + resetOnNewSearchQuery: false, + }); + + source.messageSearchChannelFilters = { cid: channel.cid }; + source.messageSearchFilters = { + 'attachments.type': { $in: [...FILE_ATTACHMENT_TYPES] }, + }; + source.activate(); + + return source; + }, [channel.cid, client]); + + const { isLoading, messages } = useStateStore( + channelFilesSearchSource.state, + channelFilesSearchSourceStateSelector, + ); + + const fileGroups = useMemo( + () => toChannelFileGroups((messages ?? []) as Array), + [messages], + ); + + useEffect( + () => () => { + channelFilesSearchSource.cancelScheduledQuery(); + }, + [channelFilesSearchSource], + ); + + return { + channelFilesSearchSource, + fileGroups, + hasResultsLoaded: Array.isArray(messages), + isLoading, + }; +}; diff --git a/src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx b/src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx new file mode 100644 index 0000000000..ef4403184f --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx @@ -0,0 +1,20 @@ +import { useTranslationContext } from '../../../../context'; +import { IconImage } from '../../../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/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx b/src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx new file mode 100644 index 0000000000..1cfb4728b3 --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx @@ -0,0 +1,162 @@ +import clsx from 'clsx'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { + useComponentContext, + useModalContext, + useTranslationContext, +} from '../../../../context'; +import { formatTime } from '../../../AudioPlayback'; +import { Avatar } from '../../../Avatar'; +import { Badge } from '../../../Badge'; +import { type BaseImageProps, BaseImage as DefaultBaseImage } from '../../../BaseImage'; +import { Prompt } from '../../../Dialog'; +import { Gallery as DefaultGallery, GalleryUI } from '../../../Gallery'; +import { IconImage, IconVideoFill } from '../../../Icons'; +import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; +import { GlobalModal } from '../../../Modal'; +import type { SectionNavigatorSectionContentProps } from '../../../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/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts b/src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts new file mode 100644 index 0000000000..784a966197 --- /dev/null +++ b/src/components/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 '../../../BaseImage'; +import type { GalleryItem } from '../../../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/components/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx b/src/components/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx new file mode 100644 index 0000000000..9b5700d9ce --- /dev/null +++ b/src/components/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx @@ -0,0 +1,193 @@ +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('../../../../InfiniteScrollPaginator/InfiniteScrollPaginator', () => ({ + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), +})); + +vi.mock('../../../../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/components/ChannelDetail/Views/ChannelMediaView/index.ts b/src/components/ChannelDetail/Views/ChannelMediaView/index.ts new file mode 100644 index 0000000000..232152b8b9 --- /dev/null +++ b/src/components/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/components/ChannelDetail/Views/ChannelMediaView/useChannelMediaSearch.ts b/src/components/ChannelDetail/Views/ChannelMediaView/useChannelMediaSearch.ts new file mode 100644 index 0000000000..fa23309348 --- /dev/null +++ b/src/components/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/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx index 63339f133c..277d344e7c 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx +++ b/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx @@ -58,6 +58,7 @@ export const ChannelMembersBrowseView = ({ const { displayedMembers, handleSearchChange, + hasMembers, membersSearchSource, searchInputResetKey, } = useChannelMembersSearch(); @@ -68,13 +69,15 @@ export const ChannelMembersBrowseView = ({ return ( - + {hasMembers && ( + + )} {displayedMembers.length > 0 ? ( displayedMembers.map((member) => { diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts b/src/components/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts index 18f4adbcb1..5215f6fb59 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts +++ b/src/components/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts @@ -23,6 +23,10 @@ export const useChannelMembersSearch = () => { () => 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, @@ -31,9 +35,9 @@ export const useChannelMembersSearch = () => { resetOnNewSearchQuery: false, }); - source.activate(); + if (hasMembers) source.activate(); return source; - }, [channel]); + }, [channel, hasMembers]); const { members } = useStateStore( membersSearchSource.state, membersSearchSourceItemsStateSelector, @@ -56,8 +60,9 @@ export const useChannelMembersSearch = () => { ); useEffect(() => { + if (!hasMembers) return; void membersSearchSource.search(''); - }, [membersSearchSource]); + }, [hasMembers, membersSearchSource]); useEffect( () => () => { @@ -69,6 +74,7 @@ export const useChannelMembersSearch = () => { return { displayedMembers: members ?? fallbackMembers, handleSearchChange, + hasMembers, membersSearchSource, resetMembersSearch, searchInputResetKey, diff --git a/src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx b/src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx index dfc1150054..11d25ced09 100644 --- a/src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx +++ b/src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx @@ -81,6 +81,7 @@ export const PinnedMessagesView: React.ComponentType = const { displayedMessages, handleSearchChange, + hasPinnedMessages, hasSearchResultsLoaded, pinnedMessagesSearchSource, } = usePinnedMessagesSearch(); @@ -93,10 +94,14 @@ export const PinnedMessagesView: React.ComponentType = title={t('Pinned messages')} /> - + {hasPinnedMessages && ( + + )} {displayedMessages.length > 0 ? ( displayedMessages.map((message) => { diff --git a/src/components/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx b/src/components/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx index 47cd86a6c2..865b47e484 100644 --- a/src/components/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx +++ b/src/components/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx @@ -251,4 +251,15 @@ describe('PinnedMessagesView', () => { 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/components/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts b/src/components/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts index a36f4c7f7a..7d0b873af9 100644 --- a/src/components/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts +++ b/src/components/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts @@ -30,6 +30,9 @@ export const usePinnedMessagesSearch = () => { ) ?? [], [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, @@ -58,10 +61,10 @@ export const usePinnedMessagesSearch = () => { source.messageSearchChannelFilters = { cid: channel.cid }; source.messageSearchFilters = { pinned: true }; - source.activate(); + if (hasPinnedMessages) source.activate(); return source; - }, [channel.cid, client]); + }, [channel.cid, client, hasPinnedMessages]); const { messages } = useStateStore( pinnedMessagesSearchSource.state, pinnedMessagesSearchSourceItemsStateSelector, @@ -95,6 +98,7 @@ export const usePinnedMessagesSearch = () => { MessageResponse | LocalMessage >, handleSearchChange, + hasPinnedMessages, hasSearchResultsLoaded: Array.isArray(messages), pinnedMessagesSearchSource, }; diff --git a/src/components/ChannelDetail/index.ts b/src/components/ChannelDetail/index.ts index dbf9df45ce..1227c42022 100644 --- a/src/components/ChannelDetail/index.ts +++ b/src/components/ChannelDetail/index.ts @@ -2,5 +2,7 @@ export * from './ChannelDetail'; export * from './ChannelDetailContext'; 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/components/ChannelDetail/styling/ChannelFilesView.scss b/src/components/ChannelDetail/styling/ChannelFilesView.scss new file mode 100644 index 0000000000..5b3830d501 --- /dev/null +++ b/src/components/ChannelDetail/styling/ChannelFilesView.scss @@ -0,0 +1,115 @@ +@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; + /* Let the month separators span edge-to-edge; the file groups are padded instead. */ + padding-inline: 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__list-item-layout.str-chat__channel-detail__files-view__list-item { + width: 100%; + text-align: start; + 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/components/ChannelDetail/styling/ChannelMediaView.scss b/src/components/ChannelDetail/styling/ChannelMediaView.scss new file mode 100644 index 0000000000..993c127ac6 --- /dev/null +++ b/src/components/ChannelDetail/styling/ChannelMediaView.scss @@ -0,0 +1,144 @@ +@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-block: var(--str-chat__spacing-xxs); + } +} + +.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/components/ChannelDetail/styling/index.scss b/src/components/ChannelDetail/styling/index.scss index 8a0651e954..c3146e2bc4 100644 --- a/src/components/ChannelDetail/styling/index.scss +++ b/src/components/ChannelDetail/styling/index.scss @@ -1,6 +1,8 @@ @use 'ChannelDetail'; +@use 'ChannelFilesView'; @use 'ChannelMemberDetailView'; @use 'ChannelManagementView'; +@use 'ChannelMediaView'; @use 'ChannelMembersView'; @use 'ChannelMembersViewListFooter'; @use 'PinnedMessagesView'; diff --git a/src/components/Icons/icons.tsx b/src/components/Icons/icons.tsx index cd38d690bf..86766ff791 100644 --- a/src/components/Icons/icons.tsx +++ b/src/components/Icons/icons.tsx @@ -611,6 +611,14 @@ export const IconFlag = createIcon( />, ); +export const IconFolder = createIcon( + 'IconFolder', + , +); + export const IconImage = createIcon( 'IconImage', Date: Fri, 12 Jun 2026 10:12:58 +0200 Subject: [PATCH 16/21] feat(ChannelDetail): fix bugs, refactor --- .../vite/src/AppSettings/AppSettings.scss | 30 +-- examples/vite/src/AppSettings/AppSettings.tsx | 11 +- .../tabs/MessageActions/MessageActionsTab.tsx | 15 +- .../tabs/SettingsTabLayoutComponents.tsx | 6 +- src/components/Avatar/ChannelAvatar.tsx | 4 +- .../Avatar/__tests__/GroupAvatar.test.tsx | 15 ++ .../ChannelDetail/ChannelDetail.tsx | 191 ++++++------------ .../ChannelDetail/ChannelDetailNavButton.tsx | 46 +++++ .../ChannelDetailSearchInput.tsx | 1 + .../ChannelFilesView/ChannelFilesView.tsx | 7 +- .../ChannelFilesView.utils.ts | 9 +- .../ChannelManagementView.tsx | 37 ++-- .../ChannelMediaView/ChannelMediaView.tsx | 7 +- .../ChannelMemberDetail.tsx | 13 +- .../ChannelMembersAddView.tsx | 9 +- .../ChannelMembersView/ChannelMembersView.tsx | 8 +- .../PinnedMessagesView/PinnedMessagesView.tsx | 9 +- .../__tests__/PinnedMessagesView.test.tsx | 2 +- .../__tests__/ChannelManagementView.test.tsx | 4 +- src/components/ChannelDetail/index.ts | 1 + .../ChannelDetail/styling/ChannelDetail.scss | 39 +++- .../styling/ChannelFilesView.scss | 8 +- .../styling/ChannelManagementView.scss | 55 +++-- .../styling/ChannelMediaView.scss | 13 ++ .../styling/ChannelMembersView.scss | 12 +- .../styling/PinnedMessagesView.scss | 11 +- .../styling/AvatarWithChannelDetail.scss | 3 - .../ChannelHeader/styling/index.scss | 1 - .../hooks/useChannelPreviewInfo.ts | 9 +- src/components/Dialog/components/Prompt.tsx | 7 +- src/components/Dialog/styling/Prompt.scss | 23 ++- src/components/FileIcon/FileIcon.tsx | 8 +- src/components/FileIcon/iconMap.ts | 5 +- src/components/Icons/icons.tsx | 12 ++ .../styling/ListItemLayout.scss | 2 +- .../SectionNavigator/SectionNavigator.tsx | 122 ++++++++--- .../SectionNavigatorHeader.tsx | 44 ++++ .../__tests__/SectionNavigator.test.tsx | 153 ++++++++++++-- .../__tests__/SectionNavigatorHeader.test.tsx | 71 +++++++ src/components/SectionNavigator/index.ts | 1 + .../styling/SectionNavigator.scss | 98 ++++++++- src/i18n/de.json | 2 + src/i18n/en.json | 2 + src/i18n/es.json | 2 + src/i18n/fr.json | 2 + src/i18n/hi.json | 2 + src/i18n/it.json | 2 + src/i18n/ja.json | 2 + src/i18n/ko.json | 2 + src/i18n/nl.json | 2 + src/i18n/pt.json | 2 + src/i18n/ru.json | 2 + src/i18n/tr.json | 2 + 53 files changed, 869 insertions(+), 277 deletions(-) create mode 100644 src/components/ChannelDetail/ChannelDetailNavButton.tsx delete mode 100644 src/components/ChannelHeader/styling/AvatarWithChannelDetail.scss create mode 100644 src/components/SectionNavigator/SectionNavigatorHeader.tsx create mode 100644 src/components/SectionNavigator/__tests__/SectionNavigatorHeader.test.tsx diff --git a/examples/vite/src/AppSettings/AppSettings.scss b/examples/vite/src/AppSettings/AppSettings.scss index 6039f31b07..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,13 +969,11 @@ 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; + overflow: hidden; } .app__settings-modal__body { - display: grid; - grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); min-height: 0; height: 100%; } @@ -1047,7 +1046,6 @@ display: flex; flex-direction: column; gap: var(--str-chat__spacing-xl); - padding-inline: var(--str-chat__spacing-xl); } .app__settings-modal__field { @@ -1153,14 +1151,6 @@ 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__body .str-chat__section-navigator__navigation { border-bottom: 0; display: block; @@ -1170,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 a801fe51f9..e619b90613 100644 --- a/examples/vite/src/AppSettings/AppSettings.tsx +++ b/examples/vite/src/AppSettings/AppSettings.tsx @@ -27,6 +27,8 @@ import { IconSun, IconTextDirection, } from '../icons.tsx'; +import { SECTION_NAVIGATOR_LAYOUT, SectionNavigatorLayout } from '../../../../src'; +import clsx from 'clsx'; type TabId = | 'channelDetail' @@ -175,6 +177,7 @@ export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => { () => createSettingsSections(closeSettingsModal), [closeSettingsModal], ); + const [layout, setLayout] = useState(); return (
    @@ -189,11 +192,15 @@ export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => { text='Settings' /> -
    +
    diff --git a/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx b/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx index 56bdb9f917..8f76d0876e 100644 --- a/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx +++ b/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx @@ -42,9 +42,8 @@ export const MessageActionsTab = ({ close }: MessageActionsTabProps) => { }, }) } - > - Enabled option configuration - + title='Enabled option configuration' + />
    It enables to configure delete request params in the Delete Message Alert like “Delete only for me”,{' '} @@ -68,9 +67,8 @@ export const MessageActionsTab = ({ close }: MessageActionsTabProps) => { }, }) } - > - Mark own messages as unread too - + title='Mark own messages as unread too' + />
    @@ -89,9 +87,8 @@ export const MessageActionsTab = ({ close }: MessageActionsTabProps) => { }, }) } - > - 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/SettingsTabLayoutComponents.tsx b/examples/vite/src/AppSettings/tabs/SettingsTabLayoutComponents.tsx index 8093f50c79..a12ab19521 100644 --- a/examples/vite/src/AppSettings/tabs/SettingsTabLayoutComponents.tsx +++ b/examples/vite/src/AppSettings/tabs/SettingsTabLayoutComponents.tsx @@ -1,4 +1,4 @@ -import { Prompt } from 'stream-chat-react'; +import { Prompt, SectionNavigatorHeader } from 'stream-chat-react'; import { type ComponentProps } from 'react'; import clsx from 'clsx'; @@ -13,7 +13,7 @@ export const SettingsTabLayoutHeader = ({ description, title, }: SettingsTabHeaderProps) => ( - ) => ( -
    + ); 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( ( @@ -40,125 +40,45 @@ const ChannelFilesNavButtonIcon = () => ( ); -export const ChannelManagementNavButton = ({ - select, - selected, -}: SectionNavigatorNavButtonProps) => { - const rootProps = useMemo( - () => ({ - 'aria-current': selected ? ('page' as const) : undefined, - className: ChannelDetailNavButtonClassName, - onClick: select, - }), - [select, selected], - ); - - return ( - - ); -}; - -export const ChannelMembersNavButton = ({ - select, - selected, -}: SectionNavigatorNavButtonProps) => { - const rootProps = useMemo( - () => ({ - 'aria-current': selected ? ('page' as const) : undefined, - className: ChannelDetailNavButtonClassName, - onClick: select, - }), - [select, selected], - ); - - return ( - - ); -}; - -export const PinnedMessagesNavButton = ({ - select, - selected, -}: SectionNavigatorNavButtonProps) => { - const rootProps = useMemo( - () => ({ - 'aria-current': selected ? ('page' as const) : undefined, - className: ChannelDetailNavButtonClassName, - onClick: select, - }), - [select, selected], - ); - - return ( - - ); -}; +export const ChannelManagementNavButton = (props: SectionNavigatorNavButtonProps) => ( + +); -export const ChannelMediaNavButton = ({ - select, - selected, -}: SectionNavigatorNavButtonProps) => { - const rootProps = useMemo( - () => ({ - 'aria-current': selected ? ('page' as const) : undefined, - className: ChannelDetailNavButtonClassName, - onClick: select, - }), - [select, selected], - ); +export const ChannelMembersNavButton = (props: SectionNavigatorNavButtonProps) => ( + +); - return ( - - ); -}; +export const PinnedMessagesNavButton = (props: SectionNavigatorNavButtonProps) => ( + +); -export const ChannelFilesNavButton = ({ - select, - selected, -}: SectionNavigatorNavButtonProps) => { - const rootProps = useMemo( - () => ({ - 'aria-current': selected ? ('page' as const) : undefined, - className: ChannelDetailNavButtonClassName, - onClick: select, - }), - [select, selected], - ); +export const ChannelMediaNavButton = (props: SectionNavigatorNavButtonProps) => ( + +); - return ( - - ); -}; +export const ChannelFilesNavButton = (props: SectionNavigatorNavButtonProps) => ( + +); export const defaultChannelDetailSections: SectionNavigatorSection[] = [ { @@ -196,12 +116,31 @@ export type ChannelDetailProps = Omit & { export const ChannelDetail = ({ channel, className, + defaultLayout = SECTION_NAVIGATOR_LAYOUT.tabs, sections = defaultChannelDetailSections, ...props -}: ChannelDetailProps) => ( - - - - - -); +}: ChannelDetailProps) => { + const [layout, setLayout] = useState(defaultLayout); + + return ( + + + + + + ); +}; diff --git a/src/components/ChannelDetail/ChannelDetailNavButton.tsx b/src/components/ChannelDetail/ChannelDetailNavButton.tsx new file mode 100644 index 0000000000..e11033693e --- /dev/null +++ b/src/components/ChannelDetail/ChannelDetailNavButton.tsx @@ -0,0 +1,46 @@ +import React, { type ComponentType, useMemo } from 'react'; + +import type { SectionNavigatorNavButtonProps } from '../SectionNavigator'; +import { ListItemLayout } from '../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 `
    + )}
    ); 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 index 0930b87161..926491b403 100644 --- a/src/components/SectionNavigator/__tests__/SectionNavigator.test.tsx +++ b/src/components/SectionNavigator/__tests__/SectionNavigator.test.tsx @@ -36,6 +36,29 @@ const createContent = (label: string) => { 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', @@ -49,6 +72,19 @@ const sections: SectionNavigatorSection[] = [ }, ]; +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[] = []; @@ -90,16 +126,22 @@ describe('SectionNavigator', () => { }); it('renders the first section content by default in inline layout', () => { - render(); + const { container } = render( + , + ); - expect(screen.queryByText('Media nav')).not.toBeInTheDocument(); - expect(screen.queryByText('Files nav')).not.toBeInTheDocument(); + 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 { rerender } = render(); + const { container, rerender } = render( + , + ); fireEvent.click(screen.getByText('Back')); @@ -119,18 +161,92 @@ describe('SectionNavigator', () => { fireEvent.click(screen.getByText('Back')); - expect(screen.queryByText('Media nav')).not.toBeInTheDocument(); - expect(screen.queryByText('Files nav')).not.toBeInTheDocument(); + 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'); }); - render( + const { container } = render( { expect(createLayoutObserver).toHaveBeenCalledWith( expect.objectContaining({ tabsLayoutMinWidth: 720 }), ); - expect(screen.queryByText('Media nav')).not.toBeInTheDocument(); + expect(container.querySelector('.str-chat__section-navigator')).toHaveAttribute( + 'data-layout', + 'inline', + ); expect(screen.getByText('Media content inline')).toBeInTheDocument(); }); @@ -156,13 +275,14 @@ describe('SectionNavigator', () => { }); it('ignores zero-width observer entries before applying the resolved layout', () => { - render( + const { container } = render(
    , ); + const root = () => container.querySelector('.str-chat__section-navigator'); - expect(screen.getByText('Media nav')).toBeInTheDocument(); + expect(root()).toHaveAttribute('data-layout', 'tabs'); act(() => { resizeObserverCallback?.( @@ -171,7 +291,7 @@ describe('SectionNavigator', () => { ); }); - expect(screen.getByText('Media nav')).toBeInTheDocument(); + expect(root()).toHaveAttribute('data-layout', 'tabs'); act(() => { resizeObserverCallback?.( @@ -180,7 +300,7 @@ describe('SectionNavigator', () => { ); }); - expect(screen.queryByText('Media nav')).not.toBeInTheDocument(); + expect(root()).toHaveAttribute('data-layout', 'inline'); act(() => { resizeObserverCallback?.( @@ -189,15 +309,16 @@ describe('SectionNavigator', () => { ); }); - expect(screen.getByText('Media nav')).toBeInTheDocument(); + expect(root()).toHaveAttribute('data-layout', 'tabs'); }); it('uses tabsLayoutMinWidth to resolve the default observer layout', () => { - render( + const { container } = render(
    , ); + const root = () => container.querySelector('.str-chat__section-navigator'); act(() => { resizeObserverCallback?.( @@ -206,7 +327,7 @@ describe('SectionNavigator', () => { ); }); - expect(screen.queryByText('Media nav')).not.toBeInTheDocument(); + expect(root()).toHaveAttribute('data-layout', 'inline'); act(() => { resizeObserverCallback?.( @@ -215,6 +336,6 @@ describe('SectionNavigator', () => { ); }); - expect(screen.getByText('Media nav')).toBeInTheDocument(); + 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 index 17cfcc9828..a3c390a6cf 100644 --- a/src/components/SectionNavigator/index.ts +++ b/src/components/SectionNavigator/index.ts @@ -1 +1,2 @@ export * from './SectionNavigator'; +export * from './SectionNavigatorHeader'; diff --git a/src/components/SectionNavigator/styling/SectionNavigator.scss b/src/components/SectionNavigator/styling/SectionNavigator.scss index e49589c03d..b492a3d9f3 100644 --- a/src/components/SectionNavigator/styling/SectionNavigator.scss +++ b/src/components/SectionNavigator/styling/SectionNavigator.scss @@ -1,6 +1,7 @@ @use '../../../styling/utils'; .str-chat__section-navigator { + position: relative; display: flex; flex: 1 1 auto; min-height: 0; @@ -9,6 +10,12 @@ 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; @@ -25,7 +32,11 @@ .str-chat__section-navigator__navigation-item { min-width: 0; width: 100%; - padding: var(--str-chat__spacing-xxs); + 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 { @@ -33,3 +44,88 @@ 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/i18n/de.json b/src/i18n/de.json index a731c2fda4..7415232a0b 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -426,6 +426,7 @@ "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", @@ -543,6 +544,7 @@ "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\" }) }}", diff --git a/src/i18n/en.json b/src/i18n/en.json index ca728bd4d0..b4a20c08c5 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -426,6 +426,7 @@ "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", @@ -543,6 +544,7 @@ "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\" }) }}", diff --git a/src/i18n/es.json b/src/i18n/es.json index 6c4865c163..9d6355bb89 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -440,6 +440,7 @@ "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", @@ -562,6 +563,7 @@ "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\" }) }}", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 04e171474b..ccd1b755cf 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -440,6 +440,7 @@ "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", @@ -562,6 +563,7 @@ "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\" }) }}", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index c72e20affa..2176235a69 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -427,6 +427,7 @@ "Open image in gallery": "เค›เคตเคฟ เค•เฅ‹ เค—เฅˆเคฒเคฐเฅ€ เคฎเฅ‡เค‚ เค–เฅ‹เคฒเฅ‡เค‚", "Open location in a map": "เคฎเคพเคจเคšเคฟเคคเฅเคฐ เคฎเฅ‡เค‚ เคธเฅเคฅเคพเคจ เค–เฅ‹เคฒเฅ‡เค‚", "Open members actions": "Open members actions", + "Open menu": "เคฎเฅ‡เคจเฅเคฏเฅ‚ เค–เฅ‹เคฒเฅ‡เค‚", "Option already exists": "เคตเคฟเค•เคฒเฅเคช เคชเคนเคฒเฅ‡ เคธเฅ‡ เคฎเฅŒเคœเฅ‚เคฆ เคนเฅˆ", "Option is empty": "เคตเคฟเค•เคฒเฅเคช เค–เคพเคฒเฅ€ เคนเฅˆ", "Options": "เคตเคฟเค•เคฒเฅเคช", @@ -544,6 +545,7 @@ "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\" }) }}", diff --git a/src/i18n/it.json b/src/i18n/it.json index 418decb215..faf70b8d70 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -440,6 +440,7 @@ "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", @@ -562,6 +563,7 @@ "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\" }) }}", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 60a37b94e7..d14bcbe4b6 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -419,6 +419,7 @@ "Open image in gallery": "็”ปๅƒใ‚’ใ‚ฎใƒฃใƒฉใƒชใƒผใง้–‹ใ", "Open location in a map": "ๅœฐๅ›ณใงไฝ็ฝฎๆƒ…ๅ ฑใ‚’้–‹ใ", "Open members actions": "Open members actions", + "Open menu": "ใƒกใƒ‹ใƒฅใƒผใ‚’้–‹ใ", "Option already exists": "ใ‚ชใƒ—ใ‚ทใƒงใƒณใฏๆ—ขใซๅญ˜ๅœจใ—ใพใ™", "Option is empty": "ใ‚ชใƒ—ใ‚ทใƒงใƒณใŒ็ฉบใงใ™", "Options": "ใ‚ชใƒ—ใ‚ทใƒงใƒณ", @@ -534,6 +535,7 @@ "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\" }) }}", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 828964c838..01d9ab72b0 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -419,6 +419,7 @@ "Open image in gallery": "๊ฐค๋Ÿฌ๋ฆฌ์—์„œ ์ด๋ฏธ์ง€ ์—ด๊ธฐ", "Open location in a map": "์ง€๋„์—์„œ ์œ„์น˜ ์—ด๊ธฐ", "Open members actions": "Open members actions", + "Open menu": "๋ฉ”๋‰ด ์—ด๊ธฐ", "Option already exists": "์˜ต์…˜์ด ์ด๋ฏธ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค", "Option is empty": "์˜ต์…˜์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค", "Options": "์˜ต์…˜", @@ -534,6 +535,7 @@ "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\" }) }}", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index fecbd175fb..d411d25857 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -426,6 +426,7 @@ "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", @@ -545,6 +546,7 @@ "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\" }) }}", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index ce82ad154a..dbf4204b37 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -440,6 +440,7 @@ "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", @@ -562,6 +563,7 @@ "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\" }) }}", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 6a523da764..1ed7a71e32 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -458,6 +458,7 @@ "Open image in gallery": "ะžั‚ะบั€ั‹ั‚ัŒ ะธะทะพะฑั€ะฐะถะตะฝะธะต ะฒ ะณะฐะปะตั€ะตะต", "Open location in a map": "ะžั‚ะบั€ั‹ั‚ัŒ ะผะตัั‚ะพะฟะพะปะพะถะตะฝะธะต ะฝะฐ ะบะฐั€ั‚ะต", "Open members actions": "Open members actions", + "Open menu": "ะžั‚ะบั€ั‹ั‚ัŒ ะผะตะฝัŽ", "Option already exists": "ะ’ะฐั€ะธะฐะฝั‚ ัƒะถะต ััƒั‰ะตัั‚ะฒัƒะตั‚", "Option is empty": "ะ’ะฐั€ะธะฐะฝั‚ ะฟัƒัั‚", "Options": "ะ’ะฐั€ะธะฐะฝั‚ั‹", @@ -585,6 +586,7 @@ "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\" }) }}", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 41dc6c3681..43b877c5ce 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -426,6 +426,7 @@ "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", @@ -543,6 +544,7 @@ "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\" }) }}", From 34cd0c9b3daaf885105f3608ac554734f17a95ea Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 12 Jun 2026 10:14:34 +0200 Subject: [PATCH 17/21] feat(ChannelDetail): fix bugs, refactor --- examples/vite/src/AppSettings/AppSettings.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/vite/src/AppSettings/AppSettings.tsx b/examples/vite/src/AppSettings/AppSettings.tsx index e619b90613..a8a6f45b21 100644 --- a/examples/vite/src/AppSettings/AppSettings.tsx +++ b/examples/vite/src/AppSettings/AppSettings.tsx @@ -1,4 +1,5 @@ import { type ComponentType, useCallback, useMemo, useState } from 'react'; +import type { SectionNavigatorLayout } from 'stream-chat-react'; import { Button, ChatViewSelectorButton, @@ -7,6 +8,7 @@ import { IconEmoji, IconMessageBubble, IconMessageBubbles, + SECTION_NAVIGATOR_LAYOUT, SectionNavigator, type SectionNavigatorNavButtonProps, type SectionNavigatorSection, @@ -27,7 +29,6 @@ import { IconSun, IconTextDirection, } from '../icons.tsx'; -import { SECTION_NAVIGATOR_LAYOUT, SectionNavigatorLayout } from '../../../../src'; import clsx from 'clsx'; type TabId = From 9a97fd5127c770ebba7a3f2bd713de4509dade77 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 12 Jun 2026 12:35:33 +0200 Subject: [PATCH 18/21] feat(ChannelDetail): wrap ListItemLayout content in a container --- .../styling/ChannelFilesView.scss | 4 +- .../styling/ChannelManagementView.scss | 4 +- .../styling/ChannelMemberDetailView.scss | 19 +++-- .../styling/ChannelMembersView.scss | 10 +-- .../styling/PinnedMessagesView.scss | 7 +- src/components/Dialog/components/Prompt.tsx | 6 +- src/components/Dialog/styling/Prompt.scss | 60 +++++++++++----- .../ListItemLayout/ListItemLayout.tsx | 63 ++++++++-------- .../styling/ListItemLayout.scss | 71 +++++++++++-------- 9 files changed, 145 insertions(+), 99 deletions(-) diff --git a/src/components/ChannelDetail/styling/ChannelFilesView.scss b/src/components/ChannelDetail/styling/ChannelFilesView.scss index a6a6e45fe1..aef2ffd9ef 100644 --- a/src/components/ChannelDetail/styling/ChannelFilesView.scss +++ b/src/components/ChannelDetail/styling/ChannelFilesView.scss @@ -100,9 +100,7 @@ } } -.str-chat__list-item-layout.str-chat__channel-detail__files-view__list-item { - width: 100%; - text-align: start; +.str-chat__channel-detail__files-view__list-item { color: inherit; text-decoration: none; } diff --git a/src/components/ChannelDetail/styling/ChannelManagementView.scss b/src/components/ChannelDetail/styling/ChannelManagementView.scss index f191c4b06e..26f76d1b0b 100644 --- a/src/components/ChannelDetail/styling/ChannelManagementView.scss +++ b/src/components/ChannelDetail/styling/ChannelManagementView.scss @@ -51,8 +51,10 @@ } .str-chat__channel-detail__channel-management-view__actions { + display: flex; + flex-direction: column; padding-block: var(--str-chat__spacing-xs); - padding-inline: var(--str-chat__spacing-xxs); + gap: var(--str-chat__spacing-xxs); .str-chat__form__switch-field .str-chat__form__switch-field__label diff --git a/src/components/ChannelDetail/styling/ChannelMemberDetailView.scss b/src/components/ChannelDetail/styling/ChannelMemberDetailView.scss index 177ab68c67..f8f8b6b221 100644 --- a/src/components/ChannelDetail/styling/ChannelMemberDetailView.scss +++ b/src/components/ChannelDetail/styling/ChannelMemberDetailView.scss @@ -1,7 +1,14 @@ -.str-chat__channel-detail__channel-member-detail-view__body { - display: flex; - flex-direction: column; - gap: var(--str-chat__spacing-2xl); +.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 { @@ -33,11 +40,13 @@ } .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); - padding-inline: var(--str-chat__spacing-xxs); } .str-chat__channel-member-detail-action { diff --git a/src/components/ChannelDetail/styling/ChannelMembersView.scss b/src/components/ChannelDetail/styling/ChannelMembersView.scss index 68cb3893cb..49315ad85c 100644 --- a/src/components/ChannelDetail/styling/ChannelMembersView.scss +++ b/src/components/ChannelDetail/styling/ChannelMembersView.scss @@ -33,9 +33,8 @@ .str-chat__infinite-scroll-paginator__content { display: flex; flex-direction: column; - gap: var(--str-chat__spacing-xs); min-height: 100%; - padding-inline: calc(var(--str-chat__spacing-xs) + var(--str-chat__spacing-xxs)); + padding-inline: var(--str-chat__spacing-xs); padding-block: var(--str-chat__spacing-xxs); } } @@ -43,15 +42,12 @@ .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-xxs); + padding-inline: var(--str-chat__spacing-xs); } } } -.str-chat__list-item-layout.str-chat__channel-detail__channel-members-view__list-item { - width: 100%; - text-align: start; - +.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); } diff --git a/src/components/ChannelDetail/styling/PinnedMessagesView.scss b/src/components/ChannelDetail/styling/PinnedMessagesView.scss index 4a1094b7a8..8fcb461996 100644 --- a/src/components/ChannelDetail/styling/PinnedMessagesView.scss +++ b/src/components/ChannelDetail/styling/PinnedMessagesView.scss @@ -28,7 +28,7 @@ display: flex; flex-direction: column; min-height: 100%; - padding-inline: calc(var(--str-chat__spacing-xs) + var(--str-chat__spacing-xxs)); + padding-inline: var(--str-chat__spacing-xs); padding-block: var(--str-chat__spacing-xxs); } } @@ -41,11 +41,6 @@ } } -.str-chat__list-item-layout.str-chat__channel-detail__pinned-messages-view__list-item { - width: 100%; - text-align: start; -} - .str-chat__channel-detail__pinned-messages-view__list-item__message-preview { overflow: hidden; text-overflow: ellipsis; diff --git a/src/components/Dialog/components/Prompt.tsx b/src/components/Dialog/components/Prompt.tsx index 0f2629302d..eefd92616c 100644 --- a/src/components/Dialog/components/Prompt.tsx +++ b/src/components/Dialog/components/Prompt.tsx @@ -49,7 +49,11 @@ const PromptHeader = ({ 'str-chat__prompt__header--withGoBack': goBack, })} > - {LeadingContent && } + {LeadingContent && ( +
    + +
    + )}
    {goBack && ( { if (!resolvedIsDmChannel) return; - return ( - Object.values(channel.state?.members ?? {}).find( - (member) => member.user?.id && member.user.id !== client.user?.id, - )?.user?.id ?? - channel.data?.members?.find( - (member) => member.user?.id && member.user.id !== client.user?.id, - )?.user?.id - ); + 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); diff --git a/src/components/ChannelHeader/index.ts b/src/components/ChannelHeader/index.ts index 48041c412c..a8a155add1 100644 --- a/src/components/ChannelHeader/index.ts +++ b/src/components/ChannelHeader/index.ts @@ -1,2 +1 @@ -export * from './AvatarWithChannelDetail'; export * from './ChannelHeader'; diff --git a/src/components/ChannelHeader/styling/ChannelHeader.scss b/src/components/ChannelHeader/styling/ChannelHeader.scss index 5ed04d05cc..1572a30103 100644 --- a/src/components/ChannelHeader/styling/ChannelHeader.scss +++ b/src/components/ChannelHeader/styling/ChannelHeader.scss @@ -25,17 +25,6 @@ min-width: 0; } - .str-chat__channel-header__avatar-button { - appearance: none; - background: none; - border: 0; - border-radius: 50%; - color: inherit; - cursor: pointer; - display: flex; - padding: 0; - } - .str-chat__channel-header__data__title, .str-chat__channel-header__data__subtitle { @include utils.ellipsis-text; From 53236f7f0cdfbd464173ce766b33793787ae7696 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 12 Jun 2026 17:40:30 +0200 Subject: [PATCH 20/21] refactor(ChannelDetail): reduce the index export for Avatar styles --- src/styling/index.scss | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/styling/index.scss b/src/styling/index.scss index 72ba436e3b..ef5a74300b 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -21,11 +21,8 @@ @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/ChannelDetail/styling' as ChannelDetail; @use '../components/ChannelHeader/styling' as ChannelHeader; @use '../components/ChannelList/styling' as ChannelList; @use '../components/ChannelListItem/styling' as ChannelListItem; From 97a07bf8ab142f5fcdcfa99ab7f693710009df59 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 12 Jun 2026 17:46:25 +0200 Subject: [PATCH 21/21] refactor(ChannelDetail): convert ChannelDetail into plugin --- examples/vite/package.json | 2 +- .../ChannelDetail/channelDetailSettings.ts | 2 +- .../ChatLayout/ConfiguredChannelDetail.tsx | 8 +++--- examples/vite/src/index.scss | 1 + package.json | 11 +++++++- scripts/watch-styling.mjs | 4 +++ src/components/Avatar/index.ts | 1 - src/components/Avatar/styling/index.scss | 1 - src/components/index.ts | 1 - .../AvatarWithChannelDetail.tsx | 9 ++++--- .../ChannelDetail/ChannelDetail.tsx | 12 ++++++--- .../ChannelDetail/ChannelDetailContext.tsx | 0 .../ChannelDetail/ChannelDetailEmptyList.tsx | 2 +- .../ChannelDetailListLoadingIndicator.tsx | 2 +- .../ChannelDetail/ChannelDetailNavButton.tsx | 4 +-- .../ChannelDetailSearchInput.tsx | 4 +-- .../ChannelFilesEmptyList.tsx | 2 +- .../ChannelFilesView/ChannelFilesView.tsx | 12 ++++----- .../ChannelFilesView.utils.ts | 0 .../__tests__/ChannelFilesView.test.tsx | 15 ++++++----- .../Views/ChannelFilesView/index.ts | 0 .../ChannelFilesView/useChannelFilesSearch.ts | 0 .../ChannelManagementActions.defaults.tsx | 22 +++++++++------ .../ChannelManagementView.tsx | 27 ++++++++++--------- .../Views/ChannelManagementView/index.ts | 0 .../ChannelMediaEmptyList.tsx | 2 +- .../ChannelMediaView/ChannelMediaView.tsx | 23 +++++++++------- .../ChannelMediaView.utils.ts | 4 +-- .../__tests__/ChannelMediaView.test.tsx | 15 ++++++----- .../Views/ChannelMediaView/index.ts | 0 .../ChannelMediaView/useChannelMediaSearch.ts | 0 .../ChannelMemberActions.defaults.tsx | 14 +++++----- .../ChannelMemberDetail.tsx | 6 ++--- .../__tests__/ChannelMemberDetail.test.tsx | 2 +- .../Views/ChannelMemberDetailView/index.ts | 0 .../ChannelMembersAddView.tsx | 14 +++++----- .../ChannelMembersBrowseView.tsx | 10 +++---- .../ChannelMembersHeaderActions.defaults.tsx | 6 ++--- .../ChannelMembersRemoveView.tsx | 10 +++---- .../ChannelMembersView/ChannelMembersView.tsx | 2 +- .../ChannelMembersView.utils.ts | 0 .../__tests__/ChannelMembersAddView.test.tsx | 19 +++++++------ .../ChannelMembersBrowseView.test.tsx | 19 +++++++------ ...nnelMembersHeaderActions.defaults.test.tsx | 2 +- .../ChannelMembersRemoveView.test.tsx | 15 ++++++----- .../__tests__/ChannelMembersView.test.tsx | 2 +- .../ChannelMembersView.utils.test.ts | 0 .../__tests__/testUtils.tsx | 0 .../Views/ChannelMembersView/index.ts | 0 .../useChannelMembersSearch.ts | 0 .../PinnedMessagesEmptyList.tsx | 2 +- .../PinnedMessagesView/PinnedMessagesView.tsx | 10 +++---- .../__tests__/PinnedMessagesView.test.tsx | 17 +++++++----- .../Views/PinnedMessagesView/index.ts | 0 .../usePinnedMessagesSearch.ts | 0 .../__tests__/ChannelDetail.test.tsx | 2 +- ...ChannelManagementActions.defaults.test.tsx | 4 +-- .../__tests__/ChannelManagementView.test.tsx | 21 ++++++++------- .../ChannelDetail/index.ts | 1 + .../styling/AvatarWithChannelDetail.scss | 0 .../ChannelDetail/styling/ChannelDetail.scss | 0 .../styling/ChannelFilesView.scss | 0 .../styling/ChannelManagementView.scss | 0 .../styling/ChannelMediaView.scss | 0 .../styling/ChannelMemberDetailView.scss | 0 .../styling/ChannelMembersView.scss | 0 .../styling/ChannelMembersViewListFooter.scss | 0 .../styling/PinnedMessagesView.scss | 0 .../ChannelDetail/styling/index.scss | 1 + vite.config.ts | 1 + 70 files changed, 211 insertions(+), 155 deletions(-) rename src/{components/Avatar => plugins/ChannelDetail}/AvatarWithChannelDetail.tsx (90%) rename src/{components => plugins}/ChannelDetail/ChannelDetail.tsx (95%) rename src/{components => plugins}/ChannelDetail/ChannelDetailContext.tsx (100%) rename src/{components => plugins}/ChannelDetail/ChannelDetailEmptyList.tsx (84%) rename src/{components => plugins}/ChannelDetail/ChannelDetailListLoadingIndicator.tsx (92%) rename src/{components => plugins}/ChannelDetail/ChannelDetailNavButton.tsx (88%) rename src/{components => plugins}/ChannelDetail/ChannelDetailSearchInput.tsx (92%) rename src/{components => plugins}/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx (92%) rename src/{components => plugins}/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx (89%) rename src/{components => plugins}/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts (100%) rename src/{components => plugins}/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx (95%) rename src/{components => plugins}/ChannelDetail/Views/ChannelFilesView/index.ts (100%) rename src/{components => plugins}/ChannelDetail/Views/ChannelFilesView/useChannelFilesSearch.ts (100%) rename src/{components => plugins}/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx (97%) rename src/{components => plugins}/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx (93%) rename src/{components => plugins}/ChannelDetail/Views/ChannelManagementView/index.ts (100%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx (92%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx (87%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts (93%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx (94%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMediaView/index.ts (100%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMediaView/useChannelMediaSearch.ts (100%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx (97%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx (97%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx (98%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMemberDetailView/index.ts (100%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx (93%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx (92%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx (97%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx (93%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx (99%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts (100%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx (92%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx (92%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx (99%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx (94%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx (99%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.utils.test.ts (100%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/__tests__/testUtils.tsx (100%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/index.ts (100%) rename src/{components => plugins}/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts (100%) rename src/{components => plugins}/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx (93%) rename src/{components => plugins}/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx (93%) rename src/{components => plugins}/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx (95%) rename src/{components => plugins}/ChannelDetail/Views/PinnedMessagesView/index.ts (100%) rename src/{components => plugins}/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts (100%) rename src/{components => plugins}/ChannelDetail/__tests__/ChannelDetail.test.tsx (94%) rename src/{components => plugins}/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx (99%) rename src/{components => plugins}/ChannelDetail/__tests__/ChannelManagementView.test.tsx (93%) rename src/{components => plugins}/ChannelDetail/index.ts (91%) rename src/{components/Avatar => plugins/ChannelDetail}/styling/AvatarWithChannelDetail.scss (100%) rename src/{components => plugins}/ChannelDetail/styling/ChannelDetail.scss (100%) rename src/{components => plugins}/ChannelDetail/styling/ChannelFilesView.scss (100%) rename src/{components => plugins}/ChannelDetail/styling/ChannelManagementView.scss (100%) rename src/{components => plugins}/ChannelDetail/styling/ChannelMediaView.scss (100%) rename src/{components => plugins}/ChannelDetail/styling/ChannelMemberDetailView.scss (100%) rename src/{components => plugins}/ChannelDetail/styling/ChannelMembersView.scss (100%) rename src/{components => plugins}/ChannelDetail/styling/ChannelMembersViewListFooter.scss (100%) rename src/{components => plugins}/ChannelDetail/styling/PinnedMessagesView.scss (100%) rename src/{components => plugins}/ChannelDetail/styling/index.scss (87%) 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/tabs/ChannelDetail/channelDetailSettings.ts b/examples/vite/src/AppSettings/tabs/ChannelDetail/channelDetailSettings.ts index 879bf5ad97..48518a4ab9 100644 --- a/examples/vite/src/AppSettings/tabs/ChannelDetail/channelDetailSettings.ts +++ b/examples/vite/src/AppSettings/tabs/ChannelDetail/channelDetailSettings.ts @@ -1,7 +1,7 @@ import { type ChannelMembersHeaderActionItem, DefaultChannelMembersHeaderActions, -} from 'stream-chat-react'; +} from 'stream-chat-react/channel-detail'; import type { ChannelDetailSettingsState, diff --git a/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx b/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx index 66052a9f81..baa0c7e95a 100644 --- a/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx +++ b/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx @@ -1,4 +1,8 @@ import { useMemo } from 'react'; +import { + type SectionNavigatorSection, + type SectionNavigatorSectionContentProps, +} from 'stream-chat-react'; import { AvatarWithChannelDetail, type AvatarWithChannelDetailProps, @@ -6,9 +10,7 @@ import { type ChannelDetailProps, ChannelMembersView, defaultChannelDetailSections, - type SectionNavigatorSection, - type SectionNavigatorSectionContentProps, -} from 'stream-chat-react'; +} from 'stream-chat-react/channel-detail'; import { useAppSettingsSelector } from '../AppSettings/state'; import { getChannelMembersHeaderActionSet } from '../AppSettings/tabs/ChannelDetail'; 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/Avatar/index.ts b/src/components/Avatar/index.ts index 41763ea92a..e41a194247 100644 --- a/src/components/Avatar/index.ts +++ b/src/components/Avatar/index.ts @@ -1,5 +1,4 @@ export * from './Avatar'; export * from './AvatarStack'; -export * from './AvatarWithChannelDetail'; export * from './ChannelAvatar'; export * from './GroupAvatar'; diff --git a/src/components/Avatar/styling/index.scss b/src/components/Avatar/styling/index.scss index e7623a2ffb..f1fc28f72c 100644 --- a/src/components/Avatar/styling/index.scss +++ b/src/components/Avatar/styling/index.scss @@ -1,4 +1,3 @@ @use 'Avatar'; @use 'AvatarStack'; -@use 'AvatarWithChannelDetail'; @use 'GroupAvatar'; diff --git a/src/components/index.ts b/src/components/index.ts index 4907c44e25..781f88fbdb 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,7 +7,6 @@ export * from './Badge'; export * from './BaseImage'; export * from './Button'; export * from './Channel'; -export * from './ChannelDetail'; export * from './ChannelHeader'; export * from './ChannelList'; export * from './ChannelListItem'; diff --git a/src/components/Avatar/AvatarWithChannelDetail.tsx b/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx similarity index 90% rename from src/components/Avatar/AvatarWithChannelDetail.tsx rename to src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx index 4a1e50e695..b1dc43e6ba 100644 --- a/src/components/Avatar/AvatarWithChannelDetail.tsx +++ b/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx @@ -6,12 +6,15 @@ import { useComponentContext, useTranslationContext, } from '../../context'; -import { type ChannelAvatarProps, ChannelAvatar as DefaultChannelAvatar } from './index'; +import { + type ChannelAvatarProps, + ChannelAvatar as DefaultChannelAvatar, +} from '../../components/Avatar/index'; import { type ChannelDetailProps, ChannelDetail as DefaultChannelDetail, -} from '../ChannelDetail/ChannelDetail'; -import { GlobalModal } from '../Modal'; +} from './ChannelDetail'; +import { GlobalModal } from '../../components/Modal'; export type AvatarWithChannelDetailProps = ChannelAvatarProps & { Avatar?: React.ComponentType; diff --git a/src/components/ChannelDetail/ChannelDetail.tsx b/src/plugins/ChannelDetail/ChannelDetail.tsx similarity index 95% rename from src/components/ChannelDetail/ChannelDetail.tsx rename to src/plugins/ChannelDetail/ChannelDetail.tsx index d222a8bb9c..87aebc945a 100644 --- a/src/components/ChannelDetail/ChannelDetail.tsx +++ b/src/plugins/ChannelDetail/ChannelDetail.tsx @@ -9,7 +9,7 @@ import { type SectionNavigatorNavButtonProps, type SectionNavigatorProps, type SectionNavigatorSection, -} from '../SectionNavigator'; +} from '../../components/SectionNavigator'; import { ChannelDetailNavButton } from './ChannelDetailNavButton'; import { ChannelDetailProvider } from './ChannelDetailContext'; import { ChannelFilesView } from './Views/ChannelFilesView'; @@ -17,8 +17,14 @@ import { ChannelManagementView } from './Views/ChannelManagementView'; import { ChannelMediaView } from './Views/ChannelMediaView'; import { ChannelMembersView } from './Views/ChannelMembersView'; import { PinnedMessagesView } from './Views/PinnedMessagesView'; -import { Prompt } from '../Dialog'; -import { IconFolder, IconImage, IconInfo, IconPin, IconUser } from '../Icons'; +import { Prompt } from '../../components/Dialog'; +import { + IconFolder, + IconImage, + IconInfo, + IconPin, + IconUser, +} from '../../components/Icons'; const ChannelManagementNavButtonIcon = () => ( diff --git a/src/components/ChannelDetail/ChannelDetailContext.tsx b/src/plugins/ChannelDetail/ChannelDetailContext.tsx similarity index 100% rename from src/components/ChannelDetail/ChannelDetailContext.tsx rename to src/plugins/ChannelDetail/ChannelDetailContext.tsx diff --git a/src/components/ChannelDetail/ChannelDetailEmptyList.tsx b/src/plugins/ChannelDetail/ChannelDetailEmptyList.tsx similarity index 84% rename from src/components/ChannelDetail/ChannelDetailEmptyList.tsx rename to src/plugins/ChannelDetail/ChannelDetailEmptyList.tsx index e1c4e10861..0991c91676 100644 --- a/src/components/ChannelDetail/ChannelDetailEmptyList.tsx +++ b/src/plugins/ChannelDetail/ChannelDetailEmptyList.tsx @@ -1,4 +1,4 @@ -import { IconSearch } from '../Icons'; +import { IconSearch } from '../../components/Icons'; import type { PropsWithChildrenOnly } from '../../types/types'; export const ChannelDetailEmptyList = ({ children }: PropsWithChildrenOnly) => ( diff --git a/src/components/ChannelDetail/ChannelDetailListLoadingIndicator.tsx b/src/plugins/ChannelDetail/ChannelDetailListLoadingIndicator.tsx similarity index 92% rename from src/components/ChannelDetail/ChannelDetailListLoadingIndicator.tsx rename to src/plugins/ChannelDetail/ChannelDetailListLoadingIndicator.tsx index 5db56bfc36..86293a1351 100644 --- a/src/components/ChannelDetail/ChannelDetailListLoadingIndicator.tsx +++ b/src/plugins/ChannelDetail/ChannelDetailListLoadingIndicator.tsx @@ -1,6 +1,6 @@ import type { SearchSource, SearchSourceState } from 'stream-chat'; import { useStateStore } from '../../store'; -import { LoadingIndicator } from '../Loading'; +import { LoadingIndicator } from '../../components/Loading'; const searchSourceFooterStateSelector = (state: SearchSourceState) => ({ hasNextPage: state.hasNext, diff --git a/src/components/ChannelDetail/ChannelDetailNavButton.tsx b/src/plugins/ChannelDetail/ChannelDetailNavButton.tsx similarity index 88% rename from src/components/ChannelDetail/ChannelDetailNavButton.tsx rename to src/plugins/ChannelDetail/ChannelDetailNavButton.tsx index e11033693e..885e8b42a3 100644 --- a/src/components/ChannelDetail/ChannelDetailNavButton.tsx +++ b/src/plugins/ChannelDetail/ChannelDetailNavButton.tsx @@ -1,7 +1,7 @@ import React, { type ComponentType, useMemo } from 'react'; -import type { SectionNavigatorNavButtonProps } from '../SectionNavigator'; -import { ListItemLayout } from '../ListItemLayout'; +import type { SectionNavigatorNavButtonProps } from '../../components/SectionNavigator'; +import { ListItemLayout } from '../../components/ListItemLayout'; import clsx from 'clsx'; export type ChannelDetailNavButtonProps = SectionNavigatorNavButtonProps & { diff --git a/src/components/ChannelDetail/ChannelDetailSearchInput.tsx b/src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx similarity index 92% rename from src/components/ChannelDetail/ChannelDetailSearchInput.tsx rename to src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx index 58a090a64c..ec591d60e5 100644 --- a/src/components/ChannelDetail/ChannelDetailSearchInput.tsx +++ b/src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useTranslationContext } from '../../context'; -import { TextInput } from '../Form'; -import { IconSearch } from '../Icons'; +import { TextInput } from '../../components/Form'; +import { IconSearch } from '../../components/Icons'; export type ChannelDetailSearchInputProps = { autoFocus?: boolean; diff --git a/src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx similarity index 92% rename from src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx rename to src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx index 822fac0faf..83669b3e76 100644 --- a/src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx @@ -1,5 +1,5 @@ import { useTranslationContext } from '../../../../context'; -import { IconFolder } from '../../../Icons'; +import { IconFolder } from '../../../../components/Icons'; export const ChannelFilesEmptyList = () => { const { t } = useTranslationContext('ChannelFilesEmptyList'); diff --git a/src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx similarity index 89% rename from src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx rename to src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx index 6edc8b6e7f..2afde0a757 100644 --- a/src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx @@ -2,15 +2,15 @@ import React from 'react'; import { useModalContext, useTranslationContext } from '../../../../context'; import { getDateString } from '../../../../i18n/utils'; -import { FileSizeIndicator } from '../../../Attachment/components/FileSizeIndicator'; -import { Prompt } from '../../../Dialog'; -import { FileIcon } from '../../../FileIcon'; -import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; -import { ListItemLayout } from '../../../ListItemLayout'; +import { FileSizeIndicator } from '../../../../components/Attachment/components/FileSizeIndicator'; +import { Prompt } from '../../../../components/Dialog'; +import { FileIcon } from '../../../../components/FileIcon'; +import { InfiniteScrollPaginator } from '../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator'; +import { ListItemLayout } from '../../../../components/ListItemLayout'; import { SectionNavigatorHeader, type SectionNavigatorSectionContentProps, -} from '../../../SectionNavigator'; +} from '../../../../components/SectionNavigator'; import { ChannelDetailListLoadingIndicator } from '../../ChannelDetailListLoadingIndicator'; import { ChannelFilesEmptyList } from './ChannelFilesEmptyList'; import type { ChannelFileItem } from './ChannelFilesView.utils'; diff --git a/src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts similarity index 100% rename from src/components/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts rename to src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts diff --git a/src/components/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx similarity index 95% rename from src/components/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx rename to src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx index 2e2403b0ec..ef3b9a7be7 100644 --- a/src/components/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx @@ -51,13 +51,16 @@ vi.mock('stream-chat', async (importOriginal) => { vi.mock('../../../../../context'); vi.mock('../../../../../store'); -vi.mock('../../../../InfiniteScrollPaginator/InfiniteScrollPaginator', () => ({ - InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => ( -
    {children}
    - ), -})); +vi.mock( + '../../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator', + () => ({ + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + }), +); -vi.mock('../../../../Dialog', () => ({ +vi.mock('../../../../../components/Dialog', () => ({ Prompt: { Body: ({ children }: { children: React.ReactNode }) =>
    {children}
    , Header: ({ title }: { title?: React.ReactNode }) => ( diff --git a/src/components/ChannelDetail/Views/ChannelFilesView/index.ts b/src/plugins/ChannelDetail/Views/ChannelFilesView/index.ts similarity index 100% rename from src/components/ChannelDetail/Views/ChannelFilesView/index.ts rename to src/plugins/ChannelDetail/Views/ChannelFilesView/index.ts diff --git a/src/components/ChannelDetail/Views/ChannelFilesView/useChannelFilesSearch.ts b/src/plugins/ChannelDetail/Views/ChannelFilesView/useChannelFilesSearch.ts similarity index 100% rename from src/components/ChannelDetail/Views/ChannelFilesView/useChannelFilesSearch.ts rename to src/plugins/ChannelDetail/Views/ChannelFilesView/useChannelFilesSearch.ts diff --git a/src/components/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx similarity index 97% rename from src/components/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx rename to src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx index 4e1cc593b9..ff596a204b 100644 --- a/src/components/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx @@ -9,15 +9,21 @@ import { useTranslationContext, } from '../../../../context'; import { isDmChannel, useStableCallback } from '../../../../utils'; -import { useIsChannelMuted } from '../../../ChannelListItem/hooks/useIsChannelMuted'; +import { useIsChannelMuted } from '../../../../components/ChannelListItem/hooks/useIsChannelMuted'; import { useStateStore } from '../../../../store'; -import { Alert } from '../../../Dialog'; -import { Button } from '../../../Button'; -import { Switch } from '../../../Form'; -import { IconAudio, IconDelete, IconLeave, IconMute, IconNoSign } from '../../../Icons'; -import { ListItemLayout } from '../../../ListItemLayout'; -import { GlobalModal } from '../../../Modal'; -import { useNotificationApi } from '../../../Notifications'; +import { Alert } from '../../../../components/Dialog'; +import { Button } from '../../../../components/Button'; +import { Switch } from '../../../../components/Form'; +import { + IconAudio, + IconDelete, + IconLeave, + IconMute, + IconNoSign, +} from '../../../../components/Icons'; +import { ListItemLayout } from '../../../../components/ListItemLayout'; +import { GlobalModal } from '../../../../components/Modal'; +import { useNotificationApi } from '../../../../components/Notifications'; import { useChannelDetailContext } from '../../ChannelDetailContext'; import clsx from 'clsx'; diff --git a/src/components/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx similarity index 93% rename from src/components/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx rename to src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx index cec009d5ed..1ca4b383ff 100644 --- a/src/components/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx @@ -17,24 +17,27 @@ import { isDmChannel } from '../../../../utils'; import { SectionNavigatorHeader, type SectionNavigatorSectionContentProps, -} from '../../../SectionNavigator'; -import { ChannelAvatar as DefaultChannelAvatar } from '../../../Avatar'; -import { useChannelPreviewInfo, useIsUserMuted } from '../../../ChannelListItem'; -import { IconCheckmark, IconMute, IconPin } from '../../../Icons'; -import { useChannelMembershipState } from '../../../ChannelList'; -import { useIsChannelMuted } from '../../../ChannelListItem/hooks/useIsChannelMuted'; -import { useChannelHasMembersOnline } from '../../../ChannelHeader/hooks/useChannelHasMembersOnline'; -import { Prompt } from '../../../Dialog'; +} 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 '../../../ChannelHeader/hooks/useChannelHeaderOnlineStatus'; +import { useChannelHeaderOnlineStatus } from '../../../../components/ChannelHeader/hooks/useChannelHeaderOnlineStatus'; import { useChannelDetailContext } from '../../ChannelDetailContext'; -import { Button } from '../../../Button'; -import { TextInput } from '../../../Form'; -import { useNotificationApi } from '../../../Notifications/hooks/useNotificationApi'; +import { Button } from '../../../../components/Button'; +import { TextInput } from '../../../../components/Form'; +import { useNotificationApi } from '../../../../components/Notifications/hooks/useNotificationApi'; export type ChannelManagementViewProps = SectionNavigatorSectionContentProps & { channelManagementActionSet?: ChannelManagementActionItem[]; diff --git a/src/components/ChannelDetail/Views/ChannelManagementView/index.ts b/src/plugins/ChannelDetail/Views/ChannelManagementView/index.ts similarity index 100% rename from src/components/ChannelDetail/Views/ChannelManagementView/index.ts rename to src/plugins/ChannelDetail/Views/ChannelManagementView/index.ts diff --git a/src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx similarity index 92% rename from src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx rename to src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx index ef4403184f..a03128e786 100644 --- a/src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx @@ -1,5 +1,5 @@ import { useTranslationContext } from '../../../../context'; -import { IconImage } from '../../../Icons'; +import { IconImage } from '../../../../components/Icons'; export const ChannelMediaEmptyList = () => { const { t } = useTranslationContext('ChannelMediaEmptyList'); diff --git a/src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx similarity index 87% rename from src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx rename to src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx index 7772b4a61e..41d4983e3b 100644 --- a/src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx @@ -6,19 +6,22 @@ import { useModalContext, useTranslationContext, } from '../../../../context'; -import { formatTime } from '../../../AudioPlayback'; -import { Avatar } from '../../../Avatar'; -import { Badge } from '../../../Badge'; -import { type BaseImageProps, BaseImage as DefaultBaseImage } from '../../../BaseImage'; -import { Prompt } from '../../../Dialog'; -import { Gallery as DefaultGallery, GalleryUI } from '../../../Gallery'; -import { IconImage, IconVideoFill } from '../../../Icons'; -import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; -import { GlobalModal } from '../../../Modal'; +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 '../../../SectionNavigator'; +} from '../../../../components/SectionNavigator'; import { ChannelDetailListLoadingIndicator } from '../../ChannelDetailListLoadingIndicator'; import { getUserDisplayName } from '../ChannelMembersView/ChannelMembersView.utils'; import { ChannelMediaEmptyList } from './ChannelMediaEmptyList'; diff --git a/src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts similarity index 93% rename from src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts rename to src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts index 784a966197..e49805b3ae 100644 --- a/src/components/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts @@ -7,8 +7,8 @@ import { type UserResponse, } from 'stream-chat'; -import { toBaseImageDescriptors } from '../../../BaseImage'; -import type { GalleryItem } from '../../../Gallery'; +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; diff --git a/src/components/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx similarity index 94% rename from src/components/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx rename to src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx index 9b5700d9ce..e56f324fc8 100644 --- a/src/components/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx @@ -51,13 +51,16 @@ vi.mock('stream-chat', async (importOriginal) => { vi.mock('../../../../../context'); vi.mock('../../../../../store'); -vi.mock('../../../../InfiniteScrollPaginator/InfiniteScrollPaginator', () => ({ - InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => ( -
    {children}
    - ), -})); +vi.mock( + '../../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator', + () => ({ + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + }), +); -vi.mock('../../../../Dialog', () => ({ +vi.mock('../../../../../components/Dialog', () => ({ Prompt: { Body: ({ children }: { children: React.ReactNode }) =>
    {children}
    , Header: ({ title }: { title?: React.ReactNode }) => ( diff --git a/src/components/ChannelDetail/Views/ChannelMediaView/index.ts b/src/plugins/ChannelDetail/Views/ChannelMediaView/index.ts similarity index 100% rename from src/components/ChannelDetail/Views/ChannelMediaView/index.ts rename to src/plugins/ChannelDetail/Views/ChannelMediaView/index.ts diff --git a/src/components/ChannelDetail/Views/ChannelMediaView/useChannelMediaSearch.ts b/src/plugins/ChannelDetail/Views/ChannelMediaView/useChannelMediaSearch.ts similarity index 100% rename from src/components/ChannelDetail/Views/ChannelMediaView/useChannelMediaSearch.ts rename to src/plugins/ChannelDetail/Views/ChannelMediaView/useChannelMediaSearch.ts diff --git a/src/components/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx similarity index 97% rename from src/components/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx rename to src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx index 78ab131f00..100b09bb59 100644 --- a/src/components/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx @@ -20,19 +20,19 @@ import { } from '../../../../context'; import { useStableCallback } from '../../../../utils'; import { useStateStore } from '../../../../store'; -import { Alert } from '../../../Dialog'; -import { Button } from '../../../Button'; -import { Switch } from '../../../Form'; +import { Alert } from '../../../../components/Dialog'; +import { Button } from '../../../../components/Button'; +import { Switch } from '../../../../components/Form'; import { IconAudio, IconMessageBubble, IconMute, IconNoSign, IconUserRemove, -} from '../../../Icons'; -import { ListItemLayout } from '../../../ListItemLayout'; -import { GlobalModal } from '../../../Modal'; -import { useNotificationApi } from '../../../Notifications'; +} 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 = diff --git a/src/components/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx similarity index 97% rename from src/components/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx rename to src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx index 09c478d96f..50542ae762 100644 --- a/src/components/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx @@ -10,9 +10,9 @@ import { import { SectionNavigatorHeader, type SectionNavigatorSectionContentProps, -} from '../../../SectionNavigator'; -import { ChannelAvatar as DefaultChannelAvatar } from '../../../Avatar'; -import { Prompt } from '../../../Dialog'; +} from '../../../../components/SectionNavigator'; +import { ChannelAvatar as DefaultChannelAvatar } from '../../../../components/Avatar'; +import { Prompt } from '../../../../components/Dialog'; import { useChannelDetailContext } from '../../ChannelDetailContext'; import { type ChannelMemberActionItem, diff --git a/src/components/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx similarity index 98% rename from src/components/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx rename to src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx index 00016c43bf..fa199b796c 100644 --- a/src/components/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx @@ -14,7 +14,7 @@ import { ChannelMemberDetail } from '../ChannelMemberDetail'; vi.mock('../../../../../context'); -vi.mock('../../../../Dialog', () => ({ +vi.mock('../../../../../components/Dialog', () => ({ Prompt: { Body: ({ children }: { children: React.ReactNode }) =>
    {children}
    , Header: ({ title }: { title: string }) =>

    {title}

    , diff --git a/src/components/ChannelDetail/Views/ChannelMemberDetailView/index.ts b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/index.ts similarity index 100% rename from src/components/ChannelDetail/Views/ChannelMemberDetailView/index.ts rename to src/plugins/ChannelDetail/Views/ChannelMemberDetailView/index.ts diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx similarity index 93% rename from src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx rename to src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx index b1eca95204..3d3d23c77f 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx @@ -3,19 +3,19 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useChatContext, useTranslationContext } from '../../../../context'; import { useStateStore } from '../../../../store'; -import { Avatar } from '../../../Avatar'; -import { Checkbox } from '../../../Form'; -import { IconMute } from '../../../Icons'; -import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; -import { ListItemLayout } from '../../../ListItemLayout'; -import { Prompt } from '../../../Dialog'; +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 '../../../Notifications'; +import { useNotificationApi } from '../../../../components/Notifications'; import { ChannelDetailSearchInput } from '../../ChannelDetailSearchInput'; import { ChannelDetailEmptyList } from '../../ChannelDetailEmptyList'; import { ChannelDetailListLoadingIndicator } from '../../ChannelDetailListLoadingIndicator'; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx similarity index 92% rename from src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx rename to src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx index 277d344e7c..46cd8ff802 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx @@ -2,11 +2,11 @@ import type { ChannelMemberResponse } from 'stream-chat'; import React, { useMemo } from 'react'; import { useChatContext, useTranslationContext } from '../../../../context'; -import { Avatar } from '../../../Avatar'; -import { IconMute } from '../../../Icons'; -import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; -import { ListItemLayout } from '../../../ListItemLayout'; -import { Prompt } from '../../../Dialog'; +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, diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx similarity index 97% rename from src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx rename to src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx index d603e381d3..f8fae77d1f 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx @@ -1,20 +1,20 @@ import React, { useMemo, useState } from 'react'; import { useComponentContext, useTranslationContext } from '../../../../context'; -import { Button } from '../../../Button'; +import { Button } from '../../../../components/Button'; import { ContextMenu, ContextMenuButton, useDialogIsOpen, useDialogOnNearestManager, -} from '../../../Dialog'; +} from '../../../../components/Dialog'; import { useChannelDetailContext } from '../../ChannelDetailContext'; import { canUpdateChannelMembers } from './ChannelMembersView.utils'; import type { ChannelMembersHeaderActionsProps, ChannelMembersViewController, } from './ChannelMembersView'; -import { IconUserAdd, IconUserRemove } from '../../../Icons'; +import { IconUserAdd, IconUserRemove } from '../../../../components/Icons'; export type ChannelMembersHeaderActionType = | 'addMembers' diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx similarity index 93% rename from src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx rename to src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx index 1f82734c45..3fa210c0fc 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx @@ -2,11 +2,11 @@ import type { ChannelMemberResponse } from 'stream-chat'; import React, { useMemo, useState } from 'react'; import { useTranslationContext } from '../../../../context'; -import { Avatar } from '../../../Avatar'; -import { Checkbox } from '../../../Form'; -import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; -import { ListItemLayout } from '../../../ListItemLayout'; -import { Prompt } from '../../../Dialog'; +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, diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx similarity index 99% rename from src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx rename to src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx index 7d958d76a8..dcb1eb5c03 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx @@ -16,7 +16,7 @@ import { ChannelMembersRemoveView } from './ChannelMembersRemoveView'; import { SectionNavigatorHeader, type SectionNavigatorSectionContentProps, -} from '../../../SectionNavigator'; +} from '../../../../components/SectionNavigator'; export type ChannelMembersHeaderActionsProps = { controller: ChannelMembersViewController; diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts similarity index 100% rename from src/components/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts rename to src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.utils.ts diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx similarity index 92% rename from src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx rename to src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx index b66c77e06d..0531dda4a8 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx @@ -20,20 +20,23 @@ const mocks = vi.hoisted(() => ({ vi.mock('../../../../../context'); vi.mock('../../../../../store'); -vi.mock('../../../../Notifications', () => ({ +vi.mock('../../../../../components/Notifications', () => ({ useNotificationApi: () => ({ addNotification: vi.fn(), }), })); -vi.mock('../../../../InfiniteScrollPaginator/InfiniteScrollPaginator', () => ({ - InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => { - mocks.infiniteScrollPaginatorRenderCount += 1; - return
    {children}
    ; - }, -})); +vi.mock( + '../../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator', + () => ({ + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => { + mocks.infiniteScrollPaginatorRenderCount += 1; + return
    {children}
    ; + }, + }), +); -vi.mock('../../../../Dialog', () => ({ +vi.mock('../../../../../components/Dialog', () => ({ Prompt: { Body: ({ children }: { children: React.ReactNode }) =>
    {children}
    , Footer: ({ children }: { children: React.ReactNode }) =>
    {children}
    , diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx similarity index 92% rename from src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx rename to src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx index 80cbe15b25..5cd3e33fc2 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx @@ -44,14 +44,17 @@ vi.mock('stream-chat', async (importOriginal) => { vi.mock('../../../../../context'); vi.mock('../../../../../store'); -vi.mock('../../../../InfiniteScrollPaginator/InfiniteScrollPaginator', () => ({ - InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => { - mocks.infiniteScrollPaginatorRenderCount += 1; - return
    {children}
    ; - }, -})); - -vi.mock('../../../../Dialog', () => ({ +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}
    , diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx similarity index 99% rename from src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx rename to src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx index 69f45fc18a..2ceddf1bcf 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx @@ -20,7 +20,7 @@ import type { vi.mock('../../../../../context'); -vi.mock('../../../../Dialog', () => ({ +vi.mock('../../../../../components/Dialog', () => ({ ContextMenu: ({ children }: { children: React.ReactNode }) => (
    {children}
    ), diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx similarity index 94% rename from src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx rename to src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx index fe1724b584..6425c69afc 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx @@ -38,13 +38,16 @@ vi.mock('stream-chat', async (importOriginal) => { vi.mock('../../../../../context'); vi.mock('../../../../../store'); -vi.mock('../../../../InfiniteScrollPaginator/InfiniteScrollPaginator', () => ({ - InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => ( -
    {children}
    - ), -})); +vi.mock( + '../../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator', + () => ({ + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + }), +); -vi.mock('../../../../Dialog', () => ({ +vi.mock('../../../../../components/Dialog', () => ({ Prompt: { Body: ({ children }: { children: React.ReactNode }) =>
    {children}
    , Footer: ({ children }: { children: React.ReactNode }) =>
    {children}
    , diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx similarity index 99% rename from src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx rename to src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx index 4eaf941820..b3ec8fd606 100644 --- a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx @@ -83,7 +83,7 @@ vi.mock('../ChannelMembersRemoveView', () => ({ ), })); -vi.mock('../../../../Dialog', () => ({ +vi.mock('../../../../../components/Dialog', () => ({ ContextMenu: ({ children }: { children: React.ReactNode }) => (
    {children}
    ), diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.utils.test.ts b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.utils.test.ts similarity index 100% rename from src/components/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.utils.test.ts rename to src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.utils.test.ts diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/__tests__/testUtils.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/testUtils.tsx similarity index 100% rename from src/components/ChannelDetail/Views/ChannelMembersView/__tests__/testUtils.tsx rename to src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/testUtils.tsx diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/index.ts b/src/plugins/ChannelDetail/Views/ChannelMembersView/index.ts similarity index 100% rename from src/components/ChannelDetail/Views/ChannelMembersView/index.ts rename to src/plugins/ChannelDetail/Views/ChannelMembersView/index.ts diff --git a/src/components/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts b/src/plugins/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts similarity index 100% rename from src/components/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts rename to src/plugins/ChannelDetail/Views/ChannelMembersView/useChannelMembersSearch.ts diff --git a/src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx similarity index 93% rename from src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx rename to src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx index b7be8cdc43..210c9610a2 100644 --- a/src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx @@ -1,4 +1,4 @@ -import { IconPin } from '../../../Icons'; +import { IconPin } from '../../../../components/Icons'; import { useTranslationContext } from '../../../../context'; export const PinnedMessagesEmptyList = () => { diff --git a/src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx similarity index 93% rename from src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx rename to src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx index 89aaba9638..310b197b3d 100644 --- a/src/components/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx @@ -8,14 +8,14 @@ import { useTranslationContext, } from '../../../../context'; import { getDateString, isDate } from '../../../../i18n/utils'; -import { Avatar } from '../../../Avatar'; -import { InfiniteScrollPaginator } from '../../../InfiniteScrollPaginator/InfiniteScrollPaginator'; -import { ListItemLayout } from '../../../ListItemLayout'; -import { Prompt } from '../../../Dialog'; +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 '../../../SectionNavigator'; +} from '../../../../components/SectionNavigator'; import { ChannelDetailSearchInput } from '../../ChannelDetailSearchInput'; import { getUserDisplayName } from '../ChannelMembersView/ChannelMembersView.utils'; import { ChannelDetailListLoadingIndicator } from '../../ChannelDetailListLoadingIndicator'; diff --git a/src/components/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx similarity index 95% rename from src/components/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx rename to src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx index fd5eb829c0..61401d02d8 100644 --- a/src/components/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx @@ -69,14 +69,17 @@ vi.mock('stream-chat', async (importOriginal) => { vi.mock('../../../../../context'); vi.mock('../../../../../store'); -vi.mock('../../../../InfiniteScrollPaginator/InfiniteScrollPaginator', () => ({ - InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => { - mocks.infiniteScrollPaginatorRenderCount += 1; - return
    {children}
    ; - }, -})); +vi.mock( + '../../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator', + () => ({ + InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => { + mocks.infiniteScrollPaginatorRenderCount += 1; + return
    {children}
    ; + }, + }), +); -vi.mock('../../../../Dialog', () => ({ +vi.mock('../../../../../components/Dialog', () => ({ Prompt: { Body: ({ children }: { children: React.ReactNode }) =>
    {children}
    , Header: ({ diff --git a/src/components/ChannelDetail/Views/PinnedMessagesView/index.ts b/src/plugins/ChannelDetail/Views/PinnedMessagesView/index.ts similarity index 100% rename from src/components/ChannelDetail/Views/PinnedMessagesView/index.ts rename to src/plugins/ChannelDetail/Views/PinnedMessagesView/index.ts diff --git a/src/components/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts b/src/plugins/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts similarity index 100% rename from src/components/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts rename to src/plugins/ChannelDetail/Views/PinnedMessagesView/usePinnedMessagesSearch.ts diff --git a/src/components/ChannelDetail/__tests__/ChannelDetail.test.tsx b/src/plugins/ChannelDetail/__tests__/ChannelDetail.test.tsx similarity index 94% rename from src/components/ChannelDetail/__tests__/ChannelDetail.test.tsx rename to src/plugins/ChannelDetail/__tests__/ChannelDetail.test.tsx index 2cc4d75fad..1f20404e13 100644 --- a/src/components/ChannelDetail/__tests__/ChannelDetail.test.tsx +++ b/src/plugins/ChannelDetail/__tests__/ChannelDetail.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, vi } from 'vitest'; import type { Channel } from 'stream-chat'; import { ChannelDetail } from '../ChannelDetail'; -import type { SectionNavigatorSection } from '../../SectionNavigator'; +import type { SectionNavigatorSection } from '../../../components/SectionNavigator'; const sections: SectionNavigatorSection[] = [ { diff --git a/src/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx b/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx similarity index 99% rename from src/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx rename to src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx index ca7abced49..ae20277ba3 100644 --- a/src/components/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx +++ b/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx @@ -116,13 +116,13 @@ vi.mock('../../../context', () => ({ }), })); -vi.mock('../../Notifications', () => ({ +vi.mock('../../../components/Notifications', () => ({ useNotificationApi: () => ({ addNotification: mocks.addNotification, }), })); -vi.mock('../../ChannelListItem/hooks/useIsChannelMuted', () => ({ +vi.mock('../../../components/ChannelListItem/hooks/useIsChannelMuted', () => ({ useIsChannelMuted: () => ({ muted: mocks.channelMuted }), })); diff --git a/src/components/ChannelDetail/__tests__/ChannelManagementView.test.tsx b/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx similarity index 93% rename from src/components/ChannelDetail/__tests__/ChannelManagementView.test.tsx rename to src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx index 28bf3556b3..3d3b1c75b3 100644 --- a/src/components/ChannelDetail/__tests__/ChannelManagementView.test.tsx +++ b/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx @@ -51,12 +51,13 @@ vi.mock('../../../context/ChatContext', () => ({ }), })); -vi.mock('../../ChannelList', () => ({ +vi.mock('../../../components/ChannelList', () => ({ useChannelMembershipState: () => mocks.channel.state.membership, })); -vi.mock('../../ChannelListItem', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../components/ChannelListItem', async (importOriginal) => { + const actual = + await importOriginal(); return { ...actual, @@ -68,19 +69,19 @@ vi.mock('../../ChannelListItem', async (importOriginal) => { }; }); -vi.mock('../../ChannelListItem/hooks/useIsChannelMuted', () => ({ +vi.mock('../../../components/ChannelListItem/hooks/useIsChannelMuted', () => ({ useIsChannelMuted: () => ({ muted: false }), })); -vi.mock('../../ChannelHeader/hooks/useChannelHasMembersOnline', () => ({ +vi.mock('../../../components/ChannelHeader/hooks/useChannelHasMembersOnline', () => ({ useChannelHasMembersOnline: () => false, })); -vi.mock('../../ChannelHeader/hooks/useChannelHeaderOnlineStatus', () => ({ +vi.mock('../../../components/ChannelHeader/hooks/useChannelHeaderOnlineStatus', () => ({ useChannelHeaderOnlineStatus: () => undefined, })); -vi.mock('../../Dialog', () => ({ +vi.mock('../../../components/Dialog', () => ({ Prompt: { Body: ({ children, @@ -131,8 +132,8 @@ vi.mock('../../Dialog', () => ({ }, })); -vi.mock('../../Icons', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../components/Icons', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, @@ -141,7 +142,7 @@ vi.mock('../../Icons', async (importOriginal) => { }; }); -vi.mock('../../Notifications/hooks/useNotificationApi', () => ({ +vi.mock('../../../components/Notifications/hooks/useNotificationApi', () => ({ useNotificationApi: () => ({ addNotification: mocks.addNotification }), })); diff --git a/src/components/ChannelDetail/index.ts b/src/plugins/ChannelDetail/index.ts similarity index 91% rename from src/components/ChannelDetail/index.ts rename to src/plugins/ChannelDetail/index.ts index 99dd43c441..f6685604a1 100644 --- a/src/components/ChannelDetail/index.ts +++ b/src/plugins/ChannelDetail/index.ts @@ -1,3 +1,4 @@ +export * from './AvatarWithChannelDetail'; export * from './ChannelDetail'; export * from './ChannelDetailContext'; export * from './ChannelDetailNavButton'; diff --git a/src/components/Avatar/styling/AvatarWithChannelDetail.scss b/src/plugins/ChannelDetail/styling/AvatarWithChannelDetail.scss similarity index 100% rename from src/components/Avatar/styling/AvatarWithChannelDetail.scss rename to src/plugins/ChannelDetail/styling/AvatarWithChannelDetail.scss diff --git a/src/components/ChannelDetail/styling/ChannelDetail.scss b/src/plugins/ChannelDetail/styling/ChannelDetail.scss similarity index 100% rename from src/components/ChannelDetail/styling/ChannelDetail.scss rename to src/plugins/ChannelDetail/styling/ChannelDetail.scss diff --git a/src/components/ChannelDetail/styling/ChannelFilesView.scss b/src/plugins/ChannelDetail/styling/ChannelFilesView.scss similarity index 100% rename from src/components/ChannelDetail/styling/ChannelFilesView.scss rename to src/plugins/ChannelDetail/styling/ChannelFilesView.scss diff --git a/src/components/ChannelDetail/styling/ChannelManagementView.scss b/src/plugins/ChannelDetail/styling/ChannelManagementView.scss similarity index 100% rename from src/components/ChannelDetail/styling/ChannelManagementView.scss rename to src/plugins/ChannelDetail/styling/ChannelManagementView.scss diff --git a/src/components/ChannelDetail/styling/ChannelMediaView.scss b/src/plugins/ChannelDetail/styling/ChannelMediaView.scss similarity index 100% rename from src/components/ChannelDetail/styling/ChannelMediaView.scss rename to src/plugins/ChannelDetail/styling/ChannelMediaView.scss diff --git a/src/components/ChannelDetail/styling/ChannelMemberDetailView.scss b/src/plugins/ChannelDetail/styling/ChannelMemberDetailView.scss similarity index 100% rename from src/components/ChannelDetail/styling/ChannelMemberDetailView.scss rename to src/plugins/ChannelDetail/styling/ChannelMemberDetailView.scss diff --git a/src/components/ChannelDetail/styling/ChannelMembersView.scss b/src/plugins/ChannelDetail/styling/ChannelMembersView.scss similarity index 100% rename from src/components/ChannelDetail/styling/ChannelMembersView.scss rename to src/plugins/ChannelDetail/styling/ChannelMembersView.scss diff --git a/src/components/ChannelDetail/styling/ChannelMembersViewListFooter.scss b/src/plugins/ChannelDetail/styling/ChannelMembersViewListFooter.scss similarity index 100% rename from src/components/ChannelDetail/styling/ChannelMembersViewListFooter.scss rename to src/plugins/ChannelDetail/styling/ChannelMembersViewListFooter.scss diff --git a/src/components/ChannelDetail/styling/PinnedMessagesView.scss b/src/plugins/ChannelDetail/styling/PinnedMessagesView.scss similarity index 100% rename from src/components/ChannelDetail/styling/PinnedMessagesView.scss rename to src/plugins/ChannelDetail/styling/PinnedMessagesView.scss diff --git a/src/components/ChannelDetail/styling/index.scss b/src/plugins/ChannelDetail/styling/index.scss similarity index 87% rename from src/components/ChannelDetail/styling/index.scss rename to src/plugins/ChannelDetail/styling/index.scss index c3146e2bc4..dcbbb0d1ee 100644 --- a/src/components/ChannelDetail/styling/index.scss +++ b/src/plugins/ChannelDetail/styling/index.scss @@ -1,3 +1,4 @@ +@use 'AvatarWithChannelDetail'; @use 'ChannelDetail'; @use 'ChannelFilesView'; @use 'ChannelMemberDetailView'; 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'), },