From 38ff4c9286bfa2e08c53736494e9cd99f2ed52c1 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Thu, 12 Nov 2026 16:06:23 -0800 Subject: [PATCH] feat(chat): never-replied chats default to General; dot inside inbox pills Uncategorized chats now go to Priority only once you have replied in them. New inbound chats and blast threads land in General, which is what Michael asked for originally (Priority is the curated view). An explicit category still wins, and an api without has_replied keeps the old behavior. The unread dot moves inside the Priority/General pill after the label instead of hanging off its corner. It turns white on the selected pill. Menus and swipe actions offer only the tab the chat is not shown in. Co-Authored-By: Claude Opus 5.5 --- .changeset/inbox-replied-priority.md | 6 + .../src/store/pages/chat/category.test.ts | 193 +++++++++++++++++- packages/common/src/store/pages/chat/index.ts | 7 +- .../common/src/store/pages/chat/selectors.ts | 11 +- packages/common/src/store/pages/chat/slice.ts | 54 +++-- packages/common/src/store/pages/chat/types.ts | 6 +- packages/common/src/store/pages/chat/utils.ts | 39 +++- .../input/SelectablePill/SelectablePill.tsx | 2 + .../components/input/SelectablePill/types.ts | 4 +- .../chat-actions-drawer/ChatActionsDrawer.tsx | 9 +- .../input/SelectablePill/SelectablePill.tsx | 2 + .../components/input/SelectablePill/types.ts | 4 + .../src/screens/chat-screen/ChatListItem.tsx | 5 +- .../src/screens/chat-screen/InboxTabs.tsx | 69 +++---- packages/sdk/src/sdk/api/chats/serverTypes.ts | 10 + .../chat-page/components/InboxTabs.test.tsx | 34 ++- .../pages/chat-page/components/InboxTabs.tsx | 61 +++--- .../components/useChatCategoryMenuItems.tsx | 7 +- 18 files changed, 419 insertions(+), 104 deletions(-) create mode 100644 .changeset/inbox-replied-priority.md diff --git a/.changeset/inbox-replied-priority.md b/.changeset/inbox-replied-priority.md new file mode 100644 index 00000000000..6bc41f19472 --- /dev/null +++ b/.changeset/inbox-replied-priority.md @@ -0,0 +1,6 @@ +--- +'@audius/sdk': minor +'@audius/harmony': minor +--- + +Add `has_replied` to user chats and `uncategorized_unreplied` to the unread-by-category counts. Add an `endAdornment` slot to `SelectablePill`. diff --git a/packages/common/src/store/pages/chat/category.test.ts b/packages/common/src/store/pages/chat/category.test.ts index c383757279d..0d5345034bd 100644 --- a/packages/common/src/store/pages/chat/category.test.ts +++ b/packages/common/src/store/pages/chat/category.test.ts @@ -60,7 +60,12 @@ const seed = (chats: UserChat[]) => const withServerCounts = ( state: ReturnType, - counts: { priority: number; general: number; uncategorized: number } + counts: { + priority: number + general: number + uncategorized: number + uncategorized_unreplied?: number + } ) => chatReducer( state, @@ -85,7 +90,37 @@ const getUserChatEntity = ( describe('chat categories', () => { describe('getInboxTabForChat', () => { - it('puts uncategorized and priority chats in Priority, general in General', () => { + it('puts uncategorized chats in Priority once the user has replied', () => { + expect(getInboxTabForChat(makeChat('a', { has_replied: true }))).toBe( + InboxTab.PRIORITY + ) + expect(getInboxTabForChat(makeChat('a', { has_replied: false }))).toBe( + InboxTab.GENERAL + ) + }) + + it('lets an explicit category override replies', () => { + expect( + getInboxTabForChat( + makeChat('a', { category: ChatCategory.PRIORITY, has_replied: false }) + ) + ).toBe(InboxTab.PRIORITY) + expect( + getInboxTabForChat( + makeChat('a', { category: ChatCategory.GENERAL, has_replied: true }) + ) + ).toBe(InboxTab.GENERAL) + }) + + it('keeps sent blasts in Priority', () => { + const blast = { + chat_id: 'follower_audience', + is_blast: true + } as unknown as UserChat + expect(getInboxTabForChat(blast)).toBe(InboxTab.PRIORITY) + }) + + it('treats chats from an api without has_replied as Priority', () => { expect(getInboxTabForChat(makeChat('a'))).toBe(InboxTab.PRIORITY) expect(getInboxTabForChat(makeChat('a', { category: null }))).toBe( InboxTab.PRIORITY @@ -305,13 +340,15 @@ describe('chat categories', () => { expect(getUnreadMessagesCountByCategory(asCommonState(next))).toEqual({ priority: 0, general: 0, - uncategorized: 0 + uncategorized: 0, + uncategorized_unreplied: 0 }) const confirmed = chatReducer(next, actions.markAllChatsAsReadSucceeded()) expect(confirmed.unreadMessagesCountByCategory).toEqual({ priority: 0, general: 0, - uncategorized: 0 + uncategorized: 0, + uncategorized_unreplied: 0 }) }) @@ -382,6 +419,154 @@ describe('chat categories', () => { }) }) + describe('unreplied chats', () => { + const unreadMessage = (chatId: string, id: string) => + ({ + message_id: id, + chat_id: chatId, + sender_user_id: '7', + created_at: '2026-01-03T00:00:00.000Z', + message: 'hi', + is_plaintext: false, + reactions: [] + }) as any + + it('lights the General dot for unread chats the user never replied to', () => { + const seeded = withServerCounts( + seed([ + makeChat('new-1', { has_replied: false, unread_message_count: 2 }) + ]), + { + priority: 0, + general: 0, + uncategorized: 2, + uncategorized_unreplied: 2 + } + ) + const state = asCommonState(seeded) + expect(getHasUnreadGeneralMessages(state)).toBe(true) + expect(getHasUnreadPriorityMessages(state)).toBe(false) + expect(getGeneralInboxChats(state).map((c) => c.chat_id)).toEqual([ + 'new-1' + ]) + }) + + it('counts unreplied uncategorized unreads as Priority on an older api', () => { + const seeded = withServerCounts( + seed([makeChat('new-1', { unread_message_count: 1 })]), + { priority: 0, general: 0, uncategorized: 1 } + ) + const state = asCommonState(seeded) + expect(getHasUnreadPriorityMessages(state)).toBe(true) + expect(getHasUnreadGeneralMessages(state)).toBe(false) + }) + + it('bumps both uncategorized buckets when a message arrives', () => { + const seeded = withServerCounts( + seed([makeChat('new-1', { has_replied: false })]), + { + priority: 0, + general: 0, + uncategorized: 0, + uncategorized_unreplied: 0 + } + ) + const next = chatReducer( + seeded, + actions.addMessage({ + chatId: 'new-1', + isSelfMessage: false, + message: unreadMessage('new-1', 'm1') + }) + ) + expect(getUnreadMessagesCountByCategory(asCommonState(next))).toEqual({ + priority: 0, + general: 0, + uncategorized: 1, + uncategorized_unreplied: 1 + }) + expect(getHasUnreadGeneralMessages(asCommonState(next))).toBe(true) + }) + + it('moves the chat to Priority when the user replies', () => { + const seeded = withServerCounts( + seed([ + makeChat('new-1', { has_replied: false, unread_message_count: 1 }) + ]), + { + priority: 0, + general: 0, + uncategorized: 1, + uncategorized_unreplied: 1 + } + ) + const replied = chatReducer( + seeded, + actions.addMessage({ + chatId: 'new-1', + isSelfMessage: true, + message: { ...unreadMessage('new-1', 'm2'), sender_user_id: '1' } + }) + ) + expect(getUserChatEntity(replied, 'new-1')?.has_replied).toBe(true) + const state = asCommonState(replied) + expect(getPriorityInboxChats(state).map((c) => c.chat_id)).toEqual([ + 'new-1' + ]) + expect(getUnreadMessagesCountByCategory(state)).toEqual({ + priority: 0, + general: 0, + uncategorized: 0, + uncategorized_unreplied: 0 + }) + }) + + it('does not count a blast copy as a reply', () => { + const seeded = seed([makeChat('new-1', { has_replied: false })]) + const next = chatReducer( + seeded, + actions.addMessage({ + chatId: 'new-1', + isSelfMessage: true, + message: { + ...unreadMessage('new-1', 'm3'), + sender_user_id: '1', + is_plaintext: true + } + }) + ) + expect(getUserChatEntity(next, 'new-1')?.has_replied).toBe(false) + }) + + it('moves unreads out of both uncategorized buckets when filed', () => { + const seeded = withServerCounts( + seed([ + makeChat('new-1', { has_replied: false, unread_message_count: 3 }) + ]), + { + priority: 0, + general: 0, + uncategorized: 3, + uncategorized_unreplied: 3 + } + ) + const moved = chatReducer( + seeded, + actions.setChatCategory({ + chatId: 'new-1', + category: ChatCategory.PRIORITY + }) + ) + expect(getUnreadMessagesCountByCategory(asCommonState(moved))).toEqual({ + priority: 3, + general: 0, + uncategorized: 0, + uncategorized_unreplied: 0 + }) + expect(getHasUnreadGeneralMessages(asCommonState(moved))).toBe(false) + }) + }) + describe('inbox tab selectors', () => { it('splits chats between the Priority and General tabs', () => { const state = asCommonState( diff --git a/packages/common/src/store/pages/chat/index.ts b/packages/common/src/store/pages/chat/index.ts index caf8a7e80d2..8fb55165842 100644 --- a/packages/common/src/store/pages/chat/index.ts +++ b/packages/common/src/store/pages/chat/index.ts @@ -3,4 +3,9 @@ export * as chatSelectors from './selectors' export { sagas as chatSagas } from './sagas' export { chatMiddleware } from './middleware' export * from './types' -export { makeChatId, getInboxTabForChat, MAX_GROUP_CHAT_MEMBERS } from './utils' +export { + makeChatId, + getInboxTabForChat, + getEffectiveChatCategory, + MAX_GROUP_CHAT_MEMBERS +} from './utils' diff --git a/packages/common/src/store/pages/chat/selectors.ts b/packages/common/src/store/pages/chat/selectors.ts index 537c7047224..4d95d2f238f 100644 --- a/packages/common/src/store/pages/chat/selectors.ts +++ b/packages/common/src/store/pages/chat/selectors.ts @@ -149,10 +149,10 @@ const makeGetChatsForInboxTab = (tab: InboxTab) => chats.filter((chat) => getInboxTabForChat(chat) === tab) ) -/** Chats shown in the Priority tab: those marked Priority plus uncategorized. */ +/** Chats shown in the Priority tab: marked Priority, or uncategorized and replied to. */ export const getPriorityInboxChats = makeGetChatsForInboxTab(InboxTab.PRIORITY) -/** Chats shown in the General tab: only those the user marked General. */ +/** Chats shown in the General tab: marked General, or never replied to. */ export const getGeneralInboxChats = makeGetChatsForInboxTab(InboxTab.GENERAL) export const getChatsForInboxTab = (state: CommonState, tab: InboxTab) => @@ -171,10 +171,13 @@ export const getHasUnreadMessagesForInboxTab = ( ) => { const counts = getUnreadMessagesCountByCategory(state) if (counts) { + // Older api versions don't report the unreplied split; their + // uncategorized chats all show in Priority. + const unreplied = counts.uncategorized_unreplied ?? 0 const tabCount = tab === InboxTab.GENERAL - ? counts.general - : counts.priority + counts.uncategorized + ? counts.general + unreplied + : counts.priority + counts.uncategorized - unreplied if (tabCount > 0) return true } return getChatsForInboxTab(state, tab).some( diff --git a/packages/common/src/store/pages/chat/slice.ts b/packages/common/src/store/pages/chat/slice.ts index 9a01ba4badd..8ceb15c3051 100644 --- a/packages/common/src/store/pages/chat/slice.ts +++ b/packages/common/src/store/pages/chat/slice.ts @@ -26,7 +26,7 @@ import { hasTail } from '~/utils/chatUtils' import dayjs from '~/utils/dayjs' import { ChatWebsocketError } from './types' -import { getChatCategoryKey } from './utils' +import { getChatCategoryKeys } from './utils' export type Chat = UserChat | ChatBlast @@ -116,18 +116,33 @@ const { selectById: getChat } = chatsAdapter.getSelectors( */ const adjustUnreadCountForChatCategory = ( state: ChatState, - chat: { category?: ChatCategory | null }, + chat: { category?: ChatCategory | null; has_replied?: boolean }, delta: number ) => { if (!state.unreadMessagesCountByCategory || delta === 0) return const counts = state.optimisticUnreadMessagesCountByCategory ?? { ...state.unreadMessagesCountByCategory } - const key = getChatCategoryKey(chat) - counts[key] = Math.max(0, counts[key] + delta) + applyUnreadCountDelta(counts, chat, delta) state.optimisticUnreadMessagesCountByCategory = counts } +/** + * Adds `delta` to every bucket `chat` counts towards. Buckets the server did + * not report (older api versions) are left absent. + */ +const applyUnreadCountDelta = ( + counts: ChatUnreadCountByCategory, + chat: { category?: ChatCategory | null; has_replied?: boolean }, + delta: number +) => { + for (const key of getChatCategoryKeys(chat)) { + const current = counts[key] + if (current === undefined) continue + counts[key] = Math.max(0, current + delta) + } +} + /** The unread count currently shown for a chat, honoring optimistic reads. */ const getDisplayedUnreadCount = (state: ChatState, chatId: ChatID) => { const optimisticRead = state.optimisticChatRead[chatId] @@ -526,11 +541,10 @@ const slice = createSlice({ if (!existingChat || existingChat.is_blast) return state.unreadMessagesCount -= existingChat?.unread_message_count ?? 0 if (state.unreadMessagesCountByCategory) { - const key = getChatCategoryKey(existingChat) - state.unreadMessagesCountByCategory[key] = Math.max( - 0, - state.unreadMessagesCountByCategory[key] - - existingChat.unread_message_count + applyUnreadCountDelta( + state.unreadMessagesCountByCategory, + existingChat, + -existingChat.unread_message_count ) } chatsAdapter.updateOne(state.chats, { @@ -571,7 +585,8 @@ const slice = createSlice({ state.optimisticUnreadMessagesCountByCategory = { priority: 0, general: 0, - uncategorized: 0 + uncategorized: 0, + uncategorized_unreplied: 0 } } }, @@ -598,7 +613,8 @@ const slice = createSlice({ state.unreadMessagesCountByCategory = { priority: 0, general: 0, - uncategorized: 0 + uncategorized: 0, + uncategorized_unreplied: 0 } } delete state.optimisticUnreadMessagesCountByCategory @@ -729,6 +745,14 @@ const slice = createSlice({ existingChat, -existingUnreadCount ) + // Replying moves an uncategorized chat into Priority. Blast copies + // (plaintext) are not replies. + if (!message.is_plaintext && existingChat.has_replied === false) { + chatsAdapter.updateOne(state.chats, { + id: chatId, + changes: { has_replied: true } + }) + } } }, /** @@ -864,7 +888,11 @@ const slice = createSlice({ } const unreadCount = getDisplayedUnreadCount(state, chatId) adjustUnreadCountForChatCategory(state, existingChat, -unreadCount) - adjustUnreadCountForChatCategory(state, { category }, unreadCount) + adjustUnreadCountForChatCategory( + state, + { category, has_replied: existingChat.has_replied }, + unreadCount + ) chatsAdapter.updateOne(state.chats, { id: chatId, changes: { category } @@ -901,7 +929,7 @@ const slice = createSlice({ adjustUnreadCountForChatCategory(state, existingChat, -unreadCount) adjustUnreadCountForChatCategory( state, - { category: previousCategory }, + { category: previousCategory, has_replied: existingChat.has_replied }, unreadCount ) chatsAdapter.updateOne(state.chats, { diff --git a/packages/common/src/store/pages/chat/types.ts b/packages/common/src/store/pages/chat/types.ts index ff5bb89e813..e70e1922161 100644 --- a/packages/common/src/store/pages/chat/types.ts +++ b/packages/common/src/store/pages/chat/types.ts @@ -1,9 +1,9 @@ import { ChatPermission, type ChatUnreadCountByCategory } from '@audius/sdk' /** - * The tab a chat is shown under in the inbox. Chats the user has not - * categorized yet ("uncategorized") always surface in the Priority tab so - * that new conversations are never buried without an explicit action. + * The tab a chat is shown under in the inbox. Priority holds chats the user + * filed there or has replied in; General holds the rest. See + * getInboxTabForChat. */ export enum InboxTab { PRIORITY = 'priority', diff --git a/packages/common/src/store/pages/chat/utils.ts b/packages/common/src/store/pages/chat/utils.ts index 3b973079d2a..8b29637ba59 100644 --- a/packages/common/src/store/pages/chat/utils.ts +++ b/packages/common/src/store/pages/chat/utils.ts @@ -6,18 +6,41 @@ import { type ChatCategoryKey, InboxTab } from './types' // `is_blast` is included so blast chats (which carry no category) satisfy the // structural check without a cast. -type Categorizable = { category?: ChatCategory | null; is_blast?: boolean } +type Categorizable = { + category?: ChatCategory | null + has_replied?: boolean + is_blast?: boolean +} -/** The unread-count bucket a chat belongs to. */ -export const getChatCategoryKey = (chat: Categorizable): ChatCategoryKey => - chat.category ?? 'uncategorized' +/** + * The unread-count buckets a chat belongs to. An uncategorized chat the user + * never replied to also counts towards `uncategorized_unreplied`. + */ +export const getChatCategoryKeys = (chat: Categorizable): ChatCategoryKey[] => { + if (chat.category) return [chat.category] + return chat.has_replied === false + ? ['uncategorized', 'uncategorized_unreplied'] + : ['uncategorized'] +} /** - * The inbox tab a chat is displayed under. Only chats explicitly marked - * General leave the Priority tab; uncategorized chats stay in Priority. + * The inbox tab a chat is displayed under. An explicit category wins. + * Otherwise chats the user has replied in are Priority and the rest (new + * inbound chats, blast threads) are General. Blasts the user sent, and chats + * from an api that doesn't report `has_replied`, stay in Priority. */ -export const getInboxTabForChat = (chat: Categorizable): InboxTab => - chat.category === ChatCategory.GENERAL ? InboxTab.GENERAL : InboxTab.PRIORITY +export const getInboxTabForChat = (chat: Categorizable): InboxTab => { + if (chat.category === ChatCategory.GENERAL) return InboxTab.GENERAL + if (chat.category === ChatCategory.PRIORITY) return InboxTab.PRIORITY + if (chat.is_blast) return InboxTab.PRIORITY + return chat.has_replied === false ? InboxTab.GENERAL : InboxTab.PRIORITY +} + +/** The category matching the tab a chat is shown in. */ +export const getEffectiveChatCategory = (chat: Categorizable): ChatCategory => + getInboxTabForChat(chat) === InboxTab.GENERAL + ? ChatCategory.GENERAL + : ChatCategory.PRIORITY /** Max members in a group chat, creator included. Matches the api. */ export const MAX_GROUP_CHAT_MEMBERS = 10 diff --git a/packages/harmony/src/components/input/SelectablePill/SelectablePill.tsx b/packages/harmony/src/components/input/SelectablePill/SelectablePill.tsx index 83d2294dd53..525eeb2b52d 100644 --- a/packages/harmony/src/components/input/SelectablePill/SelectablePill.tsx +++ b/packages/harmony/src/components/input/SelectablePill/SelectablePill.tsx @@ -21,6 +21,7 @@ export const SelectablePill = (props: SelectablePillProps) => { size = 'small', _isHovered, icon: Icon, + endAdornment, className, ...other } = props @@ -113,6 +114,7 @@ export const SelectablePill = (props: SelectablePillProps) => { {other.label} ) : null} + {endAdornment} ) diff --git a/packages/harmony/src/components/input/SelectablePill/types.ts b/packages/harmony/src/components/input/SelectablePill/types.ts index 98f60ccbffe..9e3d11c1cb2 100644 --- a/packages/harmony/src/components/input/SelectablePill/types.ts +++ b/packages/harmony/src/components/input/SelectablePill/types.ts @@ -1,4 +1,4 @@ -import type { ComponentPropsWithoutRef } from 'react' +import type { ComponentPropsWithoutRef, ReactNode } from 'react' import type { IconComponent } from '../../icon' @@ -15,6 +15,8 @@ type BaseProps = { isSelected?: boolean disabled?: boolean icon?: IconComponent + /** Rendered inside the pill after the label, e.g. an unread dot. */ + endAdornment?: ReactNode } type LabelProps = diff --git a/packages/mobile/src/components/chat-actions-drawer/ChatActionsDrawer.tsx b/packages/mobile/src/components/chat-actions-drawer/ChatActionsDrawer.tsx index b35e1f2cb3a..078bdbbd6ed 100644 --- a/packages/mobile/src/components/chat-actions-drawer/ChatActionsDrawer.tsx +++ b/packages/mobile/src/components/chat-actions-drawer/ChatActionsDrawer.tsx @@ -1,7 +1,11 @@ import { useCallback } from 'react' import { useIsGroupChat } from '@audius/common/hooks' -import { chatActions, chatSelectors } from '@audius/common/store' +import { + chatActions, + chatSelectors, + getEffectiveChatCategory +} from '@audius/common/store' import { ChatCategory } from '@audius/sdk' import { useDispatch, useSelector } from 'react-redux' @@ -38,7 +42,8 @@ export const ChatActionsDrawer = () => { getDoesBlockUser(state, userId) ) const chat = useSelector((state: AppState) => getChat(state, chatId)) - const category = chat && !chat.is_blast ? (chat.category ?? null) : null + const category = + chat && !chat.is_blast ? getEffectiveChatCategory(chat) : null const isGroup = useIsGroupChat(chatId) const closeDrawer = useCallback(() => { diff --git a/packages/mobile/src/harmony-native/components/input/SelectablePill/SelectablePill.tsx b/packages/mobile/src/harmony-native/components/input/SelectablePill/SelectablePill.tsx index 567e6b02a7a..c88defa88e1 100644 --- a/packages/mobile/src/harmony-native/components/input/SelectablePill/SelectablePill.tsx +++ b/packages/mobile/src/harmony-native/components/input/SelectablePill/SelectablePill.tsx @@ -27,6 +27,7 @@ export const SelectablePill = (props: SelectablePillProps) => { const { type, icon: Icon, + endAdornment, size = 'small', isSelected: isSelectedProp, isControlled, @@ -223,6 +224,7 @@ export const SelectablePill = (props: SelectablePillProps) => { {displayLabel} ) : null} + {endAdornment} ) diff --git a/packages/mobile/src/harmony-native/components/input/SelectablePill/types.ts b/packages/mobile/src/harmony-native/components/input/SelectablePill/types.ts index 28241135510..0312626cab2 100644 --- a/packages/mobile/src/harmony-native/components/input/SelectablePill/types.ts +++ b/packages/mobile/src/harmony-native/components/input/SelectablePill/types.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react' + import type { PressableProps, ViewProps } from 'react-native' import type { IconComponent } from 'app/harmony-native/icons' @@ -9,6 +11,8 @@ type BaseSelectablePillProps = { isControlled?: boolean value?: string icon?: IconComponent + /** Rendered inside the pill after the label, e.g. an unread dot. */ + endAdornment?: ReactNode onChange?: (value: string, isSelected?: boolean) => void fullWidth?: boolean disableUnselectAnimation?: boolean diff --git a/packages/mobile/src/screens/chat-screen/ChatListItem.tsx b/packages/mobile/src/screens/chat-screen/ChatListItem.tsx index 855cbe6a31c..253820e9913 100644 --- a/packages/mobile/src/screens/chat-screen/ChatListItem.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatListItem.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef } from 'react' import { useOtherChatUsers } from '@audius/common/api' import { useIsGroupChat, useProxySelector } from '@audius/common/hooks' -import { chatSelectors } from '@audius/common/store' +import { chatSelectors, getEffectiveChatCategory } from '@audius/common/store' import { formatGroupChatTitle } from '@audius/common/utils' import { css } from '@emotion/native' import { useTheme } from '@emotion/react' @@ -54,7 +54,8 @@ export const ChatListItem = ({ chatId }: { chatId: string }) => { const lastMessage = useRemoveLeadingWhitespace( (!chat?.is_blast && chat?.last_message) || '' ) - const category = chat && !chat.is_blast ? (chat.category ?? null) : null + const category = + chat && !chat.is_blast ? getEffectiveChatCategory(chat) : null const swipeableRef = useRef(null) const handlePress = useCallback(() => { diff --git a/packages/mobile/src/screens/chat-screen/InboxTabs.tsx b/packages/mobile/src/screens/chat-screen/InboxTabs.tsx index 523550cdecb..7898b766754 100644 --- a/packages/mobile/src/screens/chat-screen/InboxTabs.tsx +++ b/packages/mobile/src/screens/chat-screen/InboxTabs.tsx @@ -18,7 +18,7 @@ export const inboxTabLabels: Record = { const tabs: InboxTab[] = [InboxTab.PRIORITY, InboxTab.GENERAL] -const DOT_SIZE = 10 +const DOT_SIZE = 8 type InboxTabsProps = { currentTab: InboxTab @@ -26,9 +26,8 @@ type InboxTabsProps = { } /** - * Priority / General inbox switcher. Each tab shows its own purple dot when - * that tab has unread messages; uncategorized chats count towards Priority - * since that is where they are displayed. + * Priority / General inbox switcher. Each tab shows its own dot, inside the + * pill after the label, when that tab has unread messages. */ export const InboxTabs = ({ currentTab, onSelectTab }: InboxTabsProps) => { const { color } = useTheme() @@ -51,37 +50,37 @@ export const InboxTabs = ({ currentTab, onSelectTab }: InboxTabsProps) => { accessibilityRole='tablist' > {tabs.map((tab) => ( - - { - if (!isSelected) return - onSelectTab(value as InboxTab) - }} - disableUnselectAnimation - /> - {hasUnread[tab] ? ( - - ) : null} - + { + if (!isSelected) return + onSelectTab(value as InboxTab) + }} + disableUnselectAnimation + endAdornment={ + hasUnread[tab] ? ( + + ) : null + } + /> ))} ) diff --git a/packages/sdk/src/sdk/api/chats/serverTypes.ts b/packages/sdk/src/sdk/api/chats/serverTypes.ts index 2dcfc10b4b2..c787823ca25 100644 --- a/packages/sdk/src/sdk/api/chats/serverTypes.ts +++ b/packages/sdk/src/sdk/api/chats/serverTypes.ts @@ -159,6 +159,11 @@ export type UserChat = { cleared_history_at: string /** Inbox category chosen by the current user. Absent/null = uncategorized. */ category?: ChatCategory | null + /** + * Whether the current user has sent a message (not a blast) in this chat. + * Absent from older api versions. + */ + has_replied?: boolean // If blast: is_blast: false @@ -255,6 +260,11 @@ export type ChatUnreadCountByCategory = { priority: number general: number uncategorized: number + /** + * Subset of `uncategorized` the user never replied to. Absent from older + * api versions. + */ + uncategorized_unreplied?: number } export enum ChatBlastAudience { diff --git a/packages/web/src/pages/chat-page/components/InboxTabs.test.tsx b/packages/web/src/pages/chat-page/components/InboxTabs.test.tsx index 0b2c8c97f1e..c4ec21e21f2 100644 --- a/packages/web/src/pages/chat-page/components/InboxTabs.test.tsx +++ b/packages/web/src/pages/chat-page/components/InboxTabs.test.tsx @@ -48,6 +48,7 @@ const makeChat = ( const chats = [ makeChat('new-chat'), + makeChat('unreplied-chat', { has_replied: false }), makeChat('priority-chat', { category: ChatCategory.PRIORITY }), makeChat('general-chat', { category: ChatCategory.GENERAL, @@ -108,13 +109,16 @@ describe('Inbox tabs', () => { ).not.toBeInTheDocument() }) - it('shows only general chats in the General tab', () => { + it('shows general and never-replied chats in the General tab', () => { renderWithChats( ) expect( screen.getByRole('button', { name: /user general-chat/i }) ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /user unreplied-chat/i }) + ).toBeInTheDocument() expect( screen.queryByRole('button', { name: /user new-chat/i }) ).not.toBeInTheDocument() @@ -154,7 +158,31 @@ describe('Inbox tabs', () => { ).not.toBeInTheDocument() }) - it('counts uncategorized unreads towards the Priority tab dot', () => { + it('counts never-replied unreads towards the General tab dot', () => { + renderWithChats( + , + { + unreadMessagesCountByCategory: { + priority: 0, + general: 0, + uncategorized: 1, + uncategorized_unreplied: 1 + }, + optimisticChatRead: { + 'general-chat': { + unread_message_count: 0, + last_read_at: '2026-01-02T00:00:00.000Z' + } + } + } + ) + expect(screen.getByTestId('inbox-tab-dot-general')).toBeInTheDocument() + expect( + screen.queryByTestId('inbox-tab-dot-priority') + ).not.toBeInTheDocument() + }) + + it('counts replied uncategorized unreads towards the Priority tab dot', () => { renderWithChats( , { @@ -195,7 +223,7 @@ describe('Inbox tabs', () => { renderWithChats( ) - fireEvent.click(screen.getByRole('radio', { name: 'General' })) + fireEvent.click(screen.getByRole('radio', { name: /^General/ })) expect(onSelectTab).toHaveBeenCalledWith(InboxTab.GENERAL) }) }) diff --git a/packages/web/src/pages/chat-page/components/InboxTabs.tsx b/packages/web/src/pages/chat-page/components/InboxTabs.tsx index 6997b7c54ea..d1b915d1b8a 100644 --- a/packages/web/src/pages/chat-page/components/InboxTabs.tsx +++ b/packages/web/src/pages/chat-page/components/InboxTabs.tsx @@ -1,11 +1,9 @@ import { ChangeEvent, useCallback } from 'react' import { chatSelectors, InboxTab } from '@audius/common/store' -import { Flex, SelectablePill } from '@audius/harmony' +import { Flex, SelectablePill, useTheme } from '@audius/harmony' import { useSelector } from 'react-redux' -import { NotificationDot } from 'components/notification-dot/NotificationDot' - const { getHasUnreadPriorityMessages, getHasUnreadGeneralMessages } = chatSelectors @@ -23,17 +21,19 @@ export const inboxTabLabels: Record = { const tabs: InboxTab[] = [InboxTab.PRIORITY, InboxTab.GENERAL] +const DOT_SIZE = 8 + type InboxTabsProps = { currentTab: InboxTab onSelectTab: (tab: InboxTab) => void } /** - * Priority / General inbox switcher. Each tab shows its own purple dot when - * that tab has unread messages; uncategorized chats count towards Priority - * since that is where they are displayed. + * Priority / General inbox switcher. Each tab shows its own dot, inside the + * pill after the label, when that tab has unread messages. */ export const InboxTabs = ({ currentTab, onSelectTab }: InboxTabsProps) => { + const { color } = useTheme() const hasUnreadPriority = useSelector(getHasUnreadPriorityMessages) const hasUnreadGeneral = useSelector(getHasUnreadGeneralMessages) const hasUnread: Record = { @@ -56,25 +56,36 @@ export const InboxTabs = ({ currentTab, onSelectTab }: InboxTabsProps) => { onChange={handleChange} > {tabs.map((tab) => ( - - - {hasUnread[tab] ? ( - - ) : null} - + + ) : null + } + /> ))} ) diff --git a/packages/web/src/pages/chat-page/components/useChatCategoryMenuItems.tsx b/packages/web/src/pages/chat-page/components/useChatCategoryMenuItems.tsx index 5d14e59666e..71246cb8f31 100644 --- a/packages/web/src/pages/chat-page/components/useChatCategoryMenuItems.tsx +++ b/packages/web/src/pages/chat-page/components/useChatCategoryMenuItems.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' -import { chatActions } from '@audius/common/store' +import { chatActions, getEffectiveChatCategory } from '@audius/common/store' import { IconMessages, IconStar, type PopupMenuItem } from '@audius/harmony' import { ChatCategory, type ChatBlast, type UserChat } from '@audius/sdk' import { useDispatch } from 'react-redux' @@ -14,7 +14,7 @@ const messages = { /** * Popup menu items for moving a chat between the Priority and General inbox - * tabs. Only the categories the chat is not already in are offered. Blasts + * tabs. Only the tab the chat is not already shown in is offered. Blasts * (and unknown chats) get no items. */ export const useChatCategoryMenuItems = ( @@ -23,7 +23,8 @@ export const useChatCategoryMenuItems = ( const dispatch = useDispatch() const chatId = chat?.chat_id const isBlast = chat?.is_blast ?? false - const category = chat && !chat.is_blast ? (chat.category ?? null) : null + const category = + chat && !chat.is_blast ? getEffectiveChatCategory(chat) : null return useMemo(() => { if (!chatId || isBlast) return []