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..ed6dba9b738 --- /dev/null +++ b/packages/common/src/store/pages/chat/category.test.ts @@ -0,0 +1,406 @@ +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 + +/** The (non-blast) chat entity, narrowed so `category` is readable. */ +const getUserChatEntity = ( + state: ReturnType, + chatId: string +): UserChat | undefined => { + const chat = state.chats.entities[chatId] + return chat && !chat.is_blast ? chat : undefined +} + +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(getUserChatEntity(optimistic, '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(getUserChatEntity(confirmed, '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(getUserChatEntity(rolledBack, '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(getUserChatEntity(clobbered, 'chat-1')?.category).toBeNull() + const confirmed = chatReducer( + clobbered, + actions.setChatCategorySucceeded({ + chatId: 'chat-1', + category: ChatCategory.GENERAL + }) + ) + expect(getUserChatEntity(confirmed, '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 : ( <>