prevent reactions when unavailable
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user