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)