diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index 89c4a06520..366b117976 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -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 ( - + {trigger => // will always be true, since this file is platform split trigger.IS_NATIVE && ( diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx index 0d3df37637..b89712bd1f 100644 --- a/src/components/dms/ActionsWrapper.web.tsx +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -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, ]}> - - {({props, state, IS_NATIVE, control}) => { - // always false, file is platform split - if (IS_NATIVE) return null - const showMenuTrigger = showActions || control.isOpen ? 1 : 0 - return ( - - - - ) - }} - - + {reactionsAvailable && ( + + {({props, state, IS_NATIVE, control}) => { + // always false, file is platform split + if (IS_NATIVE) return null + const showMenuTrigger = showActions || control.isOpen ? 1 : 0 + return ( + + + + ) + }} + + )} + {({props, state, IS_NATIVE, control}) => { // always false, file is platform split if (IS_NATIVE) return null diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index 8e6c470d59..b055d87cc9 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -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 ( <> - {IS_NATIVE && ( + {IS_NATIVE && reactionsAvailable && ( diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 49ae4ec7fa..184a16afaa 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -437,7 +437,8 @@ let MessageItem = ({ hasReactions={hasReactions} isFromSelf={isFromSelf} message={message} - senderProfile={profile}> + senderProfile={profile} + moderationOpts={moderationOpts}> {AppBskyEmbedRecord.isView(message.embed) && ( = 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 | 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