diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx index ab9af42584..dc975fee0f 100644 --- a/src/components/AvatarBubbles.tsx +++ b/src/components/AvatarBubbles.tsx @@ -1,5 +1,6 @@ import {type StyleProp, View, type ViewStyle} from 'react-native' +import {useSession} from '#/state/session' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' @@ -10,10 +11,9 @@ type Props = { size?: 'small' | 'medium' | 'large' } -/** - * TODO This is just layout for now. - */ -export function AvatarBubbles({profiles, size = 'large'}: Props) { +export function AvatarBubbles({profiles: allProfiles, size = 'large'}: Props) { + const {currentAccount} = useSession() + const profiles = allProfiles.filter(p => p.did !== currentAccount?.did) const containerSize = size === 'small' ? 40 : size === 'medium' ? 56 : 120 const scale = size === 'small' ? 40 / 120 : size === 'medium' ? 56 / 120 : 1 const marginOffset = size === 'small' || size === 'medium' ? -2 : 0 @@ -21,14 +21,14 @@ export function AvatarBubbles({profiles, size = 'large'}: Props) { let avatars = ( <> 0 ? profiles[0] : undefined} + profile={profiles[0] ?? allProfiles[0]} size={76} x={-2} y={-2} style={[a.z_20]} /> = 1 ? profiles[1] : undefined} + profile={profiles[1]} size={76} x={42} y={42} diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index a57450e637..7d49071e15 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -1,36 +1,39 @@ -import {memo, useCallback, useMemo, useState} from 'react' +import {useCallback, useMemo, useState} from 'react' import {type GestureResponderEvent, View} from 'react-native' import { AppBskyEmbedRecord, ChatBskyConvoDefs, moderateProfile, + type ModerationDecision, type ModerationOpts, } from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {GestureActionView} from '#/lib/custom-animations/GestureActionView' import {useHaptics} from '#/lib/haptics' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {decrementBadgeCount} from '#/lib/notifications/notifications' import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import { postUriToRelativePath, toBskyAppUrl, toShortUrl, } from '#/lib/strings/url-helpers' -import {useProfileShadow} from '#/state/cache/profile-shadow' +import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import { precacheConvoQuery, useMarkAsReadMutation, } from '#/state/queries/messages/conversation' -import {precacheProfile} from '#/state/queries/profile' +import {unstableCacheProfileView} from '#/state/queries/profile' import {useSession} from '#/state/session' import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import * as tokens from '#/alf/tokens' +import {AvatarBubbles} from '#/components/AvatarBubbles' import {useDialogControl} from '#/components/Dialog' import {ConvoMenu} from '#/components/dms/ConvoMenu' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' @@ -45,11 +48,17 @@ import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' -import type * as bsky from '#/types/bsky' +import * as bsky from '#/types/bsky' export const ChatListItemPortal = createPortalGroup() -export let ChatListItem = ({ +/** + * IMPORTANT NOTE: THIS IS CURRENTLY JANKY AF AND PROBABLY BROKEN, JUST WANTED TO ADD GROUPCHAT SUPPPORT + * + * TAKE A SECOND PASS PLEASE -sfn + */ + +export function ChatListItem({ convo, showMenu = true, children, @@ -57,32 +66,61 @@ export let ChatListItem = ({ convo: ChatBskyConvoDefs.ConvoView showMenu?: boolean children?: React.ReactNode -}): React.ReactNode => { +}) { const {currentAccount} = useSession() const moderationOpts = useModerationOpts() - const otherUser = convo.members.find( - member => member.did !== currentAccount?.did, - ) - - if (!otherUser || !moderationOpts) { + if (!moderationOpts) { return null } - return ( - - {children} - - ) + switch (convo.kind) { + case 'group': { + const groupInfo = convo.kindData + // TODO: members are missing the role property - find out if intentional + // const owner = convo.members.find(member => member.role === 'owner') + const owner = convo.members[0] // owner will always be the first member + if ( + !bsky.dangerousIsType( + groupInfo, + ChatBskyConvoDefs.isGroupConvoData, + ) || + !owner + ) { + return null + } + return ( + + ) + } + case 'direct': { + const otherMember = convo.members.find( + member => member.did !== currentAccount?.did, + ) + + if (!otherMember) { + return null + } + return ( + + {children} + + ) + } + } } -ChatListItem = memo(ChatListItem) - -function ChatListItemReady({ +function DirectChatItem({ convo, profile: profileUnshadowed, moderationOpts, @@ -95,25 +133,140 @@ function ChatListItemReady({ showMenu?: boolean children?: React.ReactNode }) { - const ax = useAnalytics() - const t = useTheme() - const {_} = useLingui() - const {currentAccount} = useSession() - const menuControl = useMenuControl() - const leaveConvoControl = useDialogControl() - const {gtMobile} = useBreakpoints() + const {t: l} = useLingui() const profile = useProfileShadow(profileUnshadowed) - const {mutate: markAsRead} = useMarkAsReadMutation() + const moderation = useMemo( () => moderateProfile(profile, moderationOpts), [profile, moderationOpts], ) + + const isDeletedAccount = profile.handle === 'missing.invalid' + const displayName = isDeletedAccount + ? l`Deleted Account` + : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) + + return ( + + } + primaryProfile={profile} + primaryProfileModeration={moderation} + title={displayName} + subtitle={isDeletedAccount ? undefined : sanitizeHandle(profile.handle)} + accessibilityHint={ + !isDeletedAccount + ? l`Go to conversation with ${profile.handle}` + : l`This conversation is with a deleted or a deactivated account. Press for options` + } + showMenu={showMenu} + isDeletedAccount={isDeletedAccount} + isBlockedAccount={moderation.blocked} + showProfileBadges + postAlerts={ + + }> + {children} + + ) +} + +function GroupChatItem({ + convo, + groupOwner: groupOwnerUnshadowed, + groupInfo, + moderationOpts, + showMenu, + children, +}: { + convo: ChatBskyConvoDefs.ConvoView + groupOwner: bsky.profile.AnyProfileView + groupInfo: ChatBskyConvoDefs.GroupConvoData + moderationOpts: ModerationOpts + showMenu?: boolean + children?: React.ReactNode +}) { + const {t: l} = useLingui() + const groupOwner = useProfileShadow(groupOwnerUnshadowed) + + const moderation = useMemo( + () => moderateProfile(groupOwner, moderationOpts), + [groupOwner, moderationOpts], + ) + + const chatName = groupInfo.name ?? l`${groupOwner.handle}'s group chat` + + return ( + } + title={chatName} + accessibilityHint={l`Go to the group chat named "${chatName}"`} + primaryProfile={groupOwner} + primaryProfileModeration={moderation} + isBlockedAccount={false} + isDeletedAccount={false} + showProfileBadges={false} + showMenu={showMenu}> + {children} + + ) +} + +function BaseChatItem({ + convo, + avatar, + title, + subtitle, + accessibilityHint, + isDeletedAccount, + isBlockedAccount, + primaryProfile, + primaryProfileModeration, + showMenu, + showProfileBadges, + postAlerts, + children, +}: { + convo: ChatBskyConvoDefs.ConvoView + avatar: React.ReactNode + title: string + subtitle?: string + accessibilityHint: string + isDeletedAccount: boolean + isBlockedAccount: boolean + primaryProfile: Shadow + primaryProfileModeration: ModerationDecision + showMenu?: boolean + showProfileBadges: boolean + postAlerts?: React.ReactNode + children?: React.ReactNode +}) { + const ax = useAnalytics() + const t = useTheme() + const {t: l} = useLingui() + const {currentAccount} = useSession() + const menuControl = useMenuControl() + const leaveConvoControl = useDialogControl() + const {mutate: markAsRead} = useMarkAsReadMutation() + const {gtMobile} = useBreakpoints() + const playHaptic = useHaptics() const queryClient = useQueryClient() const isUnread = convo.unreadCount > 0 const blockInfo = useMemo(() => { - const modui = moderation.ui('profileView') + const modui = primaryProfileModeration.ui('profileView') const blocks = modui.alerts.filter(alert => alert.type === 'blocking') const listBlocks = blocks.filter(alert => alert.source.type === 'list') const userBlock = blocks.find(alert => alert.source.type === 'user') @@ -121,21 +274,13 @@ function ChatListItemReady({ listBlocks, userBlock, } - }, [moderation]) + }, [primaryProfileModeration]) - const isDeletedAccount = profile.handle === 'missing.invalid' - const displayName = isDeletedAccount - ? _(msg`Deleted Account`) - : sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) - - const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount + const isDimStyle = convo.muted || isBlockedAccount || isDeletedAccount const {lastMessage, lastMessageSentAt, latestReportableMessage} = useMemo(() => { - let lastMessage = _(msg`No messages yet`) + let lastMessage = l`No messages yet` let lastMessageSentAt: string | null = null @@ -150,14 +295,12 @@ function ChatListItemReady({ if (convo.lastMessage.text) { if (isFromMe) { - lastMessage = _(msg`You: ${convo.lastMessage.text}`) + lastMessage = l`You: ${convo.lastMessage.text}` } else { lastMessage = convo.lastMessage.text } } else if (convo.lastMessage.embed) { - const defaultEmbeddedContentMessage = _( - msg`(contains embedded content)`, - ) + const defaultEmbeddedContentMessage = l`(contains embedded content)` if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) { const embed = convo.lastMessage.embed @@ -172,14 +315,14 @@ function ChatListItemReady({ ? toShortUrl(href) : defaultEmbeddedContentMessage if (isFromMe) { - lastMessage = _(msg`You: ${short}`) + lastMessage = l`You: ${short}` } else { lastMessage = short } } } else { if (isFromMe) { - lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`) + lastMessage = l`You: ${defaultEmbeddedContentMessage}` } else { lastMessage = defaultEmbeddedContentMessage } @@ -192,8 +335,8 @@ function ChatListItemReady({ lastMessageSentAt = convo.lastMessage.sentAt lastMessage = isDeletedAccount - ? _(msg`Conversation deleted`) - : _(msg`Message deleted`) + ? l`Conversation deleted` + : l`Message deleted` } if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) { @@ -205,44 +348,36 @@ function ChatListItemReady({ const isFromMe = convo.lastReaction.reaction.sender.did === currentAccount?.did const lastMessageText = convo.lastReaction.message.text - const fallbackMessage = _( - msg({ - message: 'a message', - comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`, - }), - ) + const fallbackMessage = l({ + message: 'a message', + comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`, + }) if (isFromMe) { - lastMessage = _( - msg`You reacted ${convo.lastReaction.reaction.value} to ${ - lastMessageText - ? `"${convo.lastReaction.message.text}"` - : fallbackMessage - }`, - ) + lastMessage = l`You reacted ${convo.lastReaction.reaction.value} to ${ + lastMessageText + ? `"${convo.lastReaction.message.text}"` + : fallbackMessage + }` } else { const senderDid = convo.lastReaction.reaction.sender.did const sender = convo.members.find( member => member.did === senderDid, ) if (sender) { - lastMessage = _( - msg`${sanitizeDisplayName( - sender.displayName || sender.handle, - )} reacted ${convo.lastReaction.reaction.value} to ${ - lastMessageText - ? `"${convo.lastReaction.message.text}"` - : fallbackMessage - }`, - ) + lastMessage = l`${sanitizeDisplayName( + sender.displayName || sender.handle, + )} reacted ${convo.lastReaction.reaction.value} to ${ + lastMessageText + ? `"${convo.lastReaction.message.text}"` + : fallbackMessage + }` } else { - lastMessage = _( - msg`Someone reacted ${convo.lastReaction.reaction.value} to ${ - lastMessageText - ? `"${convo.lastReaction.message.text}"` - : fallbackMessage - }`, - ) + lastMessage = l`Someone reacted ${convo.lastReaction.reaction.value} to ${ + lastMessageText + ? `"${convo.lastReaction.message.text}"` + : fallbackMessage + }` } } } @@ -254,7 +389,7 @@ function ChatListItemReady({ latestReportableMessage, } }, [ - _, + l, convo.lastMessage, convo.lastReaction, currentAccount?.did, @@ -279,9 +414,11 @@ function ChatListItemReady({ const onPress = useCallback( (e: GestureResponderEvent) => { - precacheProfile(queryClient, profile) + for (const member of convo.members) { + unstableCacheProfileView(queryClient, member) + } precacheConvoQuery(queryClient, convo) - decrementBadgeCount(convo.unreadCount) + void decrementBadgeCount(convo.unreadCount) if (isDeletedAccount) { e.preventDefault() menuControl.open() @@ -290,7 +427,7 @@ function ChatListItemReady({ ax.metric('chat:open', {logContext: 'ChatsList'}) } }, - [ax, isDeletedAccount, menuControl, queryClient, profile, convo], + [ax, isDeletedAccount, menuControl, queryClient, convo], ) const onLongPress = useCallback(() => { @@ -345,33 +482,23 @@ function ChatListItemReady({ a.absolute, {top: tokens.space.md, left: tokens.space.lg}, ]}> - + {avatar} - {displayName} + {title} - + + {showProfileBadges && ( + + )} + {lastMessageSentAt && ( @@ -432,7 +563,7 @@ function ChatListItemReady({ )} - {(convo.muted || moderation.blocked) && ( + {(convo.muted || isBlockedAccount) && ( - {!isDeletedAccount && ( + {subtitle && ( - @{profile.handle} + {subtitle} )} @@ -474,11 +605,7 @@ function ChatListItemReady({ {lastMessage} - + {postAlerts} {children} @@ -509,7 +636,7 @@ function ChatListItemReady({ {showMenu && ( 0} @@ -529,6 +656,7 @@ function ChatListItemReady({ latestReportableMessage={latestReportableMessage} /> )} +