prevent reactions when unavailable

This commit is contained in:
Samuel Newman
2026-05-26 18:36:00 +03:00
parent 4e2949c93a
commit ade5657eb8
5 changed files with 125 additions and 33 deletions
+7 -2
View File
@@ -1,5 +1,5 @@
import {View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {type ChatBskyConvoDefs, type ModerationOpts} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf'
@@ -10,18 +10,23 @@ export function ActionsWrapper({
message,
isFromSelf,
senderProfile,
moderationOpts,
children,
}: {
message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean
isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts | undefined
children: React.ReactNode
}) {
const {t: l} = useLingui()
return (
<MessageContextMenu message={message} senderProfile={senderProfile}>
<MessageContextMenu
message={message}
senderProfile={senderProfile}
moderationOpts={moderationOpts}>
{trigger =>
// will always be true, since this file is platform split
trigger.IS_NATIVE && (
+39 -25
View File
@@ -1,8 +1,9 @@
import {useCallback, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {type ChatBskyConvoDefs, type ModerationOpts} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useConvoActive} from '#/state/messages/convo'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
@@ -12,19 +13,21 @@ import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components
import * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky'
import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util'
import {canReact, hasReachedReactionLimit} from './util'
export function ActionsWrapper({
message,
hasReactions,
isFromSelf,
senderProfile,
moderationOpts,
children,
}: {
message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean
isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts | undefined
children: React.ReactNode
}) {
const viewRef = useRef(null)
@@ -32,6 +35,12 @@ export function ActionsWrapper({
const {t: l} = useLingui()
const convo = useConvoActive()
const {currentAccount} = useSession()
const primaryMember = useMaybeProfileShadow(convo.convo.primaryMember)
const reactionsAvailable = canReact({
convoState: convo,
primaryMember,
moderationOpts,
})
const [showActions, setShowActions] = useState(false)
@@ -93,29 +102,34 @@ export function ActionsWrapper({
: [a.ml_xs, {marginRight: 'auto'}],
hasReactions ? [a.mb_2xl] : undefined,
]}>
<EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}>
{({props, state, IS_NATIVE, control}) => {
// always false, file is platform split
if (IS_NATIVE) return null
const showMenuTrigger = showActions || control.isOpen ? 1 : 0
return (
<Pressable
{...props}
style={[
{opacity: showMenuTrigger},
a.p_xs,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
]}>
<EmojiSmileIcon
size="md"
style={t.atoms.text_contrast_medium}
/>
</Pressable>
)
}}
</EmojiReactionPicker>
<MessageContextMenu message={message} senderProfile={senderProfile}>
{reactionsAvailable && (
<EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}>
{({props, state, IS_NATIVE, control}) => {
// always false, file is platform split
if (IS_NATIVE) return null
const showMenuTrigger = showActions || control.isOpen ? 1 : 0
return (
<Pressable
{...props}
style={[
{opacity: showMenuTrigger},
a.p_xs,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
]}>
<EmojiSmileIcon
size="md"
style={t.atoms.text_contrast_medium}
/>
</Pressable>
)
}}
</EmojiReactionPicker>
)}
<MessageContextMenu
message={message}
senderProfile={senderProfile}
moderationOpts={moderationOpts}>
{({props, state, IS_NATIVE, control}) => {
// always false, file is platform split
if (IS_NATIVE) return null
+17 -3
View File
@@ -1,12 +1,17 @@
import {memo, useCallback} from 'react'
import {LayoutAnimation, Platform} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
import {
type ChatBskyConvoDefs,
type ModerationOpts,
RichText,
} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
@@ -27,15 +32,17 @@ import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import type * as bsky from '#/types/bsky'
import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util'
import {canReact, hasReachedReactionLimit} from './util'
export let MessageContextMenu = ({
message,
senderProfile,
moderationOpts,
children,
}: {
message: ChatBskyConvoDefs.MessageView
senderProfile?: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts | undefined
children: TriggerProps['children']
}): React.ReactNode => {
const {t: l, i18n} = useLingui()
@@ -52,6 +59,13 @@ export let MessageContextMenu = ({
const isFromSelf = message.sender?.did === currentAccount?.did
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
const primaryMember = useMaybeProfileShadow(convo.convo.primaryMember)
const reactionsAvailable = canReact({
convoState: convo,
primaryMember,
moderationOpts,
})
const onCopyMessage = useCallback(() => {
const str = richTextToString(
new RichText({
@@ -116,7 +130,7 @@ export let MessageContextMenu = ({
return (
<>
<ContextMenu.Root>
{IS_NATIVE && (
{IS_NATIVE && reactionsAvailable && (
<ContextMenu.AuxiliaryView
align={isFromSelf ? 'right' : 'left'}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
+2 -1
View File
@@ -437,7 +437,8 @@ let MessageItem = ({
hasReactions={hasReactions}
isFromSelf={isFromSelf}
message={message}
senderProfile={profile}>
senderProfile={profile}
moderationOpts={moderationOpts}>
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed
embed={message.embed}
+60 -2
View File
@@ -1,7 +1,15 @@
import {type $Typed, ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
import {
type $Typed,
ChatBskyActorDefs,
ChatBskyConvoDefs,
moderateProfile,
type ModerationOpts,
} from '@atproto/api'
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/profile-shadow'
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
import * as bsky from '#/types/bsky'
export const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
@@ -31,8 +39,9 @@ export function canBeAddedToGroup(profile: bsky.profile.AnyProfileView) {
case 'all':
return true
case 'following':
case undefined:
return Boolean(profile.viewer?.followedBy)
case undefined:
return canBeMessaged(profile)
default:
return false
}
@@ -73,6 +82,55 @@ export function hasReachedReactionLimit(
return myReactions.length >= EMOJI_REACTION_LIMIT
}
/**
* Whether the active conversation accepts emoji reactions. Reactions are
* unavailable when:
* - the convo is in the disabled state
* - a group convo is locked or permanently locked
* - 1-1: the other user is blocked or is blocking us
* - group: we are blocking the primary member (the owner)
*/
export function canReact({
convoState,
primaryMember,
moderationOpts,
}: {
convoState: ConvoState
primaryMember: Shadow<bsky.profile.AnyProfileView> | undefined
moderationOpts: ModerationOpts | undefined
}): boolean {
if (convoState.status === ConvoStatus.Disabled) {
return false
}
if (!convoState.convo) {
return true
}
if (convoState.convo.kind === 'group') {
const {lockStatus} = convoState.convo.details
if (lockStatus === 'locked' || lockStatus === 'locked-permanently') {
return false
}
}
if (primaryMember && moderationOpts) {
const moderation = moderateProfile(primaryMember, moderationOpts)
if (convoState.convo.kind === 'direct') {
// Either direction (blocking or blocked-by) hides reactions in 1-1s
if (moderation.blocked) return false
} else {
// In groups, only "we are blocking" the owner hides reactions
const isBlockingPrimary = moderation
.ui('profileView')
.alerts.some(alert => alert.type === 'blocking')
if (isBlockingPrimary) return false
}
}
return true
}
export type GroupConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
// can be missing if account deleted
kind?: $Typed<ChatBskyActorDefs.GroupConvoMember>