From c2cb1cda04a4c14e394ecb1c4845421e52a04da8 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Thu, 10 Sep 2026 14:36:50 -0700 Subject: [PATCH 1/2] feat(chat): Priority / General inbox split Adds a "missing middle" between muting a conversation and blocking a sender: users can file each chat into a Priority or General inbox tab, and future messages from that sender route there automatically. - SDK: `chats.setCategory` (RPC `chat.set_category`), `chats.getUnreadCountByCategory`, `ChatCategory` enum, `category` on `UserChat`. - Common store: `setChatCategory` with optimistic move + rollback, per-category unread counts (server baseline + optimistic overlay that tracks reads, new messages and moves), per-tab chat list selectors and per-tab unread-dot selectors. The nav dot's fallback scan no longer stops at the first blast row, so a General unread always lights it. - Web: Priority (default) / General pill tabs in the inbox header with independent purple dots, hover options on each list row, and "Move to Priority / General" in the conversation header menu. - Mobile: same tabs and dots on the chat list screen, swipe-left on a row to reveal Priority / General, long-press for the actions drawer, and the same options in the in-conversation drawer. Uncategorized chats surface in the Priority tab (and count towards its dot) so new conversations are never buried without an explicit action. Tabs backfill older pages until they have enough rows, since chats are paginated by recency across categories. Requires the comms server change in AudiusProject/api (migration 0240, `chat.set_category`, `category` on chat rows, `/comms/chats/unread_by_category`). Older nodes still work: the client falls back to the loaded chats for the tab dots when the endpoint is unavailable. Co-Authored-By: Claude Fable 5.1 --- packages/common/src/models/Analytics.ts | 14 + .../src/store/pages/chat/category.test.ts | 397 ++++++++++++++++++ packages/common/src/store/pages/chat/index.ts | 2 +- packages/common/src/store/pages/chat/sagas.ts | 82 +++- .../common/src/store/pages/chat/selectors.ts | 68 ++- packages/common/src/store/pages/chat/slice.ts | 154 ++++++- packages/common/src/store/pages/chat/types.ts | 15 +- packages/common/src/store/pages/chat/utils.ts | 19 +- .../chat-actions-drawer/ChatActionsDrawer.tsx | 36 +- .../chat-screen/ChatCategorySwipeActions.tsx | 85 ++++ .../src/screens/chat-screen/ChatListItem.tsx | 164 +++++--- .../screens/chat-screen/ChatListScreen.tsx | 79 +++- .../src/screens/chat-screen/InboxTabs.tsx | 88 ++++ packages/sdk/src/sdk/api/chats/ChatsApi.ts | 54 +++ packages/sdk/src/sdk/api/chats/clientTypes.ts | 11 + packages/sdk/src/sdk/api/chats/serverTypes.ts | 30 ++ packages/web/src/pages/chat-page/ChatPage.tsx | 8 +- .../pages/chat-page/components/ChatHeader.tsx | 59 +-- .../pages/chat-page/components/ChatList.tsx | 46 +- .../components/ChatListItem.module.css | 24 ++ .../chat-page/components/ChatListItem.tsx | 83 ++-- .../chat-page/components/InboxTabs.test.tsx | 201 +++++++++ .../pages/chat-page/components/InboxTabs.tsx | 81 ++++ .../chat-page/components/UserChatHeader.tsx | 8 +- .../components/useChatCategoryMenuItems.tsx | 49 +++ 25 files changed, 1710 insertions(+), 147 deletions(-) create mode 100644 packages/common/src/store/pages/chat/category.test.ts create mode 100644 packages/mobile/src/screens/chat-screen/ChatCategorySwipeActions.tsx create mode 100644 packages/mobile/src/screens/chat-screen/InboxTabs.tsx create mode 100644 packages/web/src/pages/chat-page/components/InboxTabs.test.tsx create mode 100644 packages/web/src/pages/chat-page/components/InboxTabs.tsx create mode 100644 packages/web/src/pages/chat-page/components/useChatCategoryMenuItems.tsx diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index b0b437f05f7..dffbd56160e 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -389,6 +389,8 @@ export enum Name { SEND_MESSAGE_FAILURE = 'Send Message: Failure', DELETE_CHAT_SUCCESS = 'Delete Chat: Success', DELETE_CHAT_FAILURE = 'Delete Chat: Failure', + SET_CHAT_CATEGORY_SUCCESS = 'Set Chat Category: Success', + SET_CHAT_CATEGORY_FAILURE = 'Set Chat Category: Failure', BLOCK_USER_SUCCESS = 'Block User: Success', BLOCK_USER_FAILURE = 'Block User: Failure', CHANGE_INBOX_SETTINGS_SUCCESS = 'Change Inbox Settings: Success', @@ -2025,6 +2027,16 @@ type DeleteChatFailure = { eventName: Name.DELETE_CHAT_FAILURE } +type SetChatCategorySuccess = { + eventName: Name.SET_CHAT_CATEGORY_SUCCESS + category: 'priority' | 'general' | null +} + +type SetChatCategoryFailure = { + eventName: Name.SET_CHAT_CATEGORY_FAILURE + category: 'priority' | 'general' | null +} + type BlockUserSuccess = { eventName: Name.BLOCK_USER_SUCCESS blockedUserId: ID @@ -2870,6 +2882,8 @@ export type AllTrackingEvents = | SendMessageFailure | DeleteChatSuccess | DeleteChatFailure + | SetChatCategorySuccess + | SetChatCategoryFailure | BlockUserSuccess | BlockUserFailure | ChangeInboxSettingsSuccess diff --git a/packages/common/src/store/pages/chat/category.test.ts b/packages/common/src/store/pages/chat/category.test.ts new file mode 100644 index 00000000000..0ab26a30831 --- /dev/null +++ b/packages/common/src/store/pages/chat/category.test.ts @@ -0,0 +1,397 @@ +import { + ChatBlastAudience, + ChatCategory, + type TypedCommsResponse, + type UserChat +} from '@audius/sdk' +import { describe, expect, it } from 'vitest' + +import type { CommonState } from '~/store/reducers' + +import { + getChats, + getChatsForInboxTab, + getGeneralInboxChats, + getHasUnreadGeneralMessages, + getHasUnreadMessages, + getHasUnreadPriorityMessages, + getPriorityInboxChats, + getUnreadMessagesCount, + getUnreadMessagesCountByCategory +} from './selectors' +import chatReducer, { actions } from './slice' +import { InboxTab } from './types' +import { getInboxTabForChat } from './utils' + +type ChatSummary = NonNullable['summary']> + +const makeSummary = (): ChatSummary => ({ + prev_cursor: '2026-01-01T00:00:00.000Z', + prev_count: 0, + next_cursor: '2026-01-02T00:00:00.000Z', + next_count: 0, + total_count: 0 +}) + +const makeChat = ( + chatId: string, + overrides: Partial = {} +): UserChat => ({ + chat_id: chatId, + last_message: 'hello', + last_message_at: '2026-01-02T00:00:00.000Z', + last_message_is_plaintext: true, + chat_members: [], + recheck_permissions: false, + invite_code: '', + unread_message_count: 0, + last_read_at: '2026-01-02T00:00:00.000Z', + cleared_history_at: '1970-01-01T00:00:00.000Z', + is_blast: false, + audience: ChatBlastAudience.FOLLOWERS, + ...overrides +}) + +const seed = (chats: UserChat[]) => + chatReducer( + undefined, + actions.fetchMoreChatsSucceeded({ data: chats, summary: makeSummary() }) + ) + +const withServerCounts = ( + state: ReturnType, + counts: { priority: number; general: number; uncategorized: number } +) => + chatReducer( + state, + actions.fetchUnreadMessagesCountSucceeded({ + unreadMessagesCount: + counts.priority + counts.general + counts.uncategorized, + unreadMessagesCountByCategory: counts + }) + ) + +const asCommonState = (chat: ReturnType) => + ({ pages: { chat } }) as unknown as CommonState + +describe('chat categories', () => { + describe('getInboxTabForChat', () => { + it('puts uncategorized and priority chats in Priority, general in General', () => { + expect(getInboxTabForChat(makeChat('a'))).toBe(InboxTab.PRIORITY) + expect(getInboxTabForChat(makeChat('a', { category: null }))).toBe( + InboxTab.PRIORITY + ) + expect( + getInboxTabForChat(makeChat('a', { category: ChatCategory.PRIORITY })) + ).toBe(InboxTab.PRIORITY) + expect( + getInboxTabForChat(makeChat('a', { category: ChatCategory.GENERAL })) + ).toBe(InboxTab.GENERAL) + }) + }) + + describe('setChatCategory', () => { + it('optimistically moves the chat and confirms on success', () => { + const seeded = seed([makeChat('chat-1')]) + const optimistic = chatReducer( + seeded, + actions.setChatCategory({ + chatId: 'chat-1', + category: ChatCategory.GENERAL + }) + ) + expect(optimistic.chats.entities['chat-1']?.category).toBe( + ChatCategory.GENERAL + ) + expect(optimistic.pendingChatCategoryRollback['chat-1']).toBeNull() + + const confirmed = chatReducer( + optimistic, + actions.setChatCategorySucceeded({ + chatId: 'chat-1', + category: ChatCategory.GENERAL + }) + ) + expect(confirmed.chats.entities['chat-1']?.category).toBe( + ChatCategory.GENERAL + ) + expect(confirmed.pendingChatCategoryRollback['chat-1']).toBeUndefined() + }) + + it('rolls back to the previous category on failure', () => { + const seeded = seed([ + makeChat('chat-1', { category: ChatCategory.PRIORITY }) + ]) + const optimistic = chatReducer( + seeded, + actions.setChatCategory({ + chatId: 'chat-1', + category: ChatCategory.GENERAL + }) + ) + const rolledBack = chatReducer( + optimistic, + actions.setChatCategoryFailed({ chatId: 'chat-1' }) + ) + expect(rolledBack.chats.entities['chat-1']?.category).toBe( + ChatCategory.PRIORITY + ) + expect(rolledBack.pendingChatCategoryRollback['chat-1']).toBeUndefined() + }) + + it('re-asserts the confirmed category if a refetch clobbered it', () => { + const seeded = seed([makeChat('chat-1')]) + const optimistic = chatReducer( + seeded, + actions.setChatCategory({ + chatId: 'chat-1', + category: ChatCategory.GENERAL + }) + ) + // A concurrent list refetch returns the stale (uncategorized) chat; + // the server always sends an explicit null for uncategorized. + const clobbered = chatReducer( + optimistic, + actions.fetchMoreChatsSucceeded({ + data: [makeChat('chat-1', { category: null })], + summary: makeSummary() + }) + ) + expect(clobbered.chats.entities['chat-1']?.category).toBeNull() + const confirmed = chatReducer( + clobbered, + actions.setChatCategorySucceeded({ + chatId: 'chat-1', + category: ChatCategory.GENERAL + }) + ) + expect(confirmed.chats.entities['chat-1']?.category).toBe( + ChatCategory.GENERAL + ) + }) + + it('moves unread messages between category buckets', () => { + const seeded = withServerCounts( + seed([makeChat('chat-1', { unread_message_count: 3 })]), + { priority: 0, general: 0, uncategorized: 3 } + ) + const moved = chatReducer( + seeded, + actions.setChatCategory({ + chatId: 'chat-1', + category: ChatCategory.GENERAL + }) + ) + expect(getUnreadMessagesCountByCategory(asCommonState(moved))).toEqual({ + priority: 0, + general: 3, + uncategorized: 0 + }) + const rolledBack = chatReducer( + moved, + actions.setChatCategoryFailed({ chatId: 'chat-1' }) + ) + expect( + getUnreadMessagesCountByCategory(asCommonState(rolledBack)) + ).toEqual({ priority: 0, general: 0, uncategorized: 3 }) + }) + + it('ignores blasts', () => { + const blastId = 'follower_audience' + const seeded = chatReducer( + undefined, + actions.fetchMoreChatsSucceeded({ + data: [ + { + chat_id: blastId, + is_blast: true, + last_message_at: '2026-01-02T00:00:00.000Z', + audience: ChatBlastAudience.FOLLOWERS + } as unknown as UserChat + ], + summary: makeSummary() + }) + ) + const next = chatReducer( + seeded, + actions.setChatCategory({ + chatId: blastId, + category: ChatCategory.GENERAL + }) + ) + expect(next.pendingChatCategoryRollback[blastId]).toBeUndefined() + expect(getGeneralInboxChats(asCommonState(next))).toHaveLength(0) + }) + }) + + describe('per-category unread counts', () => { + it('decrements the right bucket when a chat is read', () => { + const seeded = withServerCounts( + seed([ + makeChat('general-1', { + category: ChatCategory.GENERAL, + unread_message_count: 2 + }), + makeChat('new-1', { unread_message_count: 1 }) + ]), + { priority: 0, general: 2, uncategorized: 1 } + ) + const optimistic = chatReducer( + seeded, + actions.markChatAsRead({ chatId: 'general-1' }) + ) + expect( + getUnreadMessagesCountByCategory(asCommonState(optimistic)) + ).toEqual({ priority: 0, general: 0, uncategorized: 1 }) + expect(getHasUnreadGeneralMessages(asCommonState(optimistic))).toBe(false) + expect(getHasUnreadPriorityMessages(asCommonState(optimistic))).toBe(true) + + const confirmed = chatReducer( + optimistic, + actions.markChatAsReadSucceeded({ chatId: 'general-1' }) + ) + expect(confirmed.unreadMessagesCountByCategory).toEqual({ + priority: 0, + general: 0, + uncategorized: 1 + }) + expect(confirmed.optimisticUnreadMessagesCountByCategory).toBeUndefined() + }) + + it('bumps the bucket of the chat a new message arrives in', () => { + const seeded = withServerCounts( + seed([makeChat('general-1', { category: ChatCategory.GENERAL })]), + { priority: 0, general: 0, uncategorized: 0 } + ) + const next = chatReducer( + seeded, + actions.addMessage({ + chatId: 'general-1', + isSelfMessage: false, + message: { + message_id: 'm1', + chat_id: 'general-1', + sender_user_id: '7', + created_at: '2026-01-03T00:00:00.000Z', + message: 'hi', + reactions: [] + } as any + }) + ) + expect(getUnreadMessagesCountByCategory(asCommonState(next))).toEqual({ + priority: 0, + general: 1, + uncategorized: 0 + }) + expect(getHasUnreadGeneralMessages(asCommonState(next))).toBe(true) + expect(getHasUnreadPriorityMessages(asCommonState(next))).toBe(false) + }) + + it('clears every bucket when all chats are marked read', () => { + const seeded = withServerCounts( + seed([makeChat('chat-1', { unread_message_count: 4 })]), + { priority: 1, general: 2, uncategorized: 4 } + ) + const next = chatReducer(seeded, actions.markAllChatsAsRead()) + expect(getUnreadMessagesCountByCategory(asCommonState(next))).toEqual({ + priority: 0, + general: 0, + uncategorized: 0 + }) + const confirmed = chatReducer(next, actions.markAllChatsAsReadSucceeded()) + expect(confirmed.unreadMessagesCountByCategory).toEqual({ + priority: 0, + general: 0, + uncategorized: 0 + }) + }) + + it('falls back to loaded chats when the server has no breakdown', () => { + const seeded = seed([ + makeChat('general-1', { + category: ChatCategory.GENERAL, + unread_message_count: 1 + }), + makeChat('priority-1', { category: ChatCategory.PRIORITY }) + ]) + const state = asCommonState(seeded) + expect(getUnreadMessagesCountByCategory(state)).toBeUndefined() + expect(getHasUnreadGeneralMessages(state)).toBe(true) + expect(getHasUnreadPriorityMessages(state)).toBe(false) + }) + }) + + describe('nav dot (getHasUnreadMessages)', () => { + it('fires for unread General messages via the server count', () => { + const seeded = withServerCounts( + seed([ + makeChat('general-1', { + category: ChatCategory.GENERAL, + unread_message_count: 1 + }) + ]), + { priority: 0, general: 1, uncategorized: 0 } + ) + expect(getHasUnreadMessages(asCommonState(seeded))).toBe(true) + expect(getUnreadMessagesCount(asCommonState(seeded))).toBe(1) + }) + + it('fires for an unread General chat listed after a blast', () => { + const blast = { + chat_id: 'follower_audience', + is_blast: true, + last_message_at: '2026-01-03T00:00:00.000Z', + audience: ChatBlastAudience.FOLLOWERS + } as unknown as UserChat + const seeded = seed([ + blast, + makeChat('general-1', { + category: ChatCategory.GENERAL, + unread_message_count: 1 + }) + ]) + // No server count yet: the fallback scan must not stop at the blast + expect(getChats(asCommonState(seeded))[0].chat_id).toBe(blast.chat_id) + expect(getHasUnreadMessages(asCommonState(seeded))).toBe(true) + }) + + it('goes quiet once the General chat is read', () => { + const seeded = withServerCounts( + seed([ + makeChat('general-1', { + category: ChatCategory.GENERAL, + unread_message_count: 1 + }) + ]), + { priority: 0, general: 1, uncategorized: 0 } + ) + const read = chatReducer( + seeded, + actions.markChatAsReadSucceeded({ chatId: 'general-1' }) + ) + expect(getHasUnreadMessages(asCommonState(read))).toBe(false) + }) + }) + + describe('inbox tab selectors', () => { + it('splits chats between the Priority and General tabs', () => { + const state = asCommonState( + seed([ + makeChat('new-1'), + makeChat('priority-1', { category: ChatCategory.PRIORITY }), + makeChat('general-1', { category: ChatCategory.GENERAL }) + ]) + ) + expect(getPriorityInboxChats(state).map((c) => c.chat_id)).toEqual([ + 'new-1', + 'priority-1' + ]) + expect(getGeneralInboxChats(state).map((c) => c.chat_id)).toEqual([ + 'general-1' + ]) + expect(getChatsForInboxTab(state, InboxTab.GENERAL)).toBe( + getGeneralInboxChats(state) + ) + }) + }) +}) diff --git a/packages/common/src/store/pages/chat/index.ts b/packages/common/src/store/pages/chat/index.ts index d92577b5120..7972c101905 100644 --- a/packages/common/src/store/pages/chat/index.ts +++ b/packages/common/src/store/pages/chat/index.ts @@ -3,4 +3,4 @@ export * as chatSelectors from './selectors' export { sagas as chatSagas } from './sagas' export { chatMiddleware } from './middleware' export * from './types' -export { makeChatId } from './utils' +export { makeChatId, getInboxTabForChat } from './utils' diff --git a/packages/common/src/store/pages/chat/sagas.ts b/packages/common/src/store/pages/chat/sagas.ts index d44a8f9f571..6a6584f5255 100644 --- a/packages/common/src/store/pages/chat/sagas.ts +++ b/packages/common/src/store/pages/chat/sagas.ts @@ -1,5 +1,6 @@ import { ChatBlast, + ChatCategory, HashId, Id, OptionalHashId, @@ -38,6 +39,7 @@ import { removeNullable } from '../../../utils' import { getContext } from '../../effects' +import type { CommonStoreContext } from '../../storeContext' import * as chatSelectors from './selectors' import { actions as chatActions } from './slice' @@ -91,7 +93,10 @@ const { fetchLinkUnfurl, fetchLinkUnfurlSucceeded, deleteChat, - deleteChatSucceeded + deleteChatSucceeded, + setChatCategory, + setChatCategorySucceeded, + setChatCategoryFailed } = chatActions const { getChatsSummary, getChat, getUnfurlMetadata, getNonOptimisticChat } = chatSelectors @@ -115,13 +120,40 @@ function* fetchUsersForChats(chats: UserChat[]) { yield* call(queryUsers, Array.from(userIds.values())) } +/** + * Fetches the per-category unread breakdown used by the inbox tab dots. + * Failure is non-fatal (older nodes don't serve it); the dots then fall back + * to the chats loaded in state. + */ +function* fetchUnreadMessagesCountByCategory( + sdk: Awaited> +) { + try { + const response = yield* call([ + sdk.chats, + sdk.chats.getUnreadCountByCategory + ]) + return response.data + } catch (e) { + console.warn('Chats: unread count by category unavailable', e as Error) + return undefined + } +} + function* doFetchUnreadMessagesCount() { try { const audiusSdk = yield* getContext('audiusSdk') const sdk = yield* call(audiusSdk) const response = yield* call([sdk.chats, sdk.chats.getUnreadCount]) + const unreadMessagesCountByCategory = yield* call( + fetchUnreadMessagesCountByCategory, + sdk + ) yield* put( - fetchUnreadMessagesCountSucceeded({ unreadMessagesCount: response.data }) + fetchUnreadMessagesCountSucceeded({ + unreadMessagesCount: response.data, + unreadMessagesCountByCategory + }) ) } catch (e) { yield* put(fetchUnreadMessagesCountFailed()) @@ -904,6 +936,51 @@ function* watchDeleteChat() { yield takeEvery(deleteChat, doDeleteChat) } +export function* doSetChatCategory(action: ReturnType) { + const { chatId, category } = action.payload + const { track, make } = yield* getContext('analytics') + try { + const audiusSdk = yield* getContext('audiusSdk') + const sdk = yield* call(audiusSdk) + const chat = yield* select((state) => getNonOptimisticChat(state, chatId)) + // Blasts are synthetic client-side chats and can't be categorized + if (chat?.is_blast) return + yield* call([sdk.chats, sdk.chats.setCategory], { chatId, category }) + yield* put(setChatCategorySucceeded({ chatId, category })) + yield* put( + toast({ + content: + category === ChatCategory.PRIORITY + ? 'Moved to Priority' + : category === ChatCategory.GENERAL + ? 'Moved to General' + : 'Conversation uncategorized' + }) + ) + yield* call( + track, + make({ eventName: Name.SET_CHAT_CATEGORY_SUCCESS, category }) + ) + } catch (e) { + yield* put(setChatCategoryFailed({ chatId })) + yield* put( + toast({ + type: 'error', + content: 'Failed to update conversation. Please try again.' + }) + ) + console.error('Chats', e as Error) + yield* call( + track, + make({ eventName: Name.SET_CHAT_CATEGORY_FAILURE, category }) + ) + } +} + +function* watchSetChatCategory() { + yield takeEvery(setChatCategory, doSetChatCategory) +} + function* watchLogError() { yield takeEvery(logError, doLogError) } @@ -931,6 +1008,7 @@ export const sagas = () => { watchFetchPermissions, watchFetchLinkUnfurlMetadata, watchDeleteChat, + watchSetChatCategory, watchLogError ] } diff --git a/packages/common/src/store/pages/chat/selectors.ts b/packages/common/src/store/pages/chat/selectors.ts index 587149c7d3e..050a79526f9 100644 --- a/packages/common/src/store/pages/chat/selectors.ts +++ b/packages/common/src/store/pages/chat/selectors.ts @@ -9,7 +9,8 @@ import { CommonState } from '~/store/reducers' import { Maybe, removeNullable } from '~/utils/typeUtils' import { chatMessagesAdapter, chatsAdapter } from './slice' -import { ChatPermissionAction } from './types' +import { ChatPermissionAction, InboxTab } from './types' +import { getInboxTabForChat } from './utils' const { selectById: selectChatById, selectAll: selectAllChats } = chatsAdapter.getSelectors((state) => state.pages.chat.chats) @@ -135,16 +136,73 @@ export const getUnreadMessagesCount = (state: CommonState) => { return state.pages.chat.unreadMessagesCount } +/** + * Unread counts broken down by inbox category, honoring optimistic updates. + * Undefined until the server has supplied them. + */ +export const getUnreadMessagesCountByCategory = (state: CommonState) => + state.pages.chat.optimisticUnreadMessagesCountByCategory ?? + state.pages.chat.unreadMessagesCountByCategory + +const makeGetChatsForInboxTab = (tab: InboxTab) => + createSelector([getChats], (chats) => + chats.filter((chat) => getInboxTabForChat(chat) === tab) + ) + +/** Chats shown in the Priority tab: those marked Priority plus uncategorized. */ +export const getPriorityInboxChats = makeGetChatsForInboxTab(InboxTab.PRIORITY) + +/** Chats shown in the General tab: only those the user marked General. */ +export const getGeneralInboxChats = makeGetChatsForInboxTab(InboxTab.GENERAL) + +export const getChatsForInboxTab = (state: CommonState, tab: InboxTab) => + tab === InboxTab.GENERAL + ? getGeneralInboxChats(state) + : getPriorityInboxChats(state) + +/** + * Whether the given inbox tab has unread messages. Prefers the server's + * per-category counts (which cover chats not yet paginated into state) and + * falls back to scanning the loaded chats. + */ +export const getHasUnreadMessagesForInboxTab = ( + state: CommonState, + tab: InboxTab +) => { + const counts = getUnreadMessagesCountByCategory(state) + if (counts) { + const tabCount = + tab === InboxTab.GENERAL + ? counts.general + : counts.priority + counts.uncategorized + if (tabCount > 0) return true + } + return getChatsForInboxTab(state, tab).some( + (chat) => !chat.is_blast && chat.unread_message_count > 0 + ) +} + +export const getHasUnreadPriorityMessages = (state: CommonState) => + getHasUnreadMessagesForInboxTab(state, InboxTab.PRIORITY) + +export const getHasUnreadGeneralMessages = (state: CommonState) => + getHasUnreadMessagesForInboxTab(state, InboxTab.GENERAL) + +/** + * Whether the account has any unread messages across every inbox category. + * Drives the sidebar / nav dot, so a new message in either Priority or + * General (or an uncategorized chat) lights it up. + */ export const getHasUnreadMessages = (state: CommonState) => { if (getUnreadMessagesCount(state) > 0) { return true } - // This really shouldn't be necessary since the above should be kept in sync + // This really shouldn't be necessary since the above should be kept in sync. + // Blasts never carry unread counts, so skip them rather than stopping at the + // first one (they sort to the top on ties and would hide a later unread). const chats = getChats(state) for (const chat of chats) { - if (chat.is_blast) { - return false - } + if (chat.is_blast) continue if (chat.unread_message_count > 0) { return true } diff --git a/packages/common/src/store/pages/chat/slice.ts b/packages/common/src/store/pages/chat/slice.ts index d02911640e9..ec9331d9cc8 100644 --- a/packages/common/src/store/pages/chat/slice.ts +++ b/packages/common/src/store/pages/chat/slice.ts @@ -8,6 +8,8 @@ import { type ValidatedChatPermissions, type ChatBlastAudience, type ChatBlast, + type ChatCategory, + type ChatUnreadCountByCategory, Id } from '@audius/sdk' import { @@ -24,6 +26,7 @@ import { hasTail } from '~/utils/chatUtils' import dayjs from '~/utils/dayjs' import { ChatWebsocketError } from './types' +import { getChatCategoryKey } from './utils' export type Chat = UserChat | ChatBlast @@ -48,6 +51,18 @@ type ChatState = { > unreadMessagesCount: number optimisticUnreadMessagesCount?: number + /** + * Server-reported unread counts per inbox category. Undefined until the + * server has answered (or if it doesn't support the breakdown yet), in which + * case the per-tab dots fall back to the chats loaded in state. + */ + unreadMessagesCountByCategory?: ChatUnreadCountByCategory + optimisticUnreadMessagesCountByCategory?: ChatUnreadCountByCategory + /** + * Category a chat had before an in-flight setChatCategory, keyed by chat id, + * so a failed request can be rolled back. + */ + pendingChatCategoryRollback: Record optimisticReactions: Record optimisticChatRead: Record< string, @@ -95,6 +110,32 @@ const { selectById: getChat } = chatsAdapter.getSelectors( (state: ChatState) => state.chats ) +/** + * Applies a delta to the optimistic unread count of the category bucket that + * `chat` belongs to. No-op until the server has supplied a baseline. + */ +const adjustUnreadCountForChatCategory = ( + state: ChatState, + chat: { category?: ChatCategory | null }, + 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) + state.optimisticUnreadMessagesCountByCategory = counts +} + +/** The unread count currently shown for a chat, honoring optimistic reads. */ +const getDisplayedUnreadCount = (state: ChatState, chatId: ChatID) => { + const optimisticRead = state.optimisticChatRead[chatId] + if (optimisticRead) return optimisticRead.unread_message_count + const chat = getChat(state, chatId) + return chat && !chat.is_blast ? chat.unread_message_count : 0 +} + const messageSortComparator = (a: ChatMessage, b: ChatMessage) => dayjs(a.created_at).isBefore(dayjs(b.created_at)) ? 1 : -1 @@ -136,6 +177,7 @@ const initialState: ChatState = { messages: {}, unreadMessagesCount: 0, optimisticChatRead: {}, + pendingChatCategoryRollback: {}, optimisticReactions: {}, activeChatId: null, blockees: [], @@ -188,10 +230,19 @@ const slice = createSlice({ }, fetchUnreadMessagesCountSucceeded: ( state, - action: PayloadAction<{ unreadMessagesCount: number }> + action: PayloadAction<{ + unreadMessagesCount: number + unreadMessagesCountByCategory?: ChatUnreadCountByCategory + }> ) => { - state.unreadMessagesCount = action.payload.unreadMessagesCount + const { unreadMessagesCount, unreadMessagesCountByCategory } = + action.payload + state.unreadMessagesCount = unreadMessagesCount delete state.optimisticUnreadMessagesCount + if (unreadMessagesCountByCategory) { + state.unreadMessagesCountByCategory = unreadMessagesCountByCategory + delete state.optimisticUnreadMessagesCountByCategory + } }, fetchUnreadMessagesCountFailed: (_state) => {}, goToChat: ( @@ -444,6 +495,11 @@ const slice = createSlice({ const { chatId } = action.payload const existingChat = getChat(state, chatId) if (existingChat && !existingChat.is_blast) { + adjustUnreadCountForChatCategory( + state, + existingChat, + -getDisplayedUnreadCount(state, chatId) + ) state.optimisticChatRead[chatId] = { last_read_at: existingChat.last_message_at, unread_message_count: 0 @@ -465,9 +521,18 @@ const slice = createSlice({ const { chatId } = action.payload delete state.optimisticChatRead[chatId] delete state.optimisticUnreadMessagesCount + delete state.optimisticUnreadMessagesCountByCategory const existingChat = getChat(state, chatId) 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 + ) + } chatsAdapter.updateOne(state.chats, { id: chatId, changes: { @@ -484,6 +549,7 @@ const slice = createSlice({ const { chatId } = action.payload delete state.optimisticChatRead[chatId] delete state.optimisticUnreadMessagesCount + delete state.optimisticUnreadMessagesCountByCategory }, markAllChatsAsRead: (state) => { // triggers saga @@ -501,6 +567,13 @@ const slice = createSlice({ } } state.optimisticUnreadMessagesCount = 0 + if (state.unreadMessagesCountByCategory) { + state.optimisticUnreadMessagesCountByCategory = { + priority: 0, + general: 0, + uncategorized: 0 + } + } }, markAllChatsAsReadSucceeded: (state) => { // Server confirmed every chat_member.unread_count is now 0; promote the @@ -521,12 +594,21 @@ const slice = createSlice({ } state.optimisticChatRead = {} delete state.optimisticUnreadMessagesCount + if (state.unreadMessagesCountByCategory) { + state.unreadMessagesCountByCategory = { + priority: 0, + general: 0, + uncategorized: 0 + } + } + delete state.optimisticUnreadMessagesCountByCategory }, markAllChatsAsReadFailed: (state) => { // chat.read_all is all-or-nothing; on failure undo every optimistic // read this run installed. state.optimisticChatRead = {} delete state.optimisticUnreadMessagesCount + delete state.optimisticUnreadMessagesCountByCategory }, sendMessage: ( state, @@ -629,6 +711,7 @@ const slice = createSlice({ // Web or mobile: update optimistic unread count state.optimisticUnreadMessagesCount = (state.optimisticUnreadMessagesCount ?? state.unreadMessagesCount) + 1 + adjustUnreadCountForChatCategory(state, existingChat, 1) } else { // Mark chat as read if its our own chatsAdapter.updateOne(state.chats, { @@ -641,6 +724,11 @@ const slice = createSlice({ state.optimisticUnreadMessagesCount = (state.optimisticUnreadMessagesCount ?? state.unreadMessagesCount) - existingUnreadCount + adjustUnreadCountForChatCategory( + state, + existingChat, + -existingUnreadCount + ) } }, /** @@ -756,6 +844,68 @@ const slice = createSlice({ chatsAdapter.removeOne(state.chats, chatId) chatMessagesAdapter.removeAll(state.messages[chatId]) }, + setChatCategory: ( + state, + action: PayloadAction<{ chatId: string; category: ChatCategory | null }> + ) => { + // triggers saga + // Optimistically move the chat (and its unread messages) to the new + // category so it switches tabs immediately. + const { chatId, category } = action.payload + const existingChat = getChat(state, chatId) + if (!existingChat || existingChat.is_blast) return + const previousCategory = existingChat.category ?? null + if (previousCategory === category) return + if (!(chatId in state.pendingChatCategoryRollback)) { + state.pendingChatCategoryRollback[chatId] = previousCategory + } + const unreadCount = getDisplayedUnreadCount(state, chatId) + adjustUnreadCountForChatCategory(state, existingChat, -unreadCount) + adjustUnreadCountForChatCategory(state, { category }, unreadCount) + chatsAdapter.updateOne(state.chats, { + id: chatId, + changes: { category } + }) + }, + setChatCategorySucceeded: ( + state, + action: PayloadAction<{ chatId: string; category: ChatCategory | null }> + ) => { + const { chatId, category } = action.payload + delete state.pendingChatCategoryRollback[chatId] + const existingChat = getChat(state, chatId) + if (!existingChat || existingChat.is_blast) return + // Re-assert the confirmed category in case a concurrent chat refetch + // overwrote the optimistic value with the server's stale one. + if ((existingChat.category ?? null) !== category) { + chatsAdapter.updateOne(state.chats, { + id: chatId, + changes: { category } + }) + } + }, + setChatCategoryFailed: ( + state, + action: PayloadAction<{ chatId: string }> + ) => { + const { chatId } = action.payload + const previousCategory = state.pendingChatCategoryRollback[chatId] + if (previousCategory === undefined) return + delete state.pendingChatCategoryRollback[chatId] + const existingChat = getChat(state, chatId) + if (!existingChat || existingChat.is_blast) return + const unreadCount = getDisplayedUnreadCount(state, chatId) + adjustUnreadCountForChatCategory(state, existingChat, -unreadCount) + adjustUnreadCountForChatCategory( + state, + { category: previousCategory }, + unreadCount + ) + chatsAdapter.updateOne(state.chats, { + id: chatId, + changes: { category: previousCategory } + }) + }, logError: ( _state, _action: PayloadAction<{ error: ChatWebsocketError }> diff --git a/packages/common/src/store/pages/chat/types.ts b/packages/common/src/store/pages/chat/types.ts index fd47c355754..ff5bb89e813 100644 --- a/packages/common/src/store/pages/chat/types.ts +++ b/packages/common/src/store/pages/chat/types.ts @@ -1,4 +1,17 @@ -import { ChatPermission } from '@audius/sdk' +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. + */ +export enum InboxTab { + PRIORITY = 'priority', + GENERAL = 'general' +} + +/** Bucket key used for per-category unread counts. */ +export type ChatCategoryKey = keyof ChatUnreadCountByCategory /** Action current user can take to be able to message another user */ export enum ChatPermissionAction { diff --git a/packages/common/src/store/pages/chat/utils.ts b/packages/common/src/store/pages/chat/utils.ts index 101a9015278..ab9aa580475 100644 --- a/packages/common/src/store/pages/chat/utils.ts +++ b/packages/common/src/store/pages/chat/utils.ts @@ -1,7 +1,24 @@ -import { Id } from '@audius/sdk' +import { ChatCategory, Id } from '@audius/sdk' import { ID } from '~/models/Identifiers' +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 } + +/** The unread-count bucket a chat belongs to. */ +export const getChatCategoryKey = (chat: Categorizable): ChatCategoryKey => + chat.category ?? 'uncategorized' + +/** + * The inbox tab a chat is displayed under. Only chats explicitly marked + * General leave the Priority tab; uncategorized chats stay in Priority. + */ +export const getInboxTabForChat = (chat: Categorizable): InboxTab => + chat.category === ChatCategory.GENERAL ? InboxTab.GENERAL : InboxTab.PRIORITY + export const makeChatId = (userIds: ID[]) => { return userIds .map((id) => Id.parse(id)) diff --git a/packages/mobile/src/components/chat-actions-drawer/ChatActionsDrawer.tsx b/packages/mobile/src/components/chat-actions-drawer/ChatActionsDrawer.tsx index 22fbea2c1c8..b3d40f8a8c6 100644 --- a/packages/mobile/src/components/chat-actions-drawer/ChatActionsDrawer.tsx +++ b/packages/mobile/src/components/chat-actions-drawer/ChatActionsDrawer.tsx @@ -1,6 +1,7 @@ import { useCallback } from 'react' -import { chatSelectors } from '@audius/common/store' +import { chatActions, chatSelectors } from '@audius/common/store' +import { ChatCategory } from '@audius/sdk' import { useDispatch, useSelector } from 'react-redux' import { useDrawer } from 'app/hooks/useDrawer' @@ -10,12 +11,15 @@ import { setVisibility } from 'app/store/drawers/slice' import ActionDrawer from '../action-drawer' -const { getDoesBlockUser } = chatSelectors +const { getDoesBlockUser, getChat } = chatSelectors +const { setChatCategory } = chatActions const CHAT_ACTIONS_MODAL_NAME = 'ChatActions' const messages = { visitProfile: 'Visit Profile', + moveToPriority: 'Move to Priority', + moveToGeneral: 'Move to General', blockMessages: 'Block Messages', unblockMessages: 'Unblock Messages', reportAbuse: 'Report Abuse', @@ -30,6 +34,8 @@ export const ChatActionsDrawer = () => { const doesBlockUser = useSelector((state: AppState) => getDoesBlockUser(state, userId) ) + const chat = useSelector((state: AppState) => getChat(state, chatId)) + const category = chat && !chat.is_blast ? (chat.category ?? null) : null const closeDrawer = useCallback(() => { dispatch( @@ -67,6 +73,16 @@ export const ChatActionsDrawer = () => { ) }, [closeDrawer, dispatch, userId]) + const handleMoveToPriorityPress = useCallback(() => { + closeDrawer() + dispatch(setChatCategory({ chatId, category: ChatCategory.PRIORITY })) + }, [chatId, closeDrawer, dispatch]) + + const handleMoveToGeneralPress = useCallback(() => { + closeDrawer() + dispatch(setChatCategory({ chatId, category: ChatCategory.GENERAL })) + }, [chatId, closeDrawer, dispatch]) + const handleDeletePress = useCallback(() => { closeDrawer() dispatch( @@ -86,6 +102,22 @@ export const ChatActionsDrawer = () => { text: messages.visitProfile, callback: handleVisitProfilePress }, + ...(category !== ChatCategory.PRIORITY + ? [ + { + text: messages.moveToPriority, + callback: handleMoveToPriorityPress + } + ] + : []), + ...(category !== ChatCategory.GENERAL + ? [ + { + text: messages.moveToGeneral, + callback: handleMoveToGeneralPress + } + ] + : []), { text: doesBlockUser ? messages.unblockMessages diff --git a/packages/mobile/src/screens/chat-screen/ChatCategorySwipeActions.tsx b/packages/mobile/src/screens/chat-screen/ChatCategorySwipeActions.tsx new file mode 100644 index 00000000000..236ae86eac9 --- /dev/null +++ b/packages/mobile/src/screens/chat-screen/ChatCategorySwipeActions.tsx @@ -0,0 +1,85 @@ +import { useCallback } from 'react' + +import { chatActions } from '@audius/common/store' +import { ChatCategory } from '@audius/sdk' +import { TouchableOpacity } from 'react-native' +import { useDispatch } from 'react-redux' + +import { Flex, IconMessages, IconStar, Text } from '@audius/harmony-native' +import { useThemePalette } from 'app/utils/theme' + +const { setChatCategory } = chatActions + +const messages = { + priority: 'Priority', + general: 'General', + moveToPriority: 'Move to Priority', + moveToGeneral: 'Move to General' +} + +const ACTION_WIDTH = 88 + +type ChatCategorySwipeActionsProps = { + chatId: string + category: ChatCategory | null + /** Called after an action is chosen so the row can close */ + onAction: () => void +} + +/** + * The actions revealed by swiping a chat row: one button per inbox category + * the chat is not already in. + */ +export const ChatCategorySwipeActions = ({ + chatId, + category, + onAction +}: ChatCategorySwipeActionsProps) => { + const dispatch = useDispatch() + const palette = useThemePalette() + + const handleMoveToPriority = useCallback(() => { + onAction() + dispatch(setChatCategory({ chatId, category: ChatCategory.PRIORITY })) + }, [chatId, dispatch, onAction]) + + const handleMoveToGeneral = useCallback(() => { + onAction() + dispatch(setChatCategory({ chatId, category: ChatCategory.GENERAL })) + }, [chatId, dispatch, onAction]) + + return ( + + {category !== ChatCategory.PRIORITY ? ( + + + + + {messages.priority} + + + + ) : null} + {category !== ChatCategory.GENERAL ? ( + + + + + {messages.general} + + + + ) : null} + + ) +} diff --git a/packages/mobile/src/screens/chat-screen/ChatListItem.tsx b/packages/mobile/src/screens/chat-screen/ChatListItem.tsx index 7d39228ce40..93dd21ed097 100644 --- a/packages/mobile/src/screens/chat-screen/ChatListItem.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatListItem.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useRef } from 'react' import { useOtherChatUsers } from '@audius/common/api' import { useProxySelector } from '@audius/common/hooks' @@ -6,14 +6,20 @@ import { chatSelectors } from '@audius/common/store' import { css } from '@emotion/native' import { useTheme } from '@emotion/react' import { TouchableHighlight } from 'react-native' +import ReanimatedSwipeable, { + type SwipeableMethods +} from 'react-native-gesture-handler/ReanimatedSwipeable' +import { useDispatch } from 'react-redux' import { Box, Flex, Text } from '@audius/harmony-native' import { ProfilePicture } from 'app/components/core' import { UserBadges } from 'app/components/user-badges' import { useNavigation } from 'app/hooks/useNavigation' +import { setVisibility } from 'app/store/drawers/slice' import type { AppTabScreenParamList } from '../app-screen' +import { ChatCategorySwipeActions } from './ChatCategorySwipeActions' import { ChatListItemSkeleton } from './ChatListItemSkeleton' const { getChat } = chatSelectors @@ -35,6 +41,7 @@ const useRemoveLeadingWhitespace = (message: string) => { export const ChatListItem = ({ chatId }: { chatId: string }) => { const { spacing } = useTheme() + const dispatch = useDispatch() const navigation = useNavigation() const chat = useProxySelector((state) => getChat(state, chatId), [chatId]) @@ -43,75 +50,112 @@ 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 swipeableRef = useRef(null) const handlePress = useCallback(() => { navigation.push('Chat', { chatId }) }, [navigation, chatId]) + const handleLongPress = useCallback(() => { + if (!otherUser) return + dispatch( + setVisibility({ + drawer: 'ChatActions', + visible: true, + data: { userId: otherUser.user_id, chatId } + }) + ) + }, [dispatch, otherUser, chatId]) + + const closeSwipeable = useCallback(() => { + swipeableRef.current?.close() + }, []) + + // Swipe left to reveal Priority / General; long-press for the full menu. + const renderRightActions = useCallback( + () => ( + + ), + [chatId, category, closeSwipeable] + ) + return ( - - {otherUser ? ( - - - - - - - - {otherUser.name} + + + {otherUser ? ( + + + + + + + + {otherUser.name} + + + + + @{otherUser.handle} - - - @{otherUser.handle} - - - {chat?.unread_message_count && chat?.unread_message_count > 0 ? ( - - - 0 ? ( + + - {clipMessageCount(chat?.unread_message_count ?? 0)}{' '} - {messages.new} - - - - ) : null} + + {clipMessageCount(chat?.unread_message_count ?? 0)}{' '} + {messages.new} + + + + ) : null} + + {lastMessage} - {lastMessage} - - ) : ( - - )} - + ) : ( + + )} + + ) } diff --git a/packages/mobile/src/screens/chat-screen/ChatListScreen.tsx b/packages/mobile/src/screens/chat-screen/ChatListScreen.tsx index f69a951c553..619789e30f6 100644 --- a/packages/mobile/src/screens/chat-screen/ChatListScreen.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatListScreen.tsx @@ -1,7 +1,7 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Status } from '@audius/common/models' -import { chatActions, chatSelectors } from '@audius/common/store' +import { chatActions, chatSelectors, InboxTab } from '@audius/common/store' import { FlashList } from '@shopify/flash-list' import { View, TouchableOpacity } from 'react-native' import { useDispatch, useSelector } from 'react-redux' @@ -18,11 +18,24 @@ import { useThemePalette } from 'app/utils/theme' import { ChatListBlastItem } from './ChatListBlastItem' import { ChatListItem } from './ChatListItem' import { ChatListItemSkeleton } from './ChatListItemSkeleton' +import { InboxTabs } from './InboxTabs' -const { getChats, getChatsStatus, getHasMoreChats } = chatSelectors +const { + getChats, + getChatsStatus, + getHasMoreChats, + getPriorityInboxChats, + getGeneralInboxChats +} = chatSelectors const { fetchMoreMessages, fetchLatestChats, fetchMoreChats } = chatActions const CHATS_MESSAGES_PREFETCH_LIMIT = 10 +/** + * Chats are paginated by recency across all categories, so a tab can be + * empty while older pages still hold chats that belong in it. Keep fetching + * until the tab has at least this many rows or there is nothing left. + */ +const MIN_VISIBLE_CHATS_PER_TAB = 10 // Precalculated height for perf optimization const CHAT_ITEM_HEIGHT = 88 // Calculated height: pv='l' (32px) + ProfilePicture unit12 (48px) + text/margins (~8px) @@ -31,7 +44,16 @@ const messages = { startConversation: 'Start a Conversation!', connect: 'Connect with other Audius users by\nstarting a private direct message!', - writeMessage: 'Write a Message' + writeMessage: 'Write a Message', + nothingHere: 'Nothing Here Yet', + priorityEmpty: + 'New conversations, and ones you mark as Priority, show up here.', + generalEmpty: 'Conversations you mark as General show up here.' +} + +const emptyMessageForTab: Record = { + [InboxTab.PRIORITY]: messages.priorityEmpty, + [InboxTab.GENERAL]: messages.generalEmpty } const useStyles = makeStyles(({ spacing, palette, typography }) => ({ @@ -98,15 +120,36 @@ const ChatsEmpty = ({ onPress }: { onPress: () => void }) => { ) } +const TabEmpty = ({ tab }: { tab: InboxTab }) => { + const styles = useStyles() + return ( + + + {messages.nothingHere} + + + {emptyMessageForTab[tab]} + + + ) +} + export const ChatListScreen = () => { const styles = useStyles() const palette = useThemePalette() const dispatch = useDispatch() const navigation = useNavigation() const chats = useSelector(getChats) - const nonEmptyChats = chats.filter((chat) => !!chat.last_message_at) + const [currentTab, setCurrentTab] = useState(InboxTab.PRIORITY) + const tabChats = useSelector( + currentTab === InboxTab.GENERAL + ? getGeneralInboxChats + : getPriorityInboxChats + ) + const nonEmptyChats = tabChats.filter((chat) => !!chat.last_message_at) const chatsStatus = useSelector(getChatsStatus) const hasMore = useSelector(getHasMoreChats) + const hasAnyChats = chats.length > 0 // If this is the first fetch, we want to show the fade-out loading skeleton // On subsequent loads, we want to show a skeleton in each incoming chat row. @@ -128,6 +171,15 @@ export const ChatListScreen = () => { dispatch(fetchLatestChats()) }, [dispatch]) + // Backfill the current tab from older pages when it is nearly empty + const needsBackfill = + hasMore && nonEmptyChats.length < MIN_VISIBLE_CHATS_PER_TAB + useEffect(() => { + if (chatsStatus === Status.SUCCESS && needsBackfill) { + dispatch(fetchMoreChats()) + } + }, [chatsStatus, needsBackfill, dispatch]) + // Prefetch messages for initial loaded chats useEffect(() => { if ( @@ -167,6 +219,7 @@ export const ChatListScreen = () => { + {isLoadingFirstTime ? ( Array(4) .fill(null) @@ -185,9 +238,19 @@ export const ChatListScreen = () => { data={nonEmptyChats} renderItem={renderItem} keyExtractor={keyExtractor} - ListEmptyComponent={() => ( - - )} + ListEmptyComponent={() => + hasMore && chatsStatus !== Status.ERROR ? ( + // Still backfilling this tab from older pages + <> + + + + ) : hasAnyChats ? ( + + ) : ( + + ) + } onEndReached={handleLoadMore} onEndReachedThreshold={0.7} estimatedItemSize={CHAT_ITEM_HEIGHT} diff --git a/packages/mobile/src/screens/chat-screen/InboxTabs.tsx b/packages/mobile/src/screens/chat-screen/InboxTabs.tsx new file mode 100644 index 00000000000..523550cdecb --- /dev/null +++ b/packages/mobile/src/screens/chat-screen/InboxTabs.tsx @@ -0,0 +1,88 @@ +import { chatSelectors, InboxTab } from '@audius/common/store' +import { useSelector } from 'react-redux' + +import { Box, Flex, SelectablePill, useTheme } from '@audius/harmony-native' + +const { getHasUnreadPriorityMessages, getHasUnreadGeneralMessages } = + chatSelectors + +const messages = { + priority: 'Priority', + general: 'General' +} + +export const inboxTabLabels: Record = { + [InboxTab.PRIORITY]: messages.priority, + [InboxTab.GENERAL]: messages.general +} + +const tabs: InboxTab[] = [InboxTab.PRIORITY, InboxTab.GENERAL] + +const DOT_SIZE = 10 + +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. + */ +export const InboxTabs = ({ currentTab, onSelectTab }: InboxTabsProps) => { + const { color } = useTheme() + const hasUnreadPriority = useSelector(getHasUnreadPriorityMessages) + const hasUnreadGeneral = useSelector(getHasUnreadGeneralMessages) + const hasUnread: Record = { + [InboxTab.PRIORITY]: hasUnreadPriority, + [InboxTab.GENERAL]: hasUnreadGeneral + } + + return ( + + {tabs.map((tab) => ( + + { + if (!isSelected) return + onSelectTab(value as InboxTab) + }} + disableUnselectAnimation + /> + {hasUnread[tab] ? ( + + ) : null} + + ))} + + ) +} diff --git a/packages/sdk/src/sdk/api/chats/ChatsApi.ts b/packages/sdk/src/sdk/api/chats/ChatsApi.ts index 94129a03be6..74cc4abf63d 100644 --- a/packages/sdk/src/sdk/api/chats/ChatsApi.ts +++ b/packages/sdk/src/sdk/api/chats/ChatsApi.ts @@ -54,6 +54,8 @@ import { ChatReadRequestSchema, ChatReadAllRequest, ChatReadAllRequestSchema, + ChatSetCategoryRequest, + ChatSetCategoryRequestSchema, ChatUnfurlRequest, ChatUnfurlRequestSchema, TypedCommsResponse, @@ -63,6 +65,7 @@ import { type ChatCreateRPC, type ChatInvite, type ChatMessage, + type ChatUnreadCountByCategory, type ChatWebsocketEventData, type RPCPayloadRequest, type UpgradableChatBlast, @@ -312,6 +315,33 @@ export class ChatsApi return (await res.json()) as TypedCommsResponse } + /** + * Gets the total unread message count of the current user, broken down by + * inbox category (priority / general / uncategorized). Used to drive the + * per-tab notification dots in the inbox. + * @param params.currentUserId the user to act on behalf of + * @returns the unread count by category response + */ + public async getUnreadCountByCategory(params?: ChatGetUnreadCountRequest) { + const parsedArgs = await parseParams( + 'getUnreadCountByCategory', + ChatGetUnreadCountRequestSchema + )(params) + const query: HTTPQuery = { + timestamp: new Date().getTime() + } + if (parsedArgs?.currentUserId) { + query.current_user_id = parsedArgs.currentUserId + } + const res = await this.signAndSendRequest({ + method: 'GET', + path: `/comms/chats/unread_by_category`, + headers: {}, + query + }) + return (await res.json()) as TypedCommsResponse + } + /** * Gets the permission settings of the given users * @param params.userIds the users to fetch permissions of @@ -679,6 +709,30 @@ export class ChatsApi }) } + /** + * Sets (or clears) the inbox category of a chat for the current user. + * Chats marked 'priority' or 'general' are routed to that inbox tab; + * passing null returns the chat to the uncategorized inbox. + * @param params.chatId the chat to categorize + * @param params.category 'priority' | 'general' | null + * @param params.currentUserId the user to act on behalf of + * @returns the rpc object + */ + public async setCategory(params: ChatSetCategoryRequest) { + const { currentUserId, chatId, category } = await parseParams( + 'setCategory', + ChatSetCategoryRequestSchema + )(params) + return await this.sendRpc({ + current_user_id: currentUserId, + method: 'chat.set_category', + params: { + chat_id: chatId, + category + } + }) + } + // #endregion // #region PRIVATE diff --git a/packages/sdk/src/sdk/api/chats/clientTypes.ts b/packages/sdk/src/sdk/api/chats/clientTypes.ts index f9b91e6333a..ae7e6ec6b8c 100644 --- a/packages/sdk/src/sdk/api/chats/clientTypes.ts +++ b/packages/sdk/src/sdk/api/chats/clientTypes.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { CommsResponse, ChatPermission, + ChatCategory, ChatMessage, ChatMessageNullableReaction, ChatBlastAudience @@ -143,6 +144,16 @@ export const ChatPermitRequestSchema = z.object({ export type ChatPermitRequest = z.infer +export const ChatSetCategoryRequestSchema = z.object({ + currentUserId: z.optional(z.string()), + chatId: z.string(), + category: z.nullable(z.nativeEnum(ChatCategory)) +}) + +export type ChatSetCategoryRequest = z.infer< + typeof ChatSetCategoryRequestSchema +> + export const ChatValidateCanCreateRequestSchema = z.object({ currentUserId: z.optional(z.string()), userIds: z.array(z.string()).min(1) diff --git a/packages/sdk/src/sdk/api/chats/serverTypes.ts b/packages/sdk/src/sdk/api/chats/serverTypes.ts index fa63fd36385..d47fba149b3 100644 --- a/packages/sdk/src/sdk/api/chats/serverTypes.ts +++ b/packages/sdk/src/sdk/api/chats/serverTypes.ts @@ -103,6 +103,14 @@ export type ChatPermitRPC = { } } +export type ChatSetCategoryRPC = { + method: 'chat.set_category' + params: { + chat_id: string + category: ChatCategory | null + } +} + export type RPCPayloadRequest = | ChatBlastRPC | ChatCreateRPC @@ -115,6 +123,7 @@ export type RPCPayloadRequest = | ChatBlockRPC | ChatUnblockRPC | ChatPermitRPC + | ChatSetCategoryRPC | ValidateCanChatRPC export type RPCPayload = RPCPayloadRequest & { @@ -138,6 +147,8 @@ export type UserChat = { unread_message_count: number last_read_at: string cleared_history_at: string + /** Inbox category chosen by the current user. Absent/null = uncategorized. */ + category?: ChatCategory | null // If blast: is_blast: false @@ -217,6 +228,25 @@ export enum ChatPermission { NONE = 'none' } +/** + * Per-user inbox category for a chat. Chats without a category are + * "uncategorized" and surface in the default inbox view. + */ +export enum ChatCategory { + PRIORITY = 'priority', + GENERAL = 'general' +} + +/** + * Unread message counts for the current user, broken down by inbox category. + * Blast "chats" are never counted. + */ +export type ChatUnreadCountByCategory = { + priority: number + general: number + uncategorized: number +} + export enum ChatBlastAudience { FOLLOWERS = 'follower_audience', TIPPERS = 'tipper_audience', diff --git a/packages/web/src/pages/chat-page/ChatPage.tsx b/packages/web/src/pages/chat-page/ChatPage.tsx index 54f3e3ce058..160ea1432ac 100644 --- a/packages/web/src/pages/chat-page/ChatPage.tsx +++ b/packages/web/src/pages/chat-page/ChatPage.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useCanSendMessage } from '@audius/common/hooks' import { Name, Status } from '@audius/common/models' -import { chatActions, chatSelectors } from '@audius/common/store' +import { chatActions, chatSelectors, InboxTab } from '@audius/common/store' import { ChatBlast, OptionalHashId } from '@audius/sdk' import cn from 'classnames' import { useDispatch } from 'react-redux' @@ -90,6 +90,7 @@ export const ChatPage = () => { const chats = useSelector(getChats) const chatsStatus = useSelector(getChatsStatus) + const [currentTab, setCurrentTab] = useState(InboxTab.PRIORITY) // Only collapse the sidebar once we know for sure the account has no chats. // During LOADING / IDLE we keep the sidebar visible so the skeleton loader // still renders and we don't flash a layout shift. @@ -183,6 +184,8 @@ export const ChatPage = () => { } > @@ -192,6 +195,7 @@ export const ChatPage = () => { diff --git a/packages/web/src/pages/chat-page/components/ChatHeader.tsx b/packages/web/src/pages/chat-page/components/ChatHeader.tsx index 243bbda23e5..d38587386ad 100644 --- a/packages/web/src/pages/chat-page/components/ChatHeader.tsx +++ b/packages/web/src/pages/chat-page/components/ChatHeader.tsx @@ -4,6 +4,7 @@ import { chatActions, chatSelectors, CommonState, + InboxTab, useCreateChatModal } from '@audius/common/store' import { @@ -23,6 +24,7 @@ import { useModalState } from 'common/hooks/useModalState' import { Frosted } from 'components/frosted/Frosted' import { ChatBlastHeader } from './ChatBlastHeader' +import { InboxTabs } from './InboxTabs' import { UserChatHeader } from './UserChatHeader' const messages = { @@ -38,13 +40,15 @@ const CHAT_LIST_WIDTH_PX = 400 type ChatHeaderProps = { currentChatId?: string + currentTab: InboxTab + onSelectTab: (tab: InboxTab) => void isNarrowLayout?: boolean scrollBarWidth?: number headerContainerRef?: React.RefObject } export const ChatHeader = forwardRef( - ({ currentChatId, isNarrowLayout }, ref) => { + ({ currentChatId, currentTab, onSelectTab, isNarrowLayout }, ref) => { const dispatch = useDispatch() const { onOpen: openCreateChatModal } = useCreateChatModal() const [, setInboxSettingsVisible] = useModalState('InboxSettings') @@ -86,32 +90,37 @@ export const ChatHeader = forwardRef( : []) ] + // Title row (32px) + tabs row (32px) + padding/gap fill the 112px + // --chat-header-height exactly, so the list offsets are unchanged. const headerContent = ( - - - - {messages.header} - - - - ( - trigger()} - /> - )} - /> + + + + + {messages.header} + + + + ( + trigger()} + /> + )} + /> + + ) diff --git a/packages/web/src/pages/chat-page/components/ChatList.tsx b/packages/web/src/pages/chat-page/components/ChatList.tsx index 6e26b59b37f..cb7234aec3e 100644 --- a/packages/web/src/pages/chat-page/components/ChatList.tsx +++ b/packages/web/src/pages/chat-page/components/ChatList.tsx @@ -6,7 +6,7 @@ import { } from 'react' import { Status } from '@audius/common/models' -import { chatActions, chatSelectors } from '@audius/common/store' +import { chatActions, chatSelectors, InboxTab } from '@audius/common/store' import cn from 'classnames' import InfiniteScroll from 'react-infinite-scroller' import { useDispatch } from 'react-redux' @@ -18,25 +18,40 @@ import { ChatListBlastItem } from './ChatListBlastItem' import { ChatListItem } from './ChatListItem' import { SkeletonChatListItem } from './SkeletonChatListItem' -const { getChats, getChatsStatus, getHasMoreChats } = chatSelectors +const { getChatsForInboxTab, getChatsStatus, getHasMoreChats } = chatSelectors const { fetchMoreChats } = chatActions const messages = { nothingHere: 'Nothing Here Yet', - start: 'Start a Conversation!' + priorityEmpty: + 'New conversations, and ones you mark as Priority, show up here.', + generalEmpty: 'Conversations you mark as General show up here.' } +const emptyMessageForTab: Record = { + [InboxTab.PRIORITY]: messages.priorityEmpty, + [InboxTab.GENERAL]: messages.generalEmpty +} + +/** + * Chats are paginated by recency across all categories, so a tab can be + * empty while older pages still hold chats that belong in it. Keep fetching + * until the tab has at least this many rows or there is nothing left. + */ +const MIN_VISIBLE_CHATS_PER_TAB = 10 + type ChatListProps = { currentChatId?: string + currentTab: InboxTab onChatClicked: (chatId: string) => void isCompact?: boolean } & ComponentPropsWithoutRef<'div'> export const ChatList = (props: ChatListProps) => { - const { currentChatId, onChatClicked, isCompact } = props + const { currentChatId, currentTab, onChatClicked, isCompact } = props const dispatch = useDispatch() const [hasLoadedOnce, setHasLoadedOnce] = useState(false) - const chats = useSelector(getChats) + const chats = useSelector((state) => getChatsForInboxTab(state, currentTab)) const status = useSelector(getChatsStatus) const hasMore = useSelector(getHasMoreChats) @@ -50,6 +65,19 @@ export const ChatList = (props: ChatListProps) => { } }, [status, setHasLoadedOnce]) + // Backfill the current tab from older pages when it is nearly empty + const needsBackfill = hasMore && chats.length < MIN_VISIBLE_CHATS_PER_TAB + useEffect(() => { + if (status === Status.SUCCESS && needsBackfill) { + dispatch(fetchMoreChats()) + } + }, [status, needsBackfill, dispatch]) + + // While there are still pages to load, the InfiniteScroll loader (below) + // shows skeletons, so only show the empty state once we've run out. + const isEmptyTab = + chats.length === 0 && hasLoadedOnce && (!hasMore || status === Status.ERROR) + return (
{ /> ) ) - ) : hasLoadedOnce ? ( + ) : isEmptyTab ? (
{messages.nothingHere}
-
{messages.start}
+
+ {emptyMessageForTab[currentTab]} +
- ) : ( + ) : hasLoadedOnce ? null : ( <>