Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/inbox-replied-priority.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@audius/sdk': minor
'@audius/harmony': minor
---

Add `has_replied` to user chats and `uncategorized_unreplied` to the unread-by-category counts. Add an `endAdornment` slot to `SelectablePill`.
193 changes: 189 additions & 4 deletions packages/common/src/store/pages/chat/category.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ const seed = (chats: UserChat[]) =>

const withServerCounts = (
state: ReturnType<typeof chatReducer>,
counts: { priority: number; general: number; uncategorized: number }
counts: {
priority: number
general: number
uncategorized: number
uncategorized_unreplied?: number
}
) =>
chatReducer(
state,
Expand All @@ -85,7 +90,37 @@ const getUserChatEntity = (

describe('chat categories', () => {
describe('getInboxTabForChat', () => {
it('puts uncategorized and priority chats in Priority, general in General', () => {
it('puts uncategorized chats in Priority once the user has replied', () => {
expect(getInboxTabForChat(makeChat('a', { has_replied: true }))).toBe(
InboxTab.PRIORITY
)
expect(getInboxTabForChat(makeChat('a', { has_replied: false }))).toBe(
InboxTab.GENERAL
)
})

it('lets an explicit category override replies', () => {
expect(
getInboxTabForChat(
makeChat('a', { category: ChatCategory.PRIORITY, has_replied: false })
)
).toBe(InboxTab.PRIORITY)
expect(
getInboxTabForChat(
makeChat('a', { category: ChatCategory.GENERAL, has_replied: true })
)
).toBe(InboxTab.GENERAL)
})

it('keeps sent blasts in Priority', () => {
const blast = {
chat_id: 'follower_audience',
is_blast: true
} as unknown as UserChat
expect(getInboxTabForChat(blast)).toBe(InboxTab.PRIORITY)
})

it('treats chats from an api without has_replied as Priority', () => {
expect(getInboxTabForChat(makeChat('a'))).toBe(InboxTab.PRIORITY)
expect(getInboxTabForChat(makeChat('a', { category: null }))).toBe(
InboxTab.PRIORITY
Expand Down Expand Up @@ -305,13 +340,15 @@ describe('chat categories', () => {
expect(getUnreadMessagesCountByCategory(asCommonState(next))).toEqual({
priority: 0,
general: 0,
uncategorized: 0
uncategorized: 0,
uncategorized_unreplied: 0
})
const confirmed = chatReducer(next, actions.markAllChatsAsReadSucceeded())
expect(confirmed.unreadMessagesCountByCategory).toEqual({
priority: 0,
general: 0,
uncategorized: 0
uncategorized: 0,
uncategorized_unreplied: 0
})
})

Expand Down Expand Up @@ -382,6 +419,154 @@ describe('chat categories', () => {
})
})

describe('unreplied chats', () => {
const unreadMessage = (chatId: string, id: string) =>
({
message_id: id,
chat_id: chatId,
sender_user_id: '7',
created_at: '2026-01-03T00:00:00.000Z',
message: 'hi',
is_plaintext: false,
reactions: []
}) as any

it('lights the General dot for unread chats the user never replied to', () => {
const seeded = withServerCounts(
seed([
makeChat('new-1', { has_replied: false, unread_message_count: 2 })
]),
{
priority: 0,
general: 0,
uncategorized: 2,
uncategorized_unreplied: 2
}
)
const state = asCommonState(seeded)
expect(getHasUnreadGeneralMessages(state)).toBe(true)
expect(getHasUnreadPriorityMessages(state)).toBe(false)
expect(getGeneralInboxChats(state).map((c) => c.chat_id)).toEqual([
'new-1'
])
})

it('counts unreplied uncategorized unreads as Priority on an older api', () => {
const seeded = withServerCounts(
seed([makeChat('new-1', { unread_message_count: 1 })]),
{ priority: 0, general: 0, uncategorized: 1 }
)
const state = asCommonState(seeded)
expect(getHasUnreadPriorityMessages(state)).toBe(true)
expect(getHasUnreadGeneralMessages(state)).toBe(false)
})

it('bumps both uncategorized buckets when a message arrives', () => {
const seeded = withServerCounts(
seed([makeChat('new-1', { has_replied: false })]),
{
priority: 0,
general: 0,
uncategorized: 0,
uncategorized_unreplied: 0
}
)
const next = chatReducer(
seeded,
actions.addMessage({
chatId: 'new-1',
isSelfMessage: false,
message: unreadMessage('new-1', 'm1')
})
)
expect(getUnreadMessagesCountByCategory(asCommonState(next))).toEqual({
priority: 0,
general: 0,
uncategorized: 1,
uncategorized_unreplied: 1
})
expect(getHasUnreadGeneralMessages(asCommonState(next))).toBe(true)
})

it('moves the chat to Priority when the user replies', () => {
const seeded = withServerCounts(
seed([
makeChat('new-1', { has_replied: false, unread_message_count: 1 })
]),
{
priority: 0,
general: 0,
uncategorized: 1,
uncategorized_unreplied: 1
}
)
const replied = chatReducer(
seeded,
actions.addMessage({
chatId: 'new-1',
isSelfMessage: true,
message: { ...unreadMessage('new-1', 'm2'), sender_user_id: '1' }
})
)
expect(getUserChatEntity(replied, 'new-1')?.has_replied).toBe(true)
const state = asCommonState(replied)
expect(getPriorityInboxChats(state).map((c) => c.chat_id)).toEqual([
'new-1'
])
expect(getUnreadMessagesCountByCategory(state)).toEqual({
priority: 0,
general: 0,
uncategorized: 0,
uncategorized_unreplied: 0
})
})

it('does not count a blast copy as a reply', () => {
const seeded = seed([makeChat('new-1', { has_replied: false })])
const next = chatReducer(
seeded,
actions.addMessage({
chatId: 'new-1',
isSelfMessage: true,
message: {
...unreadMessage('new-1', 'm3'),
sender_user_id: '1',
is_plaintext: true
}
})
)
expect(getUserChatEntity(next, 'new-1')?.has_replied).toBe(false)
})

it('moves unreads out of both uncategorized buckets when filed', () => {
const seeded = withServerCounts(
seed([
makeChat('new-1', { has_replied: false, unread_message_count: 3 })
]),
{
priority: 0,
general: 0,
uncategorized: 3,
uncategorized_unreplied: 3
}
)
const moved = chatReducer(
seeded,
actions.setChatCategory({
chatId: 'new-1',
category: ChatCategory.PRIORITY
})
)
expect(getUnreadMessagesCountByCategory(asCommonState(moved))).toEqual({
priority: 3,
general: 0,
uncategorized: 0,
uncategorized_unreplied: 0
})
expect(getHasUnreadGeneralMessages(asCommonState(moved))).toBe(false)
})
})

describe('inbox tab selectors', () => {
it('splits chats between the Priority and General tabs', () => {
const state = asCommonState(
Expand Down
7 changes: 6 additions & 1 deletion packages/common/src/store/pages/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,9 @@ export * as chatSelectors from './selectors'
export { sagas as chatSagas } from './sagas'
export { chatMiddleware } from './middleware'
export * from './types'
export { makeChatId, getInboxTabForChat, MAX_GROUP_CHAT_MEMBERS } from './utils'
export {
makeChatId,
getInboxTabForChat,
getEffectiveChatCategory,
MAX_GROUP_CHAT_MEMBERS
} from './utils'
11 changes: 7 additions & 4 deletions packages/common/src/store/pages/chat/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,10 @@ const makeGetChatsForInboxTab = (tab: InboxTab) =>
chats.filter((chat) => getInboxTabForChat(chat) === tab)
)

/** Chats shown in the Priority tab: those marked Priority plus uncategorized. */
/** Chats shown in the Priority tab: marked Priority, or uncategorized and replied to. */
export const getPriorityInboxChats = makeGetChatsForInboxTab(InboxTab.PRIORITY)

/** Chats shown in the General tab: only those the user marked General. */
/** Chats shown in the General tab: marked General, or never replied to. */
export const getGeneralInboxChats = makeGetChatsForInboxTab(InboxTab.GENERAL)

export const getChatsForInboxTab = (state: CommonState, tab: InboxTab) =>
Expand All @@ -171,10 +171,13 @@ export const getHasUnreadMessagesForInboxTab = (
) => {
const counts = getUnreadMessagesCountByCategory(state)
if (counts) {
// Older api versions don't report the unreplied split; their
// uncategorized chats all show in Priority.
const unreplied = counts.uncategorized_unreplied ?? 0
const tabCount =
tab === InboxTab.GENERAL
? counts.general
: counts.priority + counts.uncategorized
? counts.general + unreplied
: counts.priority + counts.uncategorized - unreplied
if (tabCount > 0) return true
}
return getChatsForInboxTab(state, tab).some(
Expand Down
54 changes: 41 additions & 13 deletions packages/common/src/store/pages/chat/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { hasTail } from '~/utils/chatUtils'
import dayjs from '~/utils/dayjs'

import { ChatWebsocketError } from './types'
import { getChatCategoryKey } from './utils'
import { getChatCategoryKeys } from './utils'

export type Chat = UserChat | ChatBlast

Expand Down Expand Up @@ -116,18 +116,33 @@ const { selectById: getChat } = chatsAdapter.getSelectors(
*/
const adjustUnreadCountForChatCategory = (
state: ChatState,
chat: { category?: ChatCategory | null },
chat: { category?: ChatCategory | null; has_replied?: boolean },
delta: number
) => {
if (!state.unreadMessagesCountByCategory || delta === 0) return
const counts = state.optimisticUnreadMessagesCountByCategory ?? {
...state.unreadMessagesCountByCategory
}
const key = getChatCategoryKey(chat)
counts[key] = Math.max(0, counts[key] + delta)
applyUnreadCountDelta(counts, chat, delta)
state.optimisticUnreadMessagesCountByCategory = counts
}

/**
* Adds `delta` to every bucket `chat` counts towards. Buckets the server did
* not report (older api versions) are left absent.
*/
const applyUnreadCountDelta = (
counts: ChatUnreadCountByCategory,
chat: { category?: ChatCategory | null; has_replied?: boolean },
delta: number
) => {
for (const key of getChatCategoryKeys(chat)) {
const current = counts[key]
if (current === undefined) continue
counts[key] = Math.max(0, current + delta)
}
}

/** The unread count currently shown for a chat, honoring optimistic reads. */
const getDisplayedUnreadCount = (state: ChatState, chatId: ChatID) => {
const optimisticRead = state.optimisticChatRead[chatId]
Expand Down Expand Up @@ -526,11 +541,10 @@ const slice = createSlice({
if (!existingChat || existingChat.is_blast) return
state.unreadMessagesCount -= existingChat?.unread_message_count ?? 0
if (state.unreadMessagesCountByCategory) {
const key = getChatCategoryKey(existingChat)
state.unreadMessagesCountByCategory[key] = Math.max(
0,
state.unreadMessagesCountByCategory[key] -
existingChat.unread_message_count
applyUnreadCountDelta(
state.unreadMessagesCountByCategory,
existingChat,
-existingChat.unread_message_count
)
}
chatsAdapter.updateOne(state.chats, {
Expand Down Expand Up @@ -571,7 +585,8 @@ const slice = createSlice({
state.optimisticUnreadMessagesCountByCategory = {
priority: 0,
general: 0,
uncategorized: 0
uncategorized: 0,
uncategorized_unreplied: 0
}
}
},
Expand All @@ -598,7 +613,8 @@ const slice = createSlice({
state.unreadMessagesCountByCategory = {
priority: 0,
general: 0,
uncategorized: 0
uncategorized: 0,
uncategorized_unreplied: 0
}
}
delete state.optimisticUnreadMessagesCountByCategory
Expand Down Expand Up @@ -729,6 +745,14 @@ const slice = createSlice({
existingChat,
-existingUnreadCount
)
// Replying moves an uncategorized chat into Priority. Blast copies
// (plaintext) are not replies.
if (!message.is_plaintext && existingChat.has_replied === false) {
chatsAdapter.updateOne(state.chats, {
id: chatId,
changes: { has_replied: true }
})
}
}
},
/**
Expand Down Expand Up @@ -864,7 +888,11 @@ const slice = createSlice({
}
const unreadCount = getDisplayedUnreadCount(state, chatId)
adjustUnreadCountForChatCategory(state, existingChat, -unreadCount)
adjustUnreadCountForChatCategory(state, { category }, unreadCount)
adjustUnreadCountForChatCategory(
state,
{ category, has_replied: existingChat.has_replied },
unreadCount
)
chatsAdapter.updateOne(state.chats, {
id: chatId,
changes: { category }
Expand Down Expand Up @@ -901,7 +929,7 @@ const slice = createSlice({
adjustUnreadCountForChatCategory(state, existingChat, -unreadCount)
adjustUnreadCountForChatCategory(
state,
{ category: previousCategory },
{ category: previousCategory, has_replied: existingChat.has_replied },
unreadCount
)
chatsAdapter.updateOne(state.chats, {
Expand Down
Loading
Loading