Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

Draft previews (#9803)

* Send deviceId and platform

* Add deviceId and deviceName to drafts, skip loading media for other devies

* WIP new preview

* show rich text in drafts list

(cherry picked from commit fb70d53d59b547816aa8d236ab475f6c5651bb72)

* New draft preview UI

* Tighten up spacing in draft list

* Add i18n comments

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by

Eric Bailey
Samuel Newman
and committed by
GitHub
748706da 45d6e50e

+406 -223
+1
app.config.js
··· 119 119 'com.apple.developer.kernel.increased-memory-limit': true, 120 120 'com.apple.developer.kernel.extended-virtual-addressing': true, 121 121 'com.apple.security.application-groups': 'group.app.bsky', 122 + 'com.apple.developer.device-information.user-assigned-device-name': true, 122 123 }, 123 124 privacyManifests: { 124 125 NSPrivacyCollectedDataTypes: [
+1 -1
package.json
··· 73 73 "icons:optimize": "svgo -f ./assets/icons" 74 74 }, 75 75 "dependencies": { 76 - "@atproto/api": "^0.18.18", 76 + "@atproto/api": "^0.18.20", 77 77 "@bitdrift/react-native": "^0.6.8", 78 78 "@braintree/sanitize-url": "^6.0.2", 79 79 "@bsky.app/alf": "^0.1.6",
+24 -7
src/components/RichText.tsx
··· 1 - import React from 'react' 1 + import {useMemo} from 'react' 2 2 import {type StyleProp, type TextStyle} from 'react-native' 3 3 import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' 4 4 ··· 27 27 interactiveStyle?: StyleProp<TextStyle> 28 28 emojiMultiplier?: number 29 29 shouldProxyLinks?: boolean 30 + /** 31 + * DANGEROUS: Disable facet lexicon validation 32 + * 33 + * `detectFacetsWithoutResolution()` generates technically invalid facets, 34 + * with a handle in place of the DID. This means that RichText that uses it 35 + * won't be able to render links. 36 + * 37 + * Use with care - only use if you're rendering facets you're generating yourself. 38 + */ 39 + disableMentionFacetValidation?: true 30 40 } 31 41 32 42 export function RichText({ ··· 44 54 onLayout, 45 55 onTextLayout, 46 56 shouldProxyLinks, 57 + disableMentionFacetValidation, 47 58 }: RichTextProps) { 48 - const richText = React.useMemo( 49 - () => 50 - value instanceof RichTextAPI ? value : new RichTextAPI({text: value}), 51 - [value], 52 - ) 59 + const richText = useMemo(() => { 60 + if (value instanceof RichTextAPI) { 61 + return value 62 + } else { 63 + const rt = new RichTextAPI({text: value}) 64 + rt.detectFacetsWithoutResolution() 65 + return rt 66 + } 67 + }, [value]) 53 68 54 69 const plainStyles = [a.leading_snug, style] 55 70 const interactiveStyles = [plainStyles, interactiveStyle] ··· 98 113 const link = segment.link 99 114 const mention = segment.mention 100 115 const tag = segment.tag 116 + 101 117 if ( 102 118 mention && 103 - AppBskyRichtextFacet.validateMention(mention).success && 119 + (disableMentionFacetValidation || 120 + AppBskyRichtextFacet.validateMention(mention).success) && 104 121 !disableLinks 105 122 ) { 106 123 els.push(
+18
src/lib/deviceName.ts
··· 1 + import * as Device from 'expo-device' 2 + 3 + import * as env from '#/env' 4 + 5 + export const FALLBACK_ANDROID = 'Android' 6 + export const FALLBACK_IOS = 'iOS' 7 + export const FALLBACK_WEB = 'Web' 8 + 9 + export function getDeviceName(): string { 10 + const deviceName = Device.deviceName 11 + if (env.IS_ANDROID) { 12 + return deviceName || FALLBACK_ANDROID 13 + } else if (env.IS_IOS) { 14 + return deviceName || FALLBACK_IOS 15 + } else { 16 + return FALLBACK_WEB // could append browser info here 17 + } 18 + }
+2 -2
src/view/com/composer/Composer.tsx
··· 138 138 type RestoredVideo, 139 139 } from './drafts/state/api' 140 140 import { 141 - loadDraft, 141 + loadDraftMedia, 142 142 useCleanupPublishedDraftMutation, 143 143 useSaveDraftMutation, 144 144 } from './drafts/state/queries' ··· 482 482 }) 483 483 484 484 // Load local media files for the draft 485 - const {loadedMedia} = await loadDraft(draftSummary.draft) 485 + const {loadedMedia} = await loadDraftMedia(draftSummary.draft) 486 486 487 487 // Extract original localRefs for orphan detection on save 488 488 const originalLocalRefs = extractLocalRefs(draftSummary.draft)
+217 -163
src/view/com/composer/drafts/DraftItem.tsx
··· 1 - import {useCallback, useEffect, useState} from 'react' 1 + import {useCallback, useEffect, useMemo, useState} from 'react' 2 2 import {Pressable, View} from 'react-native' 3 3 import * as VideoThumbnails from 'expo-video-thumbnails' 4 - import {msg, Trans} from '@lingui/macro' 4 + import {msg, plural} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 7 - import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 8 - import {sanitizeHandle} from '#/lib/strings/handles' 9 - import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' 7 + import * as device from '#/lib/deviceName' 10 8 import {logger} from '#/view/com/composer/drafts/state/logger' 11 9 import {TimeElapsed} from '#/view/com/util/TimeElapsed' 12 - import {UserAvatar} from '#/view/com/util/UserAvatar' 13 - import {atoms as a, useTheme} from '#/alf' 14 - import {Button, ButtonIcon} from '#/components/Button' 10 + import {atoms as a, select, useTheme} from '#/alf' 11 + import {Button} from '#/components/Button' 12 + import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlusIcon} from '#/components/icons/CirclePlus' 13 + import {type Props as SVGIconProps} from '#/components/icons/common' 15 14 import {DotGrid_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid' 15 + import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote' 16 + import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 16 17 import * as MediaPreview from '#/components/MediaPreview' 17 18 import * as Prompt from '#/components/Prompt' 19 + import {RichText} from '#/components/RichText' 18 20 import {Text} from '#/components/Typography' 19 21 import {IS_WEB} from '#/env' 20 22 import {type DraftPostDisplay, type DraftSummary} from './state/schema' ··· 32 34 const {_} = useLingui() 33 35 const t = useTheme() 34 36 const discardPromptControl = Prompt.usePromptControl() 37 + const post = draft.posts[0] 38 + 39 + const mediaExistsOnOtherDevice = 40 + !draft.meta.isOriginatingDevice && draft.meta.hasMissingMedia 41 + const mediaIsMissing = 42 + draft.meta.isOriginatingDevice && draft.meta.hasMissingMedia 43 + const hasMetadata = 44 + draft.meta.replyCount > 0 || 45 + mediaExistsOnOtherDevice || 46 + draft.meta.hasQuotes 47 + 48 + const deviceName = useMemo(() => { 49 + const raw = draft.draft.deviceName 50 + let name = raw 51 + switch (raw) { 52 + case device.FALLBACK_IOS: 53 + case device.FALLBACK_ANDROID: 54 + case device.FALLBACK_WEB: 55 + name = _( 56 + msg({ 57 + message: `another device`, 58 + comment: `Prefixed with "This media is stored on...". Example: "This media is stored on another device"`, 59 + }), 60 + ) 61 + break 62 + } 63 + return name 64 + }, [_, draft]) 35 65 36 66 const handleDelete = useCallback(() => { 37 67 onDelete(draft) ··· 39 69 40 70 return ( 41 71 <> 42 - <Pressable 43 - accessibilityRole="button" 44 - accessibilityLabel={_(msg`Open draft`)} 45 - accessibilityHint={_(msg`Opens this draft in the composer`)} 46 - onPress={() => onSelect(draft)} 47 - style={({pressed, hovered}) => [ 48 - a.rounded_md, 49 - a.overflow_hidden, 50 - a.border, 51 - t.atoms.bg, 52 - t.atoms.border_contrast_low, 53 - t.atoms.shadow_sm, 54 - (pressed || hovered) && t.atoms.bg_contrast_25, 55 - ]}> 56 - <View style={[a.p_md, a.gap_sm]}> 57 - {draft.hasMissingMedia && ( 58 - <View 59 - style={[ 60 - a.rounded_sm, 61 - a.px_sm, 62 - a.py_xs, 63 - a.mb_xs, 64 - t.atoms.bg_contrast_50, 65 - ]}> 66 - <Text style={[a.text_xs, t.atoms.text_contrast_medium]}> 67 - <Trans>Some media unavailable (saved on another device)</Trans> 72 + <View style={[a.relative]}> 73 + <Pressable 74 + accessibilityRole="button" 75 + accessibilityLabel={_(msg`Open draft`)} 76 + accessibilityHint={_(msg`Opens this draft in the composer`)} 77 + onPress={() => onSelect(draft)} 78 + style={({pressed, hovered}) => [ 79 + a.rounded_md, 80 + a.border, 81 + t.atoms.shadow_sm, 82 + pressed || hovered 83 + ? t.atoms.border_contrast_medium 84 + : t.atoms.border_contrast_low, 85 + { 86 + backgroundColor: select(t.name, { 87 + light: t.atoms.bg.backgroundColor, 88 + dark: t.atoms.bg_contrast_25.backgroundColor, 89 + dim: t.atoms.bg_contrast_25.backgroundColor, 90 + }), 91 + }, 92 + ]}> 93 + <View 94 + style={[ 95 + a.rounded_md, 96 + a.overflow_hidden, 97 + a.p_lg, 98 + a.pb_md, 99 + a.gap_sm, 100 + { 101 + paddingTop: 20 + a.pt_md.paddingTop, 102 + }, 103 + ]}> 104 + <RichText 105 + style={[a.text_md, a.leading_snug, a.pointer_events_none]} 106 + value={post.text} 107 + enableTags 108 + disableMentionFacetValidation 109 + /> 110 + 111 + {!mediaExistsOnOtherDevice && <DraftMediaPreview post={post} />} 112 + 113 + {hasMetadata && ( 114 + <View style={[a.gap_xs]}> 115 + {mediaExistsOnOtherDevice && ( 116 + <DraftMetadataTag 117 + icon={WarningIcon} 118 + text={_( 119 + msg({ 120 + message: `Media stored on ${deviceName}`, 121 + comment: `This media is stored on... Example: "This media is stored on John's iPhone"`, 122 + }), 123 + )} 124 + /> 125 + )} 126 + {mediaIsMissing && ( 127 + <DraftMetadataTag 128 + display="warning" 129 + icon={WarningIcon} 130 + text={_(msg`Missing media`)} 131 + /> 132 + )} 133 + {draft.meta.hasQuotes && ( 134 + <DraftMetadataTag 135 + icon={CloseQuoteIcon} 136 + text={_(msg`Quote post`)} 137 + /> 138 + )} 139 + {draft.meta.replyCount > 0 && ( 140 + <DraftMetadataTag 141 + icon={CirclePlusIcon} 142 + text={plural(draft.meta.replyCount, { 143 + one: '1 more post', 144 + other: '# more posts', 145 + })} 146 + /> 147 + )} 148 + </View> 149 + )} 150 + </View> 151 + </Pressable> 152 + 153 + {/* Timestamp */} 154 + <View 155 + pointerEvents="none" 156 + style={[ 157 + a.absolute, 158 + a.pointer_events_none, 159 + { 160 + top: a.pt_md.paddingTop, 161 + left: a.pl_lg.paddingLeft, 162 + }, 163 + ]}> 164 + <TimeElapsed timestamp={draft.updatedAt}> 165 + {({timeElapsed}) => ( 166 + <Text 167 + style={[ 168 + a.text_sm, 169 + t.atoms.text_contrast_medium, 170 + a.leading_tight, 171 + ]} 172 + numberOfLines={1}> 173 + {timeElapsed} 68 174 </Text> 69 - </View> 70 - )} 175 + )} 176 + </TimeElapsed> 177 + </View> 71 178 72 - {draft.posts.map((post, index) => ( 73 - <DraftPostRow 74 - key={post.id} 75 - post={post} 76 - isFirst={index === 0} 77 - isLast={index === draft.posts.length - 1} 78 - timestamp={draft.updatedAt} 79 - discardPromptControl={discardPromptControl} 80 - /> 81 - ))} 179 + {/* Menu button */} 180 + <View 181 + style={[ 182 + a.absolute, 183 + { 184 + top: a.pt_md.paddingTop, 185 + right: a.pr_md.paddingRight, 186 + }, 187 + ]}> 188 + <Button 189 + label={_(msg`More options`)} 190 + hitSlop={8} 191 + onPress={e => { 192 + e.stopPropagation() 193 + discardPromptControl.open() 194 + }} 195 + style={[ 196 + a.pointer, 197 + a.rounded_full, 198 + { 199 + height: 20, 200 + width: 20, 201 + }, 202 + ]}> 203 + {({pressed, hovered}) => ( 204 + <> 205 + <View 206 + style={[ 207 + a.absolute, 208 + a.rounded_full, 209 + { 210 + top: -4, 211 + bottom: -4, 212 + left: -4, 213 + right: -4, 214 + backgroundColor: 215 + pressed || hovered 216 + ? select(t.name, { 217 + light: t.atoms.bg_contrast_50.backgroundColor, 218 + dark: t.atoms.bg_contrast_100.backgroundColor, 219 + dim: t.atoms.bg_contrast_100.backgroundColor, 220 + }) 221 + : 'transparent', 222 + }, 223 + ]} 224 + /> 225 + <DotsIcon 226 + width={16} 227 + fill={t.atoms.text_contrast_low.color} 228 + style={[a.z_20]} 229 + /> 230 + </> 231 + )} 232 + </Button> 82 233 </View> 83 - </Pressable> 234 + </View> 84 235 85 236 <Prompt.Basic 86 237 control={discardPromptControl} ··· 94 245 ) 95 246 } 96 247 97 - function DraftPostRow({ 98 - post, 99 - isFirst, 100 - isLast, 101 - timestamp, 102 - discardPromptControl, 248 + function DraftMetadataTag({ 249 + display = 'info', 250 + icon: Icon, 251 + text, 103 252 }: { 104 - post: DraftPostDisplay 105 - isFirst: boolean 106 - isLast: boolean 107 - timestamp: string 108 - discardPromptControl: Prompt.PromptControlProps 253 + display?: 'info' | 'warning' 254 + icon: React.ComponentType<SVGIconProps> 255 + text: string 109 256 }) { 110 - const {_} = useLingui() 111 257 const t = useTheme() 112 - const profile = useCurrentAccountProfile() 113 - 258 + const color = { 259 + info: t.atoms.text_contrast_medium.color, 260 + warning: select(t.name, { 261 + light: '#C99A00', 262 + dark: '#FFC404', 263 + dim: '#FFC404', 264 + }), 265 + }[display] 114 266 return ( 115 - <View style={[a.flex_row, a.gap_sm]}> 116 - <View style={[a.align_center]}> 117 - <UserAvatar type="user" size={42} avatar={profile?.avatar} /> 118 - {!isLast && ( 119 - <View 120 - style={[ 121 - a.flex_1, 122 - a.mt_xs, 123 - { 124 - width: 2, 125 - backgroundColor: t.palette.contrast_100, 126 - minHeight: 8, 127 - }, 128 - ]} 129 - /> 130 - )} 131 - </View> 132 - 133 - <View style={[a.flex_1, a.gap_2xs]}> 134 - <View style={[a.flex_row, a.align_center, a.gap_xs]}> 135 - <View style={[a.flex_row, a.align_center, a.flex_1, a.gap_xs]}> 136 - {profile && ( 137 - <> 138 - <Text 139 - style={[ 140 - a.text_md, 141 - a.font_semi_bold, 142 - t.atoms.text, 143 - a.leading_snug, 144 - ]} 145 - numberOfLines={1}> 146 - {createSanitizedDisplayName(profile)} 147 - </Text> 148 - <Text 149 - style={[ 150 - a.text_md, 151 - t.atoms.text_contrast_medium, 152 - a.leading_snug, 153 - ]} 154 - numberOfLines={1}> 155 - {sanitizeHandle(profile.handle)} 156 - </Text> 157 - <Text 158 - style={[ 159 - a.text_md, 160 - t.atoms.text_contrast_medium, 161 - a.leading_snug, 162 - ]}> 163 - &middot; 164 - </Text> 165 - </> 166 - )} 167 - <TimeElapsed timestamp={timestamp}> 168 - {({timeElapsed}) => ( 169 - <Text 170 - style={[ 171 - a.text_md, 172 - t.atoms.text_contrast_medium, 173 - a.leading_snug, 174 - ]} 175 - numberOfLines={1}> 176 - {timeElapsed} 177 - </Text> 178 - )} 179 - </TimeElapsed> 180 - </View> 181 - 182 - {isFirst && ( 183 - <Button 184 - label={_(msg`More options`)} 185 - variant="ghost" 186 - color="secondary" 187 - shape="round" 188 - size="tiny" 189 - onPress={e => { 190 - e.stopPropagation() 191 - discardPromptControl.open() 192 - }}> 193 - <ButtonIcon icon={DotsIcon} /> 194 - </Button> 195 - )} 196 - </View> 197 - 198 - {post.text ? ( 199 - <Text style={[a.text_md, a.leading_snug, t.atoms.text]}> 200 - {post.text} 201 - </Text> 202 - ) : ( 203 - <Text 204 - style={[ 205 - a.text_md, 206 - a.leading_snug, 207 - t.atoms.text_contrast_medium, 208 - a.italic, 209 - ]}> 210 - <Trans>(No text)</Trans> 211 - </Text> 212 - )} 213 - 214 - <DraftMediaPreview post={post} /> 215 - </View> 267 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 268 + <Icon size="sm" fill={color} /> 269 + <Text style={[a.text_sm, a.leading_tight, {color}]}>{text}</Text> 216 270 </View> 217 271 ) 218 272 } ··· 271 325 } 272 326 273 327 return ( 274 - <MediaPreview.Outer style={[a.pt_xs]}> 328 + <MediaPreview.Outer> 275 329 {loadedImages.map((image, i) => ( 276 330 <MediaPreview.ImageItem key={i} thumbnail={image.url} alt={image.alt} /> 277 331 ))}
+15 -4
src/view/com/composer/drafts/DraftsListDialog.tsx
··· 5 5 6 6 import {useCallOnce} from '#/lib/once' 7 7 import {EmptyState} from '#/view/com/util/EmptyState' 8 - import {atoms as a, useTheme, web} from '#/alf' 8 + import {atoms as a, select, useBreakpoints, useTheme, web} from '#/alf' 9 9 import {Button, ButtonText} from '#/components/Button' 10 10 import * as Dialog from '#/components/Dialog' 11 11 import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX' ··· 26 26 }) { 27 27 const {_} = useLingui() 28 28 const t = useTheme() 29 + const {gtPhone} = useBreakpoints() 29 30 const ax = useAnalytics() 30 31 const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} = 31 32 useDraftsQuery() ··· 91 92 const renderItem = useCallback( 92 93 ({item}: {item: DraftSummary}) => { 93 94 return ( 94 - <View style={[a.px_lg, a.mt_lg]}> 95 + <View style={[gtPhone ? [a.px_md, a.pt_md] : [a.px_sm, a.pt_sm]]}> 95 96 <DraftItem 96 97 draft={item} 97 98 onSelect={handleSelectDraft} ··· 100 101 </View> 101 102 ) 102 103 }, 103 - [handleSelectDraft, handleDeleteDraft], 104 + [handleSelectDraft, handleDeleteDraft, gtPhone], 104 105 ) 105 106 106 107 const header = useMemo( ··· 162 163 ListFooterComponent={footerComponent} 163 164 onEndReached={onEndReached} 164 165 onEndReachedThreshold={0.5} 165 - style={[t.atoms.bg_contrast_50, a.px_0, web({minHeight: 500})]} 166 + style={[ 167 + a.px_0, 168 + web({minHeight: 500}), 169 + { 170 + backgroundColor: select(t.name, { 171 + light: t.palette.contrast_50, 172 + dark: t.palette.contrast_0, 173 + dim: '#000000', 174 + }), 175 + }, 176 + ]} 166 177 webInnerContentContainerStyle={[a.py_0]} 167 178 contentContainerStyle={[a.pb_xl]} 168 179 />
+45 -26
src/view/com/composer/drafts/state/api.ts
··· 5 5 import {nanoid} from 'nanoid/non-secure' 6 6 7 7 import {resolveLink} from '#/lib/api/resolve' 8 + import {getDeviceName} from '#/lib/deviceName' 8 9 import {getImageDim} from '#/lib/media/manip' 9 10 import {mimeToExt} from '#/lib/media/video/util' 10 11 import {type ComposerImage} from '#/state/gallery' ··· 17 18 type PostDraft, 18 19 } from '#/view/com/composer/state/composer' 19 20 import {type VideoState} from '#/view/com/composer/state/video' 21 + import {type AnalyticsContextType} from '#/analytics' 22 + import {getDeviceId} from '#/analytics/identifiers' 20 23 import {logger} from './logger' 21 24 import {type DraftPostDisplay, type DraftSummary} from './schema' 25 + import * as storage from './storage' 22 26 23 27 const TENOR_HOSTNAME = 'media.tenor.com' 24 28 ··· 66 70 67 71 const draft: AppBskyDraftDefs.Draft = { 68 72 $type: 'app.bsky.draft.defs#draft', 73 + deviceId: getDeviceId(), 74 + deviceName: getDeviceName().slice(0, 100), // max length of 100 in lex 69 75 posts, 70 76 threadgateAllow: threadgateAllowUISettingToAllowRecordValue( 71 77 state.thread.threadgate, ··· 265 271 * Convert server DraftView to DraftSummary for list display. 266 272 * Also checks which media files exist locally. 267 273 */ 268 - export function draftViewToSummary( 269 - view: AppBskyDraftDefs.DraftView, 270 - localMediaExists: (path: string) => boolean, 271 - ): DraftSummary { 272 - const firstPost = view.draft.posts[0] 273 - const previewText = firstPost?.text?.slice(0, 100) || '' 274 - 275 - let mediaCount = 0 276 - let hasMedia = false 277 - let hasMissingMedia = false 274 + export function draftViewToSummary({ 275 + view, 276 + analytics, 277 + }: { 278 + view: AppBskyDraftDefs.DraftView 279 + analytics: AnalyticsContextType 280 + }): DraftSummary { 281 + const meta = { 282 + isOriginatingDevice: view.draft.deviceId === getDeviceId(), 283 + postCount: view.draft.posts.length, 284 + // minus anchor post 285 + replyCount: view.draft.posts.length - 1, 286 + hasMedia: false, 287 + hasMissingMedia: false, 288 + mediaCount: 0, 289 + hasQuotes: false, 290 + quoteCount: 0, 291 + } 278 292 279 293 const posts: DraftPostDisplay[] = view.draft.posts.map((post, index) => { 280 294 const images: DraftPostDisplay['images'] = [] ··· 284 298 // Process images 285 299 if (post.embedImages) { 286 300 for (const img of post.embedImages) { 287 - mediaCount++ 288 - hasMedia = true 289 - const exists = localMediaExists(img.localRef.path) 301 + meta.mediaCount++ 302 + meta.hasMedia = true 303 + const exists = storage.mediaExists(img.localRef.path) 290 304 if (!exists) { 291 - hasMissingMedia = true 305 + meta.hasMissingMedia = true 292 306 } 293 307 images.push({ 294 308 localPath: img.localRef.path, ··· 301 315 // Process videos 302 316 if (post.embedVideos) { 303 317 for (const vid of post.embedVideos) { 304 - mediaCount++ 305 - hasMedia = true 306 - const exists = localMediaExists(vid.localRef.path) 318 + meta.mediaCount++ 319 + meta.hasMedia = true 320 + const exists = storage.mediaExists(vid.localRef.path) 307 321 if (!exists) { 308 - hasMissingMedia = true 322 + meta.hasMissingMedia = true 309 323 } 310 324 videos.push({ 311 325 localPath: vid.localRef.path, ··· 320 334 for (const ext of post.embedExternals) { 321 335 const gifData = parseGifFromUrl(ext.uri) 322 336 if (gifData) { 323 - mediaCount++ 324 - hasMedia = true 337 + meta.mediaCount++ 338 + meta.hasMedia = true 325 339 gif = gifData 326 340 } 327 341 } 328 342 } 329 343 344 + if (post.embedRecords && post.embedRecords.length > 0) { 345 + meta.quoteCount += post.embedRecords.length 346 + meta.hasQuotes = true 347 + } 348 + 330 349 return { 331 350 id: `post-${index}`, 332 351 text: post.text || '', ··· 335 354 gif, 336 355 } 337 356 }) 357 + 358 + if (meta.isOriginatingDevice && meta.hasMissingMedia) { 359 + analytics.logger.warn(`Draft is missing media on originating device`, {}) 360 + } 338 361 339 362 return { 340 363 id: view.id, 341 - draft: view.draft, 342 - previewText, 343 - hasMedia, 344 - hasMissingMedia, 345 - mediaCount, 346 - postCount: view.draft.posts.length, 347 364 createdAt: view.createdAt, 348 365 updatedAt: view.updatedAt, 366 + draft: view.draft, 349 367 posts, 368 + meta, 350 369 } 351 370 } 352 371
+20 -8
src/view/com/composer/drafts/state/queries.ts
··· 8 8 import {isNetworkError} from '#/lib/strings/errors' 9 9 import {useAgent} from '#/state/session' 10 10 import {type ComposerState} from '#/view/com/composer/state/composer' 11 + import {useAnalytics} from '#/analytics' 12 + import {getDeviceId} from '#/analytics/identifiers' 11 13 import {composerStateToDraft, draftViewToSummary} from './api' 12 14 import {logger} from './logger' 13 15 import * as storage from './storage' ··· 19 21 */ 20 22 export function useDraftsQuery() { 21 23 const agent = useAgent() 24 + const ax = useAnalytics() 22 25 23 26 return useInfiniteQuery({ 24 27 queryKey: DRAFTS_QUERY_KEY, ··· 29 32 return { 30 33 cursor: res.data.cursor, 31 34 drafts: res.data.drafts.map(view => 32 - draftViewToSummary(view, path => storage.mediaExists(path)), 35 + draftViewToSummary({ 36 + view, 37 + analytics: ax, 38 + }), 33 39 ), 34 40 } 35 41 }, ··· 42 48 * Load a draft's local media for editing. 43 49 * Takes the full Draft object (from DraftSummary) to avoid re-fetching. 44 50 */ 45 - export async function loadDraft(draft: AppBskyDraftDefs.Draft): Promise<{ 51 + export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{ 46 52 loadedMedia: Map<string, string> 47 53 }> { 48 54 // Load local media files 49 55 const loadedMedia = new Map<string, string>() 56 + 57 + // can't load media from another device 58 + if (draft.deviceId && draft.deviceId !== getDeviceId()) { 59 + return {loadedMedia} 60 + } 61 + 50 62 for (const post of draft.posts) { 51 63 // Load images 52 64 if (post.embedImages) { ··· 54 66 try { 55 67 const url = await storage.loadMediaFromLocal(img.localRef.path) 56 68 loadedMedia.set(img.localRef.path, url) 57 - } catch (e) { 58 - logger.debug('Failed to load draft image', { 69 + } catch (e: any) { 70 + logger.error('Failed to load draft image', { 59 71 path: img.localRef.path, 60 - error: e, 72 + safeMessage: e.message, 61 73 }) 62 74 } 63 75 } ··· 68 80 try { 69 81 const url = await storage.loadMediaFromLocal(vid.localRef.path) 70 82 loadedMedia.set(vid.localRef.path, url) 71 - } catch (e) { 72 - logger.debug('Failed to load draft video', { 83 + } catch (e: any) { 84 + logger.error('Failed to load draft video', { 73 85 path: vid.localRef.path, 74 - error: e, 86 + safeMessage: e.message, 75 87 }) 76 88 } 77 89 }
+21 -12
src/view/com/composer/drafts/state/schema.ts
··· 50 50 */ 51 51 export type DraftSummary = { 52 52 id: string 53 - /** The full draft data from the server */ 54 - draft: AppBskyDraftDefs.Draft 55 - /** First ~100 chars of first post */ 56 - previewText: string 57 - /** Whether the draft has media */ 58 - hasMedia: boolean 59 - /** Whether some media is missing (saved on another device) */ 60 - hasMissingMedia?: boolean 61 - /** Number of media items */ 62 - mediaCount: number 63 - /** Number of posts in thread */ 64 - postCount: number 65 53 /** ISO timestamp of creation */ 66 54 createdAt: string 67 55 /** ISO timestamp of last update */ 68 56 updatedAt: string 57 + /** The full draft data from the server */ 58 + draft: AppBskyDraftDefs.Draft 69 59 /** All posts in the draft for full display */ 70 60 posts: DraftPostDisplay[] 61 + /** Metadata about the draft for display purposes */ 62 + meta: { 63 + /** Whether this device is the originating device for the draft */ 64 + isOriginatingDevice: boolean 65 + /** Number of posts in thread */ 66 + postCount: number 67 + /** Number of replies to anchor post */ 68 + replyCount: number 69 + /** Whether the draft has media */ 70 + hasMedia: boolean 71 + /** Whether some media is missing (saved on another device) */ 72 + hasMissingMedia?: boolean 73 + /** Number of media items */ 74 + mediaCount: number 75 + /** Whether any posts in the draft has quotes */ 76 + hasQuotes: boolean 77 + /** Number of quotes in the draft */ 78 + quoteCount: number 79 + } 71 80 }
+42
yarn.lock
··· 96 96 tlds "^1.234.0" 97 97 zod "^3.23.8" 98 98 99 + "@atproto/api@^0.18.20": 100 + version "0.18.20" 101 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.20.tgz#3fdbb7b7fae90bd59101970c2b56cc31e8cf417d" 102 + integrity sha512-BZYZkh2VJIFCXEnc/vzKwAwWjAQQTgbNJ8FBxpBK+z+KYh99O0uPCsRYKoCQsRrnkgrhzdU9+g2G+7zanTIGbw== 103 + dependencies: 104 + "@atproto/common-web" "^0.4.15" 105 + "@atproto/lexicon" "^0.6.1" 106 + "@atproto/syntax" "^0.4.3" 107 + "@atproto/xrpc" "^0.7.7" 108 + await-lock "^2.2.2" 109 + multiformats "^9.9.0" 110 + tlds "^1.234.0" 111 + zod "^3.23.8" 112 + 99 113 "@atproto/aws@^0.2.31": 100 114 version "0.2.31" 101 115 resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.31.tgz#e46d7db34ee57c4f9817269f1e73a7eddba2b9b8" ··· 190 204 "@atproto/syntax" "0.4.3" 191 205 zod "^3.23.8" 192 206 207 + "@atproto/common-web@^0.4.15": 208 + version "0.4.15" 209 + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.15.tgz#1fffedf62d69b8c96f7b360e11c4180446a2cc82" 210 + integrity sha512-A4l9gyqUNez8CjZp/Trypz/D3WIQsNj8dN05WR6+RoBbvwc9JhWjKPrm+WoVYc/F16RPdXHLkE3BEJlGIyYIiA== 211 + dependencies: 212 + "@atproto/lex-data" "^0.0.10" 213 + "@atproto/lex-json" "^0.0.10" 214 + "@atproto/syntax" "^0.4.3" 215 + zod "^3.23.8" 216 + 193 217 "@atproto/common@0.1.0": 194 218 version "0.1.0" 195 219 resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210" ··· 327 351 uint8arrays "3.0.0" 328 352 unicode-segmenter "^0.14.0" 329 353 354 + "@atproto/lex-data@^0.0.10": 355 + version "0.0.10" 356 + resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.10.tgz#091c012af817a869951ce0e61eb757bd6c29797a" 357 + integrity sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ== 358 + dependencies: 359 + multiformats "^9.9.0" 360 + tslib "^2.8.1" 361 + uint8arrays "3.0.0" 362 + unicode-segmenter "^0.14.0" 363 + 330 364 "@atproto/lex-document@0.0.11": 331 365 version "0.0.11" 332 366 resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.11.tgz#8cfdd6ab5b5befac4d1409c76e2d5a310845c1dc" ··· 342 376 integrity sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw== 343 377 dependencies: 344 378 "@atproto/lex-data" "0.0.9" 379 + tslib "^2.8.1" 380 + 381 + "@atproto/lex-json@^0.0.10": 382 + version "0.0.10" 383 + resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.10.tgz#18de1c8cc3ba564e5412bce1f8e22c198c956e64" 384 + integrity sha512-L6MyXU17C5ODMeob8myQ2F3xvgCTvJUtM0ew8qSApnN//iDasB/FDGgd7ty4UVNmx4NQ/rtvz8xV94YpG6kneQ== 385 + dependencies: 386 + "@atproto/lex-data" "^0.0.10" 345 387 tslib "^2.8.1" 346 388 347 389 "@atproto/lex-resolver@0.0.12":