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 {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 {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
@@ -10,18 +10,23 @@ export function ActionsWrapper({
message, message,
isFromSelf, isFromSelf,
senderProfile, senderProfile,
moderationOpts,
children, children,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean hasReactions?: boolean
isFromSelf: boolean isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView senderProfile?: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts | undefined
children: React.ReactNode children: React.ReactNode
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
return ( return (
<MessageContextMenu message={message} senderProfile={senderProfile}> <MessageContextMenu
message={message}
senderProfile={senderProfile}
moderationOpts={moderationOpts}>
{trigger => {trigger =>
// will always be true, since this file is platform split // will always be true, since this file is platform split
trigger.IS_NATIVE && ( trigger.IS_NATIVE && (
+39 -25
View File
@@ -1,8 +1,9 @@
import {useCallback, useRef, useState} from 'react' import {useCallback, useRef, useState} from 'react'
import {Pressable, View} from 'react-native' 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 {useLingui} from '@lingui/react/macro'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useConvoActive} from '#/state/messages/convo' import {useConvoActive} from '#/state/messages/convo'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf' 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 * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {EmojiReactionPicker} from './EmojiReactionPicker' import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util' import {canReact, hasReachedReactionLimit} from './util'
export function ActionsWrapper({ export function ActionsWrapper({
message, message,
hasReactions, hasReactions,
isFromSelf, isFromSelf,
senderProfile, senderProfile,
moderationOpts,
children, children,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean hasReactions?: boolean
isFromSelf: boolean isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView senderProfile?: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts | undefined
children: React.ReactNode children: React.ReactNode
}) { }) {
const viewRef = useRef(null) const viewRef = useRef(null)
@@ -32,6 +35,12 @@ export function ActionsWrapper({
const {t: l} = useLingui() const {t: l} = useLingui()
const convo = useConvoActive() const convo = useConvoActive()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const primaryMember = useMaybeProfileShadow(convo.convo.primaryMember)
const reactionsAvailable = canReact({
convoState: convo,
primaryMember,
moderationOpts,
})
const [showActions, setShowActions] = useState(false) const [showActions, setShowActions] = useState(false)
@@ -93,29 +102,34 @@ export function ActionsWrapper({
: [a.ml_xs, {marginRight: 'auto'}], : [a.ml_xs, {marginRight: 'auto'}],
hasReactions ? [a.mb_2xl] : undefined, hasReactions ? [a.mb_2xl] : undefined,
]}> ]}>
<EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}> {reactionsAvailable && (
{({props, state, IS_NATIVE, control}) => { <EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}>
// always false, file is platform split {({props, state, IS_NATIVE, control}) => {
if (IS_NATIVE) return null // always false, file is platform split
const showMenuTrigger = showActions || control.isOpen ? 1 : 0 if (IS_NATIVE) return null
return ( const showMenuTrigger = showActions || control.isOpen ? 1 : 0
<Pressable return (
{...props} <Pressable
style={[ {...props}
{opacity: showMenuTrigger}, style={[
a.p_xs, {opacity: showMenuTrigger},
a.rounded_full, a.p_xs,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25, a.rounded_full,
]}> (state.hovered || state.pressed) && t.atoms.bg_contrast_25,
<EmojiSmileIcon ]}>
size="md" <EmojiSmileIcon
style={t.atoms.text_contrast_medium} size="md"
/> style={t.atoms.text_contrast_medium}
</Pressable> />
) </Pressable>
}} )
</EmojiReactionPicker> }}
<MessageContextMenu message={message} senderProfile={senderProfile}> </EmojiReactionPicker>
)}
<MessageContextMenu
message={message}
senderProfile={senderProfile}
moderationOpts={moderationOpts}>
{({props, state, IS_NATIVE, control}) => { {({props, state, IS_NATIVE, control}) => {
// always false, file is platform split // always false, file is platform split
if (IS_NATIVE) return null if (IS_NATIVE) return null
+17 -3
View File
@@ -1,12 +1,17 @@
import {memo, useCallback} from 'react' import {memo, useCallback} from 'react'
import {LayoutAnimation, Platform} from 'react-native' import {LayoutAnimation, Platform} from 'react-native'
import * as Clipboard from 'expo-clipboard' 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 {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useConvoActive} from '#/state/messages/convo' import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
@@ -27,15 +32,17 @@ import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {EmojiReactionPicker} from './EmojiReactionPicker' import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util' import {canReact, hasReachedReactionLimit} from './util'
export let MessageContextMenu = ({ export let MessageContextMenu = ({
message, message,
senderProfile, senderProfile,
moderationOpts,
children, children,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
senderProfile?: bsky.profile.AnyProfileView senderProfile?: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts | undefined
children: TriggerProps['children'] children: TriggerProps['children']
}): React.ReactNode => { }): React.ReactNode => {
const {t: l, i18n} = useLingui() const {t: l, i18n} = useLingui()
@@ -52,6 +59,13 @@ export let MessageContextMenu = ({
const isFromSelf = message.sender?.did === currentAccount?.did const isFromSelf = message.sender?.did === currentAccount?.did
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable) 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 onCopyMessage = useCallback(() => {
const str = richTextToString( const str = richTextToString(
new RichText({ new RichText({
@@ -116,7 +130,7 @@ export let MessageContextMenu = ({
return ( return (
<> <>
<ContextMenu.Root> <ContextMenu.Root>
{IS_NATIVE && ( {IS_NATIVE && reactionsAvailable && (
<ContextMenu.AuxiliaryView <ContextMenu.AuxiliaryView
align={isFromSelf ? 'right' : 'left'} align={isFromSelf ? 'right' : 'left'}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}> style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
+2 -1
View File
@@ -437,7 +437,8 @@ let MessageItem = ({
hasReactions={hasReactions} hasReactions={hasReactions}
isFromSelf={isFromSelf} isFromSelf={isFromSelf}
message={message} message={message}
senderProfile={profile}> senderProfile={profile}
moderationOpts={moderationOpts}>
{AppBskyEmbedRecord.isView(message.embed) && ( {AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed <MessageItemEmbed
embed={message.embed} 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 {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import {logger} from '#/logger' 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' import * as bsky from '#/types/bsky'
export const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 export const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
@@ -31,8 +39,9 @@ export function canBeAddedToGroup(profile: bsky.profile.AnyProfileView) {
case 'all': case 'all':
return true return true
case 'following': case 'following':
case undefined:
return Boolean(profile.viewer?.followedBy) return Boolean(profile.viewer?.followedBy)
case undefined:
return canBeMessaged(profile)
default: default:
return false return false
} }
@@ -73,6 +82,55 @@ export function hasReachedReactionLimit(
return myReactions.length >= EMOJI_REACTION_LIMIT 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 & { export type GroupConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
// can be missing if account deleted // can be missing if account deleted
kind?: $Typed<ChatBskyActorDefs.GroupConvoMember> kind?: $Typed<ChatBskyActorDefs.GroupConvoMember>