From 233c446c872e598cc46ec457c7610eb88f3ece8d Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 2 Jun 2026 10:19:17 +0200 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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'), }, From 7517c2bbd593212c26c1ba1d55057015c820f9d8 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 15 Jun 2026 12:05:14 +0200 Subject: [PATCH 22/29] fix(ChannelDetail): gate delete channel action behind permission check --- .../ChannelManagementActions.defaults.tsx | 6 +++++- .../ChannelManagementActions.defaults.test.tsx | 18 +++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx index ff596a204b..c968cf5d1e 100644 --- a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx @@ -151,6 +151,7 @@ const useChannelManagementActionFilterState = () => { return { canBlockUser: isDmChannelWithOtherUser && ownCapabilities?.includes('ban-channel-members'), + canDeleteChat: ownCapabilities?.includes('delete-channel'), canLeaveChannel: isGroupChannel && ownCapabilities?.includes('leave-channel'), canMuteChannel: ownCapabilities?.includes('mute-channel'), canMuteUser: isDmChannelWithOtherUser, @@ -160,7 +161,7 @@ const useChannelManagementActionFilterState = () => { export const useBaseChannelManagementActionSetFilter = ( channelManagementActionSet: ChannelManagementActionItem[], ) => { - const { canBlockUser, canLeaveChannel, canMuteChannel, canMuteUser } = + const { canBlockUser, canDeleteChat, canLeaveChannel, canMuteChannel, canMuteUser } = useChannelManagementActionFilterState(); return useMemo( @@ -169,6 +170,8 @@ export const useBaseChannelManagementActionSetFilter = ( switch (action.type) { case 'blockUser': return canBlockUser; + case 'deleteChat': + return canDeleteChat; case 'muteChannel': return canMuteChannel; case 'muteUser': @@ -181,6 +184,7 @@ export const useBaseChannelManagementActionSetFilter = ( }), [ canBlockUser, + canDeleteChat, canLeaveChannel, canMuteChannel, canMuteUser, diff --git a/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx b/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx index ae20277ba3..cdba79946f 100644 --- a/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx +++ b/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx @@ -192,6 +192,7 @@ describe('DefaultChannelManagementActions', () => { ]; mocks.channel.data.own_capabilities = [ 'ban-channel-members', + 'delete-channel', 'leave-channel', 'mute-channel', ]; @@ -423,12 +424,7 @@ describe('DefaultChannelManagementActions', () => { renderPermissionProbe(); - expect(getRenderedActionTypes()).toEqual([ - 'muteChannel', - 'muteUser', - 'blockUser', - 'deleteChat', - ]); + expect(getRenderedActionTypes()).toEqual(['muteChannel', 'muteUser', 'blockUser']); }); it('filters group actions by channel capabilities', () => { @@ -459,6 +455,14 @@ describe('DefaultChannelManagementActions', () => { renderPermissionProbe(); - expect(getRenderedActionTypes()).toEqual(['deleteChat']); + expect(getRenderedActionTypes()).toEqual([]); + }); + + it('hides delete chat when delete-channel capability is missing', () => { + mocks.channel.data.own_capabilities = ['ban-channel-members', 'mute-channel']; + + renderPermissionProbe(); + + expect(getRenderedActionTypes()).not.toContain('deleteChat'); }); }); From b89c4eeace45dff5c8e09bd040bf0229373945fa Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 15 Jun 2026 12:26:38 +0200 Subject: [PATCH 23/29] fix(ChannelDetail): propagate the destructive styles to action buttons --- .../ChannelManagementActions.defaults.tsx | 6 +++--- .../ChannelMemberActions.defaults.tsx | 4 ++-- src/plugins/ChannelDetail/styling/ChannelDetail.scss | 4 ++++ .../ChannelDetail/styling/ChannelMemberDetailView.scss | 4 ---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx index c968cf5d1e..0c971b62d2 100644 --- a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx @@ -46,10 +46,10 @@ const toError = (error: unknown) => const getDisplayName = (name?: string, fallback?: string) => name || fallback || ''; const BlockUserActionIcon = () => ( - + ); const DeleteChatActionIcon = () => ( - + ); const MuteActionIcon = () => ( @@ -58,7 +58,7 @@ const MutedActionIcon = () => ( ); const LeaveChannelActionIcon = () => ( - + ); const channelManagementViewActionClassName = 'str-chat__channel-management-view-action'; diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx index 100b09bb59..dc13634133 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx @@ -93,11 +93,11 @@ const SendDirectMessageActionIcon = () => ( ); const BlockUserActionIcon = () => ( - + ); const RemoveUserActionIcon = () => ( - + ); const channelMemberDetailActionClassName = 'str-chat__channel-member-detail-action'; diff --git a/src/plugins/ChannelDetail/styling/ChannelDetail.scss b/src/plugins/ChannelDetail/styling/ChannelDetail.scss index 324409f41e..b0a7a1b8f2 100644 --- a/src/plugins/ChannelDetail/styling/ChannelDetail.scss +++ b/src/plugins/ChannelDetail/styling/ChannelDetail.scss @@ -27,6 +27,10 @@ gap: var(--str-chat__spacing-md); padding: 0; } + + .str-chat__icon--destructive { + color: var(--str-chat__accent-error); + } } .str-chat__channel-detail--inline { diff --git a/src/plugins/ChannelDetail/styling/ChannelMemberDetailView.scss b/src/plugins/ChannelDetail/styling/ChannelMemberDetailView.scss index f8f8b6b221..c4979ccd61 100644 --- a/src/plugins/ChannelDetail/styling/ChannelMemberDetailView.scss +++ b/src/plugins/ChannelDetail/styling/ChannelMemberDetailView.scss @@ -53,10 +53,6 @@ text-transform: capitalize; } -.str-chat__channel-detail__action-icon--remove-user { - color: var(--str-chat__accent-error); -} - .str-chat__channel-member-confirmation-alert { min-width: min(304px, calc(100vw - 32px)); max-width: min(304px, calc(100vw - 32px)); From 002ad6085c1abe6616bf12c32854fb556356d0d6 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 16 Jun 2026 12:14:47 +0200 Subject: [PATCH 24/29] refactor: move member removal view to vite app --- .../ChannelDetail/channelDetailSettings.ts | 46 ++--- .../removeMembersHeaderActions.tsx | 54 ++++++ .../ChatLayout}/ChannelMembersRemoveView.tsx | 97 ++++++----- .../ChatLayout/ConfiguredChannelDetail.tsx | 23 ++- src/components/Dialog/components/Prompt.tsx | 8 +- .../InfiniteScrollPaginator/index.ts | 1 + src/components/Label/Label.tsx | 8 + src/components/Label/index.ts | 0 src/components/Label/styling/Label.scss | 12 ++ src/components/Label/styling/index.scss | 1 + .../__tests__/SectionNavigatorHeader.test.tsx | 15 ++ src/i18n/de.json | 3 + src/i18n/en.json | 3 + src/i18n/es.json | 4 + src/i18n/fr.json | 4 + src/i18n/hi.json | 3 + src/i18n/it.json | 4 + src/i18n/ja.json | 2 + src/i18n/ko.json | 2 + src/i18n/nl.json | 3 + src/i18n/pt.json | 4 + src/i18n/ru.json | 5 + src/i18n/tr.json | 3 + .../ChannelMembersAddView.tsx | 8 +- .../ChannelMembersHeaderActions.defaults.tsx | 161 ++++++----------- .../ChannelMembersView/ChannelMembersView.tsx | 136 +++++++++------ .../__tests__/ChannelMembersAddView.test.tsx | 14 +- ...nnelMembersHeaderActions.defaults.test.tsx | 143 ++++++++++++--- .../ChannelMembersRemoveView.test.tsx | 164 ------------------ .../__tests__/ChannelMembersView.test.tsx | 132 ++++++++------ .../__tests__/useChannelMemberCount.test.ts | 72 ++++++++ .../Views/ChannelMembersView/index.ts | 1 + .../useChannelMemberCount.ts | 31 ++++ src/plugins/ChannelDetail/index.ts | 3 + 34 files changed, 693 insertions(+), 477 deletions(-) create mode 100644 examples/vite/src/AppSettings/tabs/ChannelDetail/removeMembersHeaderActions.tsx rename {src/plugins/ChannelDetail/Views/ChannelMembersView => examples/vite/src/ChatLayout}/ChannelMembersRemoveView.tsx (65%) create mode 100644 src/components/Label/Label.tsx create mode 100644 src/components/Label/index.ts create mode 100644 src/components/Label/styling/Label.scss create mode 100644 src/components/Label/styling/index.scss delete mode 100644 src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx create mode 100644 src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/useChannelMemberCount.test.ts create mode 100644 src/plugins/ChannelDetail/Views/ChannelMembersView/useChannelMemberCount.ts diff --git a/examples/vite/src/AppSettings/tabs/ChannelDetail/channelDetailSettings.ts b/examples/vite/src/AppSettings/tabs/ChannelDetail/channelDetailSettings.ts index 48518a4ab9..fc5dd1160c 100644 --- a/examples/vite/src/AppSettings/tabs/ChannelDetail/channelDetailSettings.ts +++ b/examples/vite/src/AppSettings/tabs/ChannelDetail/channelDetailSettings.ts @@ -7,6 +7,10 @@ import type { ChannelDetailSettingsState, ChannelMembersHeaderActionId, } from '../../state'; +import { + RemoveMembersHeaderAction, + RemoveMembersMenuAction, +} from './removeMembersHeaderActions'; export const channelMembersHeaderActionLabels: Record< ChannelMembersHeaderActionId, @@ -16,6 +20,11 @@ export const channelMembersHeaderActionLabels: Record< removeMembers: 'Remove members', }; +// Bulk removal is an app-defined action, so the app owns its permission check. +// (The SDK gates its own `addMembers` action internally.) +const canRemoveMembers: ChannelMembersHeaderActionItem['filter'] = ({ channel }) => + channel.data?.own_capabilities?.includes('update-channel-members') ?? false; + export const getChannelMembersHeaderActionSet = ( channelDetail: ChannelDetailSettingsState, ): ChannelMembersHeaderActionItem[] => { @@ -29,30 +38,23 @@ export const getChannelMembersHeaderActionSet = ( switch (type) { case 'addMembers': - actionSet.push( - action.form === 'quick' - ? { - quick: DefaultChannelMembersHeaderActions.AddMembers, - type, - } - : { - menu: DefaultChannelMembersHeaderActions.AddMembersMenu, - type, - }, - ); + actionSet.push({ + component: + action.form === 'quick' + ? DefaultChannelMembersHeaderActions.AddMembers + : DefaultChannelMembersHeaderActions.AddMembersMenu, + placement: action.form, + type, + }); break; case 'removeMembers': - actionSet.push( - action.form === 'quick' - ? { - quick: DefaultChannelMembersHeaderActions.RemoveMembers, - type, - } - : { - menu: DefaultChannelMembersHeaderActions.RemoveMembersMenu, - type, - }, - ); + actionSet.push({ + component: + action.form === 'quick' ? RemoveMembersHeaderAction : RemoveMembersMenuAction, + filter: canRemoveMembers, + placement: action.form, + type, + }); break; default: break; diff --git a/examples/vite/src/AppSettings/tabs/ChannelDetail/removeMembersHeaderActions.tsx b/examples/vite/src/AppSettings/tabs/ChannelDetail/removeMembersHeaderActions.tsx new file mode 100644 index 0000000000..05d82881db --- /dev/null +++ b/examples/vite/src/AppSettings/tabs/ChannelDetail/removeMembersHeaderActions.tsx @@ -0,0 +1,54 @@ +import { + Button, + ContextMenuButton, + IconUserRemove, + useTranslationContext, +} from 'stream-chat-react'; +import type { ChannelMembersHeaderActionComponentProps } from 'stream-chat-react/channel-detail'; + +// Bulk member removal is an application-level feature in this demo. The SDK only +// provides the injectable `remove` mode + header-action machinery; the actions +// that switch into that mode live here, in app space. + +export const RemoveMembersHeaderAction = ({ + modeController, +}: ChannelMembersHeaderActionComponentProps) => { + const { t } = useTranslationContext(); + + if (modeController.mode !== 'browse') return null; + + return ( + + ); +}; + +export const RemoveMembersMenuAction = ({ + closeMenu, + modeController, +}: ChannelMembersHeaderActionComponentProps) => { + const { t } = useTranslationContext(); + + if (modeController.mode !== 'browse') return null; + + return ( + { + modeController.setMode('remove'); + closeMenu?.(); + }} + > + {t('Remove')} + + ); +}; diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx b/examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx similarity index 65% rename from src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx rename to examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx index 3fa210c0fc..b701e44273 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersRemoveView.tsx +++ b/examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx @@ -1,24 +1,38 @@ -import type { ChannelMemberResponse } from 'stream-chat'; -import React, { useMemo, useState } from 'react'; - -import { useTranslationContext } from '../../../../context'; -import { Avatar } from '../../../../components/Avatar'; -import { Checkbox } from '../../../../components/Form'; -import { InfiniteScrollPaginator } from '../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator'; -import { ListItemLayout } from '../../../../components/ListItemLayout'; -import { Prompt } from '../../../../components/Dialog'; -import { useChannelDetailContext } from '../../ChannelDetailContext'; +import type { ChannelMemberResponse, UserResponse } from 'stream-chat'; +import { useMemo, useState } from 'react'; import { - canUpdateChannelMembers, - getMemberDisplayName, - getMemberUserId, - getUserDisplayName, -} from './ChannelMembersView.utils'; -import { ChannelMembersBrowseView } from './ChannelMembersBrowseView'; -import { ChannelDetailEmptyList } from '../../ChannelDetailEmptyList'; -import { ChannelDetailListLoadingIndicator } from '../../ChannelDetailListLoadingIndicator'; -import { ChannelDetailSearchInput } from '../../ChannelDetailSearchInput'; -import { useChannelMembersSearch } from './useChannelMembersSearch'; + Avatar, + Checkbox, + InfiniteScrollPaginator, + ListItemLayout, + Prompt, + useNotificationApi, + useTranslationContext, +} from 'stream-chat-react'; +import { + ChannelDetailEmptyList, + ChannelDetailListLoadingIndicator, + ChannelDetailSearchInput, + type ChannelMembersModeViewProps, + useChannelDetailContext, + useChannelMembersSearch, +} from 'stream-chat-react/channel-detail'; + +// Bulk member removal is demonstrated as application code: the SDK no longer +// ships a bulk-remove view, matching the other-language Stream SDKs. It is +// wired into `ChannelMembersView` through the injectable `modeViews` registry. + +const toError = (error: unknown) => + error instanceof Error ? error : new Error('An unknown error occurred'); + +const getUserDisplayName = (user?: UserResponse) => + user?.name || user?.username || user?.id || ''; + +const getMemberDisplayName = (member: ChannelMemberResponse) => + getUserDisplayName(member.user) || member.user_id || ''; + +const getMemberUserId = (member: ChannelMemberResponse) => + member.user?.id || member.user_id; const getPresenceStatusText = ( user: ChannelMemberResponse['user'], @@ -37,20 +51,16 @@ const getPresenceStatusText = ( return t('Offline'); }; -export type ChannelMembersRemoveViewProps = { - onMembersRemoved?: (memberCount: number) => void; -}; - -const ChannelMembersRemoveList = ({ - onMembersRemoved, -}: ChannelMembersRemoveViewProps) => { +export const ChannelMembersRemoveView = ({ + modeController, +}: ChannelMembersModeViewProps) => { const { t } = useTranslationContext(); const { channel } = useChannelDetailContext(); + const { addNotification } = useNotificationApi(); const { displayedMembers, handleSearchChange, membersSearchSource, - resetMembersSearch, searchInputResetKey, } = useChannelMembersSearch(); const [isRemoving, setIsRemoving] = useState(false); @@ -76,9 +86,25 @@ const ChannelMembersRemoveList = ({ try { await channel.removeMembers(selectedMemberUserIds); - setSelectedMemberUserIds([]); - resetMembersSearch(); - onMembersRemoved?.(memberCount); + addNotification({ + context: { channel }, + emitter: 'ChannelMembersRemoveView', + message: t('Removed {{ count }} members', { count: memberCount }), + severity: 'success', + type: 'api:channel:remove-members:success', + }); + // Return to the browse list; its header member count updates reactively + // from real channel state — no manual count bookkeeping needed here. + modeController.setMode('browse'); + } catch (error) { + addNotification({ + context: { channel }, + emitter: 'ChannelMembersRemoveView', + error: toError(error), + message: t('Error removing members'), + severity: 'error', + type: 'api:channel:remove-members:failed', + }); } finally { setIsRemoving(false); } @@ -149,12 +175,3 @@ const ChannelMembersRemoveList = ({ ); }; - -export const ChannelMembersRemoveView = (props: ChannelMembersRemoveViewProps) => { - const { channel } = useChannelDetailContext(); - const canManageChannelMembers = canUpdateChannelMembers(channel); - - if (!canManageChannelMembers) return ; - - return ; -}; diff --git a/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx b/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx index baa0c7e95a..e0b2b6e989 100644 --- a/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx +++ b/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { type SectionNavigatorSection, type SectionNavigatorSectionContentProps, + useTranslationContext, } from 'stream-chat-react'; import { AvatarWithChannelDetail, @@ -9,11 +10,27 @@ import { ChannelDetail, type ChannelDetailProps, ChannelMembersView, + type ChannelMembersViewModes, defaultChannelDetailSections, } from 'stream-chat-react/channel-detail'; import { useAppSettingsSelector } from '../AppSettings/state'; import { getChannelMembersHeaderActionSet } from '../AppSettings/tabs/ChannelDetail'; +import { ChannelMembersRemoveView } from './ChannelMembersRemoveView'; + +const ChannelMembersRemoveTitle = () => { + const { t } = useTranslationContext(); + return <>{t('Manage members')}; +}; + +// Register the app-provided bulk-remove mode. The SDK ships no bulk removal; the +// `remove` mode is contributed entirely by this demo. +const channelMembersModeViews: ChannelMembersViewModes = { + remove: { + Body: ChannelMembersRemoveView, + Title: ChannelMembersRemoveTitle, + }, +}; const ConfiguredChannelDetail = (props: ChannelDetailProps) => { const channelDetail = useAppSettingsSelector((state) => state.channelDetail); @@ -29,7 +46,11 @@ const ConfiguredChannelDetail = (props: ChannelDetailProps) => { : { ...section, SectionContent: (sectionProps: SectionNavigatorSectionContentProps) => ( - + ), }, ), diff --git a/src/components/Dialog/components/Prompt.tsx b/src/components/Dialog/components/Prompt.tsx index eefd92616c..0ea4ce2937 100644 --- a/src/components/Dialog/components/Prompt.tsx +++ b/src/components/Dialog/components/Prompt.tsx @@ -12,7 +12,7 @@ const PromptRoot = ({ children, className, ...props }: ComponentProps<'div'>) => ); export type PromptHeaderProps = { - title: string; + title: React.ReactNode; className?: string; close?: () => void; description?: string; @@ -84,7 +84,11 @@ const PromptHeader = ({ - ); -}; - -const RemoveMembersMenuAction = ({ - closeMenu, - controller, -}: ChannelMembersHeaderActionComponentProps) => { - const { t } = useTranslationContext(); - - if (controller.mode !== 'browse') return null; - - return ( - { - controller.setMode('remove'); - closeMenu?.(); - }} - > - {t('Remove')} - - ); -}; - export const DefaultChannelMembersHeaderActions = { AddMembers: AddMembersHeaderAction, AddMembersMenu: AddMembersMenuAction, - RemoveMembers: RemoveMembersHeaderAction, - RemoveMembersMenu: RemoveMembersMenuAction, }; export const defaultChannelMembersHeaderActionSet: ChannelMembersHeaderActionItem[] = [ { - quick: DefaultChannelMembersHeaderActions.AddMembers, + component: DefaultChannelMembersHeaderActions.AddMembers, + placement: 'quick', type: 'addMembers', }, ]; @@ -191,9 +152,9 @@ const getHeaderActionsDialogId = (channelId?: string) => `channel-members-header-actions-${channelId ?? 'unknown'}`; export const DefaultHeaderActions = ({ - controller, headerActionSet, HeaderActionsMenuTrigger = DefaultHeaderActionsMenuTrigger, + modeController, }: ChannelMembersHeaderActionsProps) => { const { ContextMenu: ContextMenuComponent = ContextMenu } = useComponentContext(); const { channel } = useChannelDetailContext(); @@ -208,30 +169,16 @@ export const DefaultHeaderActions = ({ 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; + const quickActions = actions.filter((action) => action.placement === 'quick'); + const menuActions = actions.filter((action) => action.placement === 'menu'); return (
    - {shouldRenderSingleQuickAction && - quickActions.map(({ quick: QuickComponent, type }) => - QuickComponent ? : null, - )} - - {quickActionsOutsideMenu.map(({ quick: QuickComponent, type }) => - QuickComponent ? : null, - )} + {quickActions.map(({ component: QuickComponent, type }) => ( + + ))} - {shouldRenderMenu && ( + {menuActions.length > 0 && ( <> - {menuActions.map(({ menu: MenuComponent, type }) => - MenuComponent ? ( - dialog.close()} - controller={controller} - key={type} - /> - ) : null, - )} + {menuActions.map(({ component: MenuComponent, type }) => ( + dialog.close()} + key={type} + modeController={modeController} + /> + ))} )} diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx index dcb1eb5c03..fe3673bed4 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx @@ -12,29 +12,76 @@ import { } from './ChannelMembersHeaderActions.defaults'; import { ChannelMembersAddView } from './ChannelMembersAddView'; import { ChannelMembersBrowseView } from './ChannelMembersBrowseView'; -import { ChannelMembersRemoveView } from './ChannelMembersRemoveView'; +import { useChannelMemberCount } from './useChannelMemberCount'; import { SectionNavigatorHeader, type SectionNavigatorSectionContentProps, } from '../../../../components/SectionNavigator'; export type ChannelMembersHeaderActionsProps = { - controller: ChannelMembersViewController; + modeController: ChannelMembersModeController; HeaderActionsMenuTrigger?: React.ComponentType; headerActionSet: ChannelMembersHeaderActionItem[]; }; -export type ChannelMembersViewMode = 'add' | 'browse' | 'remove' | 'memberDetail'; +/** + * Built-in modes are rendered by `ChannelMembersView` itself. Any other string + * is treated as a custom mode and rendered from the injected `modeViews` registry. + */ +export type ChannelMembersViewMode = 'add' | 'browse' | 'memberDetail' | (string & {}); -export type ChannelMembersViewController = { +export type ChannelMembersModeController = { mode: ChannelMembersViewMode; setMode: (mode: ChannelMembersViewMode) => void; }; +export type ChannelMembersModeViewProps = { + /** + * Navigation surface for the mode. Call `modeController.setMode('browse')` to + * return to the list, or any other mode key to transition between modes. + */ + modeController: ChannelMembersModeController; +}; + +export type ChannelMembersViewModeDescriptor = { + /** Body rendered below the section header for this mode. */ + Body: React.ComponentType; + /** + * Header title for this mode. A component (rather than a string) so it can pull + * whatever it needs from context/hooks — member count, translation, etc. + */ + Title: React.ComponentType; +}; + +/** Registry of modes, keyed by the mode string passed to `setMode`. */ +export type ChannelMembersViewModes = Record; + +// `browse` (the default list) and `memberDetail` (selection-driven, renders its +// own header) are handled by ChannelMembersView directly; every other mode — +// built-in `add` or app-provided — is resolved from the mode-view registry. +const RESERVED_MODES: ChannelMembersViewMode[] = ['browse', 'memberDetail']; + +const isReservedMode = (mode: ChannelMembersViewMode) => RESERVED_MODES.includes(mode); + +const AddMembersModeTitle = () => { + const { t } = useTranslationContext(); + return <>{t('Add members')}; +}; + +/** Built-in mode descriptors. Merged with (and overridable by) the `modeViews` prop. */ +export const defaultChannelMembersModeViews: ChannelMembersViewModes = { + add: { + Body: ChannelMembersAddView, + Title: AddMembersModeTitle, + }, +}; + export type ChannelMembersViewProps = SectionNavigatorSectionContentProps & { HeaderActions?: React.ComponentType; HeaderActionsMenuTrigger?: React.ComponentType; headerActionSet?: ChannelMembersHeaderActionItem[]; + /** App-provided modes (e.g. bulk removal) rendered alongside the built-in ones. */ + modeViews?: ChannelMembersViewModes; }; export const ChannelMembersView = ({ @@ -42,32 +89,22 @@ export const ChannelMembersView = ({ headerActionSet = defaultChannelMembersHeaderActionSet, HeaderActionsMenuTrigger, layout, + modeViews: customModeViews, }: 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 memberCount = useChannelMemberCount(channel); - const isAddingMember = mode === 'add'; - const isManagingMembers = mode === 'remove'; - const isViewingMemberDetail = mode === 'memberDetail'; - const isAlternateMode = isAddingMember || isManagingMembers || isViewingMemberDetail; - - useEffect(() => { - setMemberCount(channel.data?.member_count ?? 0); - }, [channel.data?.member_count]); - - useEffect(() => { - if (!membersAddedCount) return; - - const timeout = setTimeout(() => setMembersAddedCount(0), 3000); + const modeViews = useMemo( + () => ({ ...defaultChannelMembersModeViews, ...customModeViews }), + [customModeViews], + ); - return () => clearTimeout(timeout); - }, [membersAddedCount]); + const activeModeDescriptor = isReservedMode(mode) ? undefined : modeViews[mode]; + const isViewingMemberDetail = mode === 'memberDetail'; const setViewMode = useCallback((nextMode: ChannelMembersViewMode) => { setMode(nextMode); @@ -76,9 +113,17 @@ export const ChannelMembersView = ({ } }, []); + // Fall back to the browse list if an unknown mode becomes active (e.g. the app + // stops providing the mode's descriptor while it is selected). + useEffect(() => { + if (!isReservedMode(mode) && !modeViews[mode]) { + setViewMode('browse'); + } + }, [mode, modeViews, setViewMode]); + const goBack = useCallback(() => setViewMode('browse'), [setViewMode]); - const controller = useMemo( + const modeController = useMemo( () => ({ mode, setMode: setViewMode, @@ -92,58 +137,43 @@ export const ChannelMembersView = ({ if (mode !== 'browse') return null; return ( ); }, - [HeaderActions, HeaderActionsMenuTrigger, controller, headerActionSet, mode], + [HeaderActions, HeaderActionsMenuTrigger, modeController, headerActionSet, mode], ); - const headerTitle = isAddingMember - ? t('Add members') - : isManagingMembers - ? t('Manage members') - : t('{{ count }} members', { count: memberCount }); - if (isViewingMemberDetail && selectedMember) { return ( ); } + const ActiveModeTitle = activeModeDescriptor?.Title; + const ActiveModeBody = activeModeDescriptor?.Body; + return (
    + ) : ( + t('{{ count }} members', { count: memberCount }) + ) } - title={headerTitle} TrailingContent={HeaderTrailingActions} /> - {isAddingMember ? ( - { - setMemberCount((currentCount) => currentCount + count); - setMembersAddedCount(count); - setMembersRefreshKey((currentKey) => currentKey + 1); - goBack(); - }} - /> - ) : isManagingMembers ? ( - { - setMemberCount((currentCount) => currentCount - count); - }} - /> + {ActiveModeBody ? ( + ) : ( { setSelectedMember(member); setViewMode('memberDetail'); diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx index 0531dda4a8..97638663aa 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx @@ -55,7 +55,7 @@ const searchUsers: UserResponse[] = [ ]; describe('ChannelMembersAddView', () => { - const onMembersAdded = vi.fn(); + const setMode = vi.fn(); beforeEach(() => { vi.clearAllMocks(); @@ -82,7 +82,7 @@ describe('ChannelMembersAddView', () => { renderWithChannel( , createChannel({ @@ -102,7 +102,7 @@ describe('ChannelMembersAddView', () => { renderWithChannel( , channel, @@ -119,7 +119,7 @@ describe('ChannelMembersAddView', () => { await waitFor(() => { expect(channel.addMembers).toHaveBeenCalledWith(['user-2', 'user-3']); - expect(onMembersAdded).toHaveBeenCalledWith(2); + expect(setMode).toHaveBeenCalledWith('browse'); }); }); @@ -129,7 +129,7 @@ describe('ChannelMembersAddView', () => { renderWithChannel( , channel, @@ -150,7 +150,7 @@ describe('ChannelMembersAddView', () => { renderWithChannel( , ); @@ -165,7 +165,7 @@ describe('ChannelMembersAddView', () => { renderWithChannel( , ); diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx index 2ceddf1bcf..e9fb24cbd2 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx @@ -11,10 +11,12 @@ import { import { ChannelDetailProvider } from '../../../ChannelDetailContext'; import { type ChannelMembersHeaderActionItem, + DefaultChannelMembersHeaderActions, + defaultChannelMembersHeaderActionSet, DefaultHeaderActions, } from '../ChannelMembersHeaderActions.defaults'; import type { - ChannelMembersViewController, + ChannelMembersModeController, ChannelMembersViewMode, } from '../ChannelMembersView'; @@ -62,9 +64,9 @@ const createChannel = (ownCapabilities: string[] = ['update-channel-members']) = const renderWithChannel = (ui: React.ReactElement, channel: Channel = createChannel()) => render({ui}); -const createController = ( +const createModeController = ( mode: ChannelMembersViewMode = 'browse', -): ChannelMembersViewController => ({ +): ChannelMembersModeController => ({ mode, setMode: vi.fn(), }); @@ -81,18 +83,19 @@ describe('ChannelMembersHeaderActions.defaults', () => { ); }); - it('renders quick variant for single action when available', () => { + it('renders a quick action inline without a menu trigger', () => { const actionSet: ChannelMembersHeaderActionItem[] = [ { - quick: () => Quick Add, + component: () => Quick Add, + placement: 'quick', type: 'addMembers', }, ]; renderWithChannel( , ); @@ -102,18 +105,19 @@ describe('ChannelMembersHeaderActions.defaults', () => { ).not.toBeInTheDocument(); }); - it('renders menu fallback for single action without quick variant', () => { + it('renders a menu action behind the actions trigger', () => { const actionSet: ChannelMembersHeaderActionItem[] = [ { - menu: () => Menu Add, + component: () => Menu Add, + placement: 'menu', type: 'addMembers', }, ]; renderWithChannel( , ); @@ -123,35 +127,39 @@ describe('ChannelMembersHeaderActions.defaults', () => { expect(screen.getByText('Menu Add')).toBeInTheDocument(); }); - it('prefers menu variants when multiple actions exist', () => { + it('renders quick actions inline and menu actions in the menu', () => { const actionSet: ChannelMembersHeaderActionItem[] = [ { - menu: () => Menu Add, - quick: () => Quick Add, + component: () => Quick Add, + placement: 'quick', type: 'addMembers', }, { - menu: () => Menu Manage, + component: () => Menu Manage, + placement: 'menu', type: 'removeMembers', }, ]; renderWithChannel( , ); - expect(screen.getByText('Menu Add')).toBeInTheDocument(); + expect(screen.getByText('Quick Add')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Open members actions' }), + ).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, + component: () => Menu Add, + placement: 'menu', type: 'addMembers', }, ]; @@ -174,9 +182,9 @@ describe('ChannelMembersHeaderActions.defaults', () => { renderWithChannel( , ); @@ -185,22 +193,115 @@ describe('ChannelMembersHeaderActions.defaults', () => { ).toBeInTheDocument(); }); - it('filters actions out when update-channel-members capability is missing', () => { + it('hides the addMembers action when the member-management capability is missing', () => { const actionSet: ChannelMembersHeaderActionItem[] = [ { - quick: () => Quick Add, + component: () => Quick Add, + placement: 'quick', type: 'addMembers', }, ]; renderWithChannel( , createChannel([]), ); expect(screen.queryByText('Quick Add')).not.toBeInTheDocument(); }); + + it('shows the addMembers action when the member-management capability is present', () => { + const actionSet: ChannelMembersHeaderActionItem[] = [ + { + component: () => Quick Add, + placement: 'quick', + type: 'addMembers', + }, + ]; + + renderWithChannel( + , + createChannel(['update-channel-members']), + ); + + expect(screen.getByText('Quick Add')).toBeInTheDocument(); + }); + + it('shows a non-member action regardless of the member-management capability', () => { + const actionSet: ChannelMembersHeaderActionItem[] = [ + { + component: () => Custom Action, + placement: 'quick', + type: 'customAction', + }, + ]; + + renderWithChannel( + , + createChannel([]), + ); + + expect(screen.getByText('Custom Action')).toBeInTheDocument(); + }); + + it('hides an app-defined action when its own filter returns false', () => { + const actionSet: ChannelMembersHeaderActionItem[] = [ + { + component: () => Custom Action, + filter: () => false, + placement: 'quick', + type: 'customAction', + }, + ]; + + renderWithChannel( + , + ); + + expect(screen.queryByText('Custom Action')).not.toBeInTheDocument(); + }); + + it('passes the channel to an app-defined filter', () => { + const filter = vi.fn(() => true); + const channel = createChannel(['update-channel-members']); + + renderWithChannel( + Custom Action, + filter, + placement: 'quick', + type: 'customAction', + }, + ]} + modeController={createModeController()} + />, + channel, + ); + + expect(filter).toHaveBeenCalledWith({ channel }); + }); + + it('ships no bulk member-removal surface', () => { + expect( + defaultChannelMembersHeaderActionSet.some( + (action) => action.type === 'removeMembers', + ), + ).toBe(false); + expect(DefaultChannelMembersHeaderActions).not.toHaveProperty('RemoveMembers'); + expect(DefaultChannelMembersHeaderActions).not.toHaveProperty('RemoveMembersMenu'); + }); }); diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx deleted file mode 100644 index 6425c69afc..0000000000 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersRemoveView.test.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import React from 'react'; -import type { ChannelMemberResponse } from 'stream-chat'; - -import { useChatContext, useTranslationContext } from '../../../../../context'; -import { useStateStore } from '../../../../../store'; -import { ChannelMembersRemoveView } from '../ChannelMembersRemoveView'; -import { createChannel, getSelectableMemberButton, renderWithChannel } from './testUtils'; - -const mocks = vi.hoisted(() => ({ - searchSourceActivate: vi.fn(), - searchSourceCancelScheduledQuery: vi.fn(), - searchSourceResetState: vi.fn(), - searchSourceSearch: vi.fn(), -})); - -vi.mock('stream-chat', async (importOriginal) => { - const actual = await importOriginal(); - - class ChannelMemberSearchSource { - state = {}; - - activate = mocks.searchSourceActivate; - - search = mocks.searchSourceSearch; - - resetState = mocks.searchSourceResetState; - - cancelScheduledQuery = mocks.searchSourceCancelScheduledQuery; - } - - return { - ...actual, - ChannelMemberSearchSource, - }; -}); - -vi.mock('../../../../../context'); -vi.mock('../../../../../store'); - -vi.mock( - '../../../../../components/InfiniteScrollPaginator/InfiniteScrollPaginator', - () => ({ - InfiniteScrollPaginator: ({ children }: { children: React.ReactNode }) => ( -
    {children}
    - ), - }), -); - -vi.mock('../../../../../components/Dialog', () => ({ - Prompt: { - Body: ({ children }: { children: React.ReactNode }) =>
    {children}
    , - Footer: ({ children }: { children: React.ReactNode }) =>
    {children}
    , - FooterControls: ({ children }: { children: React.ReactNode }) => ( -
    {children}
    - ), - FooterControlsButtonPrimary: ( - props: React.ButtonHTMLAttributes, - ) =>
    @@ -69,20 +72,6 @@ vi.mock('../ChannelMembersBrowseView', () => ({ ), })); -vi.mock('../ChannelMembersRemoveView', () => ({ - ChannelMembersRemoveView: ({ - onMembersRemoved, - }: { - onMembersRemoved?: (count: number) => void; - }) => ( -
    - -
    - ), -})); - vi.mock('../../../../../components/Dialog', () => ({ ContextMenu: ({ children }: { children: React.ReactNode }) => (
    {children}
    @@ -142,29 +131,50 @@ vi.mock('../../../../../components/Dialog', () => ({ }), })); +// Test double for an app-injected custom mode. Its button exercises the +// modeController the SDK hands to every custom mode. +const MockCustomModeView = ({ modeController }: ChannelMembersModeViewProps) => ( +
    + +
    +); + +const MockCustomModeTitle = () => <>Manage members; + +const customModeViews: ChannelMembersViewModes = { + remove: { + Body: MockCustomModeView, + Title: MockCustomModeTitle, + }, +}; + describe('ChannelMembersView', () => { const close = vi.fn(); const customHeaderActionSet: ChannelMembersHeaderActionItem[] = [ { - menu: () => null, + component: () => null, + placement: 'menu', type: 'removeMembers', }, { - quick: () => null, + component: () => null, + placement: 'quick', type: 'addMembers', }, ]; const CustomHeaderActions = ({ - controller, headerActionSet, + modeController, }: { - controller: { - mode: 'add' | 'browse' | 'remove' | 'memberDetail'; - setMode: (mode: 'add' | 'browse' | 'remove' | 'memberDetail') => void; + modeController: { + mode: string; + setMode: (mode: string) => void; }; headerActionSet: ChannelMembersHeaderActionItem[]; }) => { - if (controller.mode !== 'browse') return null; + if (modeController.mode !== 'browse') return null; const hasManageAction = headerActionSet.some( (action) => action.type === 'removeMembers', @@ -176,7 +186,7 @@ describe('ChannelMembersView', () => { {hasManageAction && (