Skip to content

Commit b4bb5bc

Browse files
Introduce 'quick-dropdown-toggle' action placement, adjust defaults
1 parent c7c1e73 commit b4bb5bc

8 files changed

Lines changed: 201 additions & 159 deletions

File tree

src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx

Lines changed: 82 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import { type ComponentPropsWithoutRef, useMemo, useState } from 'react';
2-
import { match, P } from 'ts-pattern';
1+
import {
2+
type ComponentPropsWithoutRef,
3+
type ComponentPropsWithRef,
4+
forwardRef,
5+
useMemo,
6+
useState,
7+
} from 'react';
38

49
import { useChatContext, useTranslationContext } from '../../context';
510
import { useChannelMembershipState, useChannelMembersState } from '../ChannelList';
@@ -9,11 +14,12 @@ import {
914
IconArchive,
1015
IconArrowBoxLeft,
1116
IconCircleBanSign,
17+
IconDotGrid1x3Horizontal,
1218
IconMute,
1319
IconPin,
1420
} from '../Icons';
1521
import { useIsChannelMuted } from './hooks/useIsChannelMuted';
16-
import { ContextMenuButton, useDialogOnNearestManager } from '../Dialog';
22+
import { ContextMenuButton, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog';
1723
import { addNotificationTargetTag, useNotificationTarget } from '../Notifications';
1824
import { ChannelListItemActionButtons } from './ChannelListItemActionButtons';
1925

@@ -99,12 +105,50 @@ const useArchiveActionButtonBehavior = () => {
99105
} satisfies ComponentPropsWithoutRef<'button'>;
100106
};
101107

102-
type ChannelActionItem = ({ placement: 'quick' } | { placement: 'dropdown' }) & {
103-
Component: React.ComponentType;
104-
type: string;
105-
};
108+
type ChannelActionItem =
109+
| (({ placement: 'quick' } | { placement: 'dropdown' }) & {
110+
type: string;
111+
Component: React.ComponentType;
112+
})
113+
| {
114+
placement: 'quick-dropdown-toggle';
115+
Component: React.ComponentType<ComponentPropsWithRef<'button'>>;
116+
};
106117

107118
export const defaultChannelActionSet: ChannelActionItem[] = [
119+
{
120+
// eslint-disable-next-line react/display-name
121+
Component: forwardRef<HTMLButtonElement>((_, ref) => {
122+
const { channel } = useChannelListItemContext();
123+
124+
const dialogId = ChannelListItemActionButtons.getDialogId({
125+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
126+
channelId: channel.id!,
127+
});
128+
const { dialog, dialogManager } = useDialogOnNearestManager({ id: dialogId });
129+
const dialogIsOpen = useDialogIsOpen(dialogId, dialogManager?.id);
130+
131+
return (
132+
<Button
133+
appearance='ghost'
134+
aria-expanded={dialogIsOpen}
135+
aria-pressed={dialogIsOpen}
136+
circular
137+
onClick={(e) => {
138+
e.stopPropagation();
139+
140+
dialog.toggle();
141+
}}
142+
ref={ref}
143+
size='sm'
144+
variant='secondary'
145+
>
146+
<IconDotGrid1x3Horizontal />
147+
</Button>
148+
);
149+
}),
150+
placement: 'quick-dropdown-toggle',
151+
},
108152
{
109153
Component() {
110154
const behaviorProps = useArchiveActionButtonBehavior();
@@ -248,7 +292,7 @@ export const defaultChannelActionSet: ChannelActionItem[] = [
248292
const membership = useChannelMembershipState(channel);
249293
const dialogId = ChannelListItemActionButtons.getDialogId(
250294
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
251-
channel.id!,
295+
{ channelId: channel.id! },
252296
);
253297
const { dialog } = useDialogOnNearestManager({ id: dialogId });
254298
const [inProgress, setInProgress] = useState(false);
@@ -364,63 +408,36 @@ export const useBaseChannelActionSetFilter = (channelActionSet: ChannelActionIte
364408
const ownCapabilities = channel.data?.own_capabilities;
365409

366410
return useMemo(() => {
367-
const filtered = channelActionSet.filter((action) =>
368-
match({
369-
action,
370-
connectedUserIsMember,
371-
isDirectMessageChannel,
372-
memberCount,
373-
ownCapabilities,
374-
})
375-
.returnType<boolean>()
376-
// only allow defined actions if they match these pre-defined conditions
377-
.with(
378-
{
379-
action: { connectedUserIsMember: true, placement: 'quick', type: 'archive' },
380-
isDirectMessageChannel: true,
381-
},
382-
{
383-
action: {
384-
connectedUserIsMember: true,
385-
placement: 'dropdown',
386-
type: 'archive',
387-
},
388-
isDirectMessageChannel: false,
389-
},
390-
{
391-
action: { placement: 'dropdown', type: 'mute' },
392-
isDirectMessageChannel: true,
393-
ownCapabilities: P.when((capabilities) =>
394-
capabilities?.includes('mute-channel'),
395-
),
396-
},
397-
{
398-
action: { placement: 'quick', type: 'mute' },
399-
isDirectMessageChannel: false,
400-
ownCapabilities: P.when((capabilities) =>
401-
capabilities?.includes('mute-channel'),
402-
),
403-
},
404-
{
405-
action: { type: 'ban' },
406-
memberCount: P.number.gt(0).and(P.number.lte(2)),
407-
ownCapabilities: P.when((capabilities) =>
408-
capabilities?.includes('ban-channel-members'),
409-
),
410-
},
411-
{
412-
action: { type: 'leave' },
413-
ownCapabilities: P.when((capabilities) =>
414-
capabilities?.includes('leave-channel'),
415-
),
416-
},
417-
{
418-
action: { connectedUserIsMember: true, type: 'pin' },
419-
},
420-
() => true,
421-
)
422-
.otherwise(() => false),
423-
);
411+
const filtered = channelActionSet.filter((action) => {
412+
if (action.placement === 'quick-dropdown-toggle') return true;
413+
414+
switch (action.type) {
415+
case 'archive':
416+
return (
417+
connectedUserIsMember &&
418+
((action.placement === 'quick' && isDirectMessageChannel) ||
419+
(action.placement === 'dropdown' && !isDirectMessageChannel))
420+
);
421+
case 'mute':
422+
return (
423+
ownCapabilities?.includes('mute-channel') &&
424+
((action.placement === 'dropdown' && isDirectMessageChannel) ||
425+
(action.placement === 'quick' && !isDirectMessageChannel))
426+
);
427+
case 'ban':
428+
return (
429+
memberCount > 0 &&
430+
memberCount <= 2 &&
431+
ownCapabilities?.includes('ban-channel-members')
432+
);
433+
case 'leave':
434+
return ownCapabilities?.includes('leave-channel');
435+
case 'pin':
436+
return connectedUserIsMember;
437+
default:
438+
return true;
439+
}
440+
});
424441

425442
return filtered;
426443
}, [
Lines changed: 12 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import React, { type ComponentProps, type ComponentType, type ReactNode } from 'react';
22

3-
import { Button } from '../Button';
4-
import { IconDotGrid1x3Horizontal } from '../Icons';
5-
63
import clsx from 'clsx';
74
import { ContextMenu, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog';
85
import {
@@ -16,28 +13,26 @@ export type ChannelListItemActionButtonsProps = ComponentProps<ComponentType>; /
1613

1714
interface ChannelListItemActionButtonsInterface {
1815
(props: ChannelListItemActionButtonsProps): ReactNode;
19-
getDialogId: (channelId: string) => string;
16+
getDialogId: (_: { channelId: string }) => string;
2017
displayName: string;
2118
}
2219

2320
export const ChannelListItemActionButtons: ChannelListItemActionButtonsInterface = () => {
2421
const { channel } = useChannelListItemContext();
2522
const [referenceElement, setReferenceElement] =
2623
React.useState<HTMLButtonElement | null>(null);
27-
const dialogId = ChannelListItemActionButtons.getDialogId(
24+
const dialogId = ChannelListItemActionButtons.getDialogId({
2825
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
29-
channel.id!,
30-
);
26+
channelId: channel.id!,
27+
});
3128
const { dialog, dialogManager } = useDialogOnNearestManager({ id: dialogId });
3229
const dialogIsOpen = useDialogIsOpen(dialogId, dialogManager?.id);
3330

3431
const filteredActionSet = useBaseChannelActionSetFilter(defaultChannelActionSet);
35-
const splitActionSet = useSplitActionSet(filteredActionSet);
32+
const { dropdownActionSet, quickActionSet, quickDropdownToggleAction } =
33+
useSplitActionSet(filteredActionSet);
3634

37-
if (
38-
splitActionSet.quickActionSet.length + splitActionSet.dropdownActionSet.length ===
39-
0
40-
) {
35+
if (quickActionSet.length + dropdownActionSet.length === 0) {
4136
// no buttons to render, omit rendering wrapper
4237
return null;
4338
}
@@ -48,25 +43,10 @@ export const ChannelListItemActionButtons: ChannelListItemActionButtonsInterface
4843
'str-chat__channel-list-item__action-buttons--active': dialogIsOpen,
4944
})}
5045
>
51-
{splitActionSet.dropdownActionSet.length > 0 && (
52-
<Button
53-
appearance='ghost'
54-
aria-expanded={dialogIsOpen}
55-
aria-pressed={dialogIsOpen}
56-
circular
57-
onClick={(e) => {
58-
e.stopPropagation();
59-
60-
dialog.toggle();
61-
}}
62-
ref={setReferenceElement}
63-
size='sm'
64-
variant='secondary'
65-
>
66-
<IconDotGrid1x3Horizontal />
67-
</Button>
46+
{quickDropdownToggleAction && dropdownActionSet.length > 0 && (
47+
<quickDropdownToggleAction.Component ref={setReferenceElement} />
6848
)}
69-
{splitActionSet.quickActionSet.map(({ Component, type }) => (
49+
{quickActionSet.map(({ Component, type }) => (
7050
<Component key={type} />
7151
))}
7252
<ContextMenu
@@ -79,15 +59,15 @@ export const ChannelListItemActionButtons: ChannelListItemActionButtonsInterface
7959
tabIndex={-1}
8060
trapFocus
8161
>
82-
{splitActionSet.dropdownActionSet.map(({ Component, type }) => (
62+
{dropdownActionSet.map(({ Component, type }) => (
8363
<Component key={type} />
8464
))}
8565
</ContextMenu>
8666
</div>
8767
);
8868
};
8969

90-
ChannelListItemActionButtons.getDialogId = (channelId: string) =>
70+
ChannelListItemActionButtons.getDialogId = ({ channelId }) =>
9171
`channel-action-buttons-${channelId}`;
9272

9373
ChannelListItemActionButtons.displayName = 'ChannelListItemActionButtons';
Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
11
import { useMemo } from 'react';
22

33
export const useSplitActionSet = <
4-
T extends { placement: 'quick' } | { placement: 'dropdown' },
4+
T extends
5+
| { placement: 'quick' }
6+
| { placement: 'dropdown' }
7+
| { placement: 'quick-dropdown-toggle' },
58
>(
69
actionSet: T[],
710
) =>
811
useMemo(() => {
912
const quickActionSet: Extract<T, { placement: 'quick' }>[] = [];
1013
const dropdownActionSet: Extract<T, { placement: 'dropdown' }>[] = [];
14+
let quickDropdownToggleAction:
15+
| Extract<T, { placement: 'quick-dropdown-toggle' }>
16+
| undefined;
1117

1218
for (const action of actionSet) {
1319
if (action.placement === 'quick')
1420
quickActionSet.push(action as (typeof quickActionSet)[number]);
1521
if (action.placement === 'dropdown')
1622
dropdownActionSet.push(action as (typeof dropdownActionSet)[number]);
23+
if (action.placement === 'quick-dropdown-toggle') {
24+
quickDropdownToggleAction ??= action as Extract<
25+
T,
26+
{ placement: 'quick-dropdown-toggle' }
27+
>;
28+
}
1729
}
1830

19-
return { dropdownActionSet, quickActionSet } as const;
31+
return { dropdownActionSet, quickActionSet, quickDropdownToggleAction } as const;
2032
}, [actionSet]);

src/components/MessageActions/MessageActions.defaults.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable sort-keys */
2-
import React, { useState } from 'react';
2+
import React, { forwardRef, useState } from 'react';
33

44
import { GlobalModal } from '../Modal';
55
import {
@@ -13,6 +13,7 @@ import {
1313
IconBubbleWideNotificationChatMessage,
1414
IconCircleBanSign,
1515
IconCloseQuote2,
16+
IconDotGrid1x3Horizontal,
1617
IconEditBig,
1718
IconEmojiSmile,
1819
IconFlag2,
@@ -36,8 +37,13 @@ import {
3637
useTranslationContext,
3738
} from '../../context';
3839
import { RemindMeSubmenu, RemindMeSubmenuHeader } from './RemindMeSubmenu';
39-
import { ContextMenuButton, useContextMenuContext } from '../Dialog';
40-
import type { MessageActionSetItem } from './MessageActions';
40+
import {
41+
ContextMenuButton,
42+
useContextMenuContext,
43+
useDialogIsOpen,
44+
useDialogOnNearestManager,
45+
} from '../Dialog';
46+
import { MessageActions, type MessageActionSetItem } from './MessageActions';
4147
import { QuickMessageActionsButton } from './QuickMessageActionButton';
4248
import clsx from 'clsx';
4349
import { DeleteMessageAlert } from './DeleteMessageAlert';
@@ -374,6 +380,33 @@ const DefaultMessageActionComponents = {
374380
},
375381
},
376382
quick: {
383+
// eslint-disable-next-line react/display-name
384+
DropdownToggle: forwardRef<HTMLButtonElement>((_, ref) => {
385+
const { t } = useTranslationContext();
386+
const { message } = useMessageContext();
387+
const dropdownDialogIsOpen = useDialogIsOpen(
388+
MessageActions.getDialogId({ messageId: message.id }),
389+
);
390+
const { dialog } = useDialogOnNearestManager({
391+
id: MessageActions.getDialogId({ messageId: message.id }),
392+
});
393+
394+
return (
395+
<QuickMessageActionsButton
396+
aria-expanded={dropdownDialogIsOpen}
397+
aria-haspopup='true'
398+
aria-label={t('aria/Open Message Actions Menu')}
399+
className='str-chat__message-actions-box-button'
400+
data-testid='message-actions-toggle-button'
401+
onClick={() => {
402+
dialog?.toggle();
403+
}}
404+
ref={ref}
405+
>
406+
<IconDotGrid1x3Horizontal className='str-chat__message-action-icon' />
407+
</QuickMessageActionsButton>
408+
);
409+
}),
377410
React() {
378411
return <ReactionSelectorWithButton ReactionIcon={IconEmojiSmile} />;
379412
},
@@ -396,7 +429,10 @@ const DefaultMessageActionComponents = {
396429
};
397430

398431
export const defaultMessageActionSet: MessageActionSetItem[] = [
399-
// { placement: 'dropdown', type: 'block' },
432+
{
433+
Component: DefaultMessageActionComponents.quick.DropdownToggle,
434+
placement: 'quick-dropdown-toggle',
435+
},
400436
{
401437
Component: DefaultMessageActionComponents.quick.Reply,
402438
placement: 'quick',

0 commit comments

Comments
 (0)