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 []