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