move convo/member data fetching out of Convo class into react query
Convo class no longer owns fetchConvo/refreshConvo. Data is fetched via useConvoQuery + useListConvoMembersQuery in the provider and synced in via setConvoData. Convo stores ConvoWithDetails instead of raw ConvoView. relatedProfiles map grows as message history is fetched. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -87,7 +87,10 @@ export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & (
|
|||||||
export function parseConvoView(
|
export function parseConvoView(
|
||||||
convoView: ChatBskyConvoDefs.ConvoView,
|
convoView: ChatBskyConvoDefs.ConvoView,
|
||||||
ownDid: string | undefined,
|
ownDid: string | undefined,
|
||||||
|
allMembers?: ChatBskyActorDefs.ProfileViewBasic[],
|
||||||
): ConvoWithDetails | null {
|
): ConvoWithDetails | null {
|
||||||
|
const memberList = allMembers ?? convoView.members
|
||||||
|
|
||||||
if (
|
if (
|
||||||
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
|
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
|
||||||
convoView.kind,
|
convoView.kind,
|
||||||
@@ -96,7 +99,7 @@ export function parseConvoView(
|
|||||||
) {
|
) {
|
||||||
let owner: GroupConvoMember | undefined = undefined
|
let owner: GroupConvoMember | undefined = undefined
|
||||||
|
|
||||||
for (const member of convoView.members) {
|
for (const member of memberList) {
|
||||||
if (
|
if (
|
||||||
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
|
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
|
||||||
member.kind,
|
member.kind,
|
||||||
@@ -104,9 +107,6 @@ export function parseConvoView(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (member.kind.role === 'owner') {
|
if (member.kind.role === 'owner') {
|
||||||
// have to do a type assertion here
|
|
||||||
// this works: {...member, kind: member.kind}
|
|
||||||
// however that's creating a new object for no good reason
|
|
||||||
owner = member as GroupConvoMember
|
owner = member as GroupConvoMember
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -127,7 +127,7 @@ export function parseConvoView(
|
|||||||
kind: 'group',
|
kind: 'group',
|
||||||
details: convoView.kind,
|
details: convoView.kind,
|
||||||
primaryMember: owner,
|
primaryMember: owner,
|
||||||
members: convoView.members as Array<GroupConvoMember>,
|
members: memberList as Array<GroupConvoMember>,
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>(
|
bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>(
|
||||||
@@ -135,7 +135,7 @@ export function parseConvoView(
|
|||||||
ChatBskyConvoDefs.isDirectConvo,
|
ChatBskyConvoDefs.isDirectConvo,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
const otherUser = convoView.members.find(m => m.did !== ownDid)
|
const otherUser = memberList.find(m => m.did !== ownDid)
|
||||||
|
|
||||||
if (!otherUser) {
|
if (!otherUser) {
|
||||||
logger.warn('No other user found in direct convo')
|
logger.warn('No other user found in direct convo')
|
||||||
@@ -147,7 +147,7 @@ export function parseConvoView(
|
|||||||
kind: 'direct',
|
kind: 'direct',
|
||||||
details: convoView.kind,
|
details: convoView.kind,
|
||||||
primaryMember: otherUser as DirectConvoMember,
|
primaryMember: otherUser as DirectConvoMember,
|
||||||
members: convoView.members as Array<DirectConvoMember>,
|
members: memberList as Array<DirectConvoMember>,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Unknown convo kind: ' + JSON.stringify(convoView.kind))
|
logger.warn('Unknown convo kind: ' + JSON.stringify(convoView.kind))
|
||||||
|
|||||||
+114
-241
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type AtpAgent,
|
type AtpAgent,
|
||||||
ChatBskyActorDefs,
|
type ChatBskyActorDefs,
|
||||||
ChatBskyConvoDefs,
|
ChatBskyConvoDefs,
|
||||||
type ChatBskyConvoGetLog,
|
type ChatBskyConvoGetLog,
|
||||||
type ChatBskyConvoSendMessage,
|
type ChatBskyConvoSendMessage,
|
||||||
@@ -19,6 +19,7 @@ import {Logger} from '#/logger'
|
|||||||
import {
|
import {
|
||||||
ACTIVE_POLL_INTERVAL,
|
ACTIVE_POLL_INTERVAL,
|
||||||
BACKGROUND_POLL_INTERVAL,
|
BACKGROUND_POLL_INTERVAL,
|
||||||
|
GET_MESSAGE_HISTORY_LIMIT,
|
||||||
INACTIVE_TIMEOUT,
|
INACTIVE_TIMEOUT,
|
||||||
NETWORK_FAILURE_STATUSES,
|
NETWORK_FAILURE_STATUSES,
|
||||||
} from '#/state/messages/convo/const'
|
} from '#/state/messages/convo/const'
|
||||||
@@ -26,7 +27,6 @@ import {
|
|||||||
type ConvoDispatch,
|
type ConvoDispatch,
|
||||||
ConvoDispatchEvent,
|
ConvoDispatchEvent,
|
||||||
type ConvoError,
|
type ConvoError,
|
||||||
ConvoErrorCode,
|
|
||||||
type ConvoEvent,
|
type ConvoEvent,
|
||||||
type ConvoItem,
|
type ConvoItem,
|
||||||
ConvoItemError,
|
ConvoItemError,
|
||||||
@@ -36,8 +36,8 @@ import {
|
|||||||
} from '#/state/messages/convo/types'
|
} from '#/state/messages/convo/types'
|
||||||
import {type MessagesEventBus} from '#/state/messages/events/agent'
|
import {type MessagesEventBus} from '#/state/messages/events/agent'
|
||||||
import {type MessagesEventBusError} from '#/state/messages/events/types'
|
import {type MessagesEventBusError} from '#/state/messages/events/types'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
|
||||||
import * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
const logger = Logger.create(Logger.Context.ConversationAgent)
|
const logger = Logger.create(Logger.Context.ConversationAgent)
|
||||||
|
|
||||||
@@ -102,10 +102,7 @@ export class Convo {
|
|||||||
{id: string; message: ChatBskyConvoSendMessage.InputSchema['message']}
|
{id: string; message: ChatBskyConvoSendMessage.InputSchema['message']}
|
||||||
> = new Map()
|
> = new Map()
|
||||||
private deletedMessages: Set<string> = new Set()
|
private deletedMessages: Set<string> = new Set()
|
||||||
private systemMessageProfiles: Map<
|
private relatedProfiles: Map<string, bsky.profile.AnyProfileView> = new Map()
|
||||||
string,
|
|
||||||
ChatBskyActorDefs.ProfileViewBasic
|
|
||||||
> = new Map()
|
|
||||||
|
|
||||||
private isProcessingPendingMessages = false
|
private isProcessingPendingMessages = false
|
||||||
|
|
||||||
@@ -114,9 +111,7 @@ export class Convo {
|
|||||||
private emitter = new EventEmitter<{event: [ConvoEvent]}>()
|
private emitter = new EventEmitter<{event: [ConvoEvent]}>()
|
||||||
|
|
||||||
convoId: string
|
convoId: string
|
||||||
convo: ChatBskyConvoDefs.ConvoView | undefined
|
convo: ConvoWithDetails | undefined
|
||||||
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
|
|
||||||
recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
|
|
||||||
snapshot: ConvoState | undefined
|
snapshot: ConvoState | undefined
|
||||||
|
|
||||||
constructor(params: ConvoParams) {
|
constructor(params: ConvoParams) {
|
||||||
@@ -126,8 +121,8 @@ export class Convo {
|
|||||||
this.events = params.events
|
this.events = params.events
|
||||||
this.senderUserDid = params.agent.assertDid
|
this.senderUserDid = params.agent.assertDid
|
||||||
|
|
||||||
if (params.placeholderData) {
|
if (params.initialData) {
|
||||||
this.setupPlaceholderData(params.placeholderData)
|
this.setupInitialData(params.initialData)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.subscribe = this.subscribe.bind(this)
|
this.subscribe = this.subscribe.bind(this)
|
||||||
@@ -141,10 +136,6 @@ export class Convo {
|
|||||||
this.markConvoAccepted = this.markConvoAccepted.bind(this)
|
this.markConvoAccepted = this.markConvoAccepted.bind(this)
|
||||||
this.addReaction = this.addReaction.bind(this)
|
this.addReaction = this.addReaction.bind(this)
|
||||||
this.removeReaction = this.removeReaction.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)
|
|
||||||
this.updateGroupName = this.updateGroupName.bind(this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private commit() {
|
private commit() {
|
||||||
@@ -179,8 +170,6 @@ export class Convo {
|
|||||||
items: [],
|
items: [],
|
||||||
convo: this.convo,
|
convo: this.convo,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
sender: this.sender,
|
|
||||||
recipients: this.recipients,
|
|
||||||
isFetchingHistory: this.isFetchingHistory,
|
isFetchingHistory: this.isFetchingHistory,
|
||||||
// Explicit null check since the value is initially undefined.
|
// Explicit null check since the value is initially undefined.
|
||||||
hasAllHistory: this.oldestRev === null,
|
hasAllHistory: this.oldestRev === null,
|
||||||
@@ -190,9 +179,6 @@ export class Convo {
|
|||||||
markConvoAccepted: undefined,
|
markConvoAccepted: undefined,
|
||||||
addReaction: undefined,
|
addReaction: undefined,
|
||||||
removeReaction: undefined,
|
removeReaction: undefined,
|
||||||
isGroup: this.isGroup,
|
|
||||||
getGroupInfo: this.getGroupInfo,
|
|
||||||
getPrimaryMember: this.getPrimaryMember,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ConvoStatus.Disabled:
|
case ConvoStatus.Disabled:
|
||||||
@@ -204,8 +190,6 @@ export class Convo {
|
|||||||
items: this.getItems(),
|
items: this.getItems(),
|
||||||
convo: this.convo!,
|
convo: this.convo!,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
sender: this.sender!,
|
|
||||||
recipients: this.recipients!,
|
|
||||||
isFetchingHistory: this.isFetchingHistory,
|
isFetchingHistory: this.isFetchingHistory,
|
||||||
// Explicit null check since the value is initially undefined.
|
// Explicit null check since the value is initially undefined.
|
||||||
hasAllHistory: this.oldestRev === null,
|
hasAllHistory: this.oldestRev === null,
|
||||||
@@ -215,10 +199,7 @@ export class Convo {
|
|||||||
markConvoAccepted: this.markConvoAccepted,
|
markConvoAccepted: this.markConvoAccepted,
|
||||||
addReaction: this.addReaction,
|
addReaction: this.addReaction,
|
||||||
removeReaction: this.removeReaction,
|
removeReaction: this.removeReaction,
|
||||||
isGroup: this.isGroup,
|
} as ConvoState
|
||||||
getGroupInfo: this.getGroupInfo,
|
|
||||||
getPrimaryMember: this.getPrimaryMember,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case ConvoStatus.Error: {
|
case ConvoStatus.Error: {
|
||||||
return {
|
return {
|
||||||
@@ -226,8 +207,6 @@ export class Convo {
|
|||||||
items: [],
|
items: [],
|
||||||
convo: undefined,
|
convo: undefined,
|
||||||
error: this.error!,
|
error: this.error!,
|
||||||
sender: undefined,
|
|
||||||
recipients: undefined,
|
|
||||||
isFetchingHistory: false,
|
isFetchingHistory: false,
|
||||||
hasAllHistory: false,
|
hasAllHistory: false,
|
||||||
deleteMessage: undefined,
|
deleteMessage: undefined,
|
||||||
@@ -236,9 +215,6 @@ export class Convo {
|
|||||||
markConvoAccepted: undefined,
|
markConvoAccepted: undefined,
|
||||||
addReaction: undefined,
|
addReaction: undefined,
|
||||||
removeReaction: undefined,
|
removeReaction: undefined,
|
||||||
isGroup: undefined,
|
|
||||||
getGroupInfo: undefined,
|
|
||||||
getPrimaryMember: undefined,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
@@ -247,8 +223,6 @@ export class Convo {
|
|||||||
items: [],
|
items: [],
|
||||||
convo: this.convo,
|
convo: this.convo,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
sender: this.sender,
|
|
||||||
recipients: this.recipients,
|
|
||||||
isFetchingHistory: false,
|
isFetchingHistory: false,
|
||||||
// Explicit null check since the value is initially undefined.
|
// Explicit null check since the value is initially undefined.
|
||||||
hasAllHistory: this.oldestRev === null,
|
hasAllHistory: this.oldestRev === null,
|
||||||
@@ -258,9 +232,6 @@ export class Convo {
|
|||||||
markConvoAccepted: undefined,
|
markConvoAccepted: undefined,
|
||||||
addReaction: undefined,
|
addReaction: undefined,
|
||||||
removeReaction: undefined,
|
removeReaction: undefined,
|
||||||
isGroup: this.isGroup,
|
|
||||||
getGroupInfo: this.getGroupInfo,
|
|
||||||
getPrimaryMember: this.getPrimaryMember,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,7 +245,7 @@ export class Convo {
|
|||||||
switch (action.event) {
|
switch (action.event) {
|
||||||
case ConvoDispatchEvent.Init: {
|
case ConvoDispatchEvent.Init: {
|
||||||
this.status = ConvoStatus.Initializing
|
this.status = ConvoStatus.Initializing
|
||||||
void this.setup()
|
this.setup()
|
||||||
this.setupFirehose()
|
this.setupFirehose()
|
||||||
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
|
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
|
||||||
break
|
break
|
||||||
@@ -321,7 +292,6 @@ export class Convo {
|
|||||||
case ConvoStatus.Ready: {
|
case ConvoStatus.Ready: {
|
||||||
switch (action.event) {
|
switch (action.event) {
|
||||||
case ConvoDispatchEvent.Resume: {
|
case ConvoDispatchEvent.Resume: {
|
||||||
void this.refreshConvo()
|
|
||||||
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
|
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -360,11 +330,10 @@ export class Convo {
|
|||||||
} else {
|
} else {
|
||||||
if (this.convo) {
|
if (this.convo) {
|
||||||
this.status = ConvoStatus.Ready
|
this.status = ConvoStatus.Ready
|
||||||
void this.refreshConvo()
|
|
||||||
this.maybeRecoverFromNetworkError()
|
this.maybeRecoverFromNetworkError()
|
||||||
} else {
|
} else {
|
||||||
this.status = ConvoStatus.Initializing
|
this.status = ConvoStatus.Initializing
|
||||||
void this.setup()
|
this.setup()
|
||||||
}
|
}
|
||||||
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
|
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
|
||||||
}
|
}
|
||||||
@@ -460,8 +429,6 @@ export class Convo {
|
|||||||
|
|
||||||
private reset() {
|
private reset() {
|
||||||
this.convo = undefined
|
this.convo = undefined
|
||||||
this.sender = undefined
|
|
||||||
this.recipients = undefined
|
|
||||||
this.snapshot = undefined
|
this.snapshot = undefined
|
||||||
|
|
||||||
this.status = ConvoStatus.Uninitialized
|
this.status = ConvoStatus.Uninitialized
|
||||||
@@ -473,7 +440,7 @@ export class Convo {
|
|||||||
this.newMessages = new Map()
|
this.newMessages = new Map()
|
||||||
this.pendingMessages = new Map()
|
this.pendingMessages = new Map()
|
||||||
this.deletedMessages = new Set()
|
this.deletedMessages = new Set()
|
||||||
this.systemMessageProfiles = new Map()
|
this.relatedProfiles = new Map()
|
||||||
|
|
||||||
this.pendingMessageFailure = null
|
this.pendingMessageFailure = null
|
||||||
this.fetchMessageHistoryError = undefined
|
this.fetchMessageHistoryError = undefined
|
||||||
@@ -498,70 +465,64 @@ export class Convo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private setupInitialData(data: NonNullable<ConvoParams['initialData']>) {
|
||||||
* Initialises the convo with placeholder data, if provided. We still refetch it before rendering the convo,
|
if (data.convo) {
|
||||||
* but this allows us to render the convo header immediately.
|
if (data.members) {
|
||||||
*/
|
this.setConvoData(data.convo, data.members)
|
||||||
private setupPlaceholderData(
|
} else {
|
||||||
data: NonNullable<ConvoParams['placeholderData']>,
|
const parsed = parseConvoView(data.convo, this.senderUserDid)
|
||||||
) {
|
if (parsed) {
|
||||||
this.convo = data.convo
|
this.convo = parsed
|
||||||
this.sender = data.convo.members.find(m => m.did === this.senderUserDid)
|
}
|
||||||
this.recipients = data.convo.members.filter(
|
for (const member of data.convo.members) {
|
||||||
m => m.did !== this.senderUserDid,
|
this.relatedProfiles.set(member.did, member)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setup() {
|
private setup() {
|
||||||
try {
|
this.tryReady()
|
||||||
const {convo, sender, recipients} = await this.fetchConvo()
|
}
|
||||||
|
|
||||||
this.convo = convo
|
private tryReady() {
|
||||||
this.sender = sender
|
if (this.status !== ConvoStatus.Initializing) return
|
||||||
this.recipients = recipients
|
if (!this.convo) return
|
||||||
|
|
||||||
/*
|
const self = this.relatedProfiles.get(this.senderUserDid)
|
||||||
* Some validation prior to `Ready` status
|
if (!self) return
|
||||||
*/
|
|
||||||
if (!this.convo) {
|
|
||||||
throw new Error('could not find convo')
|
|
||||||
}
|
|
||||||
if (!this.sender) {
|
|
||||||
throw new Error('could not find sender in convo')
|
|
||||||
}
|
|
||||||
if (!this.recipients) {
|
|
||||||
throw new Error('could not find recipients in convo')
|
|
||||||
}
|
|
||||||
|
|
||||||
const userIsDisabled = Boolean(this.sender.chatDisabled)
|
const userIsDisabled = Boolean('chatDisabled' in self && self.chatDisabled)
|
||||||
|
|
||||||
if (userIsDisabled) {
|
if (userIsDisabled) {
|
||||||
this.dispatch({event: ConvoDispatchEvent.Disable})
|
this.dispatch({event: ConvoDispatchEvent.Disable})
|
||||||
} else {
|
} else {
|
||||||
this.dispatch({event: ConvoDispatchEvent.Ready})
|
this.dispatch({event: ConvoDispatchEvent.Ready})
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const e = err as Error
|
|
||||||
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
|
|
||||||
logger.error('setup failed', {
|
|
||||||
safeMessage: e.message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dispatch({
|
|
||||||
event: ConvoDispatchEvent.Error,
|
|
||||||
payload: {
|
|
||||||
exception: e,
|
|
||||||
code: ConvoErrorCode.InitFailed,
|
|
||||||
retry: () => {
|
|
||||||
this.reset()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
this.commit()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setConvoData(
|
||||||
|
convo: ChatBskyConvoDefs.ConvoView,
|
||||||
|
members: ChatBskyActorDefs.ProfileViewBasic[],
|
||||||
|
) {
|
||||||
|
const parsed = parseConvoView(convo, this.senderUserDid, members)
|
||||||
|
if (!parsed) return
|
||||||
|
|
||||||
|
this.convo = parsed
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
this.relatedProfiles.set(member.did, member)
|
||||||
|
}
|
||||||
|
for (const member of convo.members) {
|
||||||
|
if (!this.relatedProfiles.has(member.did)) {
|
||||||
|
this.relatedProfiles.set(member.did, member)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tryReady()
|
||||||
|
this.commit()
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.dispatch({event: ConvoDispatchEvent.Init})
|
this.dispatch({event: ConvoDispatchEvent.Init})
|
||||||
}
|
}
|
||||||
@@ -601,59 +562,6 @@ export class Convo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private pendingFetchConvo:
|
|
||||||
| Promise<{
|
|
||||||
convo: ChatBskyConvoDefs.ConvoView
|
|
||||||
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
|
|
||||||
recipients: ChatBskyActorDefs.ProfileViewBasic[]
|
|
||||||
}>
|
|
||||||
| undefined
|
|
||||||
async fetchConvo() {
|
|
||||||
if (this.pendingFetchConvo) return this.pendingFetchConvo
|
|
||||||
|
|
||||||
this.pendingFetchConvo = (async () => {
|
|
||||||
try {
|
|
||||||
const response = await networkRetry(2, () => {
|
|
||||||
return this.agent.api.chat.bsky.convo.getConvo(
|
|
||||||
{
|
|
||||||
convoId: this.convoId,
|
|
||||||
},
|
|
||||||
{headers: DM_SERVICE_HEADERS},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const convo = response.data.convo
|
|
||||||
|
|
||||||
return {
|
|
||||||
convo,
|
|
||||||
sender: convo.members.find(m => m.did === this.senderUserDid),
|
|
||||||
recipients: convo.members.filter(m => m.did !== this.senderUserDid),
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.pendingFetchConvo = undefined
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return this.pendingFetchConvo
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshConvo() {
|
|
||||||
try {
|
|
||||||
const {convo, sender, recipients} = await this.fetchConvo()
|
|
||||||
// throw new Error('UNCOMMENT TO TEST REFRESH FAILURE')
|
|
||||||
this.convo = convo || this.convo
|
|
||||||
this.sender = sender || this.sender
|
|
||||||
this.recipients = recipients || this.recipients
|
|
||||||
} catch (err) {
|
|
||||||
const e = err as Error
|
|
||||||
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
|
|
||||||
logger.error(`failed to refresh convo`, {
|
|
||||||
safeMessage: e.message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fetchMessageHistoryError:
|
private fetchMessageHistoryError:
|
||||||
| {
|
| {
|
||||||
retry: () => void
|
retry: () => void
|
||||||
@@ -689,7 +597,7 @@ export class Convo {
|
|||||||
{
|
{
|
||||||
cursor: nextCursor,
|
cursor: nextCursor,
|
||||||
convoId: this.convoId,
|
convoId: this.convoId,
|
||||||
limit: IS_NATIVE ? 30 : 60,
|
limit: GET_MESSAGE_HISTORY_LIMIT,
|
||||||
},
|
},
|
||||||
{headers: DM_SERVICE_HEADERS},
|
{headers: DM_SERVICE_HEADERS},
|
||||||
)
|
)
|
||||||
@@ -700,7 +608,7 @@ export class Convo {
|
|||||||
|
|
||||||
if (relatedProfiles) {
|
if (relatedProfiles) {
|
||||||
for (const profile of relatedProfiles) {
|
for (const profile of relatedProfiles) {
|
||||||
this.systemMessageProfiles.set(profile.did, profile)
|
this.relatedProfiles.set(profile.did, profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,7 +616,7 @@ export class Convo {
|
|||||||
* If the response contained fewer messages than the limit, we know
|
* If the response contained fewer messages than the limit, we know
|
||||||
* there are no more pages, regardless of whether a cursor was returned.
|
* there are no more pages, regardless of whether a cursor was returned.
|
||||||
*/
|
*/
|
||||||
if (messages.length < (IS_NATIVE ? 30 : 60)) {
|
if (messages.length < GET_MESSAGE_HISTORY_LIMIT) {
|
||||||
this.oldestRev = null
|
this.oldestRev = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,6 +637,10 @@ export class Convo {
|
|||||||
this.pastMessages.set(message.id, message)
|
this.pastMessages.set(message.id, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const profile of relatedProfiles ?? []) {
|
||||||
|
this.relatedProfiles.set(profile.did, profile)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error
|
const e = err as Error
|
||||||
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
|
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
|
||||||
@@ -877,7 +789,7 @@ export class Convo {
|
|||||||
Array.isArray(ev.relatedProfiles)
|
Array.isArray(ev.relatedProfiles)
|
||||||
) {
|
) {
|
||||||
for (const profile of ev.relatedProfiles) {
|
for (const profile of ev.relatedProfiles) {
|
||||||
this.systemMessageProfiles.set(profile.did, profile)
|
this.relatedProfiles.set(profile.did, profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
needsCommit = true
|
needsCommit = true
|
||||||
@@ -907,10 +819,10 @@ export class Convo {
|
|||||||
id: tempId,
|
id: tempId,
|
||||||
message,
|
message,
|
||||||
})
|
})
|
||||||
if (this.convo?.status === 'request') {
|
if (this.convo?.view.status === 'request') {
|
||||||
this.convo = {
|
this.convo = {
|
||||||
...this.convo,
|
...this.convo,
|
||||||
status: 'accepted',
|
view: {...this.convo.view, status: 'accepted'},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.commit()
|
this.commit()
|
||||||
@@ -924,36 +836,7 @@ export class Convo {
|
|||||||
if (this.convo) {
|
if (this.convo) {
|
||||||
this.convo = {
|
this.convo = {
|
||||||
...this.convo,
|
...this.convo,
|
||||||
status: 'accepted',
|
view: {...this.convo.view, status: 'accepted'},
|
||||||
}
|
|
||||||
}
|
|
||||||
this.commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMuted(muted: boolean) {
|
|
||||||
if (this.convo) {
|
|
||||||
this.convo = {
|
|
||||||
...this.convo,
|
|
||||||
muted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateGroupName(name: string) {
|
|
||||||
if (
|
|
||||||
this.convo &&
|
|
||||||
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
|
|
||||||
this.convo.kind,
|
|
||||||
ChatBskyConvoDefs.isGroupConvo,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
this.convo = {
|
|
||||||
...this.convo,
|
|
||||||
kind: {
|
|
||||||
...this.convo.kind,
|
|
||||||
name,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.commit()
|
this.commit()
|
||||||
@@ -980,7 +863,7 @@ export class Convo {
|
|||||||
|
|
||||||
const {id, message} = pendingMessage
|
const {id, message} = pendingMessage
|
||||||
|
|
||||||
const response = await this.agent.api.chat.bsky.convo.sendMessage(
|
const response = await this.agent.chat.bsky.convo.sendMessage(
|
||||||
{
|
{
|
||||||
convoId: this.convoId,
|
convoId: this.convoId,
|
||||||
message,
|
message,
|
||||||
@@ -1022,10 +905,7 @@ export class Convo {
|
|||||||
case 'block between recipient and sender':
|
case 'block between recipient and sender':
|
||||||
this.emitter.emit('event', {
|
this.emitter.emit('event', {
|
||||||
type: 'invalidate-block-state',
|
type: 'invalidate-block-state',
|
||||||
accountDids: [
|
accountDids: [this.senderUserDid, ...this.relatedProfiles.keys()],
|
||||||
this.sender!.did,
|
|
||||||
...this.recipients!.map(r => r.did),
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case 'Account is disabled':
|
case 'Account is disabled':
|
||||||
@@ -1075,7 +955,7 @@ export class Convo {
|
|||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {data} = await this.agent.api.chat.bsky.convo.sendMessageBatch(
|
const {data} = await this.agent.chat.bsky.convo.sendMessageBatch(
|
||||||
{
|
{
|
||||||
items: messageArray.map(({message}) => ({
|
items: messageArray.map(({message}) => ({
|
||||||
convoId: this.convoId,
|
convoId: this.convoId,
|
||||||
@@ -1117,7 +997,7 @@ export class Convo {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await networkRetry(2, () => {
|
await networkRetry(2, () => {
|
||||||
return this.agent.api.chat.bsky.convo.deleteMessageForSelf(
|
return this.agent.chat.bsky.convo.deleteMessageForSelf(
|
||||||
{
|
{
|
||||||
convoId: this.convoId,
|
convoId: this.convoId,
|
||||||
messageId,
|
messageId,
|
||||||
@@ -1146,6 +1026,39 @@ export class Convo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getRelatedProfilesForItem(
|
||||||
|
m: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.SystemMessageView,
|
||||||
|
): bsky.profile.AnyProfileView[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const profiles: bsky.profile.AnyProfileView[] = []
|
||||||
|
|
||||||
|
const add = (did: string) => {
|
||||||
|
if (seen.has(did)) return
|
||||||
|
seen.add(did)
|
||||||
|
const p = this.relatedProfiles.get(did)
|
||||||
|
if (p) profiles.push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ChatBskyConvoDefs.isMessageView(m)) {
|
||||||
|
add(m.sender.did)
|
||||||
|
if (m.reactions) {
|
||||||
|
for (const reaction of m.reactions) {
|
||||||
|
add(reaction.sender.did)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (ChatBskyConvoDefs.isSystemMessageView(m)) {
|
||||||
|
const data = m.data
|
||||||
|
if ('member' in data && data.member?.did) {
|
||||||
|
add(data.member.did)
|
||||||
|
}
|
||||||
|
if ('addedBy' in data && data.addedBy?.did) {
|
||||||
|
add(data.addedBy.did)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Items in reverse order, since FlatList inverts
|
* Items in reverse order, since FlatList inverts
|
||||||
*/
|
*/
|
||||||
@@ -1158,6 +1071,7 @@ export class Convo {
|
|||||||
type: 'message',
|
type: 'message',
|
||||||
key: m.id,
|
key: m.id,
|
||||||
message: m,
|
message: m,
|
||||||
|
relatedProfiles: this.getRelatedProfilesForItem(m),
|
||||||
nextMessage: null,
|
nextMessage: null,
|
||||||
prevMessage: null,
|
prevMessage: null,
|
||||||
})
|
})
|
||||||
@@ -1174,7 +1088,7 @@ export class Convo {
|
|||||||
type: 'system-message',
|
type: 'system-message',
|
||||||
key: m.id,
|
key: m.id,
|
||||||
message: m,
|
message: m,
|
||||||
relatedProfiles: Array.from(this.systemMessageProfiles.values()),
|
relatedProfiles: this.getRelatedProfilesForItem(m),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1196,6 +1110,7 @@ export class Convo {
|
|||||||
type: 'message',
|
type: 'message',
|
||||||
key: m.id,
|
key: m.id,
|
||||||
message: m,
|
message: m,
|
||||||
|
relatedProfiles: this.getRelatedProfilesForItem(m),
|
||||||
nextMessage: null,
|
nextMessage: null,
|
||||||
prevMessage: null,
|
prevMessage: null,
|
||||||
})
|
})
|
||||||
@@ -1212,7 +1127,7 @@ export class Convo {
|
|||||||
type: 'system-message',
|
type: 'system-message',
|
||||||
key: m.id,
|
key: m.id,
|
||||||
message: m,
|
message: m,
|
||||||
relatedProfiles: Array.from(this.systemMessageProfiles.values()),
|
relatedProfiles: this.getRelatedProfilesForItem(m),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1234,7 +1149,7 @@ export class Convo {
|
|||||||
*/
|
*/
|
||||||
sender: {
|
sender: {
|
||||||
$type: 'chat.bsky.convo.defs#messageViewSender',
|
$type: 'chat.bsky.convo.defs#messageViewSender',
|
||||||
did: this.sender!.did,
|
did: this.senderUserDid,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
nextMessage: null,
|
nextMessage: null,
|
||||||
@@ -1448,46 +1363,4 @@ export class Convo {
|
|||||||
throw error
|
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.convo?.members.find(m => {
|
|
||||||
if (
|
|
||||||
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
|
|
||||||
m.kind,
|
|
||||||
ChatBskyActorDefs.isGroupConvoMember,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return m.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {ChatBskyConvoDefs} from '@atproto/api'
|
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {useAppState} from '#/lib/appState'
|
import {useAppState} from '#/lib/appState'
|
||||||
import {Convo} from '#/state/messages/convo/agent'
|
import {Convo} from '#/state/messages/convo/agent'
|
||||||
import {
|
import {
|
||||||
|
ConvoDispatchEvent,
|
||||||
|
ConvoErrorCode,
|
||||||
type ConvoParams,
|
type ConvoParams,
|
||||||
type ConvoState,
|
type ConvoState,
|
||||||
type ConvoStateBackgrounded,
|
type ConvoStateBackgrounded,
|
||||||
@@ -23,10 +24,11 @@ import {
|
|||||||
import {isConvoActive} from '#/state/messages/convo/util'
|
import {isConvoActive} from '#/state/messages/convo/util'
|
||||||
import {useMessagesEventBus} from '#/state/messages/events'
|
import {useMessagesEventBus} from '#/state/messages/events'
|
||||||
import {
|
import {
|
||||||
RQKEY as getConvoKey,
|
useConvoQuery,
|
||||||
useMarkAsReadMutation,
|
useMarkAsReadMutation,
|
||||||
} from '#/state/queries/messages/conversation'
|
} from '#/state/queries/messages/conversation'
|
||||||
import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations'
|
import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations'
|
||||||
|
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
|
||||||
import {RQKEY as createProfileQueryKey} from '#/state/queries/profile'
|
import {RQKEY as createProfileQueryKey} from '#/state/queries/profile'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
|
||||||
@@ -72,20 +74,52 @@ export function ConvoProvider({
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const events = useMessagesEventBus()
|
const events = useMessagesEventBus()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: convoData,
|
||||||
|
error: convoError,
|
||||||
|
refetch: refetchConvo,
|
||||||
|
} = useConvoQuery({convoId})
|
||||||
|
const {
|
||||||
|
data: membersData,
|
||||||
|
error: membersError,
|
||||||
|
refetch: refetchMembers,
|
||||||
|
} = useListConvoMembersQuery({convoId})
|
||||||
|
|
||||||
|
// eslint-disable-next-line react/hook-use-state
|
||||||
const [convo] = useState(() => {
|
const [convo] = useState(() => {
|
||||||
const placeholder = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
|
|
||||||
getConvoKey(convoId),
|
|
||||||
)
|
|
||||||
return new Convo({
|
return new Convo({
|
||||||
convoId,
|
convoId,
|
||||||
agent,
|
agent,
|
||||||
events,
|
events,
|
||||||
placeholderData: placeholder ? {convo: placeholder} : undefined,
|
initialData: {convo: convoData, members: membersData},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
|
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
|
||||||
const {mutate: markAsRead} = useMarkAsReadMutation()
|
const {mutate: markAsRead} = useMarkAsReadMutation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (convoData && membersData) {
|
||||||
|
convo.setConvoData(convoData, membersData)
|
||||||
|
}
|
||||||
|
}, [convo, convoData, membersData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if ((convoError || membersError) && !convo.convo) {
|
||||||
|
convo.dispatch({
|
||||||
|
event: ConvoDispatchEvent.Error,
|
||||||
|
payload: {
|
||||||
|
exception: (convoError || membersError) as Error,
|
||||||
|
code: ConvoErrorCode.InitFailed,
|
||||||
|
retry: () => {
|
||||||
|
void refetchConvo()
|
||||||
|
void refetchMembers()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [convo, convoError, membersError, refetchConvo, refetchMembers])
|
||||||
|
|
||||||
const appState = useAppState()
|
const appState = useAppState()
|
||||||
const isActive = appState === 'active'
|
const isActive = appState === 'active'
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@@ -93,13 +127,15 @@ export function ConvoProvider({
|
|||||||
if (isActive) {
|
if (isActive) {
|
||||||
convo.resume()
|
convo.resume()
|
||||||
markAsRead({convoId})
|
markAsRead({convoId})
|
||||||
|
void refetchConvo()
|
||||||
|
void refetchMembers()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
convo.background()
|
convo.background()
|
||||||
markAsRead({convoId})
|
markAsRead({convoId})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isActive, convo, convoId, markAsRead]),
|
}, [isActive, convo, convoId, markAsRead, refetchConvo, refetchMembers]),
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -107,11 +143,11 @@ export function ConvoProvider({
|
|||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'invalidate-block-state': {
|
case 'invalidate-block-state': {
|
||||||
for (const did of event.accountDids) {
|
for (const did of event.accountDids) {
|
||||||
queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: createProfileQueryKey(did),
|
queryKey: createProfileQueryKey(did),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: [ListConvosQueryKeyRoot],
|
queryKey: [ListConvosQueryKeyRoot],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -119,29 +155,5 @@ export function ConvoProvider({
|
|||||||
})
|
})
|
||||||
}, [convo, queryClient])
|
}, [convo, queryClient])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const [root, id] = getConvoKey(convoId)
|
|
||||||
return queryClient.getQueryCache().subscribe(event => {
|
|
||||||
const queryKey = event.query.queryKey as string[]
|
|
||||||
if (queryKey[0] === root && queryKey[1] === id) {
|
|
||||||
const data = event.query.state.data as
|
|
||||||
| ChatBskyConvoDefs.ConvoView
|
|
||||||
| undefined
|
|
||||||
if (data && convo.convo && data.muted !== convo.convo.muted) {
|
|
||||||
convo.updateMuted(data.muted)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
data &&
|
|
||||||
convo.convo &&
|
|
||||||
ChatBskyConvoDefs.isGroupConvo(data.kind) &&
|
|
||||||
ChatBskyConvoDefs.isGroupConvo(convo.convo.kind) &&
|
|
||||||
data.kind.name !== convo.convo.kind.name
|
|
||||||
) {
|
|
||||||
convo.updateGroupName(data.kind.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [convo, convoId, queryClient])
|
|
||||||
|
|
||||||
return <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
|
return <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
RQKEY_ROOT as LIST_CONVOS_KEY,
|
RQKEY_ROOT as LIST_CONVOS_KEY,
|
||||||
} from './list-conversations'
|
} from './list-conversations'
|
||||||
|
|
||||||
const RQKEY_ROOT = 'convo'
|
export const RQKEY_ROOT = 'convo'
|
||||||
export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId]
|
export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId]
|
||||||
|
|
||||||
export function useConvoQuery({convoId}: {convoId: string}) {
|
export function useConvoQuery({convoId}: {convoId: string}) {
|
||||||
@@ -31,7 +31,7 @@ export function useConvoQuery({convoId}: {convoId: string}) {
|
|||||||
)
|
)
|
||||||
return data.convo
|
return data.convo
|
||||||
},
|
},
|
||||||
staleTime: STALE.INFINITY,
|
staleTime: STALE.MINUTES.THIRTY,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user