From af7cbd5b1baaab230aa4cb9c0f686fbd7dced61a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:34:20 +0000 Subject: [PATCH 1/3] feat(channels): pin favorites and use Home rows on mobile Why: The mobile Channels list used its own row sizing and a full-width divider that looked worse than Home, and favorites had no quick access. What: Render mobile Channels rows with Home's touch row (unread rail, avatar column, inset divider) and pin the first six channel favorites in an iMessage-style grid above the feed, removing them from the list below. --- apps/web/AGENTS.md | 2 +- .../channels-view/channels-view.test.tsx | 27 ++- .../features/channels-view/channels-view.tsx | 17 ++ .../channels-view/components/ChannelPins.tsx | 54 ++++++ .../components/ChannelsMobileView.test.tsx | 85 ++++++++- .../components/ChannelsMobileView.tsx | 161 ++++++++++++------ docs/agent-guide/channels.md | 6 +- 7 files changed, 288 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/features/channels-view/components/ChannelPins.tsx diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index ab018ff786a..7ce391c0f28 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -128,7 +128,7 @@ ownership. Cancel, logout, and the ten-minute timeout retire pending callbacks. - Follow [mobile layout and chrome](../../.agents/rules/web/mobile.md#layout-and-chrome) for full-frame views and record tabs; verify their first controls below the navigation with a nonzero safe area on phones and tablets. - Keep split chrome portal targets reactive so cached views can mount before their header, and clear a retiring header's targets only while it still owns them. - Let `ViewShell.TopBar` own split close and navigation controls; do not add a second split close button inside Agents headers or their preview `headerLeading`. -- Show only the Recent conversation feed in mobile Channels, without view tabs or a persisted mobile tab selection. +- Show only the Recent conversation feed in mobile Channels, without view tabs or a persisted mobile tab selection; render its rows with Home's touch row (`ChannelCardLayout` and `InboxDivider`) and pin at most six channel favorites above it. - Mount the mobile search input before focusing it inside the dock tap; keep navigation in that handler so focus does not trigger a second navigation or lose iOS keyboard activation. - Preserve focused chrome editors during deferred split insertion/removal autofocus; explicit return-focus commands may still focus the pane. - Reconcile global focus when focused DOM subtrees are removed; iOS can omit blur events, otherwise floating creation actions stay hidden after search or editor teardown. diff --git a/apps/web/src/features/channels-view/channels-view.test.tsx b/apps/web/src/features/channels-view/channels-view.test.tsx index c6c99380cf4..bedf749839d 100644 --- a/apps/web/src/features/channels-view/channels-view.test.tsx +++ b/apps/web/src/features/channels-view/channels-view.test.tsx @@ -29,11 +29,20 @@ const mocks = vi.hoisted(() => ({ markRead: vi.fn(), resolveTarget: vi.fn(() => ({ kind: 'message', messageId: 'unread' })), refresh: vi.fn(async () => {}), + favoriteIds: (): string[] => [], })); vi.mock('./queries/channel-list-search-pool', () => ({ useChannelListSearchPool: () => () => [], })); +vi.mock('@queries/favorites/favorites', () => ({ + useFavoritesData: () => () => ({ + favorites: mocks.favoriteIds().map((entityId) => ({ + entityId, + entityType: 'channel', + })), + }), +})); vi.mock('@app/features/command/mobile/mobileSearchState', () => ({ SearchState: { isOpen: () => mocks.searchOpen(), @@ -123,9 +132,14 @@ vi.mock('./components/ChannelThreadsView', () => ({ vi.mock('./components/ChannelsMobileView', () => ({ ChannelsMobileView: (props: { source: ChannelsDataSource; + pinned: readonly ChannelEntity[]; searchQuery: string; }) => ( -
+
channel.id).join(',')} + > {(channel) =>
{channel.name}
}
@@ -228,6 +242,7 @@ let setRows: ReturnType>[1]; beforeEach(() => { vi.clearAllMocks(); mocks.mobileLayout = () => false; + mocks.favoriteIds = () => []; mocks.active = () => true; mocks.searchOpen = () => false; mocks.searchText = () => ''; @@ -298,6 +313,16 @@ describe('mobile dock search wiring', () => { expect(screen.getByText('hutch')).toBeTruthy(); expect(screen.getByTestId('mobile-channels').dataset.query).toBe(''); }); + + it('pins the first six loaded channel favorites in favorite order', () => { + mocks.mobileLayout = () => true; + setRows(['a', 'b', 'c', 'd', 'e', 'f', 'g'].map((id) => row(id))); + mocks.favoriteIds = () => ['g', 'missing', 'f', 'e', 'd', 'c', 'b', 'a']; + render(() => ); + expect(screen.getByTestId('mobile-channels').dataset.pinned).toBe( + 'g,f,e,d,c,b' + ); + }); }); describe('channel selection loading and recovery', () => { diff --git a/apps/web/src/features/channels-view/channels-view.tsx b/apps/web/src/features/channels-view/channels-view.tsx index 5f7df5dc272..b86dd0ce460 100644 --- a/apps/web/src/features/channels-view/channels-view.tsx +++ b/apps/web/src/features/channels-view/channels-view.tsx @@ -13,6 +13,7 @@ import { SplitPanel } from '@components/app/split-panel'; import { StaticMarkdownContext } from '@core/component/LexicalMarkdown/component/core/StaticMarkdown'; import { ListEntityMetadataQueryProvider } from '@entity'; import SpinnerIcon from '@phosphor/spinner.svg'; +import { useFavoritesData } from '@queries/favorites/favorites'; import { createContext, createMemo, @@ -23,6 +24,7 @@ import { } from 'solid-js'; import { ChannelsViewProvider, useChannelsView } from './channels-view-context'; import { ChannelDetailView } from './components/ChannelDetailView'; +import { MAX_PINNED_CHANNELS } from './components/ChannelPins'; import { ChannelsMobileView } from './components/ChannelsMobileView'; import { ChannelThreadsView } from './components/ChannelThreadsView'; import { ChannelsRail } from './components/rail/ChannelsRail'; @@ -58,9 +60,24 @@ function MobileChannelsList(props: { sources: ChannelsSources }) { scope: () => 'recents', source: () => props.sources.recents, }); + const favoritesData = useFavoritesData({ entityType: ['channel'] }); + // Recents carry the latest message and unread state; the channel list + // covers favorites that have not surfaced in Recents yet. + const pinned = createMemo(() => { + const channelsById = new Map( + deduplicateChannels([props.sources.recents.items(), listChannels()]).map( + (channel) => [channel.id, channel] + ) + ); + return (favoritesData()?.favorites ?? []) + .filter((favorite) => favorite.entityType === 'channel') + .flatMap((favorite) => channelsById.get(favorite.entityId) ?? []) + .slice(0, MAX_PINNED_CHANNELS); + }); return ( SearchState.setQuery('')} /> diff --git a/apps/web/src/features/channels-view/components/ChannelPins.tsx b/apps/web/src/features/channels-view/components/ChannelPins.tsx new file mode 100644 index 00000000000..08302ea3e03 --- /dev/null +++ b/apps/web/src/features/channels-view/components/ChannelPins.tsx @@ -0,0 +1,54 @@ +import { type ChannelEntity, Entity } from '@entity'; +import { cn } from '@ui'; +import type { ParentProps } from 'solid-js'; +import { Show } from 'solid-js'; + +/** How many favorites the mobile Channels list pins above its conversations. */ +export const MAX_PINNED_CHANNELS = 6; + +/** Three-across grid of pinned conversations, like iMessage pins. */ +export function ChannelPinGrid(props: ParentProps) { + return ( +
+ {props.children} +
+ ); +} + +export function ChannelPin(props: { + channel: ChannelEntity; + unread: boolean; + onActivate: (event: MouseEvent) => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/features/channels-view/components/ChannelsMobileView.test.tsx b/apps/web/src/features/channels-view/components/ChannelsMobileView.test.tsx index ef8808ef9b5..2173db2a6e2 100644 --- a/apps/web/src/features/channels-view/components/ChannelsMobileView.test.tsx +++ b/apps/web/src/features/channels-view/components/ChannelsMobileView.test.tsx @@ -64,18 +64,35 @@ vi.mock('@core/component/Toast/Toast', () => ({ toast: { failure: mocks.failure }, })); vi.mock('@core/context/user', () => ({ useUserId: () => () => 'viewer' })); -vi.mock('@entity/utils/notification', () => ({ isMutedItem: () => false })); +vi.mock('@entity', () => ({ Entity: { Icon: () => null } })); +vi.mock('@entity/composed/list-entity/shared', () => ({ + InboxDivider: () =>
, +})); +vi.mock( + '@app/features/next-soup/soup-view/views/inbox/inbox-card-layouts', + () => ({ + toInboxCardDisplayItem: (entity: ChannelEntity) => ({ entity }), + ChannelCardLayout: (props: { + item: { entity: ChannelEntity }; + onClick: (event: MouseEvent) => void; + }) => , + }) +); vi.mock('@queries/channel/notification-selection', () => ({ hydrateChannelNotificationSelection: mocks.hydrate, })); vi.mock('@solid-primitives/resize-observer', () => ({ createElementSize: () => ({ height: 0 }), })); -vi.mock('@ui', () => ({ Button: () => null, EmptyStatePanel: () => null })); +vi.mock('@ui', () => ({ + Button: () => null, + EmptyStatePanel: () => null, + cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), +})); vi.mock('virtua/solid', () => ({ Virtualizer: (props: { data: ChannelEntity[]; - children: (channel: ChannelEntity) => JSX.Element; + children: (channel: ChannelEntity, index: () => number) => JSX.Element; }) => {props.children}, })); vi.mock('./ChannelsEmptyState', () => ({ ChannelsEmptyState: () => null })); @@ -83,11 +100,7 @@ vi.mock('@channel/Channel/channel-actions', () => ({ CHANNEL_ACTION_VIEW_CONTEXT: {}, })); vi.mock('./rail/ChannelRailItems', () => ({ - CONVERSATION_CARD_HEIGHT: 80, - ConversationCard: (props: { - channel: ChannelEntity; - onActivate: (event: MouseEvent) => void; - }) => , + IncomingCallActions: () => null, })); vi.mock('./rail/hooks/useChannelCalls', () => ({ useChannelCalls: () => ({}), @@ -138,6 +151,7 @@ describe('mobile channel activation', () => { render(() => ( {}} /> @@ -171,6 +185,7 @@ describe('mobile channel activation', () => { render(() => ( {}} /> @@ -198,6 +213,7 @@ describe('mobile channel press feedback', () => { render(() => ( {}} /> @@ -226,6 +242,7 @@ describe('mobile channel press feedback', () => { render(() => ( {}} /> @@ -242,3 +259,55 @@ describe('mobile channel press feedback', () => { expect(row.classList.contains('touch-highlight')).toBe(false); }); }); + +describe('mobile channel pins', () => { + const pinnedChannel: ChannelEntity = { + ...channel, + id: 'two', + name: 'Two', + }; + const both: ChannelsDataSource = { + ...source, + items: () => [channel, pinnedChannel], + }; + + it('shows pinned conversations above the list instead of in it, and opens them', async () => { + render(() => ( + {}} + /> + )); + + const pins = screen.getByRole('group', { name: 'Pinned conversations' }); + expect(pins.textContent).toContain('Two'); + expect(screen.getAllByRole('button', { name: 'Two' })).toHaveLength(1); + expect(screen.getByRole('button', { name: 'One' })).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Two' })); + await waitFor(() => + expect(mocks.openSplit).toHaveBeenCalledExactlyOnceWith( + pinnedChannel, + expect.objectContaining({ referredFrom: 'channels' }) + ) + ); + }); + + it('lists pinned conversations normally while searching', () => { + render(() => ( + {}} + /> + )); + + expect( + screen.queryByRole('group', { name: 'Pinned conversations' }) + ).toBeNull(); + expect(screen.getByRole('button', { name: 'Two' })).toBeTruthy(); + }); +}); diff --git a/apps/web/src/features/channels-view/components/ChannelsMobileView.tsx b/apps/web/src/features/channels-view/components/ChannelsMobileView.tsx index e385e0356ab..5d4855fecc8 100644 --- a/apps/web/src/features/channels-view/components/ChannelsMobileView.tsx +++ b/apps/web/src/features/channels-view/components/ChannelsMobileView.tsx @@ -4,6 +4,10 @@ import { persistSoupNavigationTouchHighlight, soupNavigationTouchHighlight, } from '@app/features/next-soup/soup-view/soup-navigation-touch-highlight'; +import { + ChannelCardLayout, + toInboxCardDisplayItem, +} from '@app/features/next-soup/soup-view/views/inbox/inbox-card-layouts'; import { openEntityInSplitFromUnifiedList } from '@app/features/next-soup/utils'; import { SoupEntityContextMenu } from '@app/features/soup'; import { DEBUG_SETTING_KEYS, useDebugSetting } from '@app/lib/debugSettings'; @@ -12,20 +16,20 @@ import { useGlobalNotificationSource } from '@components/app/GlobalAppState'; import { PullToRefresh } from '@components/app/mobile/PullToRefresh'; import { useSplitPanelOrThrow } from '@components/app/split-layout/layoutUtils'; import { toast } from '@core/component/Toast/Toast'; -import { useUserId } from '@core/context/user'; import { fireAndForget } from '@core/util/fireAndForget'; import EmptyStateNoSearchMatchGraphic from '@design/empty-state-no-search-match.svg'; import type { ChannelEntity } from '@entity'; -import { isMutedItem } from '@entity/utils/notification'; +import { SOUP_ROW_CLASS } from '@entity/composed/list-entity/row-geometry'; +import { InboxDivider } from '@entity/composed/list-entity/shared'; import SpinnerIcon from '@phosphor/spinner.svg'; import XIcon from '@phosphor/x.svg'; import { createElementSize } from '@solid-primitives/resize-observer'; -import { Button, EmptyStatePanel } from '@ui'; +import { Button, cn, EmptyStatePanel } from '@ui'; import { createEffect, createMemo, createSignal, - createUniqueId, + For, Match, on, onCleanup, @@ -34,39 +38,51 @@ import { } from 'solid-js'; import { Virtualizer, type VirtualizerHandle } from 'virtua/solid'; import type { ChannelsDataSource } from '../queries'; -import { channelMentionsUser } from '../utils'; +import { ChannelPin, ChannelPinGrid } from './ChannelPins'; import { ChannelsEmptyState } from './ChannelsEmptyState'; -import { - CONVERSATION_CARD_HEIGHT, - ConversationCard, -} from './rail/ChannelRailItems'; +import { IncomingCallActions } from './rail/ChannelRailItems'; import { useChannelCalls } from './rail/hooks/useChannelCalls'; import { useChannelRailActivity } from './rail/hooks/useChannelRailActivity'; -const MOBILE_CHANNEL_BUFFER_SIZE = CONVERSATION_CARD_HEIGHT * 6; +const MOBILE_CHANNEL_ROW_HEIGHT = 72; +const MOBILE_CHANNEL_BUFFER_SIZE = MOBILE_CHANNEL_ROW_HEIGHT * 6; const LOAD_MORE_THRESHOLD = 300; export function ChannelsMobileView(props: { source: ChannelsDataSource; + /** Favorited conversations pinned above the list, already capped. */ + pinned: readonly ChannelEntity[]; searchQuery: string; onClearSearch: () => void; }) { const panel = useSplitPanelOrThrow(); const notificationSource = useGlobalNotificationSource(); - const currentUserId = useUserId(); const [viewport, setViewport] = createSignal(); const [virtualizer, setVirtualizer] = createSignal(); - const [topSpacer, setTopSpacer] = createSignal(); - const topSpacerSize = createElementSize(topSpacer); + const [listHeader, setListHeader] = createSignal(); + const listHeaderSize = createElementSize(listHeader); const forceEmptyState = useDebugSetting( DEBUG_SETTING_KEYS.FORCE_EMPTY_STATES ); - const listId = createUniqueId(); const channelCalls = useChannelCalls(); - const visibleChannels = createMemo(() => props.source.items()); - const channelActivity = useChannelRailActivity(visibleChannels, channelCalls); + const pinned = () => (props.searchQuery.trim() ? [] : props.pinned); + // Pinned conversations leave the list below, as in iMessage. + const visibleChannels = createMemo(() => { + const pinnedIds = new Set(pinned().map((channel) => channel.id)); + return props.source.items().filter((channel) => !pinnedIds.has(channel.id)); + }); + const activityChannels = createMemo(() => [ + ...pinned(), + ...visibleChannels(), + ]); + const channelActivity = useChannelRailActivity( + activityChannels, + channelCalls + ); + const isUnread = (channel: ChannelEntity) => + channelActivity.unreadChannelIds().has(channel.id); const actionController = createListController({ - items: visibleChannels, + items: activityChannels, getKey: (channel) => channel.id, isSelectable: () => false, }); @@ -82,7 +98,7 @@ export function ChannelsMobileView(props: { ) ); - const topInset = () => topSpacerSize.height ?? 0; + const topInset = () => listHeaderSize.height ?? 0; function loadNextPage() { if ( @@ -149,11 +165,47 @@ export function ChannelsMobileView(props: { }} class="scrollbar-hidden size-full min-h-0 overflow-y-auto overscroll-none" > -