300a50b69a
Phase 3 tasks 3-7 (parallel wave): composer/post pipeline on pdsClient/appviewClient with structural+instance blob guards and a golden CID fixture test; chat Convo/EventBus/queries on the dedicated chat client; preferences sugar to SDK actions on the PDS client; remaining state/queries producers (usePostThread unspecced flip, video scoped-token clients, notifications, starter packs, lists) to client.call; UI runtime sweep (AtUri from @atproto/syntax, moderation fns from @bsky.app/sdk/moderation, SDK RichText, ozone reason tokens, guard rewrites via #/types/bsky). Intermediate checkpoint (hooks skipped): ~114 typecheck errors remain in cross-boundary consumer files, resolved by the type-only codemod next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
726 lines
22 KiB
TypeScript
726 lines
22 KiB
TypeScript
import {useCallback, useMemo, useState} from 'react'
|
|
import {type GestureResponderEvent, View} from 'react-native'
|
|
import {type ChatBskyConvoDefs} from '@atproto/api'
|
|
import {
|
|
moderateProfile,
|
|
type ModerationDecision,
|
|
type ModerationOpts,
|
|
} from '@bsky.app/sdk/moderation'
|
|
import {plural} from '@lingui/core/macro'
|
|
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 {sanitizeHandle} from '#/lib/strings/handles'
|
|
import {
|
|
type Shadow,
|
|
useMaybeProfileShadow,
|
|
useProfileShadow,
|
|
} from '#/state/cache/profile-shadow'
|
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
|
import {
|
|
precacheConvoQuery,
|
|
useMarkAsReadMutation,
|
|
} from '#/state/queries/messages/conversation'
|
|
import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests'
|
|
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 {getMessageInfo} from '#/components/dms/getMessageInfo'
|
|
import {getReactionInfo} from '#/components/dms/getReactionInfo'
|
|
import {getSystemMessageInfo} from '#/components/dms/getSystemMessageInfo'
|
|
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
|
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
|
|
import {Bell2Off_Filled_Corner0_Rounded as BellStrokeIcon} from '#/components/icons/Bell2'
|
|
import {type Props as SVGIconProps} from '#/components/icons/common'
|
|
import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpenIcon} from '#/components/icons/EnveopeOpen'
|
|
import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
|
|
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
|
import {Link} from '#/components/Link'
|
|
import {useMenuControl} from '#/components/Menu'
|
|
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
|
import {createPortalGroup} from '#/components/Portal'
|
|
import {ProfileBadges} from '#/components/ProfileBadges'
|
|
import {Text} from '#/components/Typography'
|
|
import {useAnalytics} from '#/analytics'
|
|
import {IS_NATIVE} from '#/env'
|
|
import {chat} from '#/lexicons'
|
|
import * as bsky from '#/types/bsky'
|
|
import {useIsWithinSplitView} from './splitView/context'
|
|
|
|
export const ChatListItemPortal = createPortalGroup()
|
|
|
|
export function ChatListItem({
|
|
convo: convoView,
|
|
showMenu = true,
|
|
selected = false,
|
|
children,
|
|
}: {
|
|
convo: ChatBskyConvoDefs.ConvoView
|
|
showMenu?: boolean
|
|
selected?: boolean
|
|
children?: React.ReactNode
|
|
}) {
|
|
const {currentAccount} = useSession()
|
|
const moderationOpts = useModerationOpts()
|
|
|
|
if (!moderationOpts) {
|
|
return null
|
|
}
|
|
|
|
const convo = parseConvoView(convoView, currentAccount?.did)
|
|
|
|
switch (convo?.kind) {
|
|
case 'direct': {
|
|
return (
|
|
<DirectChatItem
|
|
convo={convo}
|
|
moderationOpts={moderationOpts}
|
|
showMenu={showMenu}
|
|
selected={selected}>
|
|
{children}
|
|
</DirectChatItem>
|
|
)
|
|
}
|
|
case 'group': {
|
|
return (
|
|
<GroupChatItem
|
|
convo={convo}
|
|
moderationOpts={moderationOpts}
|
|
showMenu={showMenu}
|
|
selected={selected}>
|
|
{children}
|
|
</GroupChatItem>
|
|
)
|
|
}
|
|
default: {
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
|
|
function DirectChatItem({
|
|
convo,
|
|
moderationOpts,
|
|
showMenu,
|
|
selected,
|
|
children,
|
|
}: {
|
|
convo: Extract<ConvoWithDetails, {kind: 'direct'}>
|
|
moderationOpts: ModerationOpts
|
|
showMenu?: boolean
|
|
selected?: boolean
|
|
children?: React.ReactNode
|
|
}) {
|
|
const {t: l} = useLingui()
|
|
const profile = useProfileShadow(convo.primaryMember)
|
|
const {isWithinLeftPanel} = useIsWithinSplitView()
|
|
|
|
const moderation = useMemo(
|
|
// TODO(phase4): drop toLex once useProfileShadow emits #/lexicons views
|
|
() => moderateProfile(bsky.toLex(profile), moderationOpts),
|
|
[profile, moderationOpts],
|
|
)
|
|
|
|
const isDeletedAccount = profile.handle === 'missing.invalid'
|
|
const displayName = isDeletedAccount
|
|
? l`Deleted Account`
|
|
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
|
|
|
|
return (
|
|
<BaseChatItem
|
|
convo={convo}
|
|
avatar={
|
|
<PreviewableUserAvatar
|
|
profile={profile}
|
|
size={isWithinLeftPanel ? 48 : 52}
|
|
moderation={moderation.ui('avatar')}
|
|
/>
|
|
}
|
|
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}
|
|
selected={selected}
|
|
isDeletedAccount={isDeletedAccount}
|
|
isBlockedAccount={moderation.blocked}
|
|
showProfileBadges
|
|
postAlerts={
|
|
isWithinLeftPanel ? null : (
|
|
<PostAlerts
|
|
modui={moderation.ui('contentList')}
|
|
style={[a.pb_2xs, a.max_w_full, a.overflow_hidden]}
|
|
/>
|
|
)
|
|
}>
|
|
{children}
|
|
</BaseChatItem>
|
|
)
|
|
}
|
|
|
|
function GroupChatItem({
|
|
convo,
|
|
moderationOpts,
|
|
showMenu,
|
|
selected,
|
|
children,
|
|
}: {
|
|
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
|
moderationOpts: ModerationOpts
|
|
showMenu?: boolean
|
|
selected?: boolean
|
|
children?: React.ReactNode
|
|
}) {
|
|
const {t: l} = useLingui()
|
|
const groupOwner = useMaybeProfileShadow(convo.primaryMember)
|
|
const {isWithinLeftPanel} = useIsWithinSplitView()
|
|
|
|
const moderation = useMemo(
|
|
() =>
|
|
groupOwner
|
|
? // TODO(phase4): drop toLex once useMaybeProfileShadow emits #/lexicons views
|
|
moderateProfile(bsky.toLex(groupOwner), moderationOpts)
|
|
: undefined,
|
|
[groupOwner, moderationOpts],
|
|
)
|
|
|
|
const chatName = convo.details.name
|
|
|
|
return (
|
|
<BaseChatItem
|
|
convo={convo}
|
|
avatar={
|
|
<AvatarBubbles
|
|
profiles={convo.members}
|
|
size={isWithinLeftPanel ? 48 : 52}
|
|
/>
|
|
}
|
|
title={chatName}
|
|
accessibilityHint={l`Go to the group chat named "${chatName}"`}
|
|
primaryProfile={groupOwner}
|
|
primaryProfileModeration={moderation}
|
|
isBlockedAccount={false}
|
|
isDeletedAccount={false}
|
|
requestInfo={
|
|
convo.details.unreadJoinRequestCount
|
|
? convo.details.unreadJoinRequestCount > JOIN_REQUESTS_THRESHOLD
|
|
? l({
|
|
message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`,
|
|
context:
|
|
'Displayed when there are more than 20 requests to join a group chat',
|
|
})
|
|
: plural(convo.details.unreadJoinRequestCount, {
|
|
one: '# new join request',
|
|
other: '# new join requests',
|
|
})
|
|
: undefined
|
|
}
|
|
showProfileBadges={false}
|
|
selected={selected}
|
|
showMenu={showMenu}>
|
|
{children}
|
|
</BaseChatItem>
|
|
)
|
|
}
|
|
|
|
function BaseChatItem({
|
|
convo,
|
|
avatar,
|
|
title,
|
|
subtitle,
|
|
requestInfo,
|
|
accessibilityHint,
|
|
isDeletedAccount,
|
|
isBlockedAccount,
|
|
primaryProfile,
|
|
primaryProfileModeration,
|
|
showMenu,
|
|
selected,
|
|
showProfileBadges,
|
|
postAlerts,
|
|
children,
|
|
}: {
|
|
convo: ConvoWithDetails
|
|
avatar: React.ReactNode
|
|
title: string
|
|
subtitle?: string
|
|
requestInfo?: string
|
|
accessibilityHint: string
|
|
isDeletedAccount: boolean
|
|
isBlockedAccount: boolean
|
|
primaryProfile?: Shadow<bsky.profile.AnyProfileView>
|
|
primaryProfileModeration?: ModerationDecision
|
|
showMenu?: boolean
|
|
selected?: boolean
|
|
showProfileBadges: boolean
|
|
postAlerts?: React.ReactNode
|
|
children?: React.ReactNode
|
|
}) {
|
|
const ax = useAnalytics()
|
|
const t = useTheme()
|
|
const {t: l, i18n} = useLingui()
|
|
const {currentAccount} = useSession()
|
|
const menuControl = useMenuControl()
|
|
const leaveConvoControl = useDialogControl()
|
|
const {mutate: markAsRead} = useMarkAsReadMutation()
|
|
const {gtMobile} = useBreakpoints()
|
|
const {isWithinLeftPanel} = useIsWithinSplitView()
|
|
|
|
const playHaptic = useHaptics()
|
|
const queryClient = useQueryClient()
|
|
const hasUnread =
|
|
!selected &&
|
|
!isDeletedAccount &&
|
|
(convo.view.unreadCount > 0 ||
|
|
(convo.kind === 'group' &&
|
|
(convo.details.unreadJoinRequestCount ?? 0) > 0)) &&
|
|
(convo.kind !== 'group' || convo.details.lockStatus === 'unlocked')
|
|
|
|
const blockInfo = useMemo(() => {
|
|
if (!primaryProfileModeration) return {listBlocks: [], userBlock: undefined}
|
|
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')
|
|
return {
|
|
listBlocks,
|
|
userBlock,
|
|
}
|
|
}, [primaryProfileModeration])
|
|
|
|
const isDimStyle =
|
|
convo.view.muted ||
|
|
isBlockedAccount ||
|
|
isDeletedAccount ||
|
|
(convo.kind === 'group' && convo.details.lockStatus !== 'unlocked')
|
|
|
|
const {lastMessage, LastMessageIcon, lastMessageSentAt} = useMemo(() => {
|
|
let lastMessage = l`No messages yet`
|
|
|
|
let LastMessageIcon: React.ComponentType<SVGIconProps> | null = null
|
|
|
|
let lastMessageSentAt: string | null = null
|
|
|
|
// Deleted message
|
|
if (
|
|
bsky.isType(
|
|
chat.bsky.convo.defs.deletedMessageView,
|
|
convo.view.lastMessage,
|
|
)
|
|
) {
|
|
lastMessageSentAt = convo.view.lastMessage.sentAt
|
|
|
|
lastMessage = isDeletedAccount
|
|
? l`Conversation deleted`
|
|
: l`Message deleted`
|
|
}
|
|
|
|
// Message
|
|
if (bsky.isType(chat.bsky.convo.defs.messageView, convo.view.lastMessage)) {
|
|
const info = getMessageInfo({
|
|
convo: convo.view,
|
|
currentAccountDid: currentAccount?.did,
|
|
primaryProfile,
|
|
i18n,
|
|
})
|
|
if (info) {
|
|
lastMessage = info.isBlockedMessage
|
|
? l`This message is hidden`
|
|
: (info.message ?? lastMessage)
|
|
lastMessageSentAt = info.sentAt
|
|
}
|
|
}
|
|
|
|
// Reaction
|
|
if (
|
|
bsky.isType(
|
|
chat.bsky.convo.defs.messageAndReactionView,
|
|
convo.view.lastReaction,
|
|
)
|
|
) {
|
|
const info = getReactionInfo({
|
|
convo: convo.view,
|
|
currentAccountDid: currentAccount?.did,
|
|
primaryProfile,
|
|
i18n,
|
|
})
|
|
if (
|
|
info &&
|
|
!info.isBlocked &&
|
|
(!lastMessageSentAt ||
|
|
new Date(lastMessageSentAt) < new Date(info.createdAt))
|
|
) {
|
|
lastMessage = info.message
|
|
lastMessageSentAt = info.createdAt
|
|
}
|
|
}
|
|
|
|
// System message
|
|
if (
|
|
bsky.isType(
|
|
chat.bsky.convo.defs.systemMessageView,
|
|
convo.view.lastMessage,
|
|
)
|
|
) {
|
|
const info = getSystemMessageInfo(
|
|
convo.view.lastMessage.data,
|
|
new Map(convo.view.members.map(m => [m.did, m])),
|
|
{short: true},
|
|
)
|
|
if (info) {
|
|
lastMessage = i18n._(info.message)
|
|
LastMessageIcon = info.Icon
|
|
lastMessageSentAt = convo.view.lastMessage.sentAt
|
|
}
|
|
}
|
|
|
|
// Chat locked - override message
|
|
if (convo.kind === 'group' && convo.details.lockStatus !== 'unlocked') {
|
|
lastMessage = l`This chat is locked`
|
|
LastMessageIcon = LockIcon
|
|
}
|
|
|
|
return {
|
|
lastMessage,
|
|
LastMessageIcon,
|
|
lastMessageSentAt,
|
|
}
|
|
}, [l, convo, currentAccount?.did, isDeletedAccount, primaryProfile, i18n])
|
|
|
|
const [showActions, setShowActions] = useState(false)
|
|
|
|
const onMouseEnter = useCallback(() => {
|
|
setShowActions(true)
|
|
}, [])
|
|
|
|
const onMouseLeave = useCallback(() => {
|
|
setShowActions(false)
|
|
}, [])
|
|
|
|
const onFocus = useCallback<React.FocusEventHandler>(e => {
|
|
if (e.nativeEvent.relatedTarget == null) return
|
|
setShowActions(true)
|
|
}, [])
|
|
|
|
const onPress = useCallback(
|
|
(e: GestureResponderEvent) => {
|
|
for (const member of convo.view.members) {
|
|
unstableCacheProfileView(queryClient, member)
|
|
}
|
|
/*
|
|
* `convo.view` comes from the still-old-typed `#/components/dms/util`
|
|
* (migrates in a later task) while the convo cache is now keyed on the
|
|
* lexicon ConvoView. TODO(phase4): drop toLex once dms/util migrates.
|
|
*/
|
|
precacheConvoQuery(
|
|
queryClient,
|
|
bsky.toLex<chat.bsky.convo.defs.ConvoView>(convo.view),
|
|
)
|
|
void decrementBadgeCount(convo.view.unreadCount)
|
|
if (isDeletedAccount) {
|
|
e.preventDefault()
|
|
menuControl.open()
|
|
return false
|
|
} else {
|
|
ax.metric('chat:open', {logContext: 'ChatsList'})
|
|
}
|
|
},
|
|
[ax, isDeletedAccount, menuControl, queryClient, convo],
|
|
)
|
|
|
|
const onLongPress = useCallback(() => {
|
|
playHaptic()
|
|
menuControl.open()
|
|
}, [playHaptic, menuControl])
|
|
|
|
const markReadAction = {
|
|
threshold: 120,
|
|
color: t.palette.primary_500,
|
|
icon: EnvelopeOpenIcon,
|
|
action: () => {
|
|
markAsRead({
|
|
convoId: convo.view.id,
|
|
})
|
|
},
|
|
}
|
|
|
|
const deleteAction = {
|
|
threshold: 225,
|
|
color: t.palette.negative_500,
|
|
icon: TrashIcon,
|
|
action: () => {
|
|
leaveConvoControl.open()
|
|
},
|
|
}
|
|
|
|
const isGroupConvo = convo.kind === 'group'
|
|
|
|
const actions = hasUnread
|
|
? {
|
|
leftFirst: markReadAction,
|
|
leftSecond: deleteAction,
|
|
}
|
|
: {
|
|
leftFirst: deleteAction,
|
|
}
|
|
|
|
const avatarSize = isWithinLeftPanel ? 48 : 52
|
|
|
|
return (
|
|
<ChatListItemPortal.Provider>
|
|
<GestureActionView actions={actions}>
|
|
<View
|
|
onMouseEnter={onMouseEnter}
|
|
onMouseLeave={onMouseLeave}
|
|
// @ts-expect-error web only
|
|
onFocus={onFocus}
|
|
onBlur={onMouseLeave}
|
|
style={[a.relative, t.atoms.bg, isWithinLeftPanel && a.mx_sm]}>
|
|
<View
|
|
style={[
|
|
a.z_10,
|
|
a.absolute,
|
|
{top: tokens.space.md, left: tokens.space.lg},
|
|
isGroupConvo && a.pointer_events_none,
|
|
]}>
|
|
{avatar}
|
|
</View>
|
|
|
|
<Link
|
|
to={`/messages/${convo.view.id}`}
|
|
// In split view, this list stays mounted alongside the open convo,
|
|
// so push would stack duplicate routes on repeated clicks.
|
|
action={isWithinLeftPanel ? 'navigate' : 'push'}
|
|
label={title}
|
|
accessibilityHint={accessibilityHint}
|
|
accessibilityActions={
|
|
showMenu && IS_NATIVE
|
|
? [
|
|
{
|
|
name: 'magicTap',
|
|
label: l`Open conversation options`,
|
|
},
|
|
{
|
|
name: 'longpress',
|
|
label: l`Open conversation options`,
|
|
},
|
|
]
|
|
: undefined
|
|
}
|
|
onPressIn={() =>
|
|
// see onPress: old-typed dms/util view into the new-typed cache
|
|
precacheConvoQuery(
|
|
queryClient,
|
|
bsky.toLex<chat.bsky.convo.defs.ConvoView>(convo.view),
|
|
)
|
|
}
|
|
onPress={onPress}
|
|
onLongPress={showMenu && IS_NATIVE ? onLongPress : undefined}
|
|
onAccessibilityAction={showMenu ? onLongPress : undefined}>
|
|
{({hovered, pressed, focused}) => (
|
|
<View
|
|
style={[
|
|
a.flex_row,
|
|
isDeletedAccount || isGroupConvo
|
|
? a.align_center
|
|
: a.align_start,
|
|
a.flex_1,
|
|
a.px_lg,
|
|
a.py_md,
|
|
a.gap_md,
|
|
isWithinLeftPanel && [a.rounded_sm, a.mt_2xs],
|
|
{
|
|
backgroundColor: hasUnread
|
|
? t.palette.primary_25
|
|
: t.palette.contrast_0,
|
|
},
|
|
(hovered || pressed || focused) && t.atoms.bg_contrast_25,
|
|
selected && t.atoms.bg_contrast_50,
|
|
]}>
|
|
{/* Avatar goes here */}
|
|
<View style={{width: avatarSize, height: avatarSize}} />
|
|
|
|
<View
|
|
style={[a.flex_1, a.justify_center, web({paddingRight: 40})]}>
|
|
<View
|
|
style={[a.w_full, a.flex_row, a.align_center, a.pb_2xs]}>
|
|
<View style={[a.flex_shrink]}>
|
|
<Text
|
|
emoji
|
|
numberOfLines={1}
|
|
style={[
|
|
a.text_md,
|
|
t.atoms.text,
|
|
a.font_semi_bold,
|
|
{lineHeight: 21},
|
|
isDimStyle && t.atoms.text_contrast_medium,
|
|
]}>
|
|
{title}
|
|
</Text>
|
|
</View>
|
|
|
|
{showProfileBadges && primaryProfile && (
|
|
<ProfileBadges
|
|
profile={primaryProfile}
|
|
size="sm"
|
|
style={[a.pl_xs, a.self_center]}
|
|
/>
|
|
)}
|
|
|
|
{lastMessageSentAt && (
|
|
<View style={[a.pl_xs]}>
|
|
<TimeElapsed timestamp={lastMessageSentAt}>
|
|
{({timeElapsed}) => (
|
|
<Text
|
|
style={[
|
|
a.text_sm,
|
|
{lineHeight: 21},
|
|
t.atoms.text_contrast_medium,
|
|
web({whiteSpace: 'preserve nowrap'}),
|
|
]}>
|
|
{timeElapsed}
|
|
</Text>
|
|
)}
|
|
</TimeElapsed>
|
|
</View>
|
|
)}
|
|
{(convo.view.muted || isBlockedAccount) && (
|
|
<Text
|
|
style={[
|
|
a.text_sm,
|
|
{lineHeight: 21},
|
|
t.atoms.text_contrast_medium,
|
|
web({whiteSpace: 'preserve nowrap'}),
|
|
]}>
|
|
{' '}
|
|
<BellStrokeIcon
|
|
size="xs"
|
|
style={[t.atoms.text_contrast_medium]}
|
|
/>
|
|
</Text>
|
|
)}
|
|
{hasUnread && (
|
|
<View
|
|
style={[
|
|
a.rounded_full,
|
|
{
|
|
backgroundColor: isDimStyle
|
|
? t.palette.contrast_200
|
|
: t.palette.primary_500,
|
|
height: 8,
|
|
width: 8,
|
|
marginLeft: 6,
|
|
},
|
|
web({whiteSpace: 'preserve nowrap'}),
|
|
]}
|
|
/>
|
|
)}
|
|
</View>
|
|
|
|
{subtitle && (
|
|
<Text
|
|
numberOfLines={1}
|
|
style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}
|
|
emoji>
|
|
{subtitle}
|
|
</Text>
|
|
)}
|
|
|
|
{postAlerts}
|
|
|
|
{requestInfo && (
|
|
<Text
|
|
numberOfLines={1}
|
|
style={[
|
|
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
|
|
isDimStyle && t.atoms.text_contrast_medium,
|
|
a.pb_2xs,
|
|
]}
|
|
emoji>
|
|
{requestInfo}
|
|
</Text>
|
|
)}
|
|
|
|
<View style={[a.flex_row, a.align_center]}>
|
|
{LastMessageIcon && (
|
|
<LastMessageIcon
|
|
size="xs"
|
|
style={[
|
|
a.mr_2xs,
|
|
hasUnread
|
|
? t.atoms.text_contrast_high
|
|
: t.atoms.text_contrast_medium,
|
|
]}
|
|
/>
|
|
)}
|
|
<Text
|
|
emoji
|
|
numberOfLines={2}
|
|
style={[
|
|
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
|
|
isDimStyle && t.atoms.text_contrast_medium,
|
|
]}>
|
|
{lastMessage}
|
|
</Text>
|
|
</View>
|
|
|
|
{children}
|
|
</View>
|
|
</View>
|
|
)}
|
|
</Link>
|
|
|
|
<ChatListItemPortal.Outlet />
|
|
|
|
{/* TODO: Allow showing menu for groups where the owner has left! */}
|
|
{showMenu && primaryProfile && (
|
|
<ConvoMenu
|
|
convo={convo}
|
|
profile={primaryProfile}
|
|
control={menuControl}
|
|
currentScreen="list"
|
|
showMarkAsRead={convo.view.unreadCount > 0}
|
|
hideTrigger={IS_NATIVE}
|
|
blockInfo={blockInfo}
|
|
style={[
|
|
a.absolute,
|
|
a.h_full,
|
|
a.self_end,
|
|
a.justify_center,
|
|
{
|
|
right: tokens.space.lg,
|
|
opacity:
|
|
!gtMobile || showActions || menuControl.isOpen ? 1 : 0,
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
|
|
<LeaveConvoPrompt
|
|
control={leaveConvoControl}
|
|
convoId={convo.view.id}
|
|
currentScreen="list"
|
|
/>
|
|
</View>
|
|
</GestureActionView>
|
|
</ChatListItemPortal.Provider>
|
|
)
|
|
}
|