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
5 changes: 5 additions & 0 deletions .changeset/group-chats.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions packages/common/src/hooks/chats/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './useChatBlastAudienceContent'
export * from './useFirstAvailableBlastAudience'
export * from './usePurchasersAudience'
export * from './useRemixersAudience'
export * from './useIsGroupChat'
8 changes: 7 additions & 1 deletion packages/common/src/hooks/chats/useCanSendMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ChatPermissionAction, CommonState } from '~/store/index'
import {
getBlockees,
getBlockers,
getChat,
getRecheckPermissions,
useCanCreateChat
} from '~/store/pages/chat/selectors'
Expand All @@ -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
Expand All @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions packages/common/src/hooks/chats/useIsGroupChat.ts
Original file line number Diff line number Diff line change
@@ -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
})
10 changes: 7 additions & 3 deletions packages/common/src/services/remote-config/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FeatureFlags, boolean>
Expand All @@ -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: {}
}
Expand Down Expand Up @@ -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
}
2 changes: 1 addition & 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,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'
111 changes: 109 additions & 2 deletions packages/common/src/store/pages/chat/sagas.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ChatBlastAudience,
Id,
type TypedCommsResponse,
type UserChat
} from '@audius/sdk'
Expand All @@ -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<TypedCommsResponse<UserChat[]>['summary']>

Expand Down Expand Up @@ -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' })
})
})
})
76 changes: 69 additions & 7 deletions packages/common/src/store/pages/chat/sagas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ChatCategory,
HashId,
Id,
makeGroupChatId,
OptionalHashId,
OptionalId,
type ChatMessage,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -394,7 +401,7 @@ function* doSetMessageReaction(action: ReturnType<typeof setMessageReaction>) {
}
}

function* doCreateChat(action: ReturnType<typeof createChat>) {
export function* doCreateChat(action: ReturnType<typeof createChat>) {
const { userIds, skipNavigation, presetMessage, replaceNavigation } =
action.payload
try {
Expand All @@ -404,8 +411,23 @@ function* doCreateChat(action: ReturnType<typeof createChat>) {
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) {
Expand All @@ -420,7 +442,8 @@ function* doCreateChat(action: ReturnType<typeof createChat>) {
// 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 })
Expand All @@ -444,6 +467,18 @@ function* doCreateChat(action: ReturnType<typeof createChat>) {
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({
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -788,6 +823,28 @@ function* doDeleteChat(action: ReturnType<typeof deleteChat>) {
}
}

export function* doLeaveChat(action: ReturnType<typeof leaveChat>) {
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<typeof logError>) {
console.error(error)
}
Expand Down Expand Up @@ -880,6 +937,10 @@ function* watchDeleteChat() {
yield takeEvery(deleteChat, doDeleteChat)
}

function* watchLeaveChat() {
yield takeEvery(leaveChat, doLeaveChat)
}

export function* doSetChatCategory(action: ReturnType<typeof setChatCategory>) {
const { chatId, category } = action.payload
try {
Expand Down Expand Up @@ -943,6 +1004,7 @@ export const sagas = () => {
watchFetchPermissions,
watchFetchLinkUnfurlMetadata,
watchDeleteChat,
watchLeaveChat,
watchSetChatCategory,
watchLogError
]
Expand Down
Loading
Loading