From 796dd80ce015aff305637e0c64a31bb2ad069e4e Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Fri, 6 Nov 2026 13:10:55 -0800 Subject: [PATCH 1/4] feat(chat): group chat create/leave in sdk and common Co-Authored-By: Claude Opus 5.5 --- .changeset/group-chats.md | 5 + .../src/hooks/chats/useCanSendMessage.ts | 8 +- .../services/remote-config/feature-flags.ts | 10 +- .../common/src/store/pages/chat/sagas.test.ts | 111 +++++++++++++++++- packages/common/src/store/pages/chat/sagas.ts | 76 ++++++++++-- .../common/src/store/pages/chat/selectors.ts | 11 +- packages/common/src/store/pages/chat/slice.ts | 3 + packages/common/src/store/pages/chat/utils.ts | 3 + packages/sdk/src/sdk/api/chats/ChatsApi.ts | 40 ++++++- packages/sdk/src/sdk/api/chats/clientTypes.ts | 20 +++- packages/sdk/src/sdk/api/chats/serverTypes.ts | 10 ++ 11 files changed, 272 insertions(+), 25 deletions(-) create mode 100644 .changeset/group-chats.md diff --git a/.changeset/group-chats.md b/.changeset/group-chats.md new file mode 100644 index 00000000000..5be6a6debfa --- /dev/null +++ b/.changeset/group-chats.md @@ -0,0 +1,5 @@ +--- +'@audius/sdk': minor +--- + +Add group chats: `chats.create` with more than one invitee makes a group (`grp_` id), new `chats.leave`, and `is_group` on `UserChat`. diff --git a/packages/common/src/hooks/chats/useCanSendMessage.ts b/packages/common/src/hooks/chats/useCanSendMessage.ts index f19e1bc2386..0635866abfa 100644 --- a/packages/common/src/hooks/chats/useCanSendMessage.ts +++ b/packages/common/src/hooks/chats/useCanSendMessage.ts @@ -6,6 +6,7 @@ import { ChatPermissionAction, CommonState } from '~/store/index' import { getBlockees, getBlockers, + getChat, getRecheckPermissions, useCanCreateChat } from '~/store/pages/chat/selectors' @@ -22,6 +23,10 @@ export const useCanSendMessage = ( firstOtherUser: User | undefined } => { const users = useOtherChatUsers(currentChatId) + const isGroup = useSelector((state: CommonState) => { + const chat = currentChatId ? getChat(state, currentChatId) : undefined + return !!chat && !chat.is_blast && !!chat.is_group + }) const firstOtherUser = users[0] const userId = firstOtherUser?.user_id @@ -34,7 +39,8 @@ export const useCanSendMessage = ( getRecheckPermissions(state, currentChatId) ) - if (!userId) { + // Any group member can post; blocks only hide messages per reader. + if (!userId || isGroup) { return { canSendMessage: true, callToAction: ChatPermissionAction.NOT_APPLICABLE, diff --git a/packages/common/src/services/remote-config/feature-flags.ts b/packages/common/src/services/remote-config/feature-flags.ts index 1f36bb0ba3a..52138d0a3f3 100644 --- a/packages/common/src/services/remote-config/feature-flags.ts +++ b/packages/common/src/services/remote-config/feature-flags.ts @@ -24,7 +24,8 @@ export enum FeatureFlags { MOBILE_MANAGER_MODE = 'mobile_manager_mode', BULK_TRACK_EDIT = 'bulk_track_edit', WEEKLY_ARTIST_RECAP = 'weekly_artist_recap', - TRACK_FEEDBACK_REQUESTS = 'track_feedback_requests' + TRACK_FEEDBACK_REQUESTS = 'track_feedback_requests', + GROUP_MESSAGES = 'group_messages' } type FlagDefaults = Record @@ -40,7 +41,8 @@ export const environmentFlagDefaults: Record< [FeatureFlags.MOBILE_MANAGER_MODE]: true, [FeatureFlags.BULK_TRACK_EDIT]: true, [FeatureFlags.WEEKLY_ARTIST_RECAP]: true, - [FeatureFlags.TRACK_FEEDBACK_REQUESTS]: true + [FeatureFlags.TRACK_FEEDBACK_REQUESTS]: true, + [FeatureFlags.GROUP_MESSAGES]: true }, production: {} } @@ -85,5 +87,7 @@ export const flagDefaults: FlagDefaults = { // saves). [FeatureFlags.WEEKLY_ARTIST_RECAP]: false, // "Looking for feedback" toggle, badge, and Explore section. - [FeatureFlags.TRACK_FEEDBACK_REQUESTS]: false + [FeatureFlags.TRACK_FEEDBACK_REQUESTS]: false, + // Creating group DMs. Existing groups always render. + [FeatureFlags.GROUP_MESSAGES]: false } diff --git a/packages/common/src/store/pages/chat/sagas.test.ts b/packages/common/src/store/pages/chat/sagas.test.ts index ef9a5ca0c76..54aeb3d1303 100644 --- a/packages/common/src/store/pages/chat/sagas.test.ts +++ b/packages/common/src/store/pages/chat/sagas.test.ts @@ -1,5 +1,6 @@ import { ChatBlastAudience, + Id, type TypedCommsResponse, type UserChat } from '@audius/sdk' @@ -9,8 +10,14 @@ import { describe, expect, it, vitest } from 'vitest' import { queryCurrentUserId, queryUsers } from '~/api' -import { doFetchLatestChats } from './sagas' -import { actions } from './slice' +import { + doCreateChat, + doFetchChatIfNecessary, + doFetchLatestChats, + doLeaveChat +} from './sagas' +import reducer, { actions } from './slice' +import { makeChatId } from './utils' type ChatSummary = NonNullable['summary']> @@ -178,4 +185,104 @@ describe('chat sagas', () => { expect(getAll).toHaveBeenCalledTimes(2) }) }) + + describe('group chats', () => { + const stateWithChats = (chats: UserChat[]) => ({ + pages: { + chat: reducer( + undefined, + actions.fetchMoreChatsSucceeded({ data: chats, summary: makeSummary() }) + ) + } + }) + + it('creates a new group with a random group id', async () => { + const create = vitest.fn().mockResolvedValue({}) + const get = vitest.fn((args: { chatId: string }) => + Promise.resolve({ + data: { ...makeChat(args.chatId), is_group: true } + }) + ) + const sdk = { chats: { create, get } } + + await expectSaga(doCreateChat, actions.createChat({ userIds: [2, 3] })) + .withState(stateWithChats([])) + .provide([ + [matchers.getContext('audiusSdk'), vitest.fn().mockResolvedValue(sdk)], + [matchers.call.fn(queryCurrentUserId), 1], + [matchers.call.fn(doFetchChatIfNecessary), undefined] + ]) + .silentRun() + + expect(create).toHaveBeenCalledTimes(1) + const params = create.mock.calls[0][0] + expect(params.chatId).toMatch(/^grp_[A-Za-z0-9]{16,}$/) + expect(params.invitedUserIds).toEqual([Id.parse(2), Id.parse(3)]) + expect(get).toHaveBeenCalledWith({ chatId: params.chatId }) + }) + + it('reuses a group with exactly the same members', async () => { + const existing: UserChat = { + ...makeChat('grp_EXISTINGGROUPID00000'), + is_group: true, + chat_members: [1, 2, 3].map((id) => ({ user_id: Id.parse(id) })) + } + const create = vitest.fn() + const sdk = { chats: { create } } + + const result = await expectSaga( + doCreateChat, + actions.createChat({ userIds: [3, 2] }) + ) + .withState(stateWithChats([existing])) + .provide([ + [matchers.getContext('audiusSdk'), vitest.fn().mockResolvedValue(sdk)], + [matchers.call.fn(queryCurrentUserId), 1], + [matchers.call.fn(doFetchChatIfNecessary), undefined] + ]) + .silentRun() + + expect(create).not.toHaveBeenCalled() + const goToChat = result.effects.put?.find( + (e) => e.payload.action.type === actions.goToChat.type + ) + expect(goToChat?.payload.action.payload.chatId).toBe(existing.chat_id) + }) + + it('keeps the deterministic id for 1:1 chats', async () => { + const create = vitest.fn().mockResolvedValue({}) + const get = vitest.fn().mockResolvedValue({ data: makeChat('x') }) + const sdk = { chats: { create, get } } + + await expectSaga(doCreateChat, actions.createChat({ userIds: [2] })) + .withState(stateWithChats([])) + .provide([ + [matchers.getContext('audiusSdk'), vitest.fn().mockResolvedValue(sdk)], + [matchers.call.fn(queryCurrentUserId), 1], + [matchers.call.fn(doFetchChatIfNecessary), undefined] + ]) + .silentRun() + + expect(create.mock.calls[0][0].chatId).toBeUndefined() + expect(get).toHaveBeenCalledWith({ chatId: makeChatId([1, 2]) }) + }) + + it('leaves a group and removes it locally', async () => { + const leave = vitest.fn().mockResolvedValue({}) + const sdk = { chats: { leave } } + + await expectSaga( + doLeaveChat, + actions.leaveChat({ chatId: 'grp_EXISTINGGROUPID00000' }) + ) + .provide([ + [matchers.getContext('audiusSdk'), vitest.fn().mockResolvedValue(sdk)] + ]) + .put(actions.goToChat({})) + .put(actions.deleteChatSucceeded({ chatId: 'grp_EXISTINGGROUPID00000' })) + .silentRun() + + expect(leave).toHaveBeenCalledWith({ chatId: 'grp_EXISTINGGROUPID00000' }) + }) + }) }) diff --git a/packages/common/src/store/pages/chat/sagas.ts b/packages/common/src/store/pages/chat/sagas.ts index 180e1262cbf..94f8cd87702 100644 --- a/packages/common/src/store/pages/chat/sagas.ts +++ b/packages/common/src/store/pages/chat/sagas.ts @@ -3,6 +3,7 @@ import { ChatCategory, HashId, Id, + makeGroupChatId, OptionalHashId, OptionalId, type ChatMessage, @@ -93,13 +94,19 @@ const { fetchLinkUnfurl, fetchLinkUnfurlSucceeded, deleteChat, + leaveChat, deleteChatSucceeded, setChatCategory, setChatCategorySucceeded, setChatCategoryFailed } = chatActions -const { getChatsSummary, getChat, getUnfurlMetadata, getNonOptimisticChat } = - chatSelectors +const { + getChatsSummary, + getChat, + getChats, + getUnfurlMetadata, + getNonOptimisticChat +} = chatSelectors const { toast } = toastActions const { open: openInboxUnavailableModal } = inboxUnavailableModalActions @@ -394,7 +401,7 @@ function* doSetMessageReaction(action: ReturnType) { } } -function* doCreateChat(action: ReturnType) { +export function* doCreateChat(action: ReturnType) { const { userIds, skipNavigation, presetMessage, replaceNavigation } = action.payload try { @@ -404,8 +411,23 @@ function* doCreateChat(action: ReturnType) { if (!currentUserId) { throw new Error('User not found') } - // Try to get existing chat: - const chatId = makeChatId([currentUserId, ...userIds]) + // Groups get a new random id unless one with exactly these members exists + const isGroup = userIds.length > 1 + let chatId = makeChatId([currentUserId, ...userIds]) + if (isGroup) { + const memberKey = makeChatId([currentUserId, ...userIds]) + const chats = yield* select(getChats) + const existingGroup = chats.find( + (c) => + !c.is_blast && + c.is_group && + c.chat_members + .map((m) => m.user_id) + .sort() + .join(':') === memberKey + ) + chatId = existingGroup?.chat_id ?? makeGroupChatId() + } // Optimistically go to the chat. If we fail to create it, we'll toast if (!skipNavigation) { @@ -420,7 +442,8 @@ function* doCreateChat(action: ReturnType) { // Create new chat yield* call([sdk.chats, sdk.chats.create], { userId: Id.parse(currentUserId), - invitedUserIds: userIds.map((id) => Id.parse(id)) + invitedUserIds: userIds.map((id) => Id.parse(id)), + chatId: isGroup ? chatId : undefined }) const res = yield* call([sdk.chats, sdk.chats.get], { chatId }) @@ -444,6 +467,18 @@ function* doCreateChat(action: ReturnType) { presetMessage }) ) + } else if (userIds.length > 1) { + // Back out of the optimistic navigation to a chat that doesn't exist + if (!skipNavigation) { + yield* put(goToChat({})) + } + yield* put( + toast({ + type: 'error', + content: + "Couldn't create the group. Someone in it may not accept messages from you." + }) + ) } else { yield* put( toast({ @@ -649,7 +684,7 @@ function* doFetchChat({ chatId }: { chatId: string }) { } } -function* doFetchChatIfNecessary(args: { chatId: string }) { +export function* doFetchChatIfNecessary(args: { chatId: string }) { const { chatId } = args const existingChat = yield* select((state) => getChat(state, chatId)) if (!existingChat) { @@ -788,6 +823,28 @@ function* doDeleteChat(action: ReturnType) { } } +export function* doLeaveChat(action: ReturnType) { + const { chatId } = action.payload + try { + const audiusSdk = yield* getContext('audiusSdk') + const sdk = yield* call(audiusSdk) + yield* call([sdk.chats, sdk.chats.leave], { chatId }) + yield* put(goToChat({})) + // Wait for render, as in doDeleteChat + yield* delay(1) + yield* put(deleteChatSucceeded({ chatId })) + yield* put(toast({ content: 'You left the group' })) + } catch (e) { + yield* put( + toast({ + type: 'error', + content: "Couldn't leave the group. Please try again." + }) + ) + console.error('Chats', e as Error) + } +} + function* doLogError({ payload: { error } }: ReturnType) { console.error(error) } @@ -880,6 +937,10 @@ function* watchDeleteChat() { yield takeEvery(deleteChat, doDeleteChat) } +function* watchLeaveChat() { + yield takeEvery(leaveChat, doLeaveChat) +} + export function* doSetChatCategory(action: ReturnType) { const { chatId, category } = action.payload try { @@ -943,6 +1004,7 @@ export const sagas = () => { watchFetchPermissions, watchFetchLinkUnfurlMetadata, watchDeleteChat, + watchLeaveChat, watchSetChatCategory, watchLogError ] diff --git a/packages/common/src/store/pages/chat/selectors.ts b/packages/common/src/store/pages/chat/selectors.ts index 421b1196258..537c7047224 100644 --- a/packages/common/src/store/pages/chat/selectors.ts +++ b/packages/common/src/store/pages/chat/selectors.ts @@ -210,9 +210,7 @@ export const getHasUnreadMessages = (state: CommonState) => { } /** - * Gets a list of the users the current user has chats with. - * Note that this only takes the first user of each chat that doesn't match the current one, - * so this will need to be adjusted when we do group chats. + * Gets a list of the users the current user has 1:1 chats with. **/ export const getUserList = createSelector( [ @@ -223,7 +221,7 @@ export const getUserList = createSelector( ], (chats, hasMore, chatsStatus, currentUserId) => { const chatUserListIds = chats - .filter((c) => !c.is_blast) + .filter((c) => !c.is_blast && !c.is_group) .map( (c) => (c as UserChat).chat_members @@ -329,9 +327,12 @@ export const useCanCreateChat = (userId: ID | null | undefined) => { // Use a callback fn to prevent iteration until necessary to improve perf // Note: this only works if the respective chat has been fetched already, like in chatsUserList const encodedUserId = Id.parse(userId) + // Only 1:1 chats count; sharing a group grants nothing. const existingChat = chats.find( (c) => - !c.is_blast && c.chat_members.find((u) => u.user_id === encodedUserId) + !c.is_blast && + !c.is_group && + c.chat_members.find((u) => u.user_id === encodedUserId) ) const userPermissions = chatPermissions[userId] diff --git a/packages/common/src/store/pages/chat/slice.ts b/packages/common/src/store/pages/chat/slice.ts index ec9331d9cc8..9a01ba4badd 100644 --- a/packages/common/src/store/pages/chat/slice.ts +++ b/packages/common/src/store/pages/chat/slice.ts @@ -839,6 +839,9 @@ const slice = createSlice({ deleteChat: (_state, _action: PayloadAction<{ chatId: string }>) => { // triggers saga }, + leaveChat: (_state, _action: PayloadAction<{ chatId: string }>) => { + // triggers saga, then deleteChatSucceeded + }, deleteChatSucceeded: (state, action: PayloadAction<{ chatId: string }>) => { const { chatId } = action.payload chatsAdapter.removeOne(state.chats, chatId) diff --git a/packages/common/src/store/pages/chat/utils.ts b/packages/common/src/store/pages/chat/utils.ts index ab9aa580475..3b973079d2a 100644 --- a/packages/common/src/store/pages/chat/utils.ts +++ b/packages/common/src/store/pages/chat/utils.ts @@ -19,6 +19,9 @@ export const getChatCategoryKey = (chat: Categorizable): ChatCategoryKey => export const getInboxTabForChat = (chat: Categorizable): InboxTab => chat.category === ChatCategory.GENERAL ? InboxTab.GENERAL : InboxTab.PRIORITY +/** Max members in a group chat, creator included. Matches the api. */ +export const MAX_GROUP_CHAT_MEMBERS = 10 + export const makeChatId = (userIds: ID[]) => { return userIds .map((id) => Id.parse(id)) diff --git a/packages/sdk/src/sdk/api/chats/ChatsApi.ts b/packages/sdk/src/sdk/api/chats/ChatsApi.ts index 63148ac37f9..ff53baa2723 100644 --- a/packages/sdk/src/sdk/api/chats/ChatsApi.ts +++ b/packages/sdk/src/sdk/api/chats/ChatsApi.ts @@ -29,6 +29,9 @@ import { ChatCreateRequestSchema, ChatDeleteRequest, ChatDeleteRequestSchema, + ChatLeaveRequest, + ChatLeaveRequestSchema, + makeGroupChatId, ChatEvents, ChatGetAllRequest, ChatGetAllRequestSchema, @@ -454,12 +457,17 @@ export class ChatsApi * @returns the rpc object */ public async create(params: ChatCreateRequest): Promise { - const { currentUserId, userId, invitedUserIds } = await parseParams( - 'create', - ChatCreateRequestSchema - )(params) - - const chatId = [userId, ...invitedUserIds].sort().join(':') + const { + currentUserId, + userId, + invitedUserIds, + chatId: requestedChatId + } = await parseParams('create', ChatCreateRequestSchema)(params) + + const isGroup = invitedUserIds.length > 1 + const chatId = isGroup + ? (requestedChatId ?? makeGroupChatId()) + : [userId, ...invitedUserIds].sort().join(':') const chatSecret = secp256k1.utils.randomPrivateKey() const invites = await this.createInvites(userId, invitedUserIds, chatSecret) @@ -499,6 +507,26 @@ export class ChatsApi }) } + /** + * Leaves a group chat + * @param params.chatId the group chat to leave + * @param params.currentUserId the user to act on behalf of + * @returns the rpc object + */ + public async leave(params: ChatLeaveRequest) { + const { currentUserId, chatId } = await parseParams( + 'leave', + ChatLeaveRequestSchema + )(params) + return await this.sendRpc({ + current_user_id: currentUserId, + method: 'chat.leave', + params: { + chat_id: chatId + } + }) + } + /** * Sends a message to a user in a chat * @param params.message the message diff --git a/packages/sdk/src/sdk/api/chats/clientTypes.ts b/packages/sdk/src/sdk/api/chats/clientTypes.ts index ae7e6ec6b8c..f455ab996a3 100644 --- a/packages/sdk/src/sdk/api/chats/clientTypes.ts +++ b/packages/sdk/src/sdk/api/chats/clientTypes.ts @@ -1,3 +1,4 @@ +import { ulid } from 'ulid' import { z } from 'zod' import { @@ -60,10 +61,20 @@ export type ChatGetBlockersRequest = z.infer< typeof ChatGetBlockersRequestSchema > +export const GROUP_CHAT_ID_PREFIX = 'grp_' + +/** New random id for a group chat. 1:1 chats use sorted "a:b" ids. */ +export const makeGroupChatId = () => `${GROUP_CHAT_ID_PREFIX}${ulid()}` + +export const isGroupChatId = (chatId: string) => + chatId.startsWith(GROUP_CHAT_ID_PREFIX) + export const ChatCreateRequestSchema = z.object({ currentUserId: z.optional(z.string()), userId: z.string(), - invitedUserIds: z.array(z.string()).min(1) + invitedUserIds: z.array(z.string()).min(1), + /** Only for group chats; defaults to a new random group id. */ + chatId: z.optional(z.string()) }) export type ChatCreateRequest = z.infer @@ -135,6 +146,13 @@ export const ChatDeleteRequestSchema = z.object({ export type ChatDeleteRequest = z.infer +export const ChatLeaveRequestSchema = z.object({ + currentUserId: z.optional(z.string()), + chatId: z.string() +}) + +export type ChatLeaveRequest = z.infer + export const ChatPermitRequestSchema = z.object({ currentUserId: z.optional(z.string()), permit: z.optional(z.nativeEnum(ChatPermission)), diff --git a/packages/sdk/src/sdk/api/chats/serverTypes.ts b/packages/sdk/src/sdk/api/chats/serverTypes.ts index d47fba149b3..2dcfc10b4b2 100644 --- a/packages/sdk/src/sdk/api/chats/serverTypes.ts +++ b/packages/sdk/src/sdk/api/chats/serverTypes.ts @@ -36,6 +36,13 @@ export type ChatDeleteRPC = { } } +export type ChatLeaveRPC = { + method: 'chat.leave' + params: { + chat_id: string + } +} + export type ChatInviteRPC = { method: 'chat.invite' params: { @@ -115,6 +122,7 @@ export type RPCPayloadRequest = | ChatBlastRPC | ChatCreateRPC | ChatDeleteRPC + | ChatLeaveRPC | ChatInviteRPC | ChatMessageRPC | ChatReactRPC @@ -141,6 +149,8 @@ export type UserChat = { last_message_is_plaintext: boolean chat_members: Array<{ user_id: string }> recheck_permissions: boolean + /** True for group chats (created with more than two members). */ + is_group?: boolean // User specific invite_code: string From 5c2485e90b5380d73997c4400dfef50073af5703 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Fri, 6 Nov 2026 13:11:30 -0800 Subject: [PATCH 2/4] feat(chat): group title helper and useIsGroupChat Co-Authored-By: Claude Opus 5.5 --- packages/common/src/hooks/chats/index.ts | 1 + .../common/src/hooks/chats/useIsGroupChat.ts | 11 +++++++ packages/common/src/utils/chatUtils.test.ts | 25 ++++++++++++++++ packages/common/src/utils/chatUtils.ts | 30 +++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 packages/common/src/hooks/chats/useIsGroupChat.ts create mode 100644 packages/common/src/utils/chatUtils.test.ts diff --git a/packages/common/src/hooks/chats/index.ts b/packages/common/src/hooks/chats/index.ts index ced40e4f454..52e1f4aa113 100644 --- a/packages/common/src/hooks/chats/index.ts +++ b/packages/common/src/hooks/chats/index.ts @@ -8,3 +8,4 @@ export * from './useChatBlastAudienceContent' export * from './useFirstAvailableBlastAudience' export * from './usePurchasersAudience' export * from './useRemixersAudience' +export * from './useIsGroupChat' diff --git a/packages/common/src/hooks/chats/useIsGroupChat.ts b/packages/common/src/hooks/chats/useIsGroupChat.ts new file mode 100644 index 00000000000..c7502d407d7 --- /dev/null +++ b/packages/common/src/hooks/chats/useIsGroupChat.ts @@ -0,0 +1,11 @@ +import { useSelector } from 'react-redux' + +import { CommonState } from '~/store/commonStore' +import { getChat } from '~/store/pages/chat/selectors' + +/** True if the chat is a group chat (more than two members at creation). */ +export const useIsGroupChat = (chatId?: string) => + useSelector((state: CommonState) => { + const chat = chatId ? getChat(state, chatId) : undefined + return !!chat && !chat.is_blast && !!chat.is_group + }) diff --git a/packages/common/src/utils/chatUtils.test.ts b/packages/common/src/utils/chatUtils.test.ts new file mode 100644 index 00000000000..f108f62ec43 --- /dev/null +++ b/packages/common/src/utils/chatUtils.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import { formatGroupChatTitle } from './chatUtils' + +describe('formatGroupChatTitle', () => { + const users = (...names: string[]) => names.map((name) => ({ name })) + + it('lists up to two names', () => { + expect(formatGroupChatTitle(users('Ann'))).toBe('Ann') + expect(formatGroupChatTitle(users('Ann', 'Bo'))).toBe('Ann & Bo') + }) + + it('summarizes the rest', () => { + expect(formatGroupChatTitle(users('Ann', 'Bo', 'Cy'))).toBe( + 'Ann, Bo & 1 other' + ) + expect(formatGroupChatTitle(users('Ann', 'Bo', 'Cy', 'Di'))).toBe( + 'Ann, Bo & 2 others' + ) + }) + + it('falls back when no one is left', () => { + expect(formatGroupChatTitle([])).toBe('Group') + }) +}) diff --git a/packages/common/src/utils/chatUtils.ts b/packages/common/src/utils/chatUtils.ts index fb61c5c5973..439075c2e9f 100644 --- a/packages/common/src/utils/chatUtils.ts +++ b/packages/common/src/utils/chatUtils.ts @@ -42,6 +42,36 @@ const messages = { blastCTACoinHolders: (ticker: string) => `everyone who holds $${ticker}` } +/** + * Title for a group chat from its other members, e.g. "Ann, Bo & 2 others". + */ +export const formatGroupChatTitle = ( + users: Array<{ name?: string | null }>, + maxNames = 2 +) => { + const names = users.map((u) => u.name ?? '').filter(Boolean) + if (names.length === 0) return 'Group' + if (names.length <= maxNames) { + return names.length === 1 + ? names[0] + : `${names.slice(0, -1).join(', ')} & ${names[names.length - 1]}` + } + const rest = names.length - maxNames + return `${names.slice(0, maxNames).join(', ')} & ${rest} ${rest === 1 ? 'other' : 'others'}` +} + +/** + * True when this message starts a new run from a different sender, which is + * where group chats show the sender's name. + */ +export const isFirstInSenderRun = ( + message: ChatMessage, + previousMessage?: ChatMessage +) => { + if (!previousMessage) return true + return hasTail(previousMessage, message) +} + /** * Checks to see if the message was sent within the time threshold for grouping it with the next message */ From ffcb3d02eb34688e1656c9c6f665c5356699a447 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Fri, 6 Nov 2026 13:16:23 -0800 Subject: [PATCH 3/4] feat(web): group chats behind group_messages flag New-message modal multi-select, group inbox rows and header, member list, leave group, and sender name/avatar on group messages. Co-Authored-By: Claude Opus 5.5 --- packages/common/src/store/pages/chat/index.ts | 2 +- packages/web/src/pages/chat-page/ChatPage.tsx | 17 +- .../pages/chat-page/components/ChatHeader.tsx | 3 + .../chat-page/components/ChatListItem.tsx | 46 +++-- .../chat-page/components/ChatMessageList.tsx | 28 ++- .../components/ChatMessageListItem.module.css | 15 ++ .../components/ChatMessageListItem.tsx | 172 +++++++++++------- .../chat-page/components/ChatPaneHeader.tsx | 3 + .../chat-page/components/CreateChatModal.tsx | 63 ++++++- .../components/CreateChatUserResult.tsx | 40 +++- .../components/GroupChatAvatar.module.css | 29 +++ .../chat-page/components/GroupChatAvatar.tsx | 31 ++++ .../chat-page/components/GroupChatHeader.tsx | 101 ++++++++++ .../chat-page/components/GroupChatUser.tsx | 41 +++++ .../components/GroupMembersModal.tsx | 76 ++++++++ .../LeaveGroupConfirmationModal.tsx | 62 +++++++ 16 files changed, 620 insertions(+), 109 deletions(-) create mode 100644 packages/web/src/pages/chat-page/components/GroupChatAvatar.module.css create mode 100644 packages/web/src/pages/chat-page/components/GroupChatAvatar.tsx create mode 100644 packages/web/src/pages/chat-page/components/GroupChatHeader.tsx create mode 100644 packages/web/src/pages/chat-page/components/GroupChatUser.tsx create mode 100644 packages/web/src/pages/chat-page/components/GroupMembersModal.tsx create mode 100644 packages/web/src/pages/chat-page/components/LeaveGroupConfirmationModal.tsx diff --git a/packages/common/src/store/pages/chat/index.ts b/packages/common/src/store/pages/chat/index.ts index 7972c101905..caf8a7e80d2 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, getInboxTabForChat } from './utils' +export { makeChatId, getInboxTabForChat, MAX_GROUP_CHAT_MEMBERS } from './utils' diff --git a/packages/web/src/pages/chat-page/ChatPage.tsx b/packages/web/src/pages/chat-page/ChatPage.tsx index 1c263eb30ed..91e44a54f6f 100644 --- a/packages/web/src/pages/chat-page/ChatPage.tsx +++ b/packages/web/src/pages/chat-page/ChatPage.tsx @@ -1,8 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { useCanSendMessage } from '@audius/common/hooks' +import { useOtherChatUsers } from '@audius/common/api' +import { useCanSendMessage, useIsGroupChat } from '@audius/common/hooks' import { Status } from '@audius/common/models' import { chatActions, chatSelectors, InboxTab } from '@audius/common/store' +import { formatGroupChatTitle } from '@audius/common/utils' import cn from 'classnames' import { useDispatch } from 'react-redux' import { useParams, useLocation, useNavigate } from 'react-router' @@ -47,6 +49,11 @@ export const ChatPage = () => { // All hooks must be called before any early returns const dispatch = useDispatch() const { firstOtherUser, canSendMessage } = useCanSendMessage(currentChatId) + const isGroup = useIsGroupChat(currentChatId) + const otherUsers = useOtherChatUsers(currentChatId) + const chatTitle = isGroup + ? formatGroupChatTitle(otherUsers) + : firstOtherUser?.name const chat = useSelector((state) => getChat(state, currentChatId ?? '')) const layoutRef = useRef(null) @@ -106,10 +113,10 @@ export const ChatPage = () => { }, [navigate, location.pathname, presetMessage]) useEffect(() => { - if (firstOtherUser && !isMobile) { + if (firstOtherUser && !isMobile && !isGroup) { dispatch(fetchPermissions({ userIds: [firstOtherUser.user_id] })) } - }, [dispatch, firstOtherUser, isMobile]) + }, [dispatch, firstOtherUser, isMobile, isGroup]) if (isMobile) { return @@ -117,9 +124,7 @@ export const ChatPage = () => { return ( ( {chat ? ( isBlast ? ( + ) : !chat.is_blast && chat.is_group ? ( + ) : ( ) diff --git a/packages/web/src/pages/chat-page/components/ChatListItem.tsx b/packages/web/src/pages/chat-page/components/ChatListItem.tsx index ca37fb1194e..f35c0df8898 100644 --- a/packages/web/src/pages/chat-page/components/ChatListItem.tsx +++ b/packages/web/src/pages/chat-page/components/ChatListItem.tsx @@ -7,6 +7,7 @@ import cn from 'classnames' import styles from './ChatListItem.module.css' import { ChatUser } from './ChatUser' +import { GroupChatUser } from './GroupChatUser' import { useChatCategoryMenuItems } from './useChatCategoryMenuItems' const messages = { @@ -35,9 +36,22 @@ export const ChatListItem = memo(function ChatListItem( onChatClicked(chat.chat_id) }, [onChatClicked, chat]) - if (users.length === 0) { + const isGroup = !!chat.is_group + if (users.length === 0 && !isGroup) { return null } + const unreadIndicator = + chat.unread_message_count > 0 ? ( + <> +
+
+ {chat.unread_message_count > 9 + ? messages.ninePlus + : chat.unread_message_count} + {messages.new} +
+ + ) : null // The row stays a single native button; the options trigger is a sibling // (not a nested button) so the row keeps its accessible name and click. return ( @@ -51,23 +65,19 @@ export const ChatListItem = memo(function ChatListItem( onClick={handleClick} aria-current={isCurrentChat ? 'page' : undefined} > - - {chat.unread_message_count > 0 ? ( - <> -
-
- {chat.unread_message_count > 9 - ? messages.ninePlus - : chat.unread_message_count} - {messages.new} -
- - ) : null} - + {isGroup ? ( + + {unreadIndicator} + + ) : ( + + {unreadIndicator} + + )}
{chat.last_message}
{!isCompact && categoryMenuItems.length > 0 ? ( diff --git a/packages/web/src/pages/chat-page/components/ChatMessageList.tsx b/packages/web/src/pages/chat-page/components/ChatMessageList.tsx index 5b9e2b85364..e93d564a85f 100644 --- a/packages/web/src/pages/chat-page/components/ChatMessageList.tsx +++ b/packages/web/src/pages/chat-page/components/ChatMessageList.tsx @@ -18,10 +18,11 @@ import { chatActions, chatSelectors } from '@audius/common/store' import { hasTail, isEarliestUnread, + isFirstInSenderRun, chatCanFetchMoreMessages } from '@audius/common/utils' import { Flex, Text } from '@audius/harmony' -import { OptionalId } from '@audius/sdk' +import { HashId, OptionalId } from '@audius/sdk' import { ResizeObserver } from '@juggle/resize-observer' import cn from 'classnames' import { throttle } from 'lodash' @@ -43,7 +44,7 @@ export const CONTENT_EXPANDED_LISTENER_KEY = 'audius:chat:content-expanded' const SPINNER_HEIGHT = 48 const { fetchMoreMessages, markChatAsRead, setActiveChat } = chatActions -const { getChatMessages, getChat } = chatSelectors +const { getChatMessages, getChat, getBlockees } = chatSelectors const messages = { newMessages: (count: number) => `${count} New Message${count > 1 ? 's' : ''}`, @@ -77,6 +78,18 @@ export const ChatMessageList = forwardRef( const { firstOtherUser, canSendMessage, callToAction } = useCanSendMessage(chatId) const chat = useSelector((state) => getChat(state, chatId ?? '')) + const isGroup = !!chat && !chat.is_blast && !!chat.is_group + const blockees = useSelector(getBlockees) + // Groups can include people you blocked; hide their messages + const visibleMessages = useMemo( + () => + isGroup && blockees.length > 0 + ? chatMessages?.filter( + (m) => !blockees.includes(HashId.parse(m.sender_user_id)) + ) + : chatMessages, + [chatMessages, isGroup, blockees] + ) const { data: userId } = useCurrentUserId() const currentUserId = OptionalId.parse(userId) ?? null const [unreadIndicatorEl, setUnreadIndicatorEl] = @@ -306,12 +319,17 @@ export const ChatMessageList = forwardRef( ) : null} {showSendMessagePrompt ? : null} {chatId && - chatMessages?.map((message, i) => ( + visibleMessages?.map((message, i) => ( {/* The separator has to come after the message to appear above it, @@ -321,7 +339,7 @@ export const ChatMessageList = forwardRef( unreadCount: unreadMessageCount, lastReadAt: chatFrozenRef.current?.last_read_at, currentMessageIndex: i, - messages: chatMessages, + messages: visibleMessages, currentUserId }) ? (
diff --git a/packages/web/src/pages/chat-page/components/ChatMessageListItem.module.css b/packages/web/src/pages/chat-page/components/ChatMessageListItem.module.css index 3a5fa52b188..055ed849794 100644 --- a/packages/web/src/pages/chat-page/components/ChatMessageListItem.module.css +++ b/packages/web/src/pages/chat-page/components/ChatMessageListItem.module.css @@ -287,3 +287,18 @@ .error svg path { fill: var(--harmony-red); } + +.senderName { + /* line up with the bubble, past the avatar */ + padding-left: calc(var(--harmony-unit-8) + var(--harmony-unit-2)); +} + +.senderAvatar { + flex-shrink: 0; + width: var(--harmony-unit-8); + height: var(--harmony-unit-8); +} + +.groupMessage .meta { + padding-left: calc(var(--harmony-unit-8) + var(--harmony-unit-2)); +} diff --git a/packages/web/src/pages/chat-page/components/ChatMessageListItem.tsx b/packages/web/src/pages/chat-page/components/ChatMessageListItem.tsx index 29a2e66b85e..1b9a9053dd2 100644 --- a/packages/web/src/pages/chat-page/components/ChatMessageListItem.tsx +++ b/packages/web/src/pages/chat-page/components/ChatMessageListItem.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useRef, useState } from 'react' -import { useCurrentUserId, useUsers } from '@audius/common/api' +import { useCurrentUserId, useUser, useUsers } from '@audius/common/api' import { useCanSendMessage } from '@audius/common/hooks' import { Status, ChatMessageWithExtras } from '@audius/common/models' import { chatActions, chatSelectors } from '@audius/common/store' @@ -9,13 +9,14 @@ import { isCollectionUrl, isTrackUrl } from '@audius/common/utils' -import { Flex, IconError, IconPlus } from '@audius/harmony' +import { Flex, IconError, IconPlus, Text } from '@audius/harmony' import { HashId, Id, OptionalHashId, ReactionTypes } from '@audius/sdk' import cn from 'classnames' import { find } from 'linkifyjs' import { useDispatch } from 'react-redux' import { useSelector } from 'common/hooks/useSelector' +import { ProfilePicture } from 'components/notification/Notification/components/ProfilePicture' import { reactionMap } from 'components/notification/Notification/components/Reaction' import { UserGeneratedTextV2 } from 'components/user-generated-text/UserGeneratedTextV2' @@ -36,6 +37,9 @@ type ChatMessageListItemProps = { chatId: string message: ChatMessageWithExtras hasTail: boolean + // Group chats show who sent each run of messages + isGroup?: boolean + isFirstInRun?: boolean } const messages = { @@ -46,7 +50,7 @@ const messages = { export const ChatMessageListItem = memo(function ChatMessageListItem( props: ChatMessageListItemProps ) { - const { chatId, message, hasTail } = props + const { chatId, message, hasTail, isGroup, isFirstInRun } = props // Refs const reactionButtonRef = useRef(null) @@ -62,11 +66,13 @@ export const ChatMessageListItem = memo(function ChatMessageListItem( const { byId: reactionUsers } = useUsers( message.reactions?.map((r) => HashId.parse(r.user_id)) ) + const senderUserId = HashId.parse(message.sender_user_id) + const isAuthor = userId === senderUserId + const showSender = !!isGroup && !isAuthor + const { data: sender } = useUser(showSender ? senderUserId : null) const chat = useSelector((state) => getChat(state, chatId ?? '')) // Derived - const senderUserId = HashId.parse(message.sender_user_id) - const isAuthor = userId === senderUserId const links = find(message.message) const link = links.filter((link) => link.type === 'url' && link.isLink)[0] const linkValue = link?.value @@ -199,79 +205,107 @@ export const ChatMessageListItem = memo(function ChatMessageListItem( 0 + [styles.hasReaction]: !hasTail && message.reactions?.length > 0, + [styles.groupMessage]: showSender })} > + {showSender && isFirstInRun && sender ? ( + + {sender.name} + + ) : null} - - - - {isCollectionUrl(linkValue) ? ( - - ) : isTrackUrl(linkValue) ? ( - - ) : link ? ( - + {hasTail && sender ? ( + ) : null} - {!hideMessage ? ( - - + ) : null} + + + + + {isCollectionUrl(linkValue) ? ( + + ) : isTrackUrl(linkValue) ? ( + + ) : link ? ( + + ) : null} + {!hideMessage ? ( + - {message.message} - - - ) : null} + + {message.message} + + + ) : null} + + {renderReactions()} + {hasTail ? ( + + + + ) : null} - {renderReactions()} - {hasTail ? ( - - - - ) : null} {message.status === Status.ERROR ? ( { {chat ? ( isBlast ? ( + ) : !chat.is_blast && chat.is_group ? ( + ) : ( ) diff --git a/packages/web/src/pages/chat-page/components/CreateChatModal.tsx b/packages/web/src/pages/chat-page/components/CreateChatModal.tsx index c2823d3e131..62ede9b28ef 100644 --- a/packages/web/src/pages/chat-page/components/CreateChatModal.tsx +++ b/packages/web/src/pages/chat-page/components/CreateChatModal.tsx @@ -1,7 +1,9 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useCurrentUserId } from '@audius/common/api' +import { useFeatureFlag } from '@audius/common/hooks' import { ID, User } from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' import { chatActions, useCreateChatModal, @@ -9,9 +11,10 @@ import { useInboxUnavailableModal, userListActions, followersUserListActions, - FOLLOWERS_USER_LIST_TAG + FOLLOWERS_USER_LIST_TAG, + MAX_GROUP_CHAT_MEMBERS } from '@audius/common/store' -import { IconCompose } from '@audius/harmony' +import { Box, Button, IconCompose } from '@audius/harmony' import { useDispatch } from 'react-redux' import { SearchUsersModal } from 'components/search-users-modal/SearchUsersModal' @@ -22,10 +25,13 @@ import { CreateChatEmptyResults } from './CreateChatEmptyResults' const DEBOUNCE_MS = 500 const messages = { - title: 'New Message' + title: 'New Message', + startChat: 'Start Chat', + startGroup: (count: number) => `Start Group Chat (${count})` } -const { fetchBlockers, fetchMoreChats, fetchPermissions } = chatActions +const { createChat, fetchBlockers, fetchMoreChats, fetchPermissions } = + chatActions const CreateChatModal = () => { const dispatch = useDispatch() @@ -33,6 +39,27 @@ const CreateChatModal = () => { const { isOpen, onClose, onClosed, data } = useCreateChatModal() const { onOpen: openInboxUnavailableModal } = useInboxUnavailableModal() const { onCancelAction, presetMessage, defaultUserList } = data + const { isEnabled: isGroupMessagesEnabled } = useFeatureFlag( + FeatureFlags.GROUP_MESSAGES + ) + const [selectedUserIds, setSelectedUserIds] = useState([]) + + useEffect(() => { + if (!isOpen) setSelectedUserIds([]) + }, [isOpen]) + + const handleToggleUser = useCallback((user: User) => { + setSelectedUserIds((ids) => + ids.includes(user.user_id) + ? ids.filter((id) => id !== user.user_id) + : [...ids, user.user_id] + ) + }, []) + + const handleStartChat = useCallback(() => { + dispatch(createChat({ userIds: selectedUserIds, presetMessage })) + onClose() + }, [dispatch, selectedUserIds, presetMessage, onClose]) const handleCancel = useCallback(() => { if (onCancelAction) { @@ -97,6 +124,18 @@ const CreateChatModal = () => { openInboxUnavailableModal={handleOpenInboxUnavailableModal} closeParentModal={closeParentModal} presetMessage={presetMessage} + selection={ + isGroupMessagesEnabled + ? { + isSelected: selectedUserIds.includes(user.user_id), + isSelecting: selectedUserIds.length > 0, + // The current user takes one of the slots + isFull: + selectedUserIds.length >= MAX_GROUP_CHAT_MEMBERS - 1, + onToggle: handleToggleUser + } + : undefined + } /> )} renderEmpty={() => } @@ -106,7 +145,19 @@ const CreateChatModal = () => { debounceMs={DEBOUNCE_MS} onClosed={onClosed} onCancel={handleCancel} - footer={} + footer={ + selectedUserIds.length > 0 ? ( + + + + ) : ( + + ) + } /> ) diff --git a/packages/web/src/pages/chat-page/components/CreateChatUserResult.tsx b/packages/web/src/pages/chat-page/components/CreateChatUserResult.tsx index cfd95177e61..cdeb4526c63 100644 --- a/packages/web/src/pages/chat-page/components/CreateChatUserResult.tsx +++ b/packages/web/src/pages/chat-page/components/CreateChatUserResult.tsx @@ -9,6 +9,7 @@ import { } from '@audius/common/store' import { removeNullable, route } from '@audius/common/utils' import { + Checkbox, IconButton, IconKebabHorizontal, IconMessage, @@ -37,7 +38,8 @@ const messages = { unblock: 'Unblock Messages', notPermitted: 'Cannot Be Messaged', unblockRequired: 'Blocked', - followRequired: 'Follow to Message' + followRequired: 'Follow to Message', + select: (name: string) => `Add ${name} to group` } type UserResultComposeProps = { @@ -45,6 +47,14 @@ type UserResultComposeProps = { closeParentModal: () => void openInboxUnavailableModal: (user: User) => void presetMessage?: string + // Group selection, only passed when group messages are enabled + selection?: { + isSelected: boolean + // At least one user is selected, so clicking a row toggles it + isSelecting: boolean + isFull: boolean + onToggle: (user: User) => void + } } const { blockUser, unblockUser } = chatActions @@ -91,8 +101,13 @@ const renderCustomChip = (callToAction: ChatPermissionAction) => { export const CreateChatUserResult = (props: UserResultComposeProps) => { const dispatch = useDispatch() - const { user, closeParentModal, openInboxUnavailableModal, presetMessage } = - props + const { + user, + closeParentModal, + openInboxUnavailableModal, + presetMessage, + selection + } = props const { data: currentUserId } = useCurrentUserId() const blockeeList = useSelector(chatSelectors.getBlockees) const isBlockee = blockeeList.includes(user.user_id) @@ -141,6 +156,14 @@ export const CreateChatUserResult = (props: UserResultComposeProps) => { } ].filter(removeNullable) + const canSelect = + !!selection && canCreateChat && (selection.isSelected || !selection.isFull) + const handleToggle = useCallback(() => { + if (canSelect) selection?.onToggle(user) + }, [canSelect, selection, user]) + const handleNameClicked = + selection?.isSelecting && canSelect ? handleToggle : handleComposeClicked + if (currentUserId === user.user_id) { return null } @@ -152,8 +175,17 @@ export const CreateChatUserResult = (props: UserResultComposeProps) => { userId={user.user_id} showPopover={false} customChips={canCreateChat ? null : renderCustomChip(callToAction)} - onClickArtistName={handleComposeClicked} + onClickArtistName={handleNameClicked} /> + {selection ? ( + + ) : null} { + const [first, second] = users + return ( +
+ {first ? ( + + ) : null} + {second ? ( + + ) : null} +
+ ) +} diff --git a/packages/web/src/pages/chat-page/components/GroupChatHeader.tsx b/packages/web/src/pages/chat-page/components/GroupChatHeader.tsx new file mode 100644 index 00000000000..a6acc9bc3cf --- /dev/null +++ b/packages/web/src/pages/chat-page/components/GroupChatHeader.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react' + +import { useOtherChatUsers } from '@audius/common/api' +import { chatSelectors, CommonState } from '@audius/common/store' +import { + Flex, + IconButton, + IconKebabHorizontal, + IconSignOut, + IconTrash, + IconUserGroup, + PopupMenu +} from '@audius/harmony' +import { useSelector } from 'react-redux' + +import { DeleteChatConfirmationModal } from './DeleteChatConfirmationModal' +import { GroupChatUser } from './GroupChatUser' +import { GroupMembersModal } from './GroupMembersModal' +import { LeaveGroupConfirmationModal } from './LeaveGroupConfirmationModal' +import { useChatCategoryMenuItems } from './useChatCategoryMenuItems' + +const { getChat } = chatSelectors + +const messages = { + chatSettings: 'Chat Settings', + members: 'View Members', + leave: 'Leave Group', + delete: 'Delete Conversation' +} + +export const GroupChatHeader = ({ chatId }: { chatId?: string }) => { + const [isMembersVisible, setIsMembersVisible] = useState(false) + const [isLeaveVisible, setIsLeaveVisible] = useState(false) + const [isDeleteVisible, setIsDeleteVisible] = useState(false) + const users = useOtherChatUsers(chatId) + const chat = useSelector((state: CommonState) => getChat(state, chatId ?? '')) + const categoryMenuItems = useChatCategoryMenuItems(chat) + const memberIds = + chat && !chat.is_blast ? chat.chat_members.map((m) => m.user_id) : [] + + const overflowItems = [ + { + text: messages.members, + icon: , + onClick: () => setIsMembersVisible(true) + }, + ...categoryMenuItems, + { + text: messages.delete, + icon: , + onClick: () => setIsDeleteVisible(true) + }, + { + text: messages.leave, + icon: , + onClick: () => setIsLeaveVisible(true) + } + ] + + return ( + <> + + + setIsMembersVisible(true)} + /> + + ( + trigger()} + /> + )} + /> + + setIsMembersVisible(false)} + memberIds={memberIds} + /> + setIsLeaveVisible(false)} + /> + setIsDeleteVisible(false)} + /> + + ) +} diff --git a/packages/web/src/pages/chat-page/components/GroupChatUser.tsx b/packages/web/src/pages/chat-page/components/GroupChatUser.tsx new file mode 100644 index 00000000000..697daca35c7 --- /dev/null +++ b/packages/web/src/pages/chat-page/components/GroupChatUser.tsx @@ -0,0 +1,41 @@ +import { ReactNode } from 'react' + +import { User } from '@audius/common/models' +import { formatGroupChatTitle } from '@audius/common/utils' +import cn from 'classnames' + +import styles from './ChatUser.module.css' +import { GroupChatAvatar } from './GroupChatAvatar' + +const messages = { + members: (count: number) => `${count} ${count === 1 ? 'member' : 'members'}` +} + +/** Group counterpart of ChatUser: avatars, member names and member count. */ +export const GroupChatUser = ({ + users, + children, + textClassName, + onClick +}: { + // Other members, not including the current user + users: User[] + children?: ReactNode + textClassName?: string + onClick?: () => void +}) => { + return ( +
+ +
+
+ {formatGroupChatTitle(users)} +
+ + {messages.members(users.length + 1)} + +
+ {children} +
+ ) +} diff --git a/packages/web/src/pages/chat-page/components/GroupMembersModal.tsx b/packages/web/src/pages/chat-page/components/GroupMembersModal.tsx new file mode 100644 index 00000000000..adb8d0a13a2 --- /dev/null +++ b/packages/web/src/pages/chat-page/components/GroupMembersModal.tsx @@ -0,0 +1,76 @@ +import { useCallback } from 'react' + +import { useCurrentUserId, useUsers } from '@audius/common/api' +import { route } from '@audius/common/utils' +import { + Flex, + IconUserGroup, + Modal, + ModalContent, + ModalHeader, + ModalTitle +} from '@audius/harmony' +import { HashId } from '@audius/sdk' +import { useDispatch } from 'react-redux' + +import { push } from 'utils/navigation' + +import { ChatUser } from './ChatUser' + +const { profilePage } = route + +const messages = { + title: 'Members' +} + +type GroupMembersModalProps = { + isVisible: boolean + onClose: () => void + memberIds: string[] +} + +export const GroupMembersModal = ({ + isVisible, + onClose, + memberIds +}: GroupMembersModalProps) => { + const dispatch = useDispatch() + const { data: currentUserId } = useCurrentUserId() + const ids = memberIds.map((id) => HashId.parse(id)) + const { data: users } = useUsers(ids) + + const handleUserClick = useCallback( + (handle: string) => { + onClose() + dispatch(push(profilePage(handle))) + }, + [dispatch, onClose] + ) + + // Current user last + const sorted = [...(users ?? [])].sort( + (a, b) => + Number(a.user_id === currentUserId) - Number(b.user_id === currentUserId) + ) + + return ( + + + } /> + + + + {sorted.map((user) => ( + handleUserClick(user.handle)} + > + + + ))} + + + + ) +} diff --git a/packages/web/src/pages/chat-page/components/LeaveGroupConfirmationModal.tsx b/packages/web/src/pages/chat-page/components/LeaveGroupConfirmationModal.tsx new file mode 100644 index 00000000000..2ec73dcaf80 --- /dev/null +++ b/packages/web/src/pages/chat-page/components/LeaveGroupConfirmationModal.tsx @@ -0,0 +1,62 @@ +import { useCallback } from 'react' + +import { chatActions } from '@audius/common/store' +import { + Modal, + ModalContent, + ModalHeader, + ModalTitle, + ModalFooter, + IconSignOut, + Button, + ModalContentText +} from '@audius/harmony' +import { useDispatch } from 'react-redux' + +const { leaveChat } = chatActions + +const messages = { + title: 'Leave Group', + content: + 'Are you sure you want to leave this group? You won’t get new messages and the conversation will be removed from your inbox.', + confirm: 'Leave Group', + cancel: 'Cancel' +} + +type LeaveGroupConfirmationModalProps = { + isVisible: boolean + onClose: () => void + chatId?: string +} + +export const LeaveGroupConfirmationModal = ({ + isVisible, + onClose, + chatId +}: LeaveGroupConfirmationModalProps) => { + const dispatch = useDispatch() + const handleConfirmClicked = useCallback(() => { + if (chatId) { + dispatch(leaveChat({ chatId })) + } + onClose() + }, [dispatch, onClose, chatId]) + return ( + + + } /> + + + {messages.content} + + + + + + + ) +} From 6ea27273740898f9c6124b51f075ad3cba5746af Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Fri, 6 Nov 2026 13:18:06 -0800 Subject: [PATCH 4/4] feat(mobile): group chats behind group_messages flag Multi-select in the new-message screen, group inbox rows and header, members screen, leave group drawer, and sender name/avatar on group messages. Co-Authored-By: Claude Opus 5.5 --- packages/mobile/src/app/Drawers.tsx | 2 + .../chat-actions-drawer/ChatActionsDrawer.tsx | 65 +++- .../leave-chat-drawer/LeaveChatDrawer.tsx | 42 +++ .../src/components/leave-chat-drawer/index.ts | 1 + .../src/screens/app-screen/AppTabScreen.tsx | 6 + .../src/screens/chat-screen/ChatListItem.tsx | 75 +++- .../screens/chat-screen/ChatMembersScreen.tsx | 38 ++ .../chat-screen/ChatMessageListItem.tsx | 342 +++++++++++------- .../src/screens/chat-screen/ChatScreen.tsx | 40 +- .../screens/chat-screen/ChatUserListItem.tsx | 75 +++- .../chat-screen/ChatUserListScreen.tsx | 104 +++++- .../screens/chat-screen/GroupChatAvatars.tsx | 38 ++ .../screens/chat-screen/GroupChatHeader.tsx | 41 +++ .../src/screens/chat-screen/ReactionPopup.tsx | 3 + packages/mobile/src/store/drawers/slice.ts | 3 + 15 files changed, 690 insertions(+), 185 deletions(-) create mode 100644 packages/mobile/src/components/leave-chat-drawer/LeaveChatDrawer.tsx create mode 100644 packages/mobile/src/components/leave-chat-drawer/index.ts create mode 100644 packages/mobile/src/screens/chat-screen/ChatMembersScreen.tsx create mode 100644 packages/mobile/src/screens/chat-screen/GroupChatAvatars.tsx create mode 100644 packages/mobile/src/screens/chat-screen/GroupChatHeader.tsx diff --git a/packages/mobile/src/app/Drawers.tsx b/packages/mobile/src/app/Drawers.tsx index c3a8c60ff37..ee9143f55a0 100644 --- a/packages/mobile/src/app/Drawers.tsx +++ b/packages/mobile/src/app/Drawers.tsx @@ -28,6 +28,7 @@ import { FanClubDetailsDrawer } from 'app/components/fan-club-details-drawer/Fan import { ForgotPasswordDrawer } from 'app/components/forgot-password-drawer' import { HostRemixContestDrawer } from 'app/components/host-remix-contest-drawer/HostRemixContestDrawer' import { InboxUnavailableDrawer } from 'app/components/inbox-unavailable-drawer/InboxUnavailableDrawer' +import { LeaveChatDrawer } from 'app/components/leave-chat-drawer' import { LeavingAudiusDrawer } from 'app/components/leaving-audius-drawer' import { LockedContentDrawer } from 'app/components/locked-content-drawer' import { LockedTextPostDrawer } from 'app/components/locked-text-post-drawer' @@ -177,6 +178,7 @@ const nativeDrawersMap: { [DrawerName in Drawer]?: LazyComponent } = { BlockMessages: () => BlockMessagesDrawer, MuteComments: () => MuteCommentsConfirmationDrawer, DeleteChat: () => DeleteChatDrawer, + LeaveChat: () => LeaveChatDrawer, Welcome: () => WelcomeDrawer, ConnectNewWallet: () => ConnectNewWalletDrawer, PickWinners: () => PickWinnersDrawer, diff --git a/packages/mobile/src/components/chat-actions-drawer/ChatActionsDrawer.tsx b/packages/mobile/src/components/chat-actions-drawer/ChatActionsDrawer.tsx index b3d40f8a8c6..b35e1f2cb3a 100644 --- a/packages/mobile/src/components/chat-actions-drawer/ChatActionsDrawer.tsx +++ b/packages/mobile/src/components/chat-actions-drawer/ChatActionsDrawer.tsx @@ -1,5 +1,6 @@ import { useCallback } from 'react' +import { useIsGroupChat } from '@audius/common/hooks' import { chatActions, chatSelectors } from '@audius/common/store' import { ChatCategory } from '@audius/sdk' import { useDispatch, useSelector } from 'react-redux' @@ -23,7 +24,9 @@ const messages = { blockMessages: 'Block Messages', unblockMessages: 'Unblock Messages', reportAbuse: 'Report Abuse', - deleteConversation: 'Delete Conversation' + deleteConversation: 'Delete Conversation', + members: 'Members', + leaveGroup: 'Leave Group' } export const ChatActionsDrawer = () => { @@ -36,6 +39,7 @@ export const ChatActionsDrawer = () => { ) const chat = useSelector((state: AppState) => getChat(state, chatId)) const category = chat && !chat.is_blast ? (chat.category ?? null) : null + const isGroup = useIsGroupChat(chatId) const closeDrawer = useCallback(() => { dispatch( @@ -83,6 +87,22 @@ export const ChatActionsDrawer = () => { dispatch(setChatCategory({ chatId, category: ChatCategory.GENERAL })) }, [chatId, closeDrawer, dispatch]) + const handleMembersPress = useCallback(() => { + closeDrawer() + navigation.push('ChatMembers', { chatId }) + }, [chatId, closeDrawer, navigation]) + + const handleLeavePress = useCallback(() => { + closeDrawer() + dispatch( + setVisibility({ + drawer: 'LeaveChat', + visible: true, + data: { chatId } + }) + ) + }, [chatId, closeDrawer, dispatch]) + const handleDeletePress = useCallback(() => { closeDrawer() dispatch( @@ -98,10 +118,15 @@ export const ChatActionsDrawer = () => { { } ] : []), - { - text: doesBlockUser - ? messages.unblockMessages - : messages.blockMessages, - callback: handleBlockMessagesPress - }, - { - text: messages.reportAbuse, - callback: handleReportPress - }, + ...(isGroup + ? [ + { + text: messages.leaveGroup, + callback: handleLeavePress, + isDestructive: true + } + ] + : [ + { + text: doesBlockUser + ? messages.unblockMessages + : messages.blockMessages, + callback: handleBlockMessagesPress + }, + { + text: messages.reportAbuse, + callback: handleReportPress + } + ]), { text: messages.deleteConversation, callback: handleDeletePress, diff --git a/packages/mobile/src/components/leave-chat-drawer/LeaveChatDrawer.tsx b/packages/mobile/src/components/leave-chat-drawer/LeaveChatDrawer.tsx new file mode 100644 index 00000000000..faf11f0ea6d --- /dev/null +++ b/packages/mobile/src/components/leave-chat-drawer/LeaveChatDrawer.tsx @@ -0,0 +1,42 @@ +import { useCallback } from 'react' + +import { chatActions } from '@audius/common/store' +import { useDispatch } from 'react-redux' + +import { IconSignOut } from '@audius/harmony-native' +import { useDrawer } from 'app/hooks/useDrawer' + +import { ConfirmationDrawer } from '../drawers' + +const { leaveChat } = chatActions + +const LEAVE_CHAT_MODAL_NAME = 'LeaveChat' + +const messages = { + header: 'Leave Group', + description: + "Are you sure you want to leave this group? You won't get new messages unless someone starts a new group with you.", + confirm: 'Leave Group', + cancel: 'Cancel' +} + +export const LeaveChatDrawer = () => { + const dispatch = useDispatch() + const { data } = useDrawer('LeaveChat') + const { chatId } = data + + const handleConfirmPress = useCallback(() => { + if (chatId) { + dispatch(leaveChat({ chatId })) + } + }, [chatId, dispatch]) + + return ( + + ) +} diff --git a/packages/mobile/src/components/leave-chat-drawer/index.ts b/packages/mobile/src/components/leave-chat-drawer/index.ts new file mode 100644 index 00000000000..d5806a5a2bf --- /dev/null +++ b/packages/mobile/src/components/leave-chat-drawer/index.ts @@ -0,0 +1 @@ +export * from './LeaveChatDrawer' diff --git a/packages/mobile/src/screens/app-screen/AppTabScreen.tsx b/packages/mobile/src/screens/app-screen/AppTabScreen.tsx index a9655bb6d89..68fb5a4d0cd 100644 --- a/packages/mobile/src/screens/app-screen/AppTabScreen.tsx +++ b/packages/mobile/src/screens/app-screen/AppTabScreen.tsx @@ -33,6 +33,7 @@ import type { AppTabNavigation } from 'app/screens/app-screen/AppTabNavigationPr import { AudioScreen } from 'app/screens/audio-screen' import { CashScreen } from 'app/screens/cash-screen' import { ChatListScreen } from 'app/screens/chat-screen/ChatListScreen' +import { ChatMembersScreen } from 'app/screens/chat-screen/ChatMembersScreen' import { ChatScreen } from 'app/screens/chat-screen/ChatScreen' import { ChatUserListScreen } from 'app/screens/chat-screen/ChatUserListScreen' import { @@ -167,6 +168,7 @@ export type AppTabScreenParamList = { chatId: string presetMessage?: string } + ChatMembers: { chatId: string } ChatBlastSelectContent: { valueName: string title: string @@ -448,6 +450,10 @@ export const AppTabScreen = ({ baseScreen, Stack }: AppTabScreenProps) => { } options={{ ...screenOptions, fullScreenGestureEnabled: false }} /> + ChatMembersScreen} + /> diff --git a/packages/mobile/src/screens/chat-screen/ChatListItem.tsx b/packages/mobile/src/screens/chat-screen/ChatListItem.tsx index 3d7125c612f..855cbe6a31c 100644 --- a/packages/mobile/src/screens/chat-screen/ChatListItem.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatListItem.tsx @@ -1,8 +1,9 @@ import { useCallback, useMemo, useRef } from 'react' import { useOtherChatUsers } from '@audius/common/api' -import { useProxySelector } from '@audius/common/hooks' +import { useIsGroupChat, useProxySelector } from '@audius/common/hooks' import { chatSelectors } from '@audius/common/store' +import { formatGroupChatTitle } from '@audius/common/utils' import { css } from '@emotion/native' import { useTheme } from '@emotion/react' import { TouchableHighlight } from 'react-native' @@ -21,11 +22,13 @@ import type { AppTabScreenParamList } from '../app-screen' import { ChatCategorySwipeActions } from './ChatCategorySwipeActions' import { ChatListItemSkeleton } from './ChatListItemSkeleton' +import { GroupChatAvatars } from './GroupChatAvatars' const { getChat } = chatSelectors const messages = { - new: 'new' + new: 'new', + members: (count: number) => `${count} members` } const clipMessageCount = (count: number) => { @@ -47,6 +50,7 @@ export const ChatListItem = ({ chatId }: { chatId: string }) => { const chat = useProxySelector((state) => getChat(state, chatId), [chatId]) const otherUsers = useOtherChatUsers(chatId) const otherUser = otherUsers[0] + const isGroup = useIsGroupChat(chatId) const lastMessage = useRemoveLeadingWhitespace( (!chat?.is_blast && chat?.last_message) || '' ) @@ -108,26 +112,59 @@ export const ChatListItem = ({ chatId }: { chatId: string }) => { > - - - - + + + ) : ( + + )} + {isGroup ? ( + + + {formatGroupChatTitle(otherUsers)} + + + {messages.members(otherUsers.length + 1)} + + + ) : ( + + - {otherUser.name} + + {otherUser.name} + + + + + @{otherUser.handle} - - - @{otherUser.handle} - - + )} {chat?.unread_message_count && chat?.unread_message_count > 0 ? ( diff --git a/packages/mobile/src/screens/chat-screen/ChatMembersScreen.tsx b/packages/mobile/src/screens/chat-screen/ChatMembersScreen.tsx new file mode 100644 index 00000000000..bc579e28b7e --- /dev/null +++ b/packages/mobile/src/screens/chat-screen/ChatMembersScreen.tsx @@ -0,0 +1,38 @@ +import { useMemo } from 'react' + +import { useCurrentUserId, useOtherChatUsers } from '@audius/common/api' + +import { useRoute } from 'app/hooks/useRoute' +import { UserList } from 'app/screens/user-list-screen/UserList' +import { UserListScreen } from 'app/screens/user-list-screen/UserListScreen' + +const messages = { + title: 'Members' +} + +export const ChatMembersScreen = () => { + const { params } = useRoute<'ChatMembers'>() + const { chatId } = params + const { data: currentUserId } = useCurrentUserId() + const otherUsers = useOtherChatUsers(chatId) + + const userIds = useMemo( + () => [ + ...(currentUserId ? [currentUserId] : []), + ...otherUsers.map((user) => user.user_id) + ], + [currentUserId, otherUsers] + ) + + return ( + + + + ) +} diff --git a/packages/mobile/src/screens/chat-screen/ChatMessageListItem.tsx b/packages/mobile/src/screens/chat-screen/ChatMessageListItem.tsx index 618cb54acc3..766f122ad29 100644 --- a/packages/mobile/src/screens/chat-screen/ChatMessageListItem.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatMessageListItem.tsx @@ -1,12 +1,13 @@ import { memo, useCallback, useState } from 'react' -import { useCurrentUserId } from '@audius/common/api' +import { useCurrentUserId, useUser } from '@audius/common/api' import { useFanClubMessageHeader } from '@audius/common/hooks' import { Status } from '@audius/common/models' import { chatSelectors } from '@audius/common/store' import { formatMessageDate, isCollectionUrl, + isFirstInSenderRun, isTrackUrl } from '@audius/common/utils' import { HashId } from '@audius/sdk' @@ -19,7 +20,11 @@ import { useSelector } from 'react-redux' import { Flex, spacing, Text } from '@audius/harmony-native' import ChatTail from 'app/assets/images/ChatTail.svg' -import { Pressable, UserGeneratedText } from 'app/components/core' +import { + Pressable, + ProfilePicture, + UserGeneratedText +} from 'app/components/core' import { makeStyles } from 'app/styles' import { useThemeColors } from 'app/utils/theme' import { zIndex } from 'app/utils/zIndex' @@ -33,9 +38,12 @@ import { LinkPreview } from './LinkPreview' import { ResendMessageButton } from './ResendMessageButton' import { REACTION_LONGPRESS_DELAY } from './constants' -const { isIdEqualToReactionsPopupMessageId, getChatMessageById } = chatSelectors +const { isIdEqualToReactionsPopupMessageId, getChatMessageById, getBlockees } = + chatSelectors const TAIL_HORIZONTAL_OFFSET = 7 +const GROUP_AVATAR_SIZE = 24 +const GROUP_AVATAR_GAP = 10 const useStyles = makeStyles(({ spacing, palette }) => ({ bubble: { @@ -99,6 +107,17 @@ const useStyles = makeStyles(({ spacing, palette }) => ({ reactionMarginBottom: { marginBottom: spacing(2) }, + groupSenderName: { + marginTop: spacing(2), + marginLeft: GROUP_AVATAR_SIZE + GROUP_AVATAR_GAP + spacing(2) + }, + groupAvatarSlot: { + width: GROUP_AVATAR_SIZE, + marginRight: GROUP_AVATAR_GAP + }, + groupIndent: { + marginLeft: GROUP_AVATAR_SIZE + GROUP_AVATAR_GAP + }, unfurl: { width: Dimensions.get('window').width - 48, minHeight: 72, @@ -143,6 +162,10 @@ type ChatMessageListItemProps = { onLongPress?: (id: string) => void handleClosePopup?: () => void itemsRef?: any + /** Group chats show sender names and avatars on others' messages. */ + isGroup?: boolean + /** The next older message, used to find the start of a sender run. */ + previousMessageId?: string } export const ChatMessageListItem = memo(function ChatMessageListItem( @@ -155,7 +178,9 @@ export const ChatMessageListItem = memo(function ChatMessageListItem( style: styleProp, onLongPress, handleClosePopup, - itemsRef + itemsRef, + isGroup = false, + previousMessageId } = props const styles = useStyles() const { data: userId } = useCurrentUserId() @@ -164,6 +189,21 @@ export const ChatMessageListItem = memo(function ChatMessageListItem( ) const senderUserId = message ? HashId.parse(message.sender_user_id) : null const isAuthor = senderUserId === userId + const isGroupOther = isGroup && !isAuthor + const previousMessage = useSelector((state) => + isGroupOther && previousMessageId + ? getChatMessageById(state, chatId, previousMessageId) + : undefined + ) + const { data: senderName } = useUser(isGroupOther ? senderUserId : null, { + select: (user) => user.name + }) + const isHiddenSender = useSelector( + (state) => + isGroupOther && + !!senderUserId && + getBlockees(state).includes(senderUserId) + ) const [isPressed, setIsPressed] = useState(false) const [emptyUnfurl, setEmptyUnfurl] = useState(false) const links = find(message?.message ?? '') @@ -211,6 +251,9 @@ export const ChatMessageListItem = memo(function ChatMessageListItem( const borderBottomWidth = hideMessage || isCollection || isTrack ? 0 : 1 const unfurlStyles = { ...styles.unfurl, + ...(isGroupOther + ? { width: styles.unfurl.width - GROUP_AVATAR_SIZE - GROUP_AVATAR_GAP } + : null), borderBottomColor, borderBottomWidth } @@ -222,153 +265,190 @@ export const ChatMessageListItem = memo(function ChatMessageListItem( const hasHeader = fanClubSymbol || isCollection || isTrack || link - return message ? ( - <> + if (!message || isHiddenSender) return null + + const showSenderName = + isGroupOther && + !isPopup && + !!senderName && + isFirstInSenderRun(message, previousMessage) + + const groupSenderNameElement = showSenderName ? ( + + {senderName} + + ) : null + + const pressableElement = ( + 0 - ? styles.reactionMarginBottom - : null, - { minHeight: spacing.unit4 }, - styleProp + styles.bubble, + isPressed + ? isAuthor + ? styles.pressedIsAuthor + : styles.pressed + : null ]} + shadow='mid' + ref={ + itemsRef + ? (el) => { + if (el) { + itemsRef.current[messageId] = el + } + } + : null + } > - + {senderUserId ? ( + + ) : null} + {isCollection ? ( + + ) : isTrack ? ( + + ) : link ? ( + + ) : null} + {!hideMessage ? ( { - if (el) { - itemsRef.current[messageId] = el - } - } - : null - } > - {senderUserId ? ( - - ) : null} - {isCollection ? ( - - ) : isTrack ? ( - - ) : link ? ( - - ) : null} - {!hideMessage ? ( - - - {message.message} - - - ) : null} + + {message.message} + - {message.hasTail ? ( - + {message.hasTail ? ( + + ) : null} + {message.reactions?.length > 0 ? ( + <> + {!isUnderneathPopup ? ( + + > + {message?.reactions.map((reaction) => { + return ( + + ) + })} + ) : null} - {message.reactions?.length > 0 ? ( - <> - {!isUnderneathPopup ? ( - - {message?.reactions.map((reaction) => { - return ( - - ) + + ) : null} + + ) + + return message ? ( + <> + 0 + ? styles.reactionMarginBottom + : null, + { minHeight: spacing.unit4 }, + styleProp + ]} + > + {groupSenderNameElement} + {isGroupOther ? ( + + + {message.hasTail && senderUserId ? ( + + /> ) : null} - - ) : null} - + + {pressableElement} +
+ ) : ( + pressableElement + )} {isAuthor && message.status === Status.ERROR ? ( ) : null} {message.hasTail ? ( <> {!isPopup ? ( - + {isUnderneathPopup ? ' ' diff --git a/packages/mobile/src/screens/chat-screen/ChatScreen.tsx b/packages/mobile/src/screens/chat-screen/ChatScreen.tsx index 0d5ab35b34b..3c352a60860 100644 --- a/packages/mobile/src/screens/chat-screen/ChatScreen.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatScreen.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCurrentUserId } from '@audius/common/api' -import { useCanSendMessage } from '@audius/common/hooks' +import { useCanSendMessage, useIsGroupChat } from '@audius/common/hooks' import { Status } from '@audius/common/models' import type { ChatMessageWithExtras } from '@audius/common/models' import { @@ -57,6 +57,7 @@ import { ChatMessageSeparator } from './ChatMessageSeparator' import { ChatTextInput } from './ChatTextInput' import { ChatUnavailable } from './ChatUnavailable' import { EmptyChatMessages } from './EmptyChatMessages' +import { GroupChatHeader } from './GroupChatHeader' import { ReactionPopup } from './ReactionPopup' import { UserChatHeader } from './UserChatHeader' import { @@ -270,6 +271,7 @@ const ChatScreenContent = () => { ) const { canSendMessage, firstOtherUser: otherUser } = useCanSendMessage(chatId) + const isGroup = useIsGroupChat(chatId) // A ref so that the unread separator doesn't disappear immediately when the chat is marked as read // Using a ref instead of state here to prevent unwanted flickers. @@ -296,10 +298,10 @@ const ChatScreenContent = () => { useEffect(() => { dispatch(fetchBlockees()) dispatch(fetchBlockers()) - if (otherUser?.user_id) { + if (otherUser?.user_id && !isGroup) { dispatch(fetchPermissions({ userIds: [otherUser.user_id] })) } - }, [chatId, dispatch, otherUser?.user_id]) + }, [chatId, dispatch, otherUser?.user_id, isGroup]) // Find earliest unread message to display unread tag correctly const earliestUnreadIndex = useMemo( @@ -469,11 +471,14 @@ const ChatScreenContent = () => { ) : null + // Only groups need the neighbor message, so 1:1 rows don't rerender on new messages + const groupMessages = isGroup ? chatMessages : undefined + // When reaction popup opens, hide reaction here so it doesn't // appear underneath the reaction of the message clone inside the // portal. const renderItem = useCallback( - ({ item }) => ( + ({ item, index }) => ( <> { itemsRef={itemsRef} isPopup={false} onLongPress={handleMessagePress} + isGroup={isGroup} + // The list is newest first, so the older neighbor is index + 1 + previousMessageId={groupMessages?.[index + 1]?.message_id} /> {item.message_id === earliestUnreadMessageId && chatFrozenRef.current?.unread_message_count ? ( @@ -492,7 +500,13 @@ const ChatScreenContent = () => { ) : null} ), - [earliestUnreadMessageId, handleMessagePress, chatId] + [ + earliestUnreadMessageId, + handleMessagePress, + chatId, + isGroup, + groupMessages + ] ) const maintainVisibleContentPosition = useMemo( @@ -545,11 +559,15 @@ const ChatScreenContent = () => { headerTitle={ isBlast ? () => - : otherUser - ? () => - : () => {messages.title} + : isGroup + ? () => + : otherUser + ? () => + : () => ( + {messages.title} + ) } - icon={otherUser ? undefined : IconMessage} + icon={otherUser || isGroup ? undefined : IconMessage} topbarRight={topBarRight} > @@ -625,7 +643,9 @@ const ChatScreenContent = () => { ) : null } ListHeaderComponent={ - !canSendMessage ? : null + !canSendMessage && !isGroup ? ( + + ) : null } ListFooterComponent={ chat?.is_blast ? ( diff --git a/packages/mobile/src/screens/chat-screen/ChatUserListItem.tsx b/packages/mobile/src/screens/chat-screen/ChatUserListItem.tsx index 1f224f58521..43a4d505c6b 100644 --- a/packages/mobile/src/screens/chat-screen/ChatUserListItem.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatUserListItem.tsx @@ -12,6 +12,7 @@ import { View, TouchableOpacity, Keyboard } from 'react-native' import { useDispatch } from 'react-redux' import { + IconCheck, IconMessageSlash, IconKebabHorizontal, IconUser @@ -124,6 +125,25 @@ const useStyles = makeStyles(({ spacing, palette, typography }) => ({ }, dim: { opacity: 0.5 + }, + selectCircle: { + width: spacing(6), + height: spacing(6), + borderRadius: spacing(3), + borderWidth: 2, + borderColor: palette.neutralLight6, + alignItems: 'center', + justifyContent: 'center', + marginLeft: spacing(3) + }, + selectCircleSelected: { + backgroundColor: palette.secondary, + borderColor: palette.secondary + }, + iconCheck: { + height: spacing(3.5), + width: spacing(3.5), + fill: palette.staticWhite } })) @@ -139,11 +159,21 @@ const ctaToTextMap: Record = { type ChatUserListItemProps = { userId: number presetMessage?: string + /** Shows a select circle for building a group chat. */ + isSelectable?: boolean + isSelected?: boolean + /** When something is already selected, tapping the row toggles it. */ + isSelectionMode?: boolean + onToggleSelect?: (userId: number) => void } export const ChatUserListItem = ({ userId, - presetMessage + presetMessage, + isSelectable = false, + isSelected = false, + isSelectionMode = false, + onToggleSelect }: ChatUserListItemProps) => { const styles = useStyles() const dispatch = useDispatch() @@ -172,6 +202,13 @@ export const ChatUserListItem = ({ } }, [openInboxUnavailableDrawer, user, presetMessage]) + const handleToggleSelect = useCallback(() => { + if (user?.user_id) { + Keyboard.dismiss() + onToggleSelect?.(user.user_id) + } + }, [onToggleSelect, user?.user_id]) + const handleKebabPress = useCallback(() => { if (user?.user_id) { Keyboard.dismiss() @@ -191,7 +228,13 @@ export const ChatUserListItem = ({ return ( @@ -265,6 +308,34 @@ export const ChatUserListItem = ({ ) : null} + {isSelectable ? ( + + + {isSelected ? ( + + ) : null} + + + ) : null} diff --git a/packages/mobile/src/screens/chat-screen/ChatUserListScreen.tsx b/packages/mobile/src/screens/chat-screen/ChatUserListScreen.tsx index 7f39c0ac5e0..465cf608855 100644 --- a/packages/mobile/src/screens/chat-screen/ChatUserListScreen.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatUserListScreen.tsx @@ -6,43 +6,62 @@ import { useSearchUsersModal, useUsers } from '@audius/common/api' +import { useFeatureFlag } from '@audius/common/hooks' import { Status, statusIsNotFinalized } from '@audius/common/models' -import type { User } from '@audius/common/models' -import { chatActions, chatSelectors } from '@audius/common/store' +import type { ID, User } from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' +import { + chatActions, + chatSelectors, + MAX_GROUP_CHAT_MEMBERS, + playbackSelectors +} from '@audius/common/store' import type { CreateChatModalState } from '@audius/common/store' -import { View, Image } from 'react-native' +import { View, Image, Keyboard } from 'react-native' import { KeyboardAwareFlatList } from 'react-native-keyboard-aware-scroll-view' import { useDispatch, useSelector } from 'react-redux' import { useDebounce } from 'react-use' -import { IconCompose, IconSearch } from '@audius/harmony-native' +import { Box, Button, IconCompose, IconSearch } from '@audius/harmony-native' import MagnifyingGlass from 'app/assets/images/leftPointingMagnifyingGlass.png' import { Screen, ScreenContent, Text, TextInput, - HeaderShadow + HeaderShadow, + KeyboardAvoidingView } from 'app/components/core' import LoadingSpinner from 'app/components/loading-spinner' import { withManagedAccountRestriction } from 'app/components/manager-mode/ManagedAccountUnavailableScreen' +import { PLAY_BAR_HEIGHT } from 'app/components/now-playing-drawer/constants' import { useRoute } from 'app/hooks/useRoute' +import { useToast } from 'app/hooks/useToast' import { makeStyles } from 'app/styles' import { ChatBlastCTA } from './ChatBlastCTA' import { ChatUserListItem } from './ChatUserListItem' +import { useKeyboardAvoidingPlaybarStyle } from './hooks/useKeyboardAvoidingPlaybarStyle' -const { fetchBlockees, fetchBlockers, fetchPermissions } = chatActions +const { createChat, fetchBlockees, fetchBlockers, fetchPermissions } = + chatActions const { getUserList: getChatsUserList } = chatSelectors +const { getHasTrack } = playbackSelectors const DEBOUNCE_MS = 150 +const MAX_GROUP_CHAT_INVITEES = MAX_GROUP_CHAT_MEMBERS - 1 +const GROUP_FOOTER_HEIGHT = 72 + const messages = { title: 'New Message', search: ' Search Users', emptyTitle: 'Search for Friends', emptyDescription: - 'Search for fellow music lovers and strike up a conversation.' + 'Search for fellow music lovers and strike up a conversation.', + startChat: 'Start chat', + startGroupChat: (count: number) => `Start group chat (${count})`, + maxMembers: `Group chats can have up to ${MAX_GROUP_CHAT_MEMBERS} people` } const useStyles = makeStyles(({ spacing, palette, typography }) => ({ @@ -127,6 +146,11 @@ const useStyles = makeStyles(({ spacing, palette, typography }) => ({ }, footerPadding: { height: spacing(30) + }, + groupFooter: { + backgroundColor: palette.white, + borderTopColor: palette.neutralLight8, + borderTopWidth: 1 } })) @@ -222,6 +246,43 @@ const ChatUserListScreenContent = () => { const { data: users } = useUsers(userIds) + const { isEnabled: isGroupMessagesEnabled } = useFeatureFlag( + FeatureFlags.GROUP_MESSAGES + ) + const { toast } = useToast() + const hasCurrentlyPlayingTrack = useSelector(getHasTrack) + const keyboardAvoidingPlaybarStyle = useKeyboardAvoidingPlaybarStyle() + const [selectedUserIds, setSelectedUserIds] = useState([]) + const isSelectionMode = selectedUserIds.length > 0 + + const handleToggleSelect = useCallback( + (userId: ID) => { + setSelectedUserIds((prev) => { + if (prev.includes(userId)) { + return prev.filter((id) => id !== userId) + } + if (prev.length >= MAX_GROUP_CHAT_INVITEES) { + toast({ content: messages.maxMembers }) + return prev + } + return [...prev, userId] + }) + }, + [toast] + ) + + const handleStartGroupChat = useCallback(() => { + if (selectedUserIds.length === 0) return + Keyboard.dismiss() + dispatch( + createChat({ + userIds: selectedUserIds, + presetMessage, + replaceNavigation: true + }) + ) + }, [dispatch, presetMessage, selectedUserIds]) + useEffect(() => { dispatch(fetchBlockees()) dispatch(fetchBlockers()) @@ -302,8 +363,13 @@ const ChatUserListScreenContent = () => { )} + extraData={selectedUserIds} keyExtractor={(user: User) => user.handle} contentContainerStyle={styles.flatListContainer} // Only show empty component if there is no search query @@ -314,7 +380,29 @@ const ChatUserListScreenContent = () => { )} - + {isGroupMessagesEnabled && isSelectionMode ? ( + + + + + + ) : ( + + )} diff --git a/packages/mobile/src/screens/chat-screen/GroupChatAvatars.tsx b/packages/mobile/src/screens/chat-screen/GroupChatAvatars.tsx new file mode 100644 index 00000000000..82fb713622c --- /dev/null +++ b/packages/mobile/src/screens/chat-screen/GroupChatAvatars.tsx @@ -0,0 +1,38 @@ +import type { User } from '@audius/common/models' +import { css } from '@emotion/native' + +import { Flex } from '@audius/harmony-native' +import { ProfilePicture } from 'app/components/core' + +type GroupChatAvatarsProps = { + users: Pick[] + /** Diameter of each avatar in points. */ + size: number + limit?: number +} + +/** Overlapping avatars for a group chat's members. */ +export const GroupChatAvatars = ({ + users, + size, + limit = 3 +}: GroupChatAvatarsProps) => { + const shown = users.slice(0, limit) + return ( + + {shown.map((user, index) => ( + + ))} + + ) +} diff --git a/packages/mobile/src/screens/chat-screen/GroupChatHeader.tsx b/packages/mobile/src/screens/chat-screen/GroupChatHeader.tsx new file mode 100644 index 00000000000..1c677ff6c8e --- /dev/null +++ b/packages/mobile/src/screens/chat-screen/GroupChatHeader.tsx @@ -0,0 +1,41 @@ +import { useOtherChatUsers } from '@audius/common/api' +import { formatGroupChatTitle } from '@audius/common/utils' +import { css } from '@emotion/native' +import { TouchableOpacity } from 'react-native' + +import { Flex, Text, useTheme } from '@audius/harmony-native' +import { useNavigation } from 'app/hooks/useNavigation' + +import type { AppTabScreenParamList } from '../app-screen' + +import { GroupChatAvatars } from './GroupChatAvatars' + +const messages = { + members: (count: number) => `${count} members` +} + +export const GroupChatHeader = ({ chatId }: { chatId: string }) => { + const navigation = useNavigation() + const { spacing } = useTheme() + const otherUsers = useOtherChatUsers(chatId) + + return ( + + navigation.push('ChatMembers', { chatId })} + > + + + + + {formatGroupChatTitle(otherUsers)} + + + {messages.members(otherUsers.length + 1)} + + + + + + ) +} diff --git a/packages/mobile/src/screens/chat-screen/ReactionPopup.tsx b/packages/mobile/src/screens/chat-screen/ReactionPopup.tsx index a37d6dd0ce1..aa10ce78804 100644 --- a/packages/mobile/src/screens/chat-screen/ReactionPopup.tsx +++ b/packages/mobile/src/screens/chat-screen/ReactionPopup.tsx @@ -2,6 +2,7 @@ import { useCallback, useRef } from 'react' import type { ReactionTypes } from '@audius/common/api' import { useCurrentUserId } from '@audius/common/api' +import { useIsGroupChat } from '@audius/common/hooks' import type { ChatMessageWithExtras } from '@audius/common/models' import { chatActions } from '@audius/common/store' import type { Nullable } from '@audius/common/utils' @@ -107,6 +108,7 @@ export const ReactionPopup = ({ const dispatch = useDispatch() const { data: userId } = useCurrentUserId() const { toast } = useToast() + const isGroup = useIsGroupChat(chatId) const newReaction = useRef(undefined) const userIdEncoded = OptionalId.parse(userId) @@ -192,6 +194,7 @@ export const ReactionPopup = ({ chatId={chatId} messageId={message.message_id} isPopup={true} + isGroup={isGroup} style={[ styles.popupChatMessage, { diff --git a/packages/mobile/src/store/drawers/slice.ts b/packages/mobile/src/store/drawers/slice.ts index 793ae3436e4..276ef4aa01f 100644 --- a/packages/mobile/src/store/drawers/slice.ts +++ b/packages/mobile/src/store/drawers/slice.ts @@ -27,6 +27,7 @@ export type Drawer = | 'BlockMessages' | 'MuteComments' | 'DeleteChat' + | 'LeaveChat' | 'OfflineListening' | 'Welcome' | 'ManagerMode' @@ -75,6 +76,7 @@ export type DrawerData = { } MuteComments: { userId: number; isMuted: boolean } DeleteChat: { chatId: string } + LeaveChat: { chatId: string } InboxUnavailable: { userId: number; shouldOpenChat: boolean } Welcome: undefined ManagerMode: undefined @@ -120,6 +122,7 @@ const initialState: DrawersState = { BlockMessages: false, MuteComments: false, DeleteChat: false, + LeaveChat: false, Welcome: false, ManagerMode: false, AccountSwitcher: false,