Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {useMemo} from 'react'
2import {View} from 'react-native'
3import {
4 type AppBskyActorDefs,
5 type ModerationCause,
6 type ModerationDecision,
7} from '@atproto/api'
8import {useLingui} from '@lingui/react/macro'
9import {useNavigation} from '@react-navigation/native'
10
11import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
12import {makeProfileLink} from '#/lib/routes/links'
13import {type NavigationProp} from '#/lib/routes/types'
14import {logger} from '#/logger'
15import {type Shadow} from '#/state/cache/profile-shadow'
16import {
17 type ActiveConvoStates,
18 isConvoActive,
19 useConvo,
20} from '#/state/messages/convo'
21import {type ConvoItem} from '#/state/messages/convo/types'
22import {useSession} from '#/state/session'
23import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
24import {atoms as a, useTheme} from '#/alf'
25import {AvatarBubbles} from '#/components/AvatarBubbles'
26import {Button, ButtonIcon} from '#/components/Button'
27import {ConvoMenu} from '#/components/dms/ConvoMenu'
28import {Bell2Off_Filled_Corner0_Rounded as BellOffIcon} from '#/components/icons/Bell2'
29import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
30import * as Layout from '#/components/Layout'
31import {Link} from '#/components/Link'
32import {ProfileBadges} from '#/components/ProfileBadges'
33import {Text} from '#/components/Typography'
34import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
35
36const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE
37
38export function MessagesListHeader({
39 profile,
40 moderation,
41}: {
42 profile?: Shadow<AppBskyActorDefs.ProfileViewDetailed>
43 moderation?: ModerationDecision | null
44}) {
45 const t = useTheme()
46
47 const convoState = useConvo()
48 const isGroupChat = convoState?.isGroup?.()
49
50 const blockInfo = useMemo(() => {
51 if (!moderation) return
52 const modui = moderation.ui('profileView')
53 const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
54 const listBlocks = blocks.filter(alert => alert.source.type === 'list')
55 const userBlock = blocks.find(alert => alert.source.type === 'user')
56 return {
57 listBlocks,
58 userBlock,
59 }
60 }, [moderation])
61
62 return (
63 <Layout.Header.Outer noBottomBorder={IS_LIQUID_GLASS}>
64 <View style={[a.w_full, a.flex_row, a.gap_xs, a.align_start]}>
65 <View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
66 <Layout.Header.BackButton />
67 </View>
68 {isConvoActive(convoState) ? (
69 moderation && blockInfo && profile && !isGroupChat ? (
70 <ProfileHeaderReady
71 convoState={convoState}
72 profile={profile}
73 moderation={moderation}
74 blockInfo={blockInfo}
75 />
76 ) : (
77 <GroupHeaderReady
78 convoState={convoState}
79 profile={profile}
80 moderation={moderation}
81 />
82 )
83 ) : (
84 <>
85 <View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1]}>
86 <View
87 style={[
88 {width: PFP_SIZE, height: PFP_SIZE},
89 a.rounded_full,
90 t.atoms.bg_contrast_25,
91 ]}
92 />
93 <View style={a.gap_xs}>
94 <View
95 style={[
96 {width: 150, height: 16},
97 a.rounded_xs,
98 t.atoms.bg_contrast_25,
99 a.mt_xs,
100 ]}
101 />
102 </View>
103 </View>
104
105 <Layout.Header.Slot />
106 </>
107 )}
108 </View>
109 </Layout.Header.Outer>
110 )
111}
112
113function ProfileHeaderReady({
114 convoState,
115 profile,
116 moderation,
117 blockInfo,
118}: {
119 convoState: ActiveConvoStates
120 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
121 moderation: ModerationDecision
122 blockInfo: {
123 listBlocks: ModerationCause[]
124 userBlock?: ModerationCause
125 }
126}) {
127 const {t: l} = useLingui()
128 const {currentAccount} = useSession()
129
130 const isDeletedAccount = profile?.handle === 'missing.invalid'
131 const displayName = isDeletedAccount
132 ? l`Deleted Account`
133 : createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
134
135 const latestMessageFromOther = convoState.items.findLast(
136 (item: ConvoItem) =>
137 item.type === 'message' &&
138 item.message.sender.did !== currentAccount?.did,
139 )
140
141 const latestReportableMessage =
142 latestMessageFromOther?.type === 'message'
143 ? latestMessageFromOther.message
144 : undefined
145
146 return (
147 <Wrapper
148 heading={
149 <Link
150 label={l`View ${displayName}鈥檚 profile`}
151 style={[a.flex_row, a.gap_md, a.flex_1, a.pr_md]}
152 to={makeProfileLink(profile)}>
153 <PreviewableUserAvatar
154 size={PFP_SIZE}
155 profile={profile}
156 moderation={moderation.ui('avatar')}
157 disableHoverCard={moderation.blocked}
158 />
159 <View style={[a.flex_row, a.align_center, a.flex_1]}>
160 <Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
161 {displayName}
162 </Text>
163 <ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
164 </View>
165 </Link>
166 }
167 muted={convoState.convo?.muted}
168 settings={
169 isConvoActive(convoState) ? (
170 <ConvoMenu
171 convo={convoState.convo}
172 profile={profile}
173 currentScreen="conversation"
174 blockInfo={blockInfo}
175 latestReportableMessage={latestReportableMessage}
176 />
177 ) : null
178 }
179 />
180 )
181}
182
183function GroupHeaderReady({
184 convoState,
185 profile,
186 moderation,
187}: {
188 convoState: ActiveConvoStates
189 profile?: Shadow<AppBskyActorDefs.ProfileViewDetailed>
190 moderation?: ModerationDecision | null
191}) {
192 const {t: l} = useLingui()
193
194 const navigation = useNavigation<NavigationProp>()
195
196 const groupInfo = convoState.getGroupInfo?.()
197
198 const isDeletedAccount = profile?.handle === 'missing.invalid'
199 const displayName = isDeletedAccount
200 ? l`Deleted Account`
201 : profile
202 ? createSanitizedDisplayName(profile, true, moderation?.ui('displayName'))
203 : undefined
204 const groupName =
205 groupInfo?.name ??
206 (displayName ? l`${displayName}鈥檚 group chat` : l`Group chat`)
207
208 const handleNavigateToSettings = () => {
209 const convoId = convoState.convo?.id
210 if (convoId) {
211 navigation.navigate('MessagesConversationSettings', {
212 conversation: convoId,
213 })
214 } else {
215 logger.error(`handleNavigateToSettings: missing convo ID`)
216 }
217 }
218
219 return (
220 <Wrapper
221 heading={
222 <>
223 <AvatarBubbles size="small" profiles={convoState.recipients ?? []} />
224 <Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
225 {groupName}
226 </Text>
227 </>
228 }
229 muted={convoState.convo?.muted}
230 settings={
231 isConvoActive(convoState) ? (
232 <Button
233 label={l`Open group chat settings`}
234 size="small"
235 color="secondary"
236 shape="round"
237 variant="ghost"
238 style={[a.bg_transparent]}
239 onPress={handleNavigateToSettings}>
240 <ButtonIcon icon={DotsHorizontalIcon} size="md" />
241 </Button>
242 ) : null
243 }
244 />
245 )
246}
247
248function Wrapper({
249 heading,
250 muted,
251 settings,
252}: {
253 heading: React.ReactNode
254 muted: boolean
255 settings: React.ReactNode
256}) {
257 return (
258 <View style={[a.flex_1]}>
259 <View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
260 <View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}>
261 {heading}
262 <MuteStatus muted={muted} />
263 </View>
264
265 <View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
266 <Layout.Header.Slot>{settings}</Layout.Header.Slot>
267 </View>
268 </View>
269 </View>
270 )
271}
272
273function MuteStatus({muted}: {muted: boolean}) {
274 const t = useTheme()
275
276 return muted ? (
277 <>
278 <Text style={[a.text_md, t.atoms.text_contrast_medium]}> · </Text>
279 <BellOffIcon size="sm" style={t.atoms.text_contrast_medium} />
280 </>
281 ) : undefined
282}