diff --git a/package.json b/package.json
index 191005c58e..b903b14100 100644
--- a/package.json
+++ b/package.json
@@ -58,7 +58,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
- "@atproto/api": "^0.14.7",
+ "@atproto/api": "^0.14.14",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 69ac0c0575..5a3e6d6750 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -1,4 +1,9 @@
-import {Platform, StyleProp, StyleSheet, ViewStyle} from 'react-native'
+import {
+ Platform,
+ type StyleProp,
+ StyleSheet,
+ type ViewStyle,
+} from 'react-native'
import * as tokens from '#/alf/tokens'
import {ios, native, platform, web} from '#/alf/util/platform'
@@ -126,6 +131,9 @@ export const atoms = {
rounded_md: {
borderRadius: tokens.borderRadius.md,
},
+ rounded_lg: {
+ borderRadius: tokens.borderRadius.lg,
+ },
rounded_full: {
borderRadius: tokens.borderRadius.full,
},
@@ -358,6 +366,9 @@ export const atoms = {
border_r: {
borderRightWidth: StyleSheet.hairlineWidth,
},
+ border_transparent: {
+ borderColor: 'transparent',
+ },
curve_circular: ios({
borderCurve: 'circular',
}),
diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts
index 1d99e5e572..576aafe956 100644
--- a/src/alf/tokens.ts
+++ b/src/alf/tokens.ts
@@ -44,6 +44,7 @@ export const borderRadius = {
xs: 4,
sm: 8,
md: 12,
+ lg: 16,
full: 999,
} as const
diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx
index 90c4487829..aebed64192 100644
--- a/src/components/ContextMenu/index.tsx
+++ b/src/components/ContextMenu/index.tsx
@@ -775,7 +775,10 @@ export function Item({
]}>
{typeof children === 'function'
- ? children(focused || pressed || context.hoveredMenuItem === id)
+ ? children(
+ (focused || pressed || context.hoveredMenuItem === id) &&
+ !rest.disabled,
+ )
: children}
diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx
index 82113eba87..aaffc0cfb7 100644
--- a/src/components/dms/ActionsWrapper.web.tsx
+++ b/src/components/dms/ActionsWrapper.web.tsx
@@ -1,12 +1,19 @@
-import React from 'react'
+import {useCallback, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
-import {ChatBskyConvoDefs} from '@atproto/api'
+import {type ChatBskyConvoDefs} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import type React from 'react'
+import {useConvoActive} from '#/state/messages/convo'
+import {useSession} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import {EmojiReactionPicker} from './EmojiReactionPicker'
+import {hasReachedReactionLimit} from './util'
export function ActionsWrapper({
message,
@@ -17,26 +24,53 @@ export function ActionsWrapper({
isFromSelf: boolean
children: React.ReactNode
}) {
- const viewRef = React.useRef(null)
+ const viewRef = useRef(null)
const t = useTheme()
+ const {_} = useLingui()
+ const convo = useConvoActive()
+ const {currentAccount} = useSession()
- const [showActions, setShowActions] = React.useState(false)
+ const [showActions, setShowActions] = useState(false)
- const onMouseEnter = React.useCallback(() => {
+ const onMouseEnter = useCallback(() => {
setShowActions(true)
}, [])
- const onMouseLeave = React.useCallback(() => {
+ const onMouseLeave = useCallback(() => {
setShowActions(false)
}, [])
// We need to handle the `onFocus` separately because we want to know if there is a related target (the element
// that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed.
- const onFocus = React.useCallback(e => {
+ const onFocus = useCallback(e => {
if (e.nativeEvent.relatedTarget == null) return
setShowActions(true)
}, [])
+ const onEmojiSelect = useCallback(
+ (emoji: string) => {
+ if (
+ message.reactions?.find(
+ reaction =>
+ reaction.value === emoji &&
+ reaction.sender.did === currentAccount?.did,
+ )
+ ) {
+ convo
+ .removeReaction(message.id, emoji)
+ .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
+ } else {
+ if (hasReachedReactionLimit(message, currentAccount?.did)) return
+ convo
+ .addReaction(message.id, emoji)
+ .catch(() =>
+ Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'),
+ )
+ }
+ },
+ [_, convo, message, currentAccount?.did],
+ )
+
return (
-
+
{({props, state, isNative, control}) => {
// always false, file is platform split
if (isNative) return null
diff --git a/src/components/dms/EmojiReactionPicker.tsx b/src/components/dms/EmojiReactionPicker.tsx
index a98cebf9a2..477f457434 100644
--- a/src/components/dms/EmojiReactionPicker.tsx
+++ b/src/components/dms/EmojiReactionPicker.tsx
@@ -1,5 +1,5 @@
import {useMemo, useState} from 'react'
-import {Alert, useWindowDimensions, View} from 'react-native'
+import {useWindowDimensions, View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -18,12 +18,15 @@ import {
import {type TriggerProps} from '#/components/Menu/types'
import {Text} from '#/components/Typography'
import {EmojiPopup} from './EmojiPopup'
+import {hasAlreadyReacted, hasReachedReactionLimit} from './util'
export function EmojiReactionPicker({
message,
+ onEmojiSelect,
}: {
message: ChatBskyConvoDefs.MessageView
children?: TriggerProps['children']
+ onEmojiSelect: (emoji: string) => void
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
@@ -39,10 +42,6 @@ export function EmojiReactionPicker({
return Math.random() < 0.01 ? EmojiHeartEyesIcon : EmojiSmileIcon
}, [])
- const handleEmojiSelect = (emoji: string) => {
- Alert.alert(emoji)
- }
-
const position = useMemo(() => {
return {
x: align === 'left' ? 12 : screenWidth - layout.width - 12,
@@ -52,6 +51,8 @@ export function EmojiReactionPicker({
}
}, [measurement, align, screenWidth, layout])
+ const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
+
return (
setLayout(evt.nativeEvent.layout)}
@@ -70,33 +71,49 @@ export function EmojiReactionPicker({
t.atoms.border_contrast_low,
a.shadow_md,
]}>
- {['👍', '😆', '❤️', '👀', '😢'].map(emoji => (
- handleEmojiSelect(emoji)}
- unstyled>
- {hovered => (
-
-
- {emoji}
-
-
- )}
-
- ))}
+ {['👍', '😆', '❤️', '👀', '😢'].map(emoji => {
+ const alreadyReacted = hasAlreadyReacted(
+ message,
+ currentAccount?.did,
+ emoji,
+ )
+ return (
+ onEmojiSelect(emoji)}
+ unstyled
+ disabled={limitReacted ? !alreadyReacted : false}>
+ {hovered => (
+
+
+ {emoji}
+
+
+ )}
+
+ )
+ })}
{
close()
- handleEmojiSelect(emoji)
+ onEmojiSelect(emoji)
}}>
void
}) {
if (!children)
throw new Error('EmojiReactionPicker requires the children prop on web')
@@ -29,15 +34,22 @@ export function EmojiReactionPicker({
{children}
-
+
)
}
-function MenuInner() {
+function MenuInner({
+ message,
+ onEmojiSelect,
+}: {
+ message: ChatBskyConvoDefs.MessageView
+ onEmojiSelect: (emoji: string) => void
+}) {
const t = useTheme()
const {control} = Menu.useMenuContext()
+ const {currentAccount} = useSession()
const [expanded, setExpanded] = useState(false)
@@ -47,29 +59,45 @@ function MenuInner() {
const handleEmojiSelect = (emoji: string) => {
control.close()
- window.alert(emoji)
+ onEmojiSelect(emoji)
}
+ const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
+
return expanded ? (
) : (
- {['👍', '😆', '❤️', '👀', '😢'].map(emoji => (
- handleEmojiSelect(emoji)}
- hoverStyle={{backgroundColor: t.palette.primary_100}}
- style={[
- a.rounded_xs,
- {height: 40, width: 40},
- a.justify_center,
- a.align_center,
- ]}>
-
- {emoji}
-
-
- ))}
+ {['👍', '😆', '❤️', '👀', '😢'].map(emoji => {
+ const alreadyReacted = hasAlreadyReacted(
+ message,
+ currentAccount?.did,
+ emoji,
+ )
+ return (
+ handleEmojiSelect(emoji)}
+ hoverStyle={{
+ backgroundColor: alreadyReacted
+ ? t.palette.negative_200
+ : !limitReacted
+ ? t.palette.primary_300
+ : undefined,
+ }}
+ style={[
+ a.rounded_xs,
+ {height: 40, width: 40},
+ a.justify_center,
+ a.align_center,
+ alreadyReacted && {backgroundColor: t.palette.primary_100},
+ ]}>
+
+ {emoji}
+
+
+ )
+ })}
setExpanded(true)}
hoverStyle={{backgroundColor: t.palette.primary_100}}
diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx
index 5591bec696..c978f1556b 100644
--- a/src/components/dms/MessageContextMenu.tsx
+++ b/src/components/dms/MessageContextMenu.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import {memo, useCallback} from 'react'
import {LayoutAnimation} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
@@ -23,6 +23,7 @@ import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/War
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import {EmojiReactionPicker} from './EmojiReactionPicker'
+import {hasReachedReactionLimit} from './util'
export let MessageContextMenu = ({
message,
@@ -41,7 +42,7 @@ export let MessageContextMenu = ({
const isFromSelf = message.sender?.did === currentAccount?.did
- const onCopyMessage = React.useCallback(() => {
+ const onCopyMessage = useCallback(() => {
const str = richTextToString(
new RichText({
text: message.text,
@@ -54,7 +55,7 @@ export let MessageContextMenu = ({
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}, [_, message.text, message.facets])
- const onPressTranslateMessage = React.useCallback(() => {
+ const onPressTranslateMessage = useCallback(() => {
const translatorUrl = getTranslatorLink(
message.text,
langPrefs.primaryLanguage,
@@ -62,7 +63,7 @@ export let MessageContextMenu = ({
openLink(translatorUrl, true)
}, [langPrefs.primaryLanguage, message.text, openLink])
- const onDelete = React.useCallback(() => {
+ const onDelete = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(message.id)
@@ -72,6 +73,30 @@ export let MessageContextMenu = ({
.catch(() => Toast.show(_(msg`Failed to delete message`)))
}, [_, convo, message.id])
+ const onEmojiSelect = useCallback(
+ (emoji: string) => {
+ if (
+ message.reactions?.find(
+ reaction =>
+ reaction.value === emoji &&
+ reaction.sender.did === currentAccount?.did,
+ )
+ ) {
+ convo
+ .removeReaction(message.id, emoji)
+ .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
+ } else {
+ if (hasReachedReactionLimit(message, currentAccount?.did)) return
+ convo
+ .addReaction(message.id, emoji)
+ .catch(() =>
+ Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'),
+ )
+ }
+ },
+ [_, convo, message, currentAccount?.did],
+ )
+
const sender = convo.convo.members.find(
member => member.did === message.sender.did,
)
@@ -81,7 +106,10 @@ export let MessageContextMenu = ({
{isNative && (
-
+
)}
@@ -156,4 +184,4 @@ export let MessageContextMenu = ({
>
)
}
-MessageContextMenu = React.memo(MessageContextMenu)
+MessageContextMenu = memo(MessageContextMenu)
diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx
index 675b6a5ee2..60b0b5ed65 100644
--- a/src/components/dms/MessageItem.tsx
+++ b/src/components/dms/MessageItem.tsx
@@ -1,23 +1,36 @@
import React, {useCallback, useMemo} from 'react'
-import {GestureResponderEvent, StyleProp, TextStyle, View} from 'react-native'
+import {
+ type GestureResponderEvent,
+ type StyleProp,
+ type TextStyle,
+ View,
+} from 'react-native'
+import Animated, {
+ LayoutAnimationConfig,
+ LinearTransition,
+ ZoomIn,
+ ZoomOut,
+} from 'react-native-reanimated'
import {
AppBskyEmbedRecord,
ChatBskyConvoDefs,
RichText as RichTextAPI,
} from '@atproto/api'
-import {I18n} from '@lingui/core'
+import {type I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {ConvoItem} from '#/state/messages/convo/types'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {useConvoActive} from '#/state/messages/convo'
+import {type ConvoItem} from '#/state/messages/convo/types'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, native, useTheme} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link'
+import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
-import {RichText} from '../RichText'
import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed'
import {localDateString} from './util'
@@ -29,6 +42,8 @@ let MessageItem = ({
}): React.ReactNode => {
const t = useTheme()
const {currentAccount} = useSession()
+ const {_} = useLingui()
+ const {convo} = useConvoActive()
const {message, nextMessage, prevMessage} = item
const isPending = item.type === 'pending-message'
@@ -134,6 +149,74 @@ let MessageItem = ({
)}
+
+ {message.reactions && message.reactions.length > 0 && (
+
+
+ {message.reactions.map((reaction, _i, reactions) => {
+ let label
+ if (reaction.sender.did === currentAccount?.did) {
+ label = _(msg`You reacted ${reaction.value}`)
+ } else {
+ const senderDid = reaction.sender.did
+ const sender = convo.members.find(
+ member => member.did === senderDid,
+ )
+ if (sender) {
+ label = _(
+ msg`${sanitizeDisplayName(
+ sender.displayName || sender.handle,
+ )} reacted ${reaction.value}`,
+ )
+ } else {
+ label = _(msg`Someone reacted ${reaction.value}`)
+ }
+ }
+ return (
+ 1 && native(ZoomOut.delay(200))
+ }
+ layout={native(LinearTransition.delay(300))}
+ key={reaction.sender.did + reaction.value}
+ style={[a.p_2xs]}
+ accessible={true}
+ accessibilityLabel={label}
+ accessibilityHint={_(
+ msg`Double tap or long press the message to add a reaction`,
+ )}>
+
+ {reaction.value}
+
+
+ )
+ })}
+
+
+ )}
+
+
{isLastInGroup && (
reaction.value === emoji && reaction.sender.did === myDid,
+ )
+}
+
+export function hasReachedReactionLimit(
+ message: ChatBskyConvoDefs.MessageView,
+ myDid: string | undefined,
+): boolean {
+ if (!message.reactions) {
+ return false
+ }
+ const myReactions = message.reactions.filter(
+ reaction => reaction.sender.did === myDid,
+ )
+ return myReactions.length >= EMOJI_REACTION_LIMIT
+}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index a105de050f..fe84f41b2f 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -1,5 +1,5 @@
-import {Insets, Platform} from 'react-native'
-import {AppBskyActorDefs} from '@atproto/api'
+import {type Insets, Platform} from 'react-native'
+import {type AppBskyActorDefs} from '@atproto/api'
export const LOCAL_DEV_SERVICE =
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
@@ -190,3 +190,5 @@ export const SUPPORTED_MIME_TYPES = [
] as const
export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
+
+export const EMOJI_REACTION_LIMIT = 5
diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx
index 96e010b8f5..d8e4b975c5 100644
--- a/src/screens/Messages/components/ChatListItem.tsx
+++ b/src/screens/Messages/components/ChatListItem.tsx
@@ -1,10 +1,10 @@
import React, {useCallback, useMemo, useState} from 'react'
-import {GestureResponderEvent, View} from 'react-native'
+import {type GestureResponderEvent, View} from 'react-native'
import {
AppBskyEmbedRecord,
ChatBskyConvoDefs,
moderateProfile,
- ModerationOpts,
+ type ModerationOpts,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -43,7 +43,7 @@ import {Link} from '#/components/Link'
import {useMenuControl} from '#/components/Menu'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {Text} from '#/components/Typography'
-import * as bsky from '#/types/bsky'
+import type * as bsky from '#/types/bsky'
export let ChatListItem = ({
convo,
@@ -189,13 +189,62 @@ function ChatListItemReady({
? _(msg`Conversation deleted`)
: _(msg`Message deleted`)
}
+ if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastMessage)) {
+ const isFromMe =
+ convo.lastMessage.reaction.sender.did === currentAccount?.did
+ const lastMessageText = convo.lastMessage.message.text
+ const fallbackMessage = _(
+ msg({
+ message: 'a message',
+ comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`,
+ }),
+ )
+
+ if (isFromMe) {
+ lastMessage = _(
+ msg`You reacted ${convo.lastMessage.reaction.value} to ${
+ lastMessageText
+ ? `"${convo.lastMessage.message.text}"`
+ : fallbackMessage
+ }`,
+ )
+ } else {
+ const senderDid = convo.lastMessage.reaction.sender.did
+ const sender = convo.members.find(member => member.did === senderDid)
+ if (sender) {
+ lastMessage = _(
+ msg`${sanitizeDisplayName(
+ sender.displayName || sender.handle,
+ )} reacted ${convo.lastMessage.reaction.value} to ${
+ lastMessageText
+ ? `"${convo.lastMessage.message.text}"`
+ : fallbackMessage
+ }`,
+ )
+ } else {
+ lastMessage = _(
+ msg`Someone reacted ${convo.lastMessage.reaction.value} to ${
+ lastMessageText
+ ? `"${convo.lastMessage.message.text}"`
+ : fallbackMessage
+ }`,
+ )
+ }
+ }
+ }
return {
lastMessage,
lastMessageSentAt,
latestReportableMessage,
}
- }, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount])
+ }, [
+ _,
+ convo.lastMessage,
+ currentAccount?.did,
+ isDeletedAccount,
+ convo.members,
+ ])
const [showActions, setShowActions] = useState(false)
diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts
index f6a8d6dc43..9092139752 100644
--- a/src/state/messages/convo/agent.ts
+++ b/src/state/messages/convo/agent.ts
@@ -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
+ }
+ }
}
diff --git a/src/state/messages/convo/index.tsx b/src/state/messages/convo/index.tsx
index f004566e8c..a53f089000 100644
--- a/src/state/messages/convo/index.tsx
+++ b/src/state/messages/convo/index.tsx
@@ -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'
diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts
index 83499de2e0..7053877935 100644
--- a/src/state/messages/convo/types.ts
+++ b/src/state/messages/convo/types.ts
@@ -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
type MarkConvoAccepted = () => void
+type AddReaction = (messageId: string, reaction: string) => Promise
+type RemoveReaction = (messageId: string, reaction: string) => Promise
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
diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx
index f5fce63478..066c25e21d 100644
--- a/src/state/queries/messages/list-conversations.tsx
+++ b/src/state/queries/messages/list-conversations.tsx
@@ -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
+ >({
+ 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()
+ }
+ }
+ }
+ }
+ }
}
}
},
diff --git a/yarn.lock b/yarn.lock
index f27a845a18..1e0a576071 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -80,15 +80,15 @@
tlds "^1.234.0"
zod "^3.23.8"
-"@atproto/api@^0.14.7":
- version "0.14.7"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.7.tgz#3ffa02d6b3baf9e265dab170367ffade08023567"
- integrity sha512-YG2kvAtsgtajLlLrorYuHcxGgepG0c/RUB2/iJyBnwKjGqDLG8joOETf38JSNiGzs6NJbNKa9NHG6BQKourxBA==
+"@atproto/api@^0.14.14":
+ version "0.14.14"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.14.tgz#5d2d2e6156eab6ca0d463c114b4a3865275e9aac"
+ integrity sha512-ryawcnmazVSWYfq11ujPHauY77GfkM3mF0rZOkqENN2Ptnl6BZXJvpA0zLA/sQ5YBLcHXSEWg5Xdq+8i1l+8gA==
dependencies:
"@atproto/common-web" "^0.4.0"
- "@atproto/lexicon" "^0.4.7"
- "@atproto/syntax" "^0.3.3"
- "@atproto/xrpc" "^0.6.9"
+ "@atproto/lexicon" "^0.4.9"
+ "@atproto/syntax" "^0.4.0"
+ "@atproto/xrpc" "^0.6.11"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
@@ -302,6 +302,17 @@
multiformats "^9.9.0"
zod "^3.23.8"
+"@atproto/lexicon@^0.4.9":
+ version "0.4.9"
+ resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.9.tgz#612951a85ecc1398366bd837cda6be89440f179d"
+ integrity sha512-/tmEuHQFr51V2V7EAVJzaA40sqJ7ylAZpR962VbOsPtmcdOHvezbjVHYEMXgfb927hS+xqbVyzBTbu5w9v8prA==
+ dependencies:
+ "@atproto/common-web" "^0.4.0"
+ "@atproto/syntax" "^0.4.0"
+ iso-datestring-validator "^2.2.2"
+ multiformats "^9.9.0"
+ zod "^3.23.8"
+
"@atproto/oauth-provider@^0.2.17":
version "0.2.17"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.17.tgz#4644d391eedbbbbe5825ecc0e8cc03f1c6433b95"
@@ -446,6 +457,11 @@
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.3.tgz#6debe8983985378104822172a128e429931bf3f7"
integrity sha512-F1LZweesNYdBbZBXVa72N/cSvchG8Q1tG4/209ZXbIuM3FwQtkgn+zgmmV4P4ORmhOeXPBNXvMBpcqiwx/gEQQ==
+"@atproto/syntax@^0.4.0":
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.0.tgz#bec71552087bb24c208a06ef418c0040b65542f2"
+ integrity sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA==
+
"@atproto/xrpc-server@^0.7.11":
version "0.7.11"
resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.11.tgz#efadcfdaaaa0ff5576d1ee97e46dcbc6dafcb0b6"
@@ -464,6 +480,14 @@
ws "^8.12.0"
zod "^3.23.8"
+"@atproto/xrpc@^0.6.11":
+ version "0.6.11"
+ resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.11.tgz#54c527e39a2f5ddd2655b11f7cb99b8f303d8364"
+ integrity sha512-J2cZP8FjoDN0UkyTYBlCvKvxwBbDm4dld47u6FQK30RJy9YpSiUkdxJJ10NYqpi7JVny3M0qWQgpWJDV94+PdA==
+ dependencies:
+ "@atproto/lexicon" "^0.4.9"
+ zod "^3.23.8"
+
"@atproto/xrpc@^0.6.9":
version "0.6.9"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.9.tgz#6e1effc42cdab40741a73ead5c276183284887d2"