[DMs] Reactions - link up API (attempt 2) (#8074)

* update package

* wire up APIs

* get reactions to display

* allow removing emoji

* handle limits better

* listen to reactions in log

* update convo list with reactions

* tweaks to reaction display

* Handle empty message fallback case

* update package

* shift reacts up by 2px

---------

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2025-03-28 16:34:07 +02:00
committed by GitHub
parent 55a40c2436
commit 152bc3c1ec
17 changed files with 659 additions and 123 deletions
+179 -13
View File
@@ -1,9 +1,9 @@
import {
BskyAgent,
ChatBskyActorDefs,
type AtpAgent,
type ChatBskyActorDefs,
ChatBskyConvoDefs,
ChatBskyConvoGetLog,
ChatBskyConvoSendMessage,
type ChatBskyConvoGetLog,
type ChatBskyConvoSendMessage,
} from '@atproto/api'
import {XRPCError} from '@atproto/xrpc'
import EventEmitter from 'eventemitter3'
@@ -19,19 +19,19 @@ import {
NETWORK_FAILURE_STATUSES,
} from '#/state/messages/convo/const'
import {
ConvoDispatch,
type ConvoDispatch,
ConvoDispatchEvent,
ConvoError,
type ConvoError,
ConvoErrorCode,
ConvoEvent,
ConvoItem,
type ConvoEvent,
type ConvoItem,
ConvoItemError,
ConvoParams,
ConvoState,
type ConvoParams,
type ConvoState,
ConvoStatus,
} from '#/state/messages/convo/types'
import {MessagesEventBus} from '#/state/messages/events/agent'
import {MessagesEventBusError} from '#/state/messages/events/types'
import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type MessagesEventBusError} from '#/state/messages/events/types'
import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
const logger = Logger.create(Logger.Context.ConversationAgent)
@@ -50,7 +50,7 @@ export function isConvoItemMessage(
export class Convo {
private id: string
private agent: BskyAgent
private agent: AtpAgent
private events: MessagesEventBus
private senderUserDid: string
@@ -106,6 +106,8 @@ export class Convo {
this.onFirehoseConnect = this.onFirehoseConnect.bind(this)
this.onFirehoseError = this.onFirehoseError.bind(this)
this.markConvoAccepted = this.markConvoAccepted.bind(this)
this.addReaction = this.addReaction.bind(this)
this.removeReaction = this.removeReaction.bind(this)
}
private commit() {
@@ -147,6 +149,8 @@ export class Convo {
sendMessage: undefined,
fetchMessageHistory: undefined,
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
}
}
case ConvoStatus.Disabled:
@@ -165,6 +169,8 @@ export class Convo {
sendMessage: this.sendMessage,
fetchMessageHistory: this.fetchMessageHistory,
markConvoAccepted: this.markConvoAccepted,
addReaction: this.addReaction,
removeReaction: this.removeReaction,
}
}
case ConvoStatus.Error: {
@@ -180,6 +186,8 @@ export class Convo {
sendMessage: undefined,
fetchMessageHistory: undefined,
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
}
}
default: {
@@ -195,6 +203,8 @@ export class Convo {
sendMessage: undefined,
fetchMessageHistory: undefined,
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
}
}
}
@@ -760,6 +770,22 @@ export class Convo {
this.deletedMessages.delete(ev.message.id)
needsCommit = true
}
} else if (
(ChatBskyConvoDefs.isLogAddReaction(ev) ||
ChatBskyConvoDefs.isLogRemoveReaction(ev)) &&
ChatBskyConvoDefs.isMessageView(ev.message)
) {
/*
* Update if we have this in state - replace message wholesale. If we don't, don't worry about it.
*/
if (this.pastMessages.has(ev.message.id)) {
this.pastMessages.set(ev.message.id, ev.message)
needsCommit = true
}
if (this.newMessages.has(ev.message.id)) {
this.newMessages.set(ev.message.id, ev.message)
needsCommit = true
}
}
}
}
@@ -1141,4 +1167,144 @@ export class Convo {
return item
})
}
/**
* Add an emoji reaction to a message
*
* @param messageId - the id of the message to add the reaction to
* @param emoji - must be one grapheme
*/
async addReaction(messageId: string, emoji: string) {
const optimisticReaction = {
value: emoji,
sender: {did: this.senderUserDid},
createdAt: new Date().toISOString(),
}
let restore: null | (() => void) = null
if (this.pastMessages.has(messageId)) {
const prevMessage = this.pastMessages.get(messageId)
if (
ChatBskyConvoDefs.isMessageView(prevMessage) &&
// skip optimistic update if reaction already exists
!prevMessage.reactions?.find(
reaction =>
reaction.sender.did === this.senderUserDid &&
reaction.value === emoji,
)
) {
if (prevMessage.reactions) {
if (
prevMessage.reactions.filter(
reaction => reaction.sender.did === this.senderUserDid,
).length >= 5
) {
throw new Error('Maximum reactions reached')
}
}
this.pastMessages.set(messageId, {
...prevMessage,
reactions: [...(prevMessage.reactions ?? []), optimisticReaction],
})
this.commit()
restore = () => {
this.pastMessages.set(messageId, prevMessage)
this.commit()
}
}
} else if (this.newMessages.has(messageId)) {
const prevMessage = this.newMessages.get(messageId)
if (
ChatBskyConvoDefs.isMessageView(prevMessage) &&
!prevMessage.reactions?.find(reaction => reaction.value === emoji)
) {
if (prevMessage.reactions && prevMessage.reactions.length >= 5)
throw new Error('Maximum reactions reached')
this.newMessages.set(messageId, {
...prevMessage,
reactions: [...(prevMessage.reactions ?? []), optimisticReaction],
})
this.commit()
restore = () => {
this.newMessages.set(messageId, prevMessage)
this.commit()
}
}
}
try {
logger.info(`Adding reaction ${emoji} to message ${messageId}`)
const {data} = await this.agent.chat.bsky.convo.addReaction(
{messageId, value: emoji, convoId: this.convoId},
{encoding: 'application/json', headers: DM_SERVICE_HEADERS},
)
if (ChatBskyConvoDefs.isMessageView(data.message)) {
if (this.pastMessages.has(messageId)) {
this.pastMessages.set(messageId, data.message)
this.commit()
} else if (this.newMessages.has(messageId)) {
this.newMessages.set(messageId, data.message)
this.commit()
}
}
} catch (error) {
if (restore) restore()
throw error
}
}
/*
* Remove a reaction from a message.
*
* @param messageId - The ID of the message to remove the reaction from.
* @param emoji - The emoji to remove.
*/
async removeReaction(messageId: string, emoji: string) {
let restore: null | (() => void) = null
if (this.pastMessages.has(messageId)) {
const prevMessage = this.pastMessages.get(messageId)
if (ChatBskyConvoDefs.isMessageView(prevMessage)) {
this.pastMessages.set(messageId, {
...prevMessage,
reactions: prevMessage.reactions?.filter(
reaction =>
reaction.value !== emoji ||
reaction.sender.did !== this.senderUserDid,
),
})
this.commit()
restore = () => {
this.pastMessages.set(messageId, prevMessage)
this.commit()
}
}
} else if (this.newMessages.has(messageId)) {
const prevMessage = this.newMessages.get(messageId)
if (ChatBskyConvoDefs.isMessageView(prevMessage)) {
this.newMessages.set(messageId, {
...prevMessage,
reactions: prevMessage.reactions?.filter(
reaction =>
reaction.value !== emoji ||
reaction.sender.did !== this.senderUserDid,
),
})
this.commit()
restore = () => {
this.newMessages.set(messageId, prevMessage)
this.commit()
}
}
}
try {
logger.info(`Removing reaction ${emoji} from message ${messageId}`)
await this.agent.chat.bsky.convo.removeReaction(
{messageId, value: emoji, convoId: this.convoId},
{encoding: 'application/json', headers: DM_SERVICE_HEADERS},
)
} catch (error) {
if (restore) restore()
throw error
}
}
}
+7 -7
View File
@@ -1,17 +1,17 @@
import React, {useContext, useState, useSyncExternalStore} from 'react'
import {ChatBskyConvoDefs} from '@atproto/api'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {useFocusEffect} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useAppState} from '#/lib/hooks/useAppState'
import {Convo} from '#/state/messages/convo/agent'
import {
ConvoParams,
ConvoState,
ConvoStateBackgrounded,
ConvoStateDisabled,
ConvoStateReady,
ConvoStateSuspended,
type ConvoParams,
type ConvoState,
type ConvoStateBackgrounded,
type ConvoStateDisabled,
type ConvoStateReady,
type ConvoStateSuspended,
} from '#/state/messages/convo/types'
import {isConvoActive} from '#/state/messages/convo/util'
import {useMessagesEventBus} from '#/state/messages/events'
+21 -5
View File
@@ -1,11 +1,11 @@
import {
BskyAgent,
ChatBskyActorDefs,
ChatBskyConvoDefs,
ChatBskyConvoSendMessage,
type BskyAgent,
type ChatBskyActorDefs,
type ChatBskyConvoDefs,
type ChatBskyConvoSendMessage,
} from '@atproto/api'
import {MessagesEventBus} from '#/state/messages/events/agent'
import {type MessagesEventBus} from '#/state/messages/events/agent'
export type ConvoParams = {
convoId: string
@@ -142,6 +142,8 @@ type SendMessage = (
) => void
type FetchMessageHistory = () => Promise<void>
type MarkConvoAccepted = () => void
type AddReaction = (messageId: string, reaction: string) => Promise<void>
type RemoveReaction = (messageId: string, reaction: string) => Promise<void>
export type ConvoStateUninitialized = {
status: ConvoStatus.Uninitialized
@@ -155,6 +157,8 @@ export type ConvoStateUninitialized = {
sendMessage: undefined
fetchMessageHistory: undefined
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
}
export type ConvoStateInitializing = {
status: ConvoStatus.Initializing
@@ -168,6 +172,8 @@ export type ConvoStateInitializing = {
sendMessage: undefined
fetchMessageHistory: undefined
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
}
export type ConvoStateReady = {
status: ConvoStatus.Ready
@@ -181,6 +187,8 @@ export type ConvoStateReady = {
sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
}
export type ConvoStateBackgrounded = {
status: ConvoStatus.Backgrounded
@@ -194,6 +202,8 @@ export type ConvoStateBackgrounded = {
sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
}
export type ConvoStateSuspended = {
status: ConvoStatus.Suspended
@@ -207,6 +217,8 @@ export type ConvoStateSuspended = {
sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
}
export type ConvoStateError = {
status: ConvoStatus.Error
@@ -220,6 +232,8 @@ export type ConvoStateError = {
sendMessage: undefined
fetchMessageHistory: undefined
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
}
export type ConvoStateDisabled = {
status: ConvoStatus.Disabled
@@ -233,6 +247,8 @@ export type ConvoStateDisabled = {
sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
}
export type ConvoState =
| ConvoStateUninitialized
@@ -1,19 +1,13 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
} from 'react'
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
import {
ChatBskyConvoDefs,
ChatBskyConvoListConvos,
type ChatBskyConvoListConvos,
moderateProfile,
ModerationOpts,
type ModerationOpts,
} from '@atproto/api'
import {
InfiniteData,
QueryClient,
type InfiniteData,
type QueryClient,
useInfiniteQuery,
useQueryClient,
} from '@tanstack/react-query'
@@ -316,6 +310,57 @@ export function ListConvosProviderInner({
rev: logRef.rev,
})),
)
} else if (ChatBskyConvoDefs.isLogAddReaction(log)) {
const logRef: ChatBskyConvoDefs.LogAddReaction = log
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
optimisticUpdate(logRef.convoId, old, convo => ({
...convo,
lastMessage: {
$type: 'chat.bsky.convo.defs#messageAndReactionView',
reaction: logRef.reaction,
message: logRef.message,
},
rev: logRef.rev,
})),
)
} else if (ChatBskyConvoDefs.isLogRemoveReaction(log)) {
if (ChatBskyConvoDefs.isMessageView(log.message)) {
for (const [_queryKey, queryData] of queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({
queryKey: [RQKEY_ROOT],
})) {
if (!queryData?.pages) {
continue
}
for (const page of queryData.pages) {
for (const convo of page.convos) {
if (
// if the convo is the same
log.convoId === convo.id &&
ChatBskyConvoDefs.isMessageAndReactionView(
convo.lastMessage,
) &&
ChatBskyConvoDefs.isMessageView(
convo.lastMessage.message,
) &&
// ...and the message is the same
convo.lastMessage.message.id === log.message.id &&
// ...and the reaction is the same
convo.lastMessage.reaction.sender.did ===
log.reaction.sender.did &&
convo.lastMessage.reaction.value === log.reaction.value
) {
// refetch, because we don't know what the last message is now
debouncedRefetch()
}
}
}
}
}
}
}
},