diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 490bab0e89..6ac91f0c41 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -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, diff --git a/src/components/dms/ReactionsDialog.tsx b/src/components/dms/ReactionsDialog.tsx index 8119cc1b7c..3918143d37 100644 --- a/src/components/dms/ReactionsDialog.tsx +++ b/src/components/dms/ReactionsDialog.tsx @@ -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({ @@ -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} /> diff --git a/src/components/dms/getMessageInfo.ts b/src/components/dms/getMessageInfo.ts index b506dcce65..5aaa3a8d7d 100644 --- a/src/components/dms/getMessageInfo.ts +++ b/src/components/dms/getMessageInfo.ts @@ -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) { diff --git a/src/components/dms/getReactionInfo.ts b/src/components/dms/getReactionInfo.ts index 351c659698..307eebdea7 100644 --- a/src/components/dms/getReactionInfo.ts +++ b/src/components/dms/getReactionInfo.ts @@ -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, } } diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts index d10b2296fb..d37c93430c 100644 --- a/src/components/dms/util.ts +++ b/src/components/dms/util.ts @@ -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, +): 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, diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index 4be159c2a6..b9e867f8cd 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -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)