[Chat] Hide blocked accounts' reactions in chats (#10889)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-12 21:19:22 +03:00
committed by GitHub
parent 581d2b296d
commit 7310426ea7
6 changed files with 103 additions and 13 deletions
+16 -6
View File
@@ -53,7 +53,11 @@ import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed'
import {MessageItemInviteEmbed} from './MessageItemInviteEmbed'
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 CLUSTERED_MESSAGE_GAP = 2
@@ -168,9 +172,15 @@ let MessageItem = ({
const isInMiddleOfCluster =
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 =
prevIsMessage && prevMessage.reactions && prevMessage.reactions.length > 0
prevIsMessage &&
filterBlockedReactions(prevMessage.reactions, relatedProfiles).length > 0
const isNextEmojiOnly = nextIsMessage && isOnlyEmoji(nextMessage.text)
const isPrevEmojiOnly = prevIsMessage && isOnlyEmoji(prevMessage.text)
const squaredBottomCorner =
@@ -240,11 +250,11 @@ let MessageItem = ({
)
const groupedReactions = useMemo(
() => groupReactions(message.reactions),
[message.reactions],
() => groupReactions(visibleReactions),
[visibleReactions],
)
const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
const reactions = visibleReactions
const hasSelfReacted = reactions.some(
r => r.sender.did === currentAccount?.did,
+9 -5
View File
@@ -19,6 +19,7 @@ import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {filterBlockedReactions} from '#/components/dms/util'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_NATIVE, IS_WEB} from '#/env'
@@ -52,10 +53,13 @@ export function ReactionsDialog({
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 filteredReactions = reactions?.filter(
const filteredReactions = reactions.filter(
r => selected === 'all' || r.value === selected,
)
@@ -69,7 +73,7 @@ export function ReactionsDialog({
<ReactionTabs
groupedReactions={groupedReactions}
selected={selected}
totalReactions={reactions?.length ?? 0}
totalReactions={reactions.length}
onFilter={setSelected}
/>
<Dialog.Close />
@@ -96,7 +100,7 @@ export function ReactionsDialog({
header={IS_WEB ? header : null}
style={[web({maxWidth: 400})]}>
{filteredReactions
?.sort((a, b) => {
.sort((a, b) => {
if (a.sender.did === currentAccount?.did) return -1
if (b.sender.did === currentAccount?.did) return 1
return 0
@@ -113,7 +117,7 @@ export function ReactionsDialog({
message={message}
profile={sender}
reaction={reaction}
allReactions={reactions ?? []}
allReactions={reactions}
selected={selected}
setSelected={setSelected}
/>
+33 -1
View File
@@ -1,5 +1,6 @@
import {
AppBskyEmbedRecord,
type ChatBskyActorDefs,
ChatBskyConvoDefs,
ChatBskyEmbedJoinLink,
} from '@atproto/api'
@@ -13,6 +14,7 @@ import {
toBskyAppUrl,
toShortUrl,
} from '#/lib/strings/url-helpers'
import type * as bsky from '#/types/bsky'
export type UserMessageInfo = {
message: string | null
@@ -21,13 +23,39 @@ export type UserMessageInfo = {
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({
convo,
currentAccountDid,
primaryProfile,
i18n,
}: {
convo: ChatBskyConvoDefs.ConvoView
currentAccountDid: string | undefined
primaryProfile?: bsky.profile.AnyProfileView
i18n: I18n
}): UserMessageInfo | null {
if (!ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
@@ -42,7 +70,11 @@ export function getMessageInfo({
const isGroup = ChatBskyConvoDefs.isGroupConvo(convo.kind)
const reportableMessage = isFromMe ? undefined : lastMessage
const isBlockedMessage = sender ? isBlockedOrBlocking(sender) : false
const isBlockedMessage = isDidBlockedInConvo({
did: senderDid,
members: convo.members,
primaryProfile,
})
const prefix = (message: string) => {
if (isFromMe) {
+21
View File
@@ -3,19 +3,24 @@ import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
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 = {
message: string
createdAt: string
isBlocked: boolean
}
export function getReactionInfo({
convo,
currentAccountDid,
primaryProfile,
i18n,
}: {
convo: ChatBskyConvoDefs.ConvoView
currentAccountDid: string | undefined
primaryProfile?: bsky.profile.AnyProfileView
i18n: I18n
}): UserReactionInfo | null {
if (!ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) {
@@ -28,6 +33,21 @@ export function getReactionInfo({
const sender = convo.members.find(m => m.did === senderDid)
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 fallbackMessage = i18n._(
msg({
@@ -50,5 +70,6 @@ export function getReactionInfo({
return {
message,
createdAt: reaction.createdAt,
isBlocked,
}
}
+20
View File
@@ -7,6 +7,7 @@ import {
} from '@atproto/api'
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/profile-shadow'
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(
message: ChatBskyConvoDefs.MessageView,
myDid: string | undefined,
@@ -327,6 +327,7 @@ function BaseChatItem({
const info = getMessageInfo({
convo: convo.view,
currentAccountDid: currentAccount?.did,
primaryProfile,
i18n,
})
if (info) {
@@ -342,10 +343,12 @@ function BaseChatItem({
const info = getReactionInfo({
convo: convo.view,
currentAccountDid: currentAccount?.did,
primaryProfile,
i18n,
})
if (
info &&
!info.isBlocked &&
(!lastMessageSentAt ||
new Date(lastMessageSentAt) < new Date(info.createdAt))
) {
@@ -379,7 +382,7 @@ function BaseChatItem({
LastMessageIcon,
lastMessageSentAt,
}
}, [l, convo, currentAccount?.did, isDeletedAccount, i18n])
}, [l, convo, currentAccount?.did, isDeletedAccount, primaryProfile, i18n])
const [showActions, setShowActions] = useState(false)