Bluesky app fork with some witchin' additions ๐Ÿ’ซ

[๐Ÿด] Option to share via chat in post dropdown (#4231)

* add send via chat button to post dropdown

(cherry picked from commit d8458c0bc344f993266f7bc7e325d47e40619648)

* let usePostQuery take uris with DIDs

(cherry picked from commit 16b577ce749fd07e1d5f8461e8ca71c5b874a936)

* add embed preview in composer

(cherry picked from commit 795ceb98d55b6a3ab5b83187a582f9656d71db69)

* rm log

(cherry picked from commit 374d6b8869459f08d8442a3a47d67149e8d9ddd4)

* remove params properly, or at least as close to

(cherry picked from commit c20e0062c2ca4d9c2b28324eee5e713a1a3ab251)

* show images in preview

(cherry picked from commit 5bb617a3ce00f67bfc79784b2f81ef8dcb5bfc25)

* Register embed immediately

(cherry picked from commit ee120d5438a2c91c8980288665576d6a29b4c7e7)

* Add hover to match embeds

(cherry picked from commit 5297a5b06e499f46a9f6da510124610005db2448)

* Update post dropdown copy

(cherry picked from commit bc7e9f6a4303926a53c5c889f1f1b136faf20491)

* Embed preview style tweaks

(cherry picked from commit 9e3ccb0f25ac2f3ce6af538bb29112a3e96e01b1)

* use hydrated posts from API and just use postembed component

(cherry picked from commit cc0b84db87ca812d76cc69f46170ae84cfdde4ef)

* fix type error

(cherry picked from commit 9c49b940e1248e8a7c3b64190c5cb20750043619)

* undo needless export

(cherry picked from commit 1186701c997c50c0b29a809637cb9bc061b8c0a0)

* fix overflow

(cherry picked from commit 8868d5075062d0199c8ef6946fabde27e46ea378)

---------

Co-authored-by: Eric Bailey <git@esb.lol>

authored by samuel.fm

Eric Bailey and committed by
GitHub
cd3b502b 22e1eb18

+712 -406
+1 -1
package.json
··· 49 49 "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" 50 50 }, 51 51 "dependencies": { 52 - "@atproto/api": "^0.12.13", 52 + "@atproto/api": "^0.12.14", 53 53 "@bam.tech/react-native-image-resizer": "^3.0.4", 54 54 "@braintree/sanitize-url": "^6.0.2", 55 55 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+1 -1
src/components/dms/MessageItem.tsx
··· 82 82 return ( 83 83 <View style={[isFromSelf ? a.mr_md : a.ml_md]}> 84 84 <ActionsWrapper isFromSelf={isFromSelf} message={message}> 85 - {AppBskyEmbedRecord.isMain(message.embed) && ( 85 + {AppBskyEmbedRecord.isView(message.embed) && ( 86 86 <MessageItemEmbed embed={message.embed} /> 87 87 )} 88 88 {rt.text.length > 0 && (
+6 -93
src/components/dms/MessageItemEmbed.tsx
··· 1 - import React, {useMemo} from 'react' 1 + import React from 'react' 2 2 import {View} from 'react-native' 3 - import { 4 - AppBskyEmbedRecord, 5 - AppBskyFeedPost, 6 - AtUri, 7 - RichText as RichTextAPI, 8 - } from '@atproto/api' 3 + import {AppBskyEmbedRecord} from '@atproto/api' 9 4 10 - import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 11 - import {makeProfileLink} from '#/lib/routes/links' 12 - import {useModerationOpts} from '#/state/preferences/moderation-opts' 13 - import {usePostQuery} from '#/state/queries/post' 14 5 import {PostEmbeds} from '#/view/com/util/post-embeds' 15 - import {PostMeta} from '#/view/com/util/PostMeta' 16 6 import {atoms as a, useTheme} from '#/alf' 17 - import {Link} from '#/components/Link' 18 - import {ContentHider} from '#/components/moderation/ContentHider' 19 - import {PostAlerts} from '#/components/moderation/PostAlerts' 20 - import {RichText} from '#/components/RichText' 21 7 22 8 let MessageItemEmbed = ({ 23 9 embed, 24 10 }: { 25 - embed: AppBskyEmbedRecord.Main 11 + embed: AppBskyEmbedRecord.View 26 12 }): React.ReactNode => { 27 13 const t = useTheme() 28 - const {data: post} = usePostQuery(embed.record.uri) 29 - 30 - const moderationOpts = useModerationOpts() 31 - const moderation = useMemo( 32 - () => 33 - moderationOpts && post ? moderatePost(post, moderationOpts) : undefined, 34 - [moderationOpts, post], 35 - ) 36 - 37 - const {rt, record} = useMemo(() => { 38 - if ( 39 - post && 40 - AppBskyFeedPost.isRecord(post.record) && 41 - AppBskyFeedPost.validateRecord(post.record).success 42 - ) { 43 - return { 44 - rt: new RichTextAPI({ 45 - text: post.record.text, 46 - facets: post.record.facets, 47 - }), 48 - record: post.record, 49 - } 50 - } 51 - 52 - return {rt: undefined, record: undefined} 53 - }, [post]) 54 - 55 - if (!post || !moderation || !rt || !record) { 56 - return null 57 - } 58 - 59 - const itemUrip = new AtUri(post.uri) 60 - const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey) 61 14 62 15 return ( 63 - <Link to={itemHref}> 64 - <View 65 - style={[ 66 - a.w_full, 67 - t.atoms.bg, 68 - t.atoms.border_contrast_low, 69 - a.rounded_md, 70 - a.border, 71 - a.p_md, 72 - a.my_xs, 73 - ]}> 74 - <PostMeta 75 - showAvatar 76 - author={post.author} 77 - moderation={moderation} 78 - authorHasWarning={!!post.author.labels?.length} 79 - timestamp={post.indexedAt} 80 - postHref={itemHref} 81 - /> 82 - <ContentHider modui={moderation.ui('contentView')}> 83 - <PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} /> 84 - {rt.text && ( 85 - <View style={a.mt_xs}> 86 - <RichText 87 - enableTags 88 - testID="postText" 89 - value={rt} 90 - style={[a.text_sm, t.atoms.text_contrast_high]} 91 - authorHandle={post.author.handle} 92 - /> 93 - </View> 94 - )} 95 - {post.embed && ( 96 - <PostEmbeds 97 - embed={post.embed} 98 - moderation={moderation} 99 - style={a.mt_xs} 100 - quoteTextStyle={[a.text_sm, t.atoms.text_contrast_high]} 101 - /> 102 - )} 103 - </ContentHider> 104 - </View> 105 - </Link> 16 + <View style={[a.my_xs, t.atoms.bg, a.rounded_md, {flexBasis: 0}]}> 17 + <PostEmbeds embed={embed} /> 18 + </View> 106 19 ) 107 20 } 108 21 MessageItemEmbed = React.memo(MessageItemEmbed)
src/components/dms/NewChatDialog/TextInput.tsx src/components/dms/dialogs/TextInput.tsx
src/components/dms/NewChatDialog/TextInput.web.tsx src/components/dms/dialogs/TextInput.web.tsx
+204 -250
src/components/dms/NewChatDialog/index.tsx src/components/dms/dialogs/SearchablePeopleList.tsx
··· 16 16 import {sanitizeHandle} from '#/lib/strings/handles' 17 17 import {isWeb} from '#/platform/detection' 18 18 import {useModerationOpts} from '#/state/preferences/moderation-opts' 19 - import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 20 19 import {useProfileFollowsQuery} from '#/state/queries/profile-follows' 21 20 import {useSession} from '#/state/session' 22 - import {logEvent} from 'lib/statsig/statsig' 23 21 import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete' 24 - import {FAB} from '#/view/com/util/fab/FAB' 25 - import * as Toast from '#/view/com/util/Toast' 26 22 import {UserAvatar} from '#/view/com/util/UserAvatar' 27 23 import {atoms as a, native, useTheme, web} from '#/alf' 28 24 import {Button} from '#/components/Button' 29 25 import * as Dialog from '#/components/Dialog' 30 - import {TextInput} from '#/components/dms/NewChatDialog/TextInput' 26 + import {TextInput} from '#/components/dms/dialogs/TextInput' 31 27 import {canBeMessaged} from '#/components/dms/util' 32 28 import {useInteractionState} from '#/components/hooks/useInteractionState' 33 29 import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' 34 30 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 35 - import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 36 31 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 37 32 import {Text} from '#/components/Typography' 38 33 ··· 57 52 key: string 58 53 } 59 54 60 - export function NewChat({ 61 - control, 62 - onNewChat, 63 - }: { 64 - control: Dialog.DialogControlProps 65 - onNewChat: (chatId: string) => void 66 - }) { 67 - const t = useTheme() 68 - const {_} = useLingui() 69 - 70 - const {mutate: createChat} = useGetConvoForMembers({ 71 - onSuccess: data => { 72 - onNewChat(data.convo.id) 73 - 74 - if (!data.convo.lastMessage) { 75 - logEvent('chat:create', {logContext: 'NewChatDialog'}) 76 - } 77 - logEvent('chat:open', {logContext: 'NewChatDialog'}) 78 - }, 79 - onError: error => { 80 - Toast.show(error.message) 81 - }, 82 - }) 83 - 84 - const onCreateChat = useCallback( 85 - (did: string) => { 86 - control.close(() => createChat([did])) 87 - }, 88 - [control, createChat], 89 - ) 90 - 91 - return ( 92 - <> 93 - <FAB 94 - testID="newChatFAB" 95 - onPress={control.open} 96 - icon={<Plus size="lg" fill={t.palette.white} />} 97 - accessibilityRole="button" 98 - accessibilityLabel={_(msg`New chat`)} 99 - accessibilityHint="" 100 - /> 101 - 102 - <Dialog.Outer 103 - control={control} 104 - testID="newChatDialog" 105 - nativeOptions={{sheet: {snapPoints: ['100%']}}}> 106 - <SearchablePeopleList onCreateChat={onCreateChat} /> 107 - </Dialog.Outer> 108 - </> 109 - ) 110 - } 111 - 112 - function ProfileCard({ 113 - enabled, 114 - profile, 115 - moderationOpts, 116 - onPress, 117 - }: { 118 - enabled: boolean 119 - profile: AppBskyActorDefs.ProfileView 120 - moderationOpts: ModerationOpts 121 - onPress: (did: string) => void 122 - }) { 123 - const t = useTheme() 124 - const {_} = useLingui() 125 - const moderation = moderateProfile(profile, moderationOpts) 126 - const handle = sanitizeHandle(profile.handle, '@') 127 - const displayName = sanitizeDisplayName( 128 - profile.displayName || sanitizeHandle(profile.handle), 129 - moderation.ui('displayName'), 130 - ) 131 - 132 - const handleOnPress = useCallback(() => { 133 - onPress(profile.did) 134 - }, [onPress, profile.did]) 135 - 136 - return ( 137 - <Button 138 - disabled={!enabled} 139 - label={_(msg`Start chat with ${displayName}`)} 140 - onPress={handleOnPress}> 141 - {({hovered, pressed, focused}) => ( 142 - <View 143 - style={[ 144 - a.flex_1, 145 - a.py_md, 146 - a.px_lg, 147 - a.gap_md, 148 - a.align_center, 149 - a.flex_row, 150 - !enabled 151 - ? {opacity: 0.5} 152 - : pressed || focused 153 - ? t.atoms.bg_contrast_25 154 - : hovered 155 - ? t.atoms.bg_contrast_50 156 - : t.atoms.bg, 157 - ]}> 158 - <UserAvatar 159 - size={42} 160 - avatar={profile.avatar} 161 - moderation={moderation.ui('avatar')} 162 - type={profile.associated?.labeler ? 'labeler' : 'user'} 163 - /> 164 - <View style={[a.flex_1, a.gap_2xs]}> 165 - <Text 166 - style={[t.atoms.text, a.font_bold, a.leading_snug]} 167 - numberOfLines={1}> 168 - {displayName} 169 - </Text> 170 - <Text style={t.atoms.text_contrast_high} numberOfLines={2}> 171 - {!enabled ? <Trans>{handle} can't be messaged</Trans> : handle} 172 - </Text> 173 - </View> 174 - </View> 175 - )} 176 - </Button> 177 - ) 178 - } 179 - 180 - function ProfileCardSkeleton() { 181 - const t = useTheme() 182 - 183 - return ( 184 - <View 185 - style={[ 186 - a.flex_1, 187 - a.py_md, 188 - a.px_lg, 189 - a.gap_md, 190 - a.align_center, 191 - a.flex_row, 192 - ]}> 193 - <View 194 - style={[ 195 - a.rounded_full, 196 - {width: 42, height: 42}, 197 - t.atoms.bg_contrast_25, 198 - ]} 199 - /> 200 - 201 - <View style={[a.flex_1, a.gap_sm]}> 202 - <View 203 - style={[ 204 - a.rounded_xs, 205 - {width: 80, height: 14}, 206 - t.atoms.bg_contrast_25, 207 - ]} 208 - /> 209 - <View 210 - style={[ 211 - a.rounded_xs, 212 - {width: 120, height: 10}, 213 - t.atoms.bg_contrast_25, 214 - ]} 215 - /> 216 - </View> 217 - </View> 218 - ) 219 - } 220 - 221 - function Empty({message}: {message: string}) { 222 - const t = useTheme() 223 - return ( 224 - <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> 225 - <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> 226 - {message} 227 - </Text> 228 - 229 - <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(โ•ฏยฐโ–กยฐ)โ•ฏ๏ธต โ”ปโ”โ”ป</Text> 230 - </View> 231 - ) 232 - } 233 - 234 - function SearchInput({ 235 - value, 236 - onChangeText, 237 - onEscape, 238 - inputRef, 239 - }: { 240 - value: string 241 - onChangeText: (text: string) => void 242 - onEscape: () => void 243 - inputRef: React.RefObject<TextInputType> 244 - }) { 245 - const t = useTheme() 246 - const {_} = useLingui() 247 - const { 248 - state: hovered, 249 - onIn: onMouseEnter, 250 - onOut: onMouseLeave, 251 - } = useInteractionState() 252 - const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 253 - const interacted = hovered || focused 254 - 255 - return ( 256 - <View 257 - {...web({ 258 - onMouseEnter, 259 - onMouseLeave, 260 - })} 261 - style={[a.flex_row, a.align_center, a.gap_sm]}> 262 - <Search 263 - size="md" 264 - fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} 265 - /> 266 - 267 - <TextInput 268 - // @ts-ignore bottom sheet input types issue โ€” esb 269 - ref={inputRef} 270 - placeholder={_(msg`Search`)} 271 - value={value} 272 - onChangeText={onChangeText} 273 - onFocus={onFocus} 274 - onBlur={onBlur} 275 - style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} 276 - placeholderTextColor={t.palette.contrast_500} 277 - keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} 278 - returnKeyType="search" 279 - clearButtonMode="while-editing" 280 - maxLength={50} 281 - onKeyPress={({nativeEvent}) => { 282 - if (nativeEvent.key === 'Escape') { 283 - onEscape() 284 - } 285 - }} 286 - autoCorrect={false} 287 - autoComplete="off" 288 - autoCapitalize="none" 289 - autoFocus 290 - accessibilityLabel={_(msg`Search profiles`)} 291 - accessibilityHint={_(msg`Search profiles`)} 292 - /> 293 - </View> 294 - ) 295 - } 296 - 297 - function SearchablePeopleList({ 298 - onCreateChat, 55 + export function SearchablePeopleList({ 56 + title, 57 + onSelectChat, 299 58 }: { 300 - onCreateChat: (did: string) => void 59 + title: string 60 + onSelectChat: (did: string) => void 301 61 }) { 302 62 const t = useTheme() 303 63 const {_} = useLingui() ··· 388 148 enabled={item.enabled} 389 149 profile={item.profile} 390 150 moderationOpts={moderationOpts!} 391 - onPress={onCreateChat} 151 + onPress={onSelectChat} 392 152 /> 393 153 ) 394 154 } ··· 402 162 return null 403 163 } 404 164 }, 405 - [moderationOpts, onCreateChat], 165 + [moderationOpts, onSelectChat], 406 166 ) 407 167 408 168 useLayoutEffect(() => { ··· 464 224 a.leading_tight, 465 225 t.atoms.text_contrast_high, 466 226 ]}> 467 - <Trans>Start a new chat</Trans> 227 + {title} 468 228 </Text> 469 229 </View> 470 230 ··· 481 241 </View> 482 242 </View> 483 243 ) 484 - }, [t, _, control, searchText]) 244 + }, [ 245 + t.atoms.border_contrast_low, 246 + t.atoms.bg, 247 + t.atoms.text_contrast_high, 248 + t.palette.contrast_500, 249 + _, 250 + title, 251 + searchText, 252 + control, 253 + ]) 485 254 486 255 return ( 487 256 <Dialog.InnerFlatList ··· 507 276 /> 508 277 ) 509 278 } 279 + 280 + function ProfileCard({ 281 + enabled, 282 + profile, 283 + moderationOpts, 284 + onPress, 285 + }: { 286 + enabled: boolean 287 + profile: AppBskyActorDefs.ProfileView 288 + moderationOpts: ModerationOpts 289 + onPress: (did: string) => void 290 + }) { 291 + const t = useTheme() 292 + const {_} = useLingui() 293 + const moderation = moderateProfile(profile, moderationOpts) 294 + const handle = sanitizeHandle(profile.handle, '@') 295 + const displayName = sanitizeDisplayName( 296 + profile.displayName || sanitizeHandle(profile.handle), 297 + moderation.ui('displayName'), 298 + ) 299 + 300 + const handleOnPress = useCallback(() => { 301 + onPress(profile.did) 302 + }, [onPress, profile.did]) 303 + 304 + return ( 305 + <Button 306 + disabled={!enabled} 307 + label={_(msg`Start chat with ${displayName}`)} 308 + onPress={handleOnPress}> 309 + {({hovered, pressed, focused}) => ( 310 + <View 311 + style={[ 312 + a.flex_1, 313 + a.py_md, 314 + a.px_lg, 315 + a.gap_md, 316 + a.align_center, 317 + a.flex_row, 318 + !enabled 319 + ? {opacity: 0.5} 320 + : pressed || focused 321 + ? t.atoms.bg_contrast_25 322 + : hovered 323 + ? t.atoms.bg_contrast_50 324 + : t.atoms.bg, 325 + ]}> 326 + <UserAvatar 327 + size={42} 328 + avatar={profile.avatar} 329 + moderation={moderation.ui('avatar')} 330 + type={profile.associated?.labeler ? 'labeler' : 'user'} 331 + /> 332 + <View style={[a.flex_1, a.gap_2xs]}> 333 + <Text 334 + style={[t.atoms.text, a.font_bold, a.leading_snug]} 335 + numberOfLines={1}> 336 + {displayName} 337 + </Text> 338 + <Text style={t.atoms.text_contrast_high} numberOfLines={2}> 339 + {!enabled ? <Trans>{handle} can't be messaged</Trans> : handle} 340 + </Text> 341 + </View> 342 + </View> 343 + )} 344 + </Button> 345 + ) 346 + } 347 + 348 + function ProfileCardSkeleton() { 349 + const t = useTheme() 350 + 351 + return ( 352 + <View 353 + style={[ 354 + a.flex_1, 355 + a.py_md, 356 + a.px_lg, 357 + a.gap_md, 358 + a.align_center, 359 + a.flex_row, 360 + ]}> 361 + <View 362 + style={[ 363 + a.rounded_full, 364 + {width: 42, height: 42}, 365 + t.atoms.bg_contrast_25, 366 + ]} 367 + /> 368 + 369 + <View style={[a.flex_1, a.gap_sm]}> 370 + <View 371 + style={[ 372 + a.rounded_xs, 373 + {width: 80, height: 14}, 374 + t.atoms.bg_contrast_25, 375 + ]} 376 + /> 377 + <View 378 + style={[ 379 + a.rounded_xs, 380 + {width: 120, height: 10}, 381 + t.atoms.bg_contrast_25, 382 + ]} 383 + /> 384 + </View> 385 + </View> 386 + ) 387 + } 388 + 389 + function Empty({message}: {message: string}) { 390 + const t = useTheme() 391 + return ( 392 + <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> 393 + <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> 394 + {message} 395 + </Text> 396 + 397 + <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(โ•ฏยฐโ–กยฐ)โ•ฏ๏ธต โ”ปโ”โ”ป</Text> 398 + </View> 399 + ) 400 + } 401 + 402 + function SearchInput({ 403 + value, 404 + onChangeText, 405 + onEscape, 406 + inputRef, 407 + }: { 408 + value: string 409 + onChangeText: (text: string) => void 410 + onEscape: () => void 411 + inputRef: React.RefObject<TextInputType> 412 + }) { 413 + const t = useTheme() 414 + const {_} = useLingui() 415 + const { 416 + state: hovered, 417 + onIn: onMouseEnter, 418 + onOut: onMouseLeave, 419 + } = useInteractionState() 420 + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 421 + const interacted = hovered || focused 422 + 423 + return ( 424 + <View 425 + {...web({ 426 + onMouseEnter, 427 + onMouseLeave, 428 + })} 429 + style={[a.flex_row, a.align_center, a.gap_sm]}> 430 + <Search 431 + size="md" 432 + fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} 433 + /> 434 + 435 + <TextInput 436 + // @ts-ignore bottom sheet input types issue โ€” esb 437 + ref={inputRef} 438 + placeholder={_(msg`Search`)} 439 + value={value} 440 + onChangeText={onChangeText} 441 + onFocus={onFocus} 442 + onBlur={onBlur} 443 + style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} 444 + placeholderTextColor={t.palette.contrast_500} 445 + keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} 446 + returnKeyType="search" 447 + clearButtonMode="while-editing" 448 + maxLength={50} 449 + onKeyPress={({nativeEvent}) => { 450 + if (nativeEvent.key === 'Escape') { 451 + onEscape() 452 + } 453 + }} 454 + autoCorrect={false} 455 + autoComplete="off" 456 + autoCapitalize="none" 457 + autoFocus 458 + accessibilityLabel={_(msg`Search profiles`)} 459 + accessibilityHint={_(msg`Search profiles`)} 460 + /> 461 + </View> 462 + ) 463 + }
+67
src/components/dms/dialogs/NewChatDialog.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 6 + import {logEvent} from 'lib/statsig/statsig' 7 + import {FAB} from '#/view/com/util/fab/FAB' 8 + import * as Toast from '#/view/com/util/Toast' 9 + import {useTheme} from '#/alf' 10 + import * as Dialog from '#/components/Dialog' 11 + import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 12 + import {SearchablePeopleList} from './SearchablePeopleList' 13 + 14 + export function NewChat({ 15 + control, 16 + onNewChat, 17 + }: { 18 + control: Dialog.DialogControlProps 19 + onNewChat: (chatId: string) => void 20 + }) { 21 + const t = useTheme() 22 + const {_} = useLingui() 23 + 24 + const {mutate: createChat} = useGetConvoForMembers({ 25 + onSuccess: data => { 26 + onNewChat(data.convo.id) 27 + 28 + if (!data.convo.lastMessage) { 29 + logEvent('chat:create', {logContext: 'NewChatDialog'}) 30 + } 31 + logEvent('chat:open', {logContext: 'NewChatDialog'}) 32 + }, 33 + onError: error => { 34 + Toast.show(error.message) 35 + }, 36 + }) 37 + 38 + const onCreateChat = useCallback( 39 + (did: string) => { 40 + control.close(() => createChat([did])) 41 + }, 42 + [control, createChat], 43 + ) 44 + 45 + return ( 46 + <> 47 + <FAB 48 + testID="newChatFAB" 49 + onPress={control.open} 50 + icon={<Plus size="lg" fill={t.palette.white} />} 51 + accessibilityRole="button" 52 + accessibilityLabel={_(msg`New chat`)} 53 + accessibilityHint="" 54 + /> 55 + 56 + <Dialog.Outer 57 + control={control} 58 + testID="newChatDialog" 59 + nativeOptions={{sheet: {snapPoints: ['100%']}}}> 60 + <SearchablePeopleList 61 + title={_(msg`Start a new chat`)} 62 + onSelectChat={onCreateChat} 63 + /> 64 + </Dialog.Outer> 65 + </> 66 + ) 67 + }
+52
src/components/dms/dialogs/ShareViaChatDialog.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 6 + import {logEvent} from 'lib/statsig/statsig' 7 + import * as Toast from '#/view/com/util/Toast' 8 + import * as Dialog from '#/components/Dialog' 9 + import {SearchablePeopleList} from './SearchablePeopleList' 10 + 11 + export function SendViaChatDialog({ 12 + control, 13 + onSelectChat, 14 + }: { 15 + control: Dialog.DialogControlProps 16 + onSelectChat: (chatId: string) => void 17 + }) { 18 + const {_} = useLingui() 19 + 20 + const {mutate: createChat} = useGetConvoForMembers({ 21 + onSuccess: data => { 22 + onSelectChat(data.convo.id) 23 + 24 + if (!data.convo.lastMessage) { 25 + logEvent('chat:create', {logContext: 'SendViaChatDialog'}) 26 + } 27 + logEvent('chat:open', {logContext: 'SendViaChatDialog'}) 28 + }, 29 + onError: error => { 30 + Toast.show(error.message) 31 + }, 32 + }) 33 + 34 + const onCreateChat = useCallback( 35 + (did: string) => { 36 + control.close(() => createChat([did])) 37 + }, 38 + [control, createChat], 39 + ) 40 + 41 + return ( 42 + <Dialog.Outer 43 + control={control} 44 + testID="sendViaChatChatDialog" 45 + nativeOptions={{sheet: {snapPoints: ['100%']}}}> 46 + <SearchablePeopleList 47 + title={_(msg`Send post to...`)} 48 + onSelectChat={onCreateChat} 49 + /> 50 + </Dialog.Outer> 51 + ) 52 + }
+1 -1
src/lib/routes/types.ts
··· 38 38 AccessibilitySettings: undefined 39 39 Search: {q?: string} 40 40 Hashtag: {tag: string; author?: string} 41 - MessagesConversation: {conversation: string} 41 + MessagesConversation: {conversation: string; embed?: string} 42 42 MessagesSettings: undefined 43 43 } 44 44
+6 -2
src/lib/statsig/events.ts
··· 130 130 | 'AvatarButton' 131 131 } 132 132 'chat:create': { 133 - logContext: 'ProfileHeader' | 'NewChatDialog' 133 + logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' 134 134 } 135 135 'chat:open': { 136 - logContext: 'ProfileHeader' | 'NewChatDialog' | 'ChatsList' 136 + logContext: 137 + | 'ProfileHeader' 138 + | 'NewChatDialog' 139 + | 'ChatsList' 140 + | 'SendViaChatDialog' 137 141 } 138 142 139 143 'test:all:always': {}
+21 -2
src/screens/Messages/Conversation/MessageInput.tsx
··· 27 27 import {atoms as a, useTheme} from '#/alf' 28 28 import {useSharedInputStyles} from '#/components/forms/TextField' 29 29 import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' 30 + import {useExtractEmbedFromFacets} from './MessageInputEmbed' 30 31 31 32 const AnimatedTextInput = Animated.createAnimatedComponent(TextInput) 32 33 33 34 export function MessageInput({ 34 35 onSendMessage, 36 + hasEmbed, 37 + setEmbed, 38 + children, 35 39 }: { 36 40 onSendMessage: (message: string) => void 41 + hasEmbed: boolean 42 + setEmbed: (embedUrl: string | undefined) => void 43 + children?: React.ReactNode 37 44 }) { 38 45 const {_} = useLingui() 39 46 const t = useTheme() ··· 53 60 const inputRef = useAnimatedRef<TextInput>() 54 61 55 62 useSaveMessageDraft(message) 63 + useExtractEmbedFromFacets(message, setEmbed) 56 64 57 65 const onSubmit = React.useCallback(() => { 58 - if (message.trim() === '') { 66 + if (!hasEmbed && message.trim() === '') { 59 67 return 60 68 } 61 69 if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { ··· 66 74 onSendMessage(message) 67 75 playHaptic() 68 76 setMessage('') 77 + setEmbed(undefined) 69 78 70 79 // Pressing the send button causes the text input to lose focus, so we need to 71 80 // re-focus it after sending 72 81 setTimeout(() => { 73 82 inputRef.current?.focus() 74 83 }, 100) 75 - }, [message, clearDraft, onSendMessage, playHaptic, _, inputRef]) 84 + }, [ 85 + hasEmbed, 86 + message, 87 + clearDraft, 88 + onSendMessage, 89 + playHaptic, 90 + setEmbed, 91 + _, 92 + inputRef, 93 + ]) 76 94 77 95 useFocusedInputHandler( 78 96 { ··· 101 119 102 120 return ( 103 121 <View style={[a.px_md, a.pb_sm, a.pt_xs]}> 122 + {children} 104 123 <View 105 124 style={[ 106 125 a.w_full,
+12 -2
src/screens/Messages/Conversation/MessageInput.web.tsx
··· 16 16 import {atoms as a, useTheme} from '#/alf' 17 17 import {useSharedInputStyles} from '#/components/forms/TextField' 18 18 import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' 19 + import {useExtractEmbedFromFacets} from './MessageInputEmbed' 19 20 20 21 export function MessageInput({ 21 22 onSendMessage, 23 + hasEmbed, 24 + setEmbed, 25 + children, 22 26 }: { 23 27 onSendMessage: (message: string) => void 28 + hasEmbed: boolean 29 + setEmbed: (embedUrl: string | undefined) => void 30 + children?: React.ReactNode 24 31 }) { 25 32 const {isTabletOrDesktop} = useWebMediaQueries() 26 33 const {_} = useLingui() ··· 35 42 const [textAreaHeight, setTextAreaHeight] = React.useState(38) 36 43 37 44 const onSubmit = React.useCallback(() => { 38 - if (message.trim() === '') { 45 + if (!hasEmbed && message.trim() === '') { 39 46 return 40 47 } 41 48 if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { ··· 45 52 clearDraft() 46 53 onSendMessage(message) 47 54 setMessage('') 48 - }, [message, onSendMessage, _, clearDraft]) 55 + setEmbed(undefined) 56 + }, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed]) 49 57 50 58 const onKeyDown = React.useCallback( 51 59 (e: React.KeyboardEvent<HTMLTextAreaElement>) => { ··· 87 95 ) 88 96 89 97 useSaveMessageDraft(message) 98 + useExtractEmbedFromFacets(message, setEmbed) 90 99 91 100 return ( 92 101 <View style={a.p_sm}> 102 + {children} 93 103 <View 94 104 style={[ 95 105 a.flex_row,
+231
src/screens/Messages/Conversation/MessageInputEmbed.tsx
··· 1 + import React, {useCallback, useEffect, useMemo, useState} from 'react' 2 + import {LayoutAnimation, View} from 'react-native' 3 + import { 4 + AppBskyEmbedImages, 5 + AppBskyEmbedRecordWithMedia, 6 + AppBskyFeedPost, 7 + AppBskyRichtextFacet, 8 + AtUri, 9 + RichText as RichTextAPI, 10 + } from '@atproto/api' 11 + import {msg} from '@lingui/macro' 12 + import {useLingui} from '@lingui/react' 13 + import {RouteProp, useNavigation, useRoute} from '@react-navigation/native' 14 + 15 + import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 16 + import {makeProfileLink} from '#/lib/routes/links' 17 + import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' 18 + import { 19 + convertBskyAppUrlIfNeeded, 20 + isBskyPostUrl, 21 + makeRecordUri, 22 + } from '#/lib/strings/url-helpers' 23 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 24 + import {usePostQuery} from '#/state/queries/post' 25 + import {ImageHorzList} from '#/view/com/util/images/ImageHorzList' 26 + import {PostMeta} from '#/view/com/util/PostMeta' 27 + import {atoms as a, useTheme} from '#/alf' 28 + import {Button, ButtonIcon} from '#/components/Button' 29 + import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 30 + import {Loader} from '#/components/Loader' 31 + import {ContentHider} from '#/components/moderation/ContentHider' 32 + import {PostAlerts} from '#/components/moderation/PostAlerts' 33 + import {RichText} from '#/components/RichText' 34 + import {Text} from '#/components/Typography' 35 + 36 + export function useMessageEmbed() { 37 + const route = 38 + useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>() 39 + const navigation = useNavigation<NavigationProp>() 40 + const embedFromParams = route.params.embed 41 + 42 + const [embedUri, setEmbed] = useState(embedFromParams) 43 + 44 + if (embedFromParams && embedUri !== embedFromParams) { 45 + setEmbed(embedFromParams) 46 + } 47 + 48 + return { 49 + embedUri, 50 + setEmbed: useCallback( 51 + (embedUrl: string | undefined) => { 52 + if (!embedUrl) { 53 + navigation.setParams({embed: ''}) 54 + setEmbed(undefined) 55 + return 56 + } 57 + 58 + if (embedFromParams) return 59 + 60 + const url = convertBskyAppUrlIfNeeded(embedUrl) 61 + const [_0, user, _1, rkey] = url.split('/').filter(Boolean) 62 + const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) 63 + 64 + setEmbed(uri) 65 + }, 66 + [embedFromParams, navigation], 67 + ), 68 + } 69 + } 70 + 71 + export function useExtractEmbedFromFacets( 72 + message: string, 73 + setEmbed: (embedUrl: string | undefined) => void, 74 + ) { 75 + const rt = new RichTextAPI({text: message}) 76 + rt.detectFacetsWithoutResolution() 77 + 78 + let uriFromFacet: string | undefined 79 + 80 + for (const facet of rt.facets ?? []) { 81 + for (const feature of facet.features) { 82 + if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) { 83 + uriFromFacet = feature.uri 84 + break 85 + } 86 + } 87 + } 88 + 89 + useEffect(() => { 90 + if (uriFromFacet) { 91 + setEmbed(uriFromFacet) 92 + } 93 + }, [uriFromFacet, setEmbed]) 94 + } 95 + 96 + export function MessageInputEmbed({ 97 + embedUri, 98 + setEmbed, 99 + }: { 100 + embedUri: string | undefined 101 + setEmbed: (embedUrl: string | undefined) => void 102 + }) { 103 + const t = useTheme() 104 + const {_} = useLingui() 105 + 106 + const {data: post, status} = usePostQuery(embedUri) 107 + 108 + const moderationOpts = useModerationOpts() 109 + const moderation = useMemo( 110 + () => 111 + moderationOpts && post ? moderatePost(post, moderationOpts) : undefined, 112 + [moderationOpts, post], 113 + ) 114 + 115 + const {rt, record} = useMemo(() => { 116 + if ( 117 + post && 118 + AppBskyFeedPost.isRecord(post.record) && 119 + AppBskyFeedPost.validateRecord(post.record).success 120 + ) { 121 + return { 122 + rt: new RichTextAPI({ 123 + text: post.record.text, 124 + facets: post.record.facets, 125 + }), 126 + record: post.record, 127 + } 128 + } 129 + 130 + return {rt: undefined, record: undefined} 131 + }, [post]) 132 + 133 + if (!embedUri) { 134 + return null 135 + } 136 + 137 + let content = null 138 + switch (status) { 139 + case 'pending': 140 + content = ( 141 + <View 142 + style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}> 143 + <Loader /> 144 + </View> 145 + ) 146 + break 147 + case 'error': 148 + content = ( 149 + <View 150 + style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}> 151 + <Text style={a.text_center}>Could not fetch post</Text> 152 + </View> 153 + ) 154 + break 155 + case 'success': 156 + const itemUrip = new AtUri(post.uri) 157 + const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey) 158 + 159 + if (!post || !moderation || !rt || !record) { 160 + return null 161 + } 162 + 163 + const images = AppBskyEmbedImages.isView(post.embed) 164 + ? post.embed.images 165 + : AppBskyEmbedRecordWithMedia.isView(post.embed) && 166 + AppBskyEmbedImages.isView(post.embed.media) 167 + ? post.embed.media.images 168 + : undefined 169 + 170 + content = ( 171 + <View 172 + style={[ 173 + a.flex_1, 174 + t.atoms.bg, 175 + t.atoms.border_contrast_low, 176 + a.rounded_md, 177 + a.border, 178 + a.p_sm, 179 + a.mb_sm, 180 + ]} 181 + pointerEvents="none"> 182 + <PostMeta 183 + showAvatar 184 + author={post.author} 185 + moderation={moderation} 186 + authorHasWarning={!!post.author.labels?.length} 187 + timestamp={post.indexedAt} 188 + postHref={itemHref} 189 + style={a.flex_0} 190 + /> 191 + <ContentHider modui={moderation.ui('contentView')}> 192 + <PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} /> 193 + {rt.text && ( 194 + <View style={a.mt_xs}> 195 + <RichText 196 + enableTags 197 + testID="postText" 198 + value={rt} 199 + style={[a.text_sm, t.atoms.text_contrast_high]} 200 + authorHandle={post.author.handle} 201 + numberOfLines={3} 202 + /> 203 + </View> 204 + )} 205 + {images && images?.length > 0 && ( 206 + <ImageHorzList images={images} style={a.mt_xs} /> 207 + )} 208 + </ContentHider> 209 + </View> 210 + ) 211 + break 212 + } 213 + 214 + return ( 215 + <View style={[a.flex_row, a.gap_sm]}> 216 + {content} 217 + <Button 218 + label={_(msg`Remove embed`)} 219 + onPress={() => { 220 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 221 + setEmbed(undefined) 222 + }} 223 + size="tiny" 224 + variant="solid" 225 + color="secondary" 226 + shape="round"> 227 + <ButtonIcon icon={X} /> 228 + </Button> 229 + </View> 230 + ) 231 + }
+50 -37
src/screens/Messages/Conversation/MessagesList.tsx
··· 15 15 import {useSafeAreaInsets} from 'react-native-safe-area-context' 16 16 import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api' 17 17 18 - import {getPostAsQuote} from '#/lib/link-meta/bsky' 19 18 import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' 20 - import {isBskyPostUrl} from '#/lib/strings/url-helpers' 19 + import { 20 + convertBskyAppUrlIfNeeded, 21 + isBskyPostUrl, 22 + } from '#/lib/strings/url-helpers' 21 23 import {logger} from '#/logger' 22 24 import {isNative} from '#/platform/detection' 23 25 import {isConvoActive, useConvoActive} from '#/state/messages/convo' ··· 36 38 import {NewMessagesPill} from '#/components/dms/NewMessagesPill' 37 39 import {Loader} from '#/components/Loader' 38 40 import {Text} from '#/components/Typography' 41 + import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' 39 42 40 43 function MaybeLoader({isLoading}: {isLoading: boolean}) { 41 44 return ( ··· 85 88 const convoState = useConvoActive() 86 89 const agent = useAgent() 87 90 const getPost = useGetPost() 91 + const {embedUri, setEmbed} = useMessageEmbed() 88 92 89 93 const flatListRef = useAnimatedRef<FlatList>() 90 94 ··· 277 281 rt.detectFacetsWithoutResolution() 278 282 279 283 let embed: AppBskyEmbedRecord.Main | undefined 280 - // find the first link facet that is a link to a post 281 - const postLinkFacet = rt.facets?.find(facet => { 282 - return facet.features.find(feature => { 283 - if (AppBskyRichtextFacet.isLink(feature)) { 284 - return isBskyPostUrl(feature.uri) 285 - } 286 - return false 287 - }) 288 - }) 289 284 290 - // if we found a post link, get the post and embed it 291 - if (postLinkFacet) { 292 - const postLink = postLinkFacet.features.find( 293 - AppBskyRichtextFacet.isLink, 294 - ) 295 - if (!postLink) return 296 - 285 + if (embedUri) { 297 286 try { 298 - const post = await getPostAsQuote(getPost, postLink.uri) 287 + const post = await getPost({uri: embedUri}) 299 288 if (post) { 300 289 embed = { 301 290 $type: 'app.bsky.embed.record', ··· 305 294 }, 306 295 } 307 296 308 - // remove the post link from the text 309 - rt.delete( 310 - postLinkFacet.index.byteStart, 311 - postLinkFacet.index.byteEnd, 312 - ) 297 + // look for the embed uri in the facets, so we can remove it from the text 298 + const postLinkFacet = rt.facets?.find(facet => { 299 + return facet.features.find(feature => { 300 + if (AppBskyRichtextFacet.isLink(feature)) { 301 + if (isBskyPostUrl(feature.uri)) { 302 + const url = convertBskyAppUrlIfNeeded(feature.uri) 303 + const [_0, _1, _2, rkey] = url.split('/').filter(Boolean) 304 + 305 + // this might have a handle instead of a DID 306 + // so just compare the rkey - not particularly dangerous 307 + return post.uri.endsWith(rkey) 308 + } 309 + } 310 + return false 311 + }) 312 + }) 313 313 314 - // re-trim the text, now that we've removed the post link 315 - // 316 - // if the post link is at the start of the text, we don't want to leave a leading space 317 - // so trim on both sides 318 - if (postLinkFacet.index.byteStart === 0) { 319 - rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true}) 320 - } else { 321 - // otherwise just trim the end 322 - rt = new RichText( 323 - {text: rt.text.trimEnd()}, 324 - {cleanNewlines: true}, 314 + if (postLinkFacet) { 315 + // remove the post link from the text 316 + rt.delete( 317 + postLinkFacet.index.byteStart, 318 + postLinkFacet.index.byteEnd, 325 319 ) 320 + 321 + // re-trim the text, now that we've removed the post link 322 + // 323 + // if the post link is at the start of the text, we don't want to leave a leading space 324 + // so trim on both sides 325 + if (postLinkFacet.index.byteStart === 0) { 326 + rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true}) 327 + } else { 328 + // otherwise just trim the end 329 + rt = new RichText( 330 + {text: rt.text.trimEnd()}, 331 + {cleanNewlines: true}, 332 + ) 333 + } 326 334 } 327 335 } 328 336 } catch (error) { ··· 345 353 embed, 346 354 }) 347 355 }, 348 - [agent, convoState, getPost, hasScrolled, setHasScrolled], 356 + [agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled], 349 357 ) 350 358 351 359 // -- List layout changes (opening emoji keyboard, etc.) ··· 420 428 {isConvoActive(convoState) && 421 429 !convoState.isFetchingHistory && 422 430 convoState.items.length === 0 && <ChatEmptyPill />} 423 - <MessageInput onSendMessage={onSendMessage} /> 431 + <MessageInput 432 + onSendMessage={onSendMessage} 433 + hasEmbed={!!embedUri} 434 + setEmbed={setEmbed}> 435 + <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> 436 + </MessageInput> 424 437 </> 425 438 )} 426 439 </KeyboardStickyView>
+1 -1
src/screens/Messages/List/index.tsx
··· 21 21 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 22 22 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 23 23 import {DialogControlProps, useDialogControl} from '#/components/Dialog' 24 + import {NewChat} from '#/components/dms/dialogs/NewChatDialog' 24 25 import {MessagesNUX} from '#/components/dms/MessagesNUX' 25 - import {NewChat} from '#/components/dms/NewChatDialog' 26 26 import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' 27 27 import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise' 28 28 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+1
src/state/messages/convo/agent.ts
··· 1018 1018 key: m.id, 1019 1019 message: { 1020 1020 ...m.message, 1021 + embed: undefined, 1021 1022 $type: 'chat.bsky.convo.defs#messageView', 1022 1023 id: nanoid(), 1023 1024 rev: '__fake__',
+11 -2
src/state/queries/post.ts
··· 18 18 return useQuery<AppBskyFeedDefs.PostView>({ 19 19 queryKey: RQKEY(uri || ''), 20 20 async queryFn() { 21 - const res = await agent.getPosts({uris: [uri!]}) 21 + const urip = new AtUri(uri!) 22 + 23 + if (!urip.host.startsWith('did:')) { 24 + const res = await agent.resolveHandle({ 25 + handle: urip.host, 26 + }) 27 + urip.host = res.data.did 28 + } 29 + 30 + const res = await agent.getPosts({uris: [urip.toString()]}) 22 31 if (res.success && res.data.posts[0]) { 23 32 return res.data.posts[0] 24 33 } ··· 47 56 } 48 57 49 58 const res = await agent.getPosts({ 50 - uris: [urip.toString()!], 59 + uris: [urip.toString()], 51 60 }) 52 61 53 62 if (res.success && res.data.posts[0]) {
+1 -1
src/view/com/notifications/FeedItem.tsx
··· 451 451 return ( 452 452 <> 453 453 {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} 454 - {images && images?.length > 0 && ( 454 + {images && images.length > 0 && ( 455 455 <ImageHorzList images={images} style={styles.additionalPostImages} /> 456 456 )} 457 457 </>
+33 -3
src/view/com/util/forms/PostDropdownBtn.tsx
··· 12 12 AtUri, 13 13 RichText as RichTextAPI, 14 14 } from '@atproto/api' 15 - import {msg} from '@lingui/macro' 15 + import {msg, Trans} from '@lingui/macro' 16 16 import {useLingui} from '@lingui/react' 17 17 import {useNavigation} from '@react-navigation/native' 18 18 19 19 import {makeProfileLink} from '#/lib/routes/links' 20 - import {CommonNavigatorParams} from '#/lib/routes/types' 20 + import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' 21 21 import {richTextToString} from '#/lib/strings/rich-text-helpers' 22 22 import {getTranslatorLink} from '#/locale/helpers' 23 23 import {logger} from '#/logger' ··· 37 37 import {useDialogControl} from '#/components/Dialog' 38 38 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 39 39 import {EmbedDialog} from '#/components/dialogs/Embed' 40 + import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' 40 41 import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 41 42 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' 42 43 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' ··· 49 50 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 50 51 import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 51 52 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 53 + import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' 52 54 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 53 55 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 54 56 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' ··· 102 104 const {hidePost} = useHiddenPostsApi() 103 105 const feedFeedback = useFeedFeedbackContext() 104 106 const openLink = useOpenLink() 105 - const navigation = useNavigation() 107 + const navigation = useNavigation<NavigationProp>() 106 108 const {mutedWordsDialogControl} = useGlobalDialogsControlContext() 107 109 const reportDialogControl = useReportDialogControl() 108 110 const deletePromptControl = useDialogControl() 109 111 const hidePromptControl = useDialogControl() 110 112 const loggedOutWarningPromptControl = useDialogControl() 111 113 const embedPostControl = useDialogControl() 114 + const sendViaChatControl = useDialogControl() 112 115 113 116 const rootUri = record.reply?.root?.uri || postUri 114 117 const isThreadMuted = mutedThreads.includes(rootUri) ··· 229 232 Toast.show('Feedback sent!') 230 233 }, [feedFeedback, postUri, postFeedContext]) 231 234 235 + const onSelectChatToShareTo = React.useCallback( 236 + (conversation: string) => { 237 + navigation.navigate('MessagesConversation', { 238 + conversation, 239 + embed: postUri, 240 + }) 241 + }, 242 + [navigation, postUri], 243 + ) 244 + 232 245 const canEmbed = isWeb && gtMobile && !hideInPWI 233 246 234 247 return ( ··· 278 291 <Menu.ItemIcon icon={ClipboardIcon} position="right" /> 279 292 </Menu.Item> 280 293 </> 294 + )} 295 + 296 + {hasSession && ( 297 + <Menu.Item 298 + testID="postDropdownSendViaDMBtn" 299 + label={_(msg`Send via direct message`)} 300 + onPress={sendViaChatControl.open}> 301 + <Menu.ItemText> 302 + <Trans>Send via direct message</Trans> 303 + </Menu.ItemText> 304 + <Menu.ItemIcon icon={Send} position="right" /> 305 + </Menu.Item> 281 306 )} 282 307 283 308 <Menu.Item ··· 449 474 timestamp={timestamp} 450 475 /> 451 476 )} 477 + 478 + <SendViaChatDialog 479 + control={sendViaChatControl} 480 + onSelectChat={onSelectChatToShareTo} 481 + /> 452 482 </EventStopper> 453 483 ) 454 484 }
+7 -4
src/view/com/util/images/ImageHorzList.tsx
··· 27 27 } 28 28 29 29 const styles = StyleSheet.create({ 30 - flexRow: {flexDirection: 'row'}, 30 + flexRow: { 31 + flexDirection: 'row', 32 + gap: 5, 33 + }, 31 34 image: { 32 - width: 100, 33 - height: 100, 35 + maxWidth: 100, 36 + aspectRatio: 1, 37 + flex: 1, 34 38 borderRadius: 4, 35 - marginRight: 5, 36 39 }, 37 40 })
+6 -6
yarn.lock
··· 34 34 jsonpointer "^5.0.0" 35 35 leven "^3.1.0" 36 36 37 - "@atproto/api@^0.12.13": 38 - version "0.12.13" 39 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.13.tgz#269d6c57ea894e23f20b28bd3cbfed944bd28528" 40 - integrity sha512-pRSID6w8AUiZJoCxgctMPRTSGVFHq7wphAnxEbRLBP3OQ1g+BRZUcqFw+e+17Pd3wrc8VImjiD4HCWtCJvCx3w== 37 + "@atproto/api@^0.12.14": 38 + version "0.12.14" 39 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.14.tgz#81252fd166ec8fe950056531e690d563437720fa" 40 + integrity sha512-ZPh/afoRjFEQDQgMZW2FQiG5CDUifY7SxBqI0zVJUwed8Zi6fqYzGYM8fcDvD8yJfflRCqRxUE72g5fKiA1zAQ== 41 41 dependencies: 42 42 "@atproto/common-web" "^0.3.0" 43 43 "@atproto/lexicon" "^0.4.0" ··· 22564 22564 resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.3.0.tgz#2cfe81b62d044e0453d1aa3ae7c32a2f36dde9af" 22565 22565 integrity sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw== 22566 22566 22567 - zod@^3.14.2, zod@^3.20.2, zod@^3.21.4: 22567 + zod@^3.14.2, zod@^3.20.2: 22568 22568 version "3.22.2" 22569 22569 resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b" 22570 22570 integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg== 22571 22571 22572 - zod@^3.22.4: 22572 + zod@^3.21.4, zod@^3.22.4: 22573 22573 version "3.23.8" 22574 22574 resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" 22575 22575 integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==