[Chat] Hide blocked accounts' reactions in chats (#10889)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,7 +53,11 @@ import {DateDivider} from './DateDivider'
|
|||||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||||
import {MessageItemInviteEmbed} from './MessageItemInviteEmbed'
|
import {MessageItemInviteEmbed} from './MessageItemInviteEmbed'
|
||||||
import {groupReactions} from './ReactionsDialog'
|
import {groupReactions} from './ReactionsDialog'
|
||||||
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
|
import {
|
||||||
|
CLUSTERED_MESSAGE_THRESHOLD_MS,
|
||||||
|
filterBlockedReactions,
|
||||||
|
MESSAGE_GAP_THRESHOLD_MS,
|
||||||
|
} from './util'
|
||||||
|
|
||||||
const AVATAR_SIZE = 28
|
const AVATAR_SIZE = 28
|
||||||
const CLUSTERED_MESSAGE_GAP = 2
|
const CLUSTERED_MESSAGE_GAP = 2
|
||||||
@@ -168,9 +172,15 @@ let MessageItem = ({
|
|||||||
const isInMiddleOfCluster =
|
const isInMiddleOfCluster =
|
||||||
isInCluster && !isFirstInCluster && !isLastInCluster
|
isInCluster && !isFirstInCluster && !isLastInCluster
|
||||||
|
|
||||||
const hasReactions = message.reactions && message.reactions.length > 0
|
const visibleReactions = useMemo(
|
||||||
|
() => filterBlockedReactions(message.reactions, relatedProfiles),
|
||||||
|
[message.reactions, relatedProfiles],
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasReactions = visibleReactions.length > 0
|
||||||
const prevHasReactions =
|
const prevHasReactions =
|
||||||
prevIsMessage && prevMessage.reactions && prevMessage.reactions.length > 0
|
prevIsMessage &&
|
||||||
|
filterBlockedReactions(prevMessage.reactions, relatedProfiles).length > 0
|
||||||
const isNextEmojiOnly = nextIsMessage && isOnlyEmoji(nextMessage.text)
|
const isNextEmojiOnly = nextIsMessage && isOnlyEmoji(nextMessage.text)
|
||||||
const isPrevEmojiOnly = prevIsMessage && isOnlyEmoji(prevMessage.text)
|
const isPrevEmojiOnly = prevIsMessage && isOnlyEmoji(prevMessage.text)
|
||||||
const squaredBottomCorner =
|
const squaredBottomCorner =
|
||||||
@@ -240,11 +250,11 @@ let MessageItem = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const groupedReactions = useMemo(
|
const groupedReactions = useMemo(
|
||||||
() => groupReactions(message.reactions),
|
() => groupReactions(visibleReactions),
|
||||||
[message.reactions],
|
[visibleReactions],
|
||||||
)
|
)
|
||||||
|
|
||||||
const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
|
const reactions = visibleReactions
|
||||||
|
|
||||||
const hasSelfReacted = reactions.some(
|
const hasSelfReacted = reactions.some(
|
||||||
r => r.sender.did === currentAccount?.did,
|
r => r.sender.did === currentAccount?.did,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
|||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {filterBlockedReactions} from '#/components/dms/util'
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||||
@@ -52,10 +53,13 @@ export function ReactionsDialog({
|
|||||||
|
|
||||||
const [selected, setSelected] = useState('all')
|
const [selected, setSelected] = useState('all')
|
||||||
|
|
||||||
const reactions = message.reactions
|
const reactions = useMemo(
|
||||||
|
() => filterBlockedReactions(message.reactions, relatedProfiles),
|
||||||
|
[message.reactions, relatedProfiles],
|
||||||
|
)
|
||||||
const groupedReactions = useMemo(() => groupReactions(reactions), [reactions])
|
const groupedReactions = useMemo(() => groupReactions(reactions), [reactions])
|
||||||
|
|
||||||
const filteredReactions = reactions?.filter(
|
const filteredReactions = reactions.filter(
|
||||||
r => selected === 'all' || r.value === selected,
|
r => selected === 'all' || r.value === selected,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,7 +73,7 @@ export function ReactionsDialog({
|
|||||||
<ReactionTabs
|
<ReactionTabs
|
||||||
groupedReactions={groupedReactions}
|
groupedReactions={groupedReactions}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
totalReactions={reactions?.length ?? 0}
|
totalReactions={reactions.length}
|
||||||
onFilter={setSelected}
|
onFilter={setSelected}
|
||||||
/>
|
/>
|
||||||
<Dialog.Close />
|
<Dialog.Close />
|
||||||
@@ -96,7 +100,7 @@ export function ReactionsDialog({
|
|||||||
header={IS_WEB ? header : null}
|
header={IS_WEB ? header : null}
|
||||||
style={[web({maxWidth: 400})]}>
|
style={[web({maxWidth: 400})]}>
|
||||||
{filteredReactions
|
{filteredReactions
|
||||||
?.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.sender.did === currentAccount?.did) return -1
|
if (a.sender.did === currentAccount?.did) return -1
|
||||||
if (b.sender.did === currentAccount?.did) return 1
|
if (b.sender.did === currentAccount?.did) return 1
|
||||||
return 0
|
return 0
|
||||||
@@ -113,7 +117,7 @@ export function ReactionsDialog({
|
|||||||
message={message}
|
message={message}
|
||||||
profile={sender}
|
profile={sender}
|
||||||
reaction={reaction}
|
reaction={reaction}
|
||||||
allReactions={reactions ?? []}
|
allReactions={reactions}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
setSelected={setSelected}
|
setSelected={setSelected}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
|
type ChatBskyActorDefs,
|
||||||
ChatBskyConvoDefs,
|
ChatBskyConvoDefs,
|
||||||
ChatBskyEmbedJoinLink,
|
ChatBskyEmbedJoinLink,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
toBskyAppUrl,
|
toBskyAppUrl,
|
||||||
toShortUrl,
|
toShortUrl,
|
||||||
} from '#/lib/strings/url-helpers'
|
} from '#/lib/strings/url-helpers'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
export type UserMessageInfo = {
|
export type UserMessageInfo = {
|
||||||
message: string | null
|
message: string | null
|
||||||
@@ -21,13 +23,39 @@ export type UserMessageInfo = {
|
|||||||
isBlockedMessage: boolean
|
isBlockedMessage: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves whether the given did is blocked (in either direction) within a
|
||||||
|
* convo. Prefers the passed-in shadowed `primaryProfile` so optimistic blocks
|
||||||
|
* reflect immediately, before the convo list refetches - the raw `members`
|
||||||
|
* fetched with the convo are invisible to the profile shadow cache. Group
|
||||||
|
* members other than the owner fall back to the raw (potentially stale) member.
|
||||||
|
*/
|
||||||
|
export function isDidBlockedInConvo({
|
||||||
|
did,
|
||||||
|
members,
|
||||||
|
primaryProfile,
|
||||||
|
}: {
|
||||||
|
did: string | undefined
|
||||||
|
members: ChatBskyActorDefs.ProfileViewBasic[]
|
||||||
|
primaryProfile?: bsky.profile.AnyProfileView
|
||||||
|
}): boolean {
|
||||||
|
if (!did) return false
|
||||||
|
if (primaryProfile && primaryProfile.did === did) {
|
||||||
|
return isBlockedOrBlocking(primaryProfile)
|
||||||
|
}
|
||||||
|
const member = members.find(m => m.did === did)
|
||||||
|
return member ? isBlockedOrBlocking(member) : false
|
||||||
|
}
|
||||||
|
|
||||||
export function getMessageInfo({
|
export function getMessageInfo({
|
||||||
convo,
|
convo,
|
||||||
currentAccountDid,
|
currentAccountDid,
|
||||||
|
primaryProfile,
|
||||||
i18n,
|
i18n,
|
||||||
}: {
|
}: {
|
||||||
convo: ChatBskyConvoDefs.ConvoView
|
convo: ChatBskyConvoDefs.ConvoView
|
||||||
currentAccountDid: string | undefined
|
currentAccountDid: string | undefined
|
||||||
|
primaryProfile?: bsky.profile.AnyProfileView
|
||||||
i18n: I18n
|
i18n: I18n
|
||||||
}): UserMessageInfo | null {
|
}): UserMessageInfo | null {
|
||||||
if (!ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
|
if (!ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
|
||||||
@@ -42,7 +70,11 @@ export function getMessageInfo({
|
|||||||
const isGroup = ChatBskyConvoDefs.isGroupConvo(convo.kind)
|
const isGroup = ChatBskyConvoDefs.isGroupConvo(convo.kind)
|
||||||
|
|
||||||
const reportableMessage = isFromMe ? undefined : lastMessage
|
const reportableMessage = isFromMe ? undefined : lastMessage
|
||||||
const isBlockedMessage = sender ? isBlockedOrBlocking(sender) : false
|
const isBlockedMessage = isDidBlockedInConvo({
|
||||||
|
did: senderDid,
|
||||||
|
members: convo.members,
|
||||||
|
primaryProfile,
|
||||||
|
})
|
||||||
|
|
||||||
const prefix = (message: string) => {
|
const prefix = (message: string) => {
|
||||||
if (isFromMe) {
|
if (isFromMe) {
|
||||||
|
|||||||
@@ -3,19 +3,24 @@ import {type I18n} from '@lingui/core'
|
|||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
|
|
||||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||||
|
import {isDidBlockedInConvo} from '#/components/dms/getMessageInfo'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
export type UserReactionInfo = {
|
export type UserReactionInfo = {
|
||||||
message: string
|
message: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
isBlocked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReactionInfo({
|
export function getReactionInfo({
|
||||||
convo,
|
convo,
|
||||||
currentAccountDid,
|
currentAccountDid,
|
||||||
|
primaryProfile,
|
||||||
i18n,
|
i18n,
|
||||||
}: {
|
}: {
|
||||||
convo: ChatBskyConvoDefs.ConvoView
|
convo: ChatBskyConvoDefs.ConvoView
|
||||||
currentAccountDid: string | undefined
|
currentAccountDid: string | undefined
|
||||||
|
primaryProfile?: bsky.profile.AnyProfileView
|
||||||
i18n: I18n
|
i18n: I18n
|
||||||
}): UserReactionInfo | null {
|
}): UserReactionInfo | null {
|
||||||
if (!ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) {
|
if (!ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) {
|
||||||
@@ -28,6 +33,21 @@ export function getReactionInfo({
|
|||||||
const sender = convo.members.find(m => m.did === senderDid)
|
const sender = convo.members.find(m => m.did === senderDid)
|
||||||
const name = sender ? createSanitizedDisplayName(sender) : null
|
const name = sender ? createSanitizedDisplayName(sender) : null
|
||||||
|
|
||||||
|
// Hide the preview when either the reactor or the author of the reacted-to
|
||||||
|
// message is blocked - otherwise a blocked reactor's name or a blocked
|
||||||
|
// sender's message text would leak into the chat list.
|
||||||
|
const isBlocked =
|
||||||
|
isDidBlockedInConvo({
|
||||||
|
did: senderDid,
|
||||||
|
members: convo.members,
|
||||||
|
primaryProfile,
|
||||||
|
}) ||
|
||||||
|
isDidBlockedInConvo({
|
||||||
|
did: reactedTo.sender?.did,
|
||||||
|
members: convo.members,
|
||||||
|
primaryProfile,
|
||||||
|
})
|
||||||
|
|
||||||
const lastMessageText = reactedTo.text
|
const lastMessageText = reactedTo.text
|
||||||
const fallbackMessage = i18n._(
|
const fallbackMessage = i18n._(
|
||||||
msg({
|
msg({
|
||||||
@@ -50,5 +70,6 @@ export function getReactionInfo({
|
|||||||
return {
|
return {
|
||||||
message,
|
message,
|
||||||
createdAt: reaction.createdAt,
|
createdAt: reaction.createdAt,
|
||||||
|
isBlocked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
|
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
|
||||||
|
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {type Shadow} from '#/state/cache/profile-shadow'
|
import {type Shadow} from '#/state/cache/profile-shadow'
|
||||||
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
|
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
|
||||||
@@ -86,6 +87,25 @@ export function hasAlreadyReacted(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops reactions from accounts the viewer is blocking or blocked by, so a
|
||||||
|
* blocked reactor's identity is never surfaced via the reaction pills or the
|
||||||
|
* reactions dialog. `relatedProfiles` is shadow-synced by the convo agent, so
|
||||||
|
* this reflects optimistic blocks. Reactions whose sender isn't in
|
||||||
|
* `relatedProfiles` are kept - we can't determine their block status, and they
|
||||||
|
* already render anonymously ("Someone reacted").
|
||||||
|
*/
|
||||||
|
export function filterBlockedReactions(
|
||||||
|
reactions: ChatBskyConvoDefs.ReactionView[] | undefined,
|
||||||
|
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>,
|
||||||
|
): ChatBskyConvoDefs.ReactionView[] {
|
||||||
|
if (!reactions) return []
|
||||||
|
return reactions.filter(reaction => {
|
||||||
|
const profile = relatedProfiles.get(reaction.sender.did)
|
||||||
|
return !profile || !isBlockedOrBlocking(profile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function hasReachedReactionLimit(
|
export function hasReachedReactionLimit(
|
||||||
message: ChatBskyConvoDefs.MessageView,
|
message: ChatBskyConvoDefs.MessageView,
|
||||||
myDid: string | undefined,
|
myDid: string | undefined,
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ function BaseChatItem({
|
|||||||
const info = getMessageInfo({
|
const info = getMessageInfo({
|
||||||
convo: convo.view,
|
convo: convo.view,
|
||||||
currentAccountDid: currentAccount?.did,
|
currentAccountDid: currentAccount?.did,
|
||||||
|
primaryProfile,
|
||||||
i18n,
|
i18n,
|
||||||
})
|
})
|
||||||
if (info) {
|
if (info) {
|
||||||
@@ -342,10 +343,12 @@ function BaseChatItem({
|
|||||||
const info = getReactionInfo({
|
const info = getReactionInfo({
|
||||||
convo: convo.view,
|
convo: convo.view,
|
||||||
currentAccountDid: currentAccount?.did,
|
currentAccountDid: currentAccount?.did,
|
||||||
|
primaryProfile,
|
||||||
i18n,
|
i18n,
|
||||||
})
|
})
|
||||||
if (
|
if (
|
||||||
info &&
|
info &&
|
||||||
|
!info.isBlocked &&
|
||||||
(!lastMessageSentAt ||
|
(!lastMessageSentAt ||
|
||||||
new Date(lastMessageSentAt) < new Date(info.createdAt))
|
new Date(lastMessageSentAt) < new Date(info.createdAt))
|
||||||
) {
|
) {
|
||||||
@@ -379,7 +382,7 @@ function BaseChatItem({
|
|||||||
LastMessageIcon,
|
LastMessageIcon,
|
||||||
lastMessageSentAt,
|
lastMessageSentAt,
|
||||||
}
|
}
|
||||||
}, [l, convo, currentAccount?.did, isDeletedAccount, i18n])
|
}, [l, convo, currentAccount?.did, isDeletedAccount, primaryProfile, i18n])
|
||||||
|
|
||||||
const [showActions, setShowActions] = useState(false)
|
const [showActions, setShowActions] = useState(false)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user