Groupchats feature branch (#10181)

Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-04-15 12:15:45 -07:00
committed by GitHub
parent 75c9e2c181
commit d3f5093817
31 changed files with 2826 additions and 635 deletions
+85 -29
View File
@@ -1,6 +1,6 @@
import {
type AtpAgent,
type ChatBskyActorDefs,
ChatBskyActorDefs,
ChatBskyConvoDefs,
type ChatBskyConvoGetLog,
type ChatBskyConvoSendMessage,
@@ -37,6 +37,7 @@ import {
import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type MessagesEventBusError} from '#/state/messages/events/types'
import {IS_NATIVE} from '#/env'
import * as bsky from '#/types/bsky'
const logger = Logger.create(Logger.Context.ConversationAgent)
@@ -112,6 +113,9 @@ export class Convo {
this.markConvoAccepted = this.markConvoAccepted.bind(this)
this.addReaction = this.addReaction.bind(this)
this.removeReaction = this.removeReaction.bind(this)
this.isGroup = this.isGroup.bind(this)
this.getGroupInfo = this.getGroupInfo.bind(this)
this.getPrimaryMember = this.getPrimaryMember.bind(this)
}
private commit() {
@@ -155,6 +159,9 @@ export class Convo {
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
isGroup: this.isGroup,
getGroupInfo: this.getGroupInfo,
getPrimaryMember: this.getPrimaryMember,
}
}
case ConvoStatus.Disabled:
@@ -175,6 +182,9 @@ export class Convo {
markConvoAccepted: this.markConvoAccepted,
addReaction: this.addReaction,
removeReaction: this.removeReaction,
isGroup: this.isGroup,
getGroupInfo: this.getGroupInfo,
getPrimaryMember: this.getPrimaryMember,
}
}
case ConvoStatus.Error: {
@@ -192,6 +202,9 @@ export class Convo {
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
isGroup: undefined,
getGroupInfo: undefined,
getPrimaryMember: undefined,
}
}
default: {
@@ -209,6 +222,9 @@ export class Convo {
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
isGroup: this.isGroup,
getGroupInfo: this.getGroupInfo,
getPrimaryMember: this.getPrimaryMember,
}
}
}
@@ -222,7 +238,7 @@ export class Convo {
switch (action.event) {
case ConvoDispatchEvent.Init: {
this.status = ConvoStatus.Initializing
this.setup()
void this.setup()
this.setupFirehose()
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
break
@@ -234,12 +250,12 @@ export class Convo {
switch (action.event) {
case ConvoDispatchEvent.Ready: {
this.status = ConvoStatus.Ready
this.fetchMessageHistory()
void this.fetchMessageHistory()
break
}
case ConvoDispatchEvent.Background: {
this.status = ConvoStatus.Backgrounded
this.fetchMessageHistory()
void this.fetchMessageHistory()
this.requestPollInterval(BACKGROUND_POLL_INTERVAL)
break
}
@@ -258,7 +274,7 @@ export class Convo {
}
case ConvoDispatchEvent.Disable: {
this.status = ConvoStatus.Disabled
this.fetchMessageHistory() // finish init
void this.fetchMessageHistory() // finish init
this.cleanupFirehoseConnection?.()
this.withdrawRequestedPollInterval()
break
@@ -269,7 +285,7 @@ export class Convo {
case ConvoStatus.Ready: {
switch (action.event) {
case ConvoDispatchEvent.Resume: {
this.refreshConvo()
void this.refreshConvo()
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
break
}
@@ -308,11 +324,11 @@ export class Convo {
} else {
if (this.convo) {
this.status = ConvoStatus.Ready
this.refreshConvo()
void this.refreshConvo()
this.maybeRecoverFromNetworkError()
} else {
this.status = ConvoStatus.Initializing
this.setup()
void this.setup()
}
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
}
@@ -435,7 +451,7 @@ export class Convo {
this.firehoseError = undefined
this.commit()
} else {
this.batchRetryPendingMessages()
void this.batchRetryPendingMessages()
}
if (this.fetchMessageHistoryError) {
@@ -487,7 +503,8 @@ export class Convo {
} else {
this.dispatch({event: ConvoDispatchEvent.Ready})
}
} catch (e: any) {
} catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error('setup failed', {
safeMessage: e.message,
@@ -557,11 +574,7 @@ export class Convo {
async fetchConvo() {
if (this.pendingFetchConvo) return this.pendingFetchConvo
this.pendingFetchConvo = new Promise<{
convo: ChatBskyConvoDefs.ConvoView
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
recipients: ChatBskyActorDefs.ProfileViewBasic[]
}>(async (resolve, reject) => {
this.pendingFetchConvo = (async () => {
try {
const response = await networkRetry(2, () => {
return this.agent.api.chat.bsky.convo.getConvo(
@@ -574,17 +587,15 @@ export class Convo {
const convo = response.data.convo
resolve({
return {
convo,
sender: convo.members.find(m => m.did === this.senderUserDid),
recipients: convo.members.filter(m => m.did !== this.senderUserDid),
})
} catch (e) {
reject(e)
}
} finally {
this.pendingFetchConvo = undefined
}
})
})()
return this.pendingFetchConvo
}
@@ -596,7 +607,8 @@ export class Convo {
this.convo = convo || this.convo
this.sender = sender || this.sender
this.recipients = recipients || this.recipients
} catch (e: any) {
} catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error(`failed to refresh convo`, {
safeMessage: e.message,
@@ -664,7 +676,8 @@ export class Convo {
this.pastMessages.set(message.id, message)
}
}
} catch (e: any) {
} catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error('failed to fetch message history', {
safeMessage: e.message,
@@ -673,7 +686,7 @@ export class Convo {
this.fetchMessageHistoryError = {
retry: () => {
this.fetchMessageHistory()
void this.fetchMessageHistory()
},
}
} finally {
@@ -716,7 +729,7 @@ export class Convo {
onFirehoseConnect() {
this.firehoseError = undefined
this.batchRetryPendingMessages()
void this.batchRetryPendingMessages()
this.commit()
}
@@ -761,8 +774,8 @@ export class Convo {
/**
* If this message is already in new messages, it was added by our
* sending logic, and is based on client-ordering. When we receive
* the "commited" event from the log, we should replace this
* reference and re-insert in order to respect the order we receied
* the "committed" event from the log, we should replace this
* reference and re-insert in order to respect the order we received
* from the log.
*/
if (this.newMessages.has(ev.message.id)) {
@@ -836,7 +849,7 @@ export class Convo {
this.commit()
if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) {
this.processPendingMessages()
void this.processPendingMessages()
}
}
@@ -912,7 +925,7 @@ export class Convo {
}
}
private handleSendMessageFailure(e: any) {
private handleSendMessageFailure(e: Error | XRPCError) {
if (e instanceof XRPCError) {
if (NETWORK_FAILURE_STATUSES.includes(e.status)) {
this.pendingMessageFailure = 'recoverable'
@@ -1026,7 +1039,8 @@ export class Convo {
{encoding: 'application/json', headers: DM_SERVICE_HEADERS},
)
})
} catch (e: any) {
} catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error(`failed to delete message`, {
safeMessage: e.message,
@@ -1334,4 +1348,46 @@ export class Convo {
throw error
}
}
// Group utilities
isGroup(): boolean | undefined {
if (!this.convo) return undefined
const info = this.getGroupInfo()
return !!info
}
getGroupInfo(): ChatBskyConvoDefs.GroupConvo | undefined {
if (
this.convo &&
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
this.convo.kind,
ChatBskyConvoDefs.isGroupConvo,
)
) {
return this.convo.kind
}
return undefined
}
getPrimaryMember(): ChatBskyActorDefs.ProfileViewBasic | undefined {
if (this.isGroup()) {
return this.recipients?.find(r => {
if (
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
r.kind,
ChatBskyActorDefs.isGroupConvoMember,
)
) {
return r.kind.role === 'owner'
} else {
throw new Error(
'Expected a GroupConvoMember, got an unknown kind of member',
)
}
})
} else {
return this.recipients?.find(r => r.did !== this.senderUserDid)
}
}
}
+24
View File
@@ -144,6 +144,9 @@ type FetchMessageHistory = () => Promise<void>
type MarkConvoAccepted = () => void
type AddReaction = (messageId: string, reaction: string) => Promise<void>
type RemoveReaction = (messageId: string, reaction: string) => Promise<void>
type IsGroup = () => boolean | undefined
type GetGroupInfo = () => ChatBskyConvoDefs.GroupConvo | undefined
type GetPrimaryMember = () => ChatBskyActorDefs.ProfileViewBasic | undefined
export type ConvoStateUninitialized = {
status: ConvoStatus.Uninitialized
@@ -159,6 +162,9 @@ export type ConvoStateUninitialized = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateInitializing = {
status: ConvoStatus.Initializing
@@ -174,6 +180,9 @@ export type ConvoStateInitializing = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateReady = {
status: ConvoStatus.Ready
@@ -189,6 +198,9 @@ export type ConvoStateReady = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateBackgrounded = {
status: ConvoStatus.Backgrounded
@@ -204,6 +216,9 @@ export type ConvoStateBackgrounded = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateSuspended = {
status: ConvoStatus.Suspended
@@ -219,6 +234,9 @@ export type ConvoStateSuspended = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateError = {
status: ConvoStatus.Error
@@ -234,6 +252,9 @@ export type ConvoStateError = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
isGroup: undefined
getGroupInfo: undefined
getPrimaryMember: undefined
}
export type ConvoStateDisabled = {
status: ConvoStatus.Disabled
@@ -249,6 +270,9 @@ export type ConvoStateDisabled = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoState =
| ConvoStateUninitialized
@@ -0,0 +1,37 @@
import {type ChatBskyGroupCreateGroup} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {precacheConvoQuery} from './conversation'
export function useCreateGroupChat({
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupCreateGroup.OutputSchema) => void
onError?: (error: Error) => void
}) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async ({name, members}: {name: string; members: string[]}) => {
const {data} = await agent.chat.bsky.group.createGroup(
{name, members},
{headers: DM_SERVICE_HEADERS},
)
return data
},
onSuccess: data => {
precacheConvoQuery(queryClient, data.convo)
onSuccess?.(data)
},
onError: error => {
logger.error(error)
onError?.(error)
},
})
}
@@ -31,13 +31,13 @@ export function useMuteConvo(
mutationFn: async ({mute}: {mute: boolean}) => {
if (!convoId) throw new Error('No convoId provided')
if (mute) {
const {data} = await agent.api.chat.bsky.convo.muteConvo(
const {data} = await agent.chat.bsky.convo.muteConvo(
{convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
} else {
const {data} = await agent.api.chat.bsky.convo.unmuteConvo(
const {data} = await agent.chat.bsky.convo.unmuteConvo(
{convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)