diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index 2de79499b..396877efc 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -838,3 +838,22 @@ describe('ThreadListItem fallbacks', () => { expect(screen.queryByText('(no subject)')).toBeNull(); }); }); + +describe('ThreadListItem swipe on a conversation', () => { + it('archives the conversation\'s row like a single message\'s', () => { + useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false }); + useSettingsStore.setState({ swipeRightAction: 'archive', swipeLeftAction: 'delete' } as never); + const first = makeEmail({ id: 'a', threadId: 't', receivedAt: '2024-01-15T09:00:00Z' }); + const second = makeEmail({ id: 'b', threadId: 't', receivedAt: '2024-01-15T10:00:00Z' }); + const [thread] = groupEmailsByThread([first, second]); + const onArchive = vi.fn(); + const { container } = render( + {}} onEmailSelect={() => {}} onArchive={onArchive} onDelete={vi.fn()} />, + ); + const row = container.querySelector('[data-unread]')!; + fireEvent.touchStart(row, { touches: [{ clientX: 10, clientY: 10 }] }); + fireEvent.touchMove(row, { touches: [{ clientX: 160, clientY: 12 }] }); + fireEvent.touchEnd(row); + expect(onArchive).toHaveBeenCalledWith(expect.objectContaining({ id: 'b' })); + }); +}); diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index e1ab9857b..d393878c1 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -1015,6 +1015,53 @@ export const ThreadListItem = React.forwardRef state.swipeRightAction); + const threadSwipeLeftAction = useSettingsStore((state) => state.swipeLeftAction); + const runThreadSwipe = (action: SwipeAction) => { + switch (action) { + case 'archive': onArchive?.(latestEmail); break; + case 'delete': onDelete?.(latestEmail); break; + case 'markRead': onMarkAsRead?.(latestEmail, hasUnread); break; + case 'star': onToggleStar?.(latestEmail); break; + case 'spam': onMarkAsSpam?.(latestEmail); break; + default: break; + } + }; + const threadActionable = (action: SwipeAction): boolean => { + switch (action) { + case 'archive': return !!onArchive; + case 'delete': return !!onDelete; + case 'markRead': return !!onMarkAsRead; + case 'star': return !!onToggleStar; + case 'spam': return !!onMarkAsSpam; + default: return false; + } + }; + const threadHasRight = threadActionable(threadSwipeRightAction); + const threadHasLeft = threadActionable(threadSwipeLeftAction); + const threadSwipeEnabled = isMobile && (threadHasLeft || threadHasRight); + const { + swipeHandlers: threadSwipeHandlers, offsetX: threadOffsetX, side: threadSwipeSide, + willCommit: threadWillCommit, consumeTap: threadConsumeTap, + } = useSwipeActions({ + enabled: threadSwipeEnabled, + hasLeft: threadHasLeft, + hasRight: threadHasRight, + onCommit: (s) => runThreadSwipe(s === 'right' ? threadSwipeRightAction : threadSwipeLeftAction), + }); + const threadSwipeAction = threadSwipeSide === 'right' ? threadSwipeRightAction : threadSwipeSide === 'left' ? threadSwipeLeftAction : 'none'; + const threadSwipeMeta = threadSwipeAction !== 'none' ? SWIPE_ACTION_META[threadSwipeAction] : null; + const threadTouchHandlers = threadSwipeEnabled + ? { + onTouchStart: (e: React.TouchEvent) => { threadLongPressHandlers.onTouchStart(e); threadSwipeHandlers.onTouchStart(e); }, + onTouchMove: (e: React.TouchEvent) => { threadLongPressHandlers.onTouchMove(e); threadSwipeHandlers.onTouchMove(e); }, + onTouchEnd: (e: React.TouchEvent) => { threadLongPressHandlers.onTouchEnd(e); threadSwipeHandlers.onTouchEnd(); }, + onTouchCancel: () => { threadLongPressHandlers.onTouchCancel(); threadSwipeHandlers.onTouchCancel(); }, + } + : threadLongPressHandlers; + const { sortTagIds, tagColor } = useKeywordFormat(); const { variant: tagVariant, placement: tagPlacement } = useTagDisplay(); const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); @@ -1091,6 +1138,7 @@ export const ThreadListItem = React.forwardRef 0 && !mvRow; const handleHeaderClick = (e: React.MouseEvent) => { + if (threadSwipeEnabled && threadConsumeTap()) { return; } if (e.ctrlKey || e.metaKey) { e.preventDefault(); // Ctrl+Click: toggle selection for all thread emails @@ -1148,7 +1196,7 @@ export const ThreadListItem = React.forwardRef
{ if (onPrefetch && isMousePressOnRow(e)) onPrefetch(latestEmail); }} aria-current={isSelected ? 'true' : undefined} @@ -1184,11 +1232,21 @@ export const ThreadListItem = React.forwardRef + {threadSwipeEnabled && threadSwipeMeta && ( + // As on a single message's row: the physically exposed edge. +
+ +
+ )}
{/* Checkbox for thread selection - only for extra-compact density (no avatar) while in selection mode */} {!mvRow && density === 'extra-compact' && selectedEmailKeys.size > 0 && (