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 fb70d53d59)

* New draft preview UI

* Tighten up spacing in draft list

* Add i18n comments

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Eric Bailey
2026-01-30 17:00:54 -06:00
committed by GitHub
parent 45d6e50eb4
commit 748706daab
11 changed files with 407 additions and 224 deletions
+1
View File
@@ -119,6 +119,7 @@ module.exports = function (_config) {
'com.apple.developer.kernel.increased-memory-limit': true, 'com.apple.developer.kernel.increased-memory-limit': true,
'com.apple.developer.kernel.extended-virtual-addressing': true, 'com.apple.developer.kernel.extended-virtual-addressing': true,
'com.apple.security.application-groups': 'group.app.bsky', 'com.apple.security.application-groups': 'group.app.bsky',
'com.apple.developer.device-information.user-assigned-device-name': true,
}, },
privacyManifests: { privacyManifests: {
NSPrivacyCollectedDataTypes: [ NSPrivacyCollectedDataTypes: [
+1 -1
View File
@@ -73,7 +73,7 @@
"icons:optimize": "svgo -f ./assets/icons" "icons:optimize": "svgo -f ./assets/icons"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.18.18", "@atproto/api": "^0.18.20",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6", "@bsky.app/alf": "^0.1.6",
+24 -7
View File
@@ -1,4 +1,4 @@
import React from 'react' import {useMemo} from 'react'
import {type StyleProp, type TextStyle} from 'react-native' import {type StyleProp, type TextStyle} from 'react-native'
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
@@ -27,6 +27,16 @@ export type RichTextProps = TextStyleProp &
interactiveStyle?: StyleProp<TextStyle> interactiveStyle?: StyleProp<TextStyle>
emojiMultiplier?: number emojiMultiplier?: number
shouldProxyLinks?: boolean shouldProxyLinks?: boolean
/**
* DANGEROUS: Disable facet lexicon validation
*
* `detectFacetsWithoutResolution()` generates technically invalid facets,
* with a handle in place of the DID. This means that RichText that uses it
* won't be able to render links.
*
* Use with care - only use if you're rendering facets you're generating yourself.
*/
disableMentionFacetValidation?: true
} }
export function RichText({ export function RichText({
@@ -44,12 +54,17 @@ export function RichText({
onLayout, onLayout,
onTextLayout, onTextLayout,
shouldProxyLinks, shouldProxyLinks,
disableMentionFacetValidation,
}: RichTextProps) { }: RichTextProps) {
const richText = React.useMemo( const richText = useMemo(() => {
() => if (value instanceof RichTextAPI) {
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}), return value
[value], } else {
) const rt = new RichTextAPI({text: value})
rt.detectFacetsWithoutResolution()
return rt
}
}, [value])
const plainStyles = [a.leading_snug, style] const plainStyles = [a.leading_snug, style]
const interactiveStyles = [plainStyles, interactiveStyle] const interactiveStyles = [plainStyles, interactiveStyle]
@@ -98,9 +113,11 @@ export function RichText({
const link = segment.link const link = segment.link
const mention = segment.mention const mention = segment.mention
const tag = segment.tag const tag = segment.tag
if ( if (
mention && mention &&
AppBskyRichtextFacet.validateMention(mention).success && (disableMentionFacetValidation ||
AppBskyRichtextFacet.validateMention(mention).success) &&
!disableLinks !disableLinks
) { ) {
els.push( els.push(
+18
View File
@@ -0,0 +1,18 @@
import * as Device from 'expo-device'
import * as env from '#/env'
export const FALLBACK_ANDROID = 'Android'
export const FALLBACK_IOS = 'iOS'
export const FALLBACK_WEB = 'Web'
export function getDeviceName(): string {
const deviceName = Device.deviceName
if (env.IS_ANDROID) {
return deviceName || FALLBACK_ANDROID
} else if (env.IS_IOS) {
return deviceName || FALLBACK_IOS
} else {
return FALLBACK_WEB // could append browser info here
}
}
+2 -2
View File
@@ -138,7 +138,7 @@ import {
type RestoredVideo, type RestoredVideo,
} from './drafts/state/api' } from './drafts/state/api'
import { import {
loadDraft, loadDraftMedia,
useCleanupPublishedDraftMutation, useCleanupPublishedDraftMutation,
useSaveDraftMutation, useSaveDraftMutation,
} from './drafts/state/queries' } from './drafts/state/queries'
@@ -482,7 +482,7 @@ export const ComposePost = ({
}) })
// Load local media files for the draft // Load local media files for the draft
const {loadedMedia} = await loadDraft(draftSummary.draft) const {loadedMedia} = await loadDraftMedia(draftSummary.draft)
// Extract original localRefs for orphan detection on save // Extract original localRefs for orphan detection on save
const originalLocalRefs = extractLocalRefs(draftSummary.draft) const originalLocalRefs = extractLocalRefs(draftSummary.draft)
+218 -164
View File
@@ -1,20 +1,22 @@
import {useCallback, useEffect, useState} from 'react' import {useCallback, useEffect, useMemo, useState} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import * as VideoThumbnails from 'expo-video-thumbnails' import * as VideoThumbnails from 'expo-video-thumbnails'
import {msg, Trans} from '@lingui/macro' import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import * as device from '#/lib/deviceName'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
import {logger} from '#/view/com/composer/drafts/state/logger' import {logger} from '#/view/com/composer/drafts/state/logger'
import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, select, useTheme} from '#/alf'
import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button'
import {Button, ButtonIcon} from '#/components/Button' import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlusIcon} from '#/components/icons/CirclePlus'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {DotGrid_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid' import {DotGrid_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import * as MediaPreview from '#/components/MediaPreview' import * as MediaPreview from '#/components/MediaPreview'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
import {type DraftPostDisplay, type DraftSummary} from './state/schema' import {type DraftPostDisplay, type DraftSummary} from './state/schema'
@@ -32,6 +34,34 @@ export function DraftItem({
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const discardPromptControl = Prompt.usePromptControl() const discardPromptControl = Prompt.usePromptControl()
const post = draft.posts[0]
const mediaExistsOnOtherDevice =
!draft.meta.isOriginatingDevice && draft.meta.hasMissingMedia
const mediaIsMissing =
draft.meta.isOriginatingDevice && draft.meta.hasMissingMedia
const hasMetadata =
draft.meta.replyCount > 0 ||
mediaExistsOnOtherDevice ||
draft.meta.hasQuotes
const deviceName = useMemo(() => {
const raw = draft.draft.deviceName
let name = raw
switch (raw) {
case device.FALLBACK_IOS:
case device.FALLBACK_ANDROID:
case device.FALLBACK_WEB:
name = _(
msg({
message: `another device`,
comment: `Prefixed with "This media is stored on...". Example: "This media is stored on another device"`,
}),
)
break
}
return name
}, [_, draft])
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
onDelete(draft) onDelete(draft)
@@ -39,48 +69,169 @@ export function DraftItem({
return ( return (
<> <>
<Pressable <View style={[a.relative]}>
accessibilityRole="button" <Pressable
accessibilityLabel={_(msg`Open draft`)} accessibilityRole="button"
accessibilityHint={_(msg`Opens this draft in the composer`)} accessibilityLabel={_(msg`Open draft`)}
onPress={() => onSelect(draft)} accessibilityHint={_(msg`Opens this draft in the composer`)}
style={({pressed, hovered}) => [ onPress={() => onSelect(draft)}
a.rounded_md, style={({pressed, hovered}) => [
a.overflow_hidden, a.rounded_md,
a.border, a.border,
t.atoms.bg, t.atoms.shadow_sm,
t.atoms.border_contrast_low, pressed || hovered
t.atoms.shadow_sm, ? t.atoms.border_contrast_medium
(pressed || hovered) && t.atoms.bg_contrast_25, : t.atoms.border_contrast_low,
]}> {
<View style={[a.p_md, a.gap_sm]}> backgroundColor: select(t.name, {
{draft.hasMissingMedia && ( light: t.atoms.bg.backgroundColor,
<View dark: t.atoms.bg_contrast_25.backgroundColor,
style={[ dim: t.atoms.bg_contrast_25.backgroundColor,
a.rounded_sm, }),
a.px_sm, },
a.py_xs, ]}>
a.mb_xs, <View
t.atoms.bg_contrast_50, style={[
]}> a.rounded_md,
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}> a.overflow_hidden,
<Trans>Some media unavailable (saved on another device)</Trans> a.p_lg,
</Text> a.pb_md,
</View> a.gap_sm,
)} {
paddingTop: 20 + a.pt_md.paddingTop,
{draft.posts.map((post, index) => ( },
<DraftPostRow ]}>
key={post.id} <RichText
post={post} style={[a.text_md, a.leading_snug, a.pointer_events_none]}
isFirst={index === 0} value={post.text}
isLast={index === draft.posts.length - 1} enableTags
timestamp={draft.updatedAt} disableMentionFacetValidation
discardPromptControl={discardPromptControl}
/> />
))}
{!mediaExistsOnOtherDevice && <DraftMediaPreview post={post} />}
{hasMetadata && (
<View style={[a.gap_xs]}>
{mediaExistsOnOtherDevice && (
<DraftMetadataTag
icon={WarningIcon}
text={_(
msg({
message: `Media stored on ${deviceName}`,
comment: `This media is stored on... Example: "This media is stored on John's iPhone"`,
}),
)}
/>
)}
{mediaIsMissing && (
<DraftMetadataTag
display="warning"
icon={WarningIcon}
text={_(msg`Missing media`)}
/>
)}
{draft.meta.hasQuotes && (
<DraftMetadataTag
icon={CloseQuoteIcon}
text={_(msg`Quote post`)}
/>
)}
{draft.meta.replyCount > 0 && (
<DraftMetadataTag
icon={CirclePlusIcon}
text={plural(draft.meta.replyCount, {
one: '1 more post',
other: '# more posts',
})}
/>
)}
</View>
)}
</View>
</Pressable>
{/* Timestamp */}
<View
pointerEvents="none"
style={[
a.absolute,
a.pointer_events_none,
{
top: a.pt_md.paddingTop,
left: a.pl_lg.paddingLeft,
},
]}>
<TimeElapsed timestamp={draft.updatedAt}>
{({timeElapsed}) => (
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
a.leading_tight,
]}
numberOfLines={1}>
{timeElapsed}
</Text>
)}
</TimeElapsed>
</View> </View>
</Pressable>
{/* Menu button */}
<View
style={[
a.absolute,
{
top: a.pt_md.paddingTop,
right: a.pr_md.paddingRight,
},
]}>
<Button
label={_(msg`More options`)}
hitSlop={8}
onPress={e => {
e.stopPropagation()
discardPromptControl.open()
}}
style={[
a.pointer,
a.rounded_full,
{
height: 20,
width: 20,
},
]}>
{({pressed, hovered}) => (
<>
<View
style={[
a.absolute,
a.rounded_full,
{
top: -4,
bottom: -4,
left: -4,
right: -4,
backgroundColor:
pressed || hovered
? select(t.name, {
light: t.atoms.bg_contrast_50.backgroundColor,
dark: t.atoms.bg_contrast_100.backgroundColor,
dim: t.atoms.bg_contrast_100.backgroundColor,
})
: 'transparent',
},
]}
/>
<DotsIcon
width={16}
fill={t.atoms.text_contrast_low.color}
style={[a.z_20]}
/>
</>
)}
</Button>
</View>
</View>
<Prompt.Basic <Prompt.Basic
control={discardPromptControl} control={discardPromptControl}
@@ -94,125 +245,28 @@ export function DraftItem({
) )
} }
function DraftPostRow({ function DraftMetadataTag({
post, display = 'info',
isFirst, icon: Icon,
isLast, text,
timestamp,
discardPromptControl,
}: { }: {
post: DraftPostDisplay display?: 'info' | 'warning'
isFirst: boolean icon: React.ComponentType<SVGIconProps>
isLast: boolean text: string
timestamp: string
discardPromptControl: Prompt.PromptControlProps
}) { }) {
const {_} = useLingui()
const t = useTheme() const t = useTheme()
const profile = useCurrentAccountProfile() const color = {
info: t.atoms.text_contrast_medium.color,
warning: select(t.name, {
light: '#C99A00',
dark: '#FFC404',
dim: '#FFC404',
}),
}[display]
return ( return (
<View style={[a.flex_row, a.gap_sm]}> <View style={[a.flex_row, a.align_center, a.gap_xs]}>
<View style={[a.align_center]}> <Icon size="sm" fill={color} />
<UserAvatar type="user" size={42} avatar={profile?.avatar} /> <Text style={[a.text_sm, a.leading_tight, {color}]}>{text}</Text>
{!isLast && (
<View
style={[
a.flex_1,
a.mt_xs,
{
width: 2,
backgroundColor: t.palette.contrast_100,
minHeight: 8,
},
]}
/>
)}
</View>
<View style={[a.flex_1, a.gap_2xs]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<View style={[a.flex_row, a.align_center, a.flex_1, a.gap_xs]}>
{profile && (
<>
<Text
style={[
a.text_md,
a.font_semi_bold,
t.atoms.text,
a.leading_snug,
]}
numberOfLines={1}>
{createSanitizedDisplayName(profile)}
</Text>
<Text
style={[
a.text_md,
t.atoms.text_contrast_medium,
a.leading_snug,
]}
numberOfLines={1}>
{sanitizeHandle(profile.handle)}
</Text>
<Text
style={[
a.text_md,
t.atoms.text_contrast_medium,
a.leading_snug,
]}>
&middot;
</Text>
</>
)}
<TimeElapsed timestamp={timestamp}>
{({timeElapsed}) => (
<Text
style={[
a.text_md,
t.atoms.text_contrast_medium,
a.leading_snug,
]}
numberOfLines={1}>
{timeElapsed}
</Text>
)}
</TimeElapsed>
</View>
{isFirst && (
<Button
label={_(msg`More options`)}
variant="ghost"
color="secondary"
shape="round"
size="tiny"
onPress={e => {
e.stopPropagation()
discardPromptControl.open()
}}>
<ButtonIcon icon={DotsIcon} />
</Button>
)}
</View>
{post.text ? (
<Text style={[a.text_md, a.leading_snug, t.atoms.text]}>
{post.text}
</Text>
) : (
<Text
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
a.italic,
]}>
<Trans>(No text)</Trans>
</Text>
)}
<DraftMediaPreview post={post} />
</View>
</View> </View>
) )
} }
@@ -271,7 +325,7 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
} }
return ( return (
<MediaPreview.Outer style={[a.pt_xs]}> <MediaPreview.Outer>
{loadedImages.map((image, i) => ( {loadedImages.map((image, i) => (
<MediaPreview.ImageItem key={i} thumbnail={image.url} alt={image.alt} /> <MediaPreview.ImageItem key={i} thumbnail={image.url} alt={image.alt} />
))} ))}
@@ -5,7 +5,7 @@ import {useLingui} from '@lingui/react'
import {useCallOnce} from '#/lib/once' import {useCallOnce} from '#/lib/once'
import {EmptyState} from '#/view/com/util/EmptyState' import {EmptyState} from '#/view/com/util/EmptyState'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, select, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX' import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX'
@@ -26,6 +26,7 @@ export function DraftsListDialog({
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {gtPhone} = useBreakpoints()
const ax = useAnalytics() const ax = useAnalytics()
const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} = const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} =
useDraftsQuery() useDraftsQuery()
@@ -91,7 +92,7 @@ export function DraftsListDialog({
const renderItem = useCallback( const renderItem = useCallback(
({item}: {item: DraftSummary}) => { ({item}: {item: DraftSummary}) => {
return ( return (
<View style={[a.px_lg, a.mt_lg]}> <View style={[gtPhone ? [a.px_md, a.pt_md] : [a.px_sm, a.pt_sm]]}>
<DraftItem <DraftItem
draft={item} draft={item}
onSelect={handleSelectDraft} onSelect={handleSelectDraft}
@@ -100,7 +101,7 @@ export function DraftsListDialog({
</View> </View>
) )
}, },
[handleSelectDraft, handleDeleteDraft], [handleSelectDraft, handleDeleteDraft, gtPhone],
) )
const header = useMemo( const header = useMemo(
@@ -162,7 +163,17 @@ export function DraftsListDialog({
ListFooterComponent={footerComponent} ListFooterComponent={footerComponent}
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={0.5} onEndReachedThreshold={0.5}
style={[t.atoms.bg_contrast_50, a.px_0, web({minHeight: 500})]} style={[
a.px_0,
web({minHeight: 500}),
{
backgroundColor: select(t.name, {
light: t.palette.contrast_50,
dark: t.palette.contrast_0,
dim: '#000000',
}),
},
]}
webInnerContentContainerStyle={[a.py_0]} webInnerContentContainerStyle={[a.py_0]}
contentContainerStyle={[a.pb_xl]} contentContainerStyle={[a.pb_xl]}
/> />
+45 -26
View File
@@ -5,6 +5,7 @@ import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {resolveLink} from '#/lib/api/resolve' import {resolveLink} from '#/lib/api/resolve'
import {getDeviceName} from '#/lib/deviceName'
import {getImageDim} from '#/lib/media/manip' import {getImageDim} from '#/lib/media/manip'
import {mimeToExt} from '#/lib/media/video/util' import {mimeToExt} from '#/lib/media/video/util'
import {type ComposerImage} from '#/state/gallery' import {type ComposerImage} from '#/state/gallery'
@@ -17,8 +18,11 @@ import {
type PostDraft, type PostDraft,
} from '#/view/com/composer/state/composer' } from '#/view/com/composer/state/composer'
import {type VideoState} from '#/view/com/composer/state/video' import {type VideoState} from '#/view/com/composer/state/video'
import {type AnalyticsContextType} from '#/analytics'
import {getDeviceId} from '#/analytics/identifiers'
import {logger} from './logger' import {logger} from './logger'
import {type DraftPostDisplay, type DraftSummary} from './schema' import {type DraftPostDisplay, type DraftSummary} from './schema'
import * as storage from './storage'
const TENOR_HOSTNAME = 'media.tenor.com' const TENOR_HOSTNAME = 'media.tenor.com'
@@ -66,6 +70,8 @@ export async function composerStateToDraft(state: ComposerState): Promise<{
const draft: AppBskyDraftDefs.Draft = { const draft: AppBskyDraftDefs.Draft = {
$type: 'app.bsky.draft.defs#draft', $type: 'app.bsky.draft.defs#draft',
deviceId: getDeviceId(),
deviceName: getDeviceName().slice(0, 100), // max length of 100 in lex
posts, posts,
threadgateAllow: threadgateAllowUISettingToAllowRecordValue( threadgateAllow: threadgateAllowUISettingToAllowRecordValue(
state.thread.threadgate, state.thread.threadgate,
@@ -265,16 +271,24 @@ function serializeGif(gifMedia: {
* Convert server DraftView to DraftSummary for list display. * Convert server DraftView to DraftSummary for list display.
* Also checks which media files exist locally. * Also checks which media files exist locally.
*/ */
export function draftViewToSummary( export function draftViewToSummary({
view: AppBskyDraftDefs.DraftView, view,
localMediaExists: (path: string) => boolean, analytics,
): DraftSummary { }: {
const firstPost = view.draft.posts[0] view: AppBskyDraftDefs.DraftView
const previewText = firstPost?.text?.slice(0, 100) || '' analytics: AnalyticsContextType
}): DraftSummary {
let mediaCount = 0 const meta = {
let hasMedia = false isOriginatingDevice: view.draft.deviceId === getDeviceId(),
let hasMissingMedia = false postCount: view.draft.posts.length,
// minus anchor post
replyCount: view.draft.posts.length - 1,
hasMedia: false,
hasMissingMedia: false,
mediaCount: 0,
hasQuotes: false,
quoteCount: 0,
}
const posts: DraftPostDisplay[] = view.draft.posts.map((post, index) => { const posts: DraftPostDisplay[] = view.draft.posts.map((post, index) => {
const images: DraftPostDisplay['images'] = [] const images: DraftPostDisplay['images'] = []
@@ -284,11 +298,11 @@ export function draftViewToSummary(
// Process images // Process images
if (post.embedImages) { if (post.embedImages) {
for (const img of post.embedImages) { for (const img of post.embedImages) {
mediaCount++ meta.mediaCount++
hasMedia = true meta.hasMedia = true
const exists = localMediaExists(img.localRef.path) const exists = storage.mediaExists(img.localRef.path)
if (!exists) { if (!exists) {
hasMissingMedia = true meta.hasMissingMedia = true
} }
images.push({ images.push({
localPath: img.localRef.path, localPath: img.localRef.path,
@@ -301,11 +315,11 @@ export function draftViewToSummary(
// Process videos // Process videos
if (post.embedVideos) { if (post.embedVideos) {
for (const vid of post.embedVideos) { for (const vid of post.embedVideos) {
mediaCount++ meta.mediaCount++
hasMedia = true meta.hasMedia = true
const exists = localMediaExists(vid.localRef.path) const exists = storage.mediaExists(vid.localRef.path)
if (!exists) { if (!exists) {
hasMissingMedia = true meta.hasMissingMedia = true
} }
videos.push({ videos.push({
localPath: vid.localRef.path, localPath: vid.localRef.path,
@@ -320,13 +334,18 @@ export function draftViewToSummary(
for (const ext of post.embedExternals) { for (const ext of post.embedExternals) {
const gifData = parseGifFromUrl(ext.uri) const gifData = parseGifFromUrl(ext.uri)
if (gifData) { if (gifData) {
mediaCount++ meta.mediaCount++
hasMedia = true meta.hasMedia = true
gif = gifData gif = gifData
} }
} }
} }
if (post.embedRecords && post.embedRecords.length > 0) {
meta.quoteCount += post.embedRecords.length
meta.hasQuotes = true
}
return { return {
id: `post-${index}`, id: `post-${index}`,
text: post.text || '', text: post.text || '',
@@ -336,17 +355,17 @@ export function draftViewToSummary(
} }
}) })
if (meta.isOriginatingDevice && meta.hasMissingMedia) {
analytics.logger.warn(`Draft is missing media on originating device`, {})
}
return { return {
id: view.id, id: view.id,
draft: view.draft,
previewText,
hasMedia,
hasMissingMedia,
mediaCount,
postCount: view.draft.posts.length,
createdAt: view.createdAt, createdAt: view.createdAt,
updatedAt: view.updatedAt, updatedAt: view.updatedAt,
draft: view.draft,
posts, posts,
meta,
} }
} }
+20 -8
View File
@@ -8,6 +8,8 @@ import {
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {type ComposerState} from '#/view/com/composer/state/composer' import {type ComposerState} from '#/view/com/composer/state/composer'
import {useAnalytics} from '#/analytics'
import {getDeviceId} from '#/analytics/identifiers'
import {composerStateToDraft, draftViewToSummary} from './api' import {composerStateToDraft, draftViewToSummary} from './api'
import {logger} from './logger' import {logger} from './logger'
import * as storage from './storage' import * as storage from './storage'
@@ -19,6 +21,7 @@ const DRAFTS_QUERY_KEY = ['drafts']
*/ */
export function useDraftsQuery() { export function useDraftsQuery() {
const agent = useAgent() const agent = useAgent()
const ax = useAnalytics()
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: DRAFTS_QUERY_KEY, queryKey: DRAFTS_QUERY_KEY,
@@ -29,7 +32,10 @@ export function useDraftsQuery() {
return { return {
cursor: res.data.cursor, cursor: res.data.cursor,
drafts: res.data.drafts.map(view => drafts: res.data.drafts.map(view =>
draftViewToSummary(view, path => storage.mediaExists(path)), draftViewToSummary({
view,
analytics: ax,
}),
), ),
} }
}, },
@@ -42,11 +48,17 @@ export function useDraftsQuery() {
* Load a draft's local media for editing. * Load a draft's local media for editing.
* Takes the full Draft object (from DraftSummary) to avoid re-fetching. * Takes the full Draft object (from DraftSummary) to avoid re-fetching.
*/ */
export async function loadDraft(draft: AppBskyDraftDefs.Draft): Promise<{ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
loadedMedia: Map<string, string> loadedMedia: Map<string, string>
}> { }> {
// Load local media files // Load local media files
const loadedMedia = new Map<string, string>() const loadedMedia = new Map<string, string>()
// can't load media from another device
if (draft.deviceId && draft.deviceId !== getDeviceId()) {
return {loadedMedia}
}
for (const post of draft.posts) { for (const post of draft.posts) {
// Load images // Load images
if (post.embedImages) { if (post.embedImages) {
@@ -54,10 +66,10 @@ export async function loadDraft(draft: AppBskyDraftDefs.Draft): Promise<{
try { try {
const url = await storage.loadMediaFromLocal(img.localRef.path) const url = await storage.loadMediaFromLocal(img.localRef.path)
loadedMedia.set(img.localRef.path, url) loadedMedia.set(img.localRef.path, url)
} catch (e) { } catch (e: any) {
logger.debug('Failed to load draft image', { logger.error('Failed to load draft image', {
path: img.localRef.path, path: img.localRef.path,
error: e, safeMessage: e.message,
}) })
} }
} }
@@ -68,10 +80,10 @@ export async function loadDraft(draft: AppBskyDraftDefs.Draft): Promise<{
try { try {
const url = await storage.loadMediaFromLocal(vid.localRef.path) const url = await storage.loadMediaFromLocal(vid.localRef.path)
loadedMedia.set(vid.localRef.path, url) loadedMedia.set(vid.localRef.path, url)
} catch (e) { } catch (e: any) {
logger.debug('Failed to load draft video', { logger.error('Failed to load draft video', {
path: vid.localRef.path, path: vid.localRef.path,
error: e, safeMessage: e.message,
}) })
} }
} }
+21 -12
View File
@@ -50,22 +50,31 @@ export type DraftPostDisplay = {
*/ */
export type DraftSummary = { export type DraftSummary = {
id: string id: string
/** The full draft data from the server */
draft: AppBskyDraftDefs.Draft
/** First ~100 chars of first post */
previewText: string
/** Whether the draft has media */
hasMedia: boolean
/** Whether some media is missing (saved on another device) */
hasMissingMedia?: boolean
/** Number of media items */
mediaCount: number
/** Number of posts in thread */
postCount: number
/** ISO timestamp of creation */ /** ISO timestamp of creation */
createdAt: string createdAt: string
/** ISO timestamp of last update */ /** ISO timestamp of last update */
updatedAt: string updatedAt: string
/** The full draft data from the server */
draft: AppBskyDraftDefs.Draft
/** All posts in the draft for full display */ /** All posts in the draft for full display */
posts: DraftPostDisplay[] posts: DraftPostDisplay[]
/** Metadata about the draft for display purposes */
meta: {
/** Whether this device is the originating device for the draft */
isOriginatingDevice: boolean
/** Number of posts in thread */
postCount: number
/** Number of replies to anchor post */
replyCount: number
/** Whether the draft has media */
hasMedia: boolean
/** Whether some media is missing (saved on another device) */
hasMissingMedia?: boolean
/** Number of media items */
mediaCount: number
/** Whether any posts in the draft has quotes */
hasQuotes: boolean
/** Number of quotes in the draft */
quoteCount: number
}
} }
+42
View File
@@ -96,6 +96,20 @@
tlds "^1.234.0" tlds "^1.234.0"
zod "^3.23.8" zod "^3.23.8"
"@atproto/api@^0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.20.tgz#3fdbb7b7fae90bd59101970c2b56cc31e8cf417d"
integrity sha512-BZYZkh2VJIFCXEnc/vzKwAwWjAQQTgbNJ8FBxpBK+z+KYh99O0uPCsRYKoCQsRrnkgrhzdU9+g2G+7zanTIGbw==
dependencies:
"@atproto/common-web" "^0.4.15"
"@atproto/lexicon" "^0.6.1"
"@atproto/syntax" "^0.4.3"
"@atproto/xrpc" "^0.7.7"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
zod "^3.23.8"
"@atproto/aws@^0.2.31": "@atproto/aws@^0.2.31":
version "0.2.31" version "0.2.31"
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.31.tgz#e46d7db34ee57c4f9817269f1e73a7eddba2b9b8" resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.31.tgz#e46d7db34ee57c4f9817269f1e73a7eddba2b9b8"
@@ -190,6 +204,16 @@
"@atproto/syntax" "0.4.3" "@atproto/syntax" "0.4.3"
zod "^3.23.8" zod "^3.23.8"
"@atproto/common-web@^0.4.15":
version "0.4.15"
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.15.tgz#1fffedf62d69b8c96f7b360e11c4180446a2cc82"
integrity sha512-A4l9gyqUNez8CjZp/Trypz/D3WIQsNj8dN05WR6+RoBbvwc9JhWjKPrm+WoVYc/F16RPdXHLkE3BEJlGIyYIiA==
dependencies:
"@atproto/lex-data" "^0.0.10"
"@atproto/lex-json" "^0.0.10"
"@atproto/syntax" "^0.4.3"
zod "^3.23.8"
"@atproto/common@0.1.0": "@atproto/common@0.1.0":
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210" resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
@@ -327,6 +351,16 @@
uint8arrays "3.0.0" uint8arrays "3.0.0"
unicode-segmenter "^0.14.0" unicode-segmenter "^0.14.0"
"@atproto/lex-data@^0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.10.tgz#091c012af817a869951ce0e61eb757bd6c29797a"
integrity sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==
dependencies:
multiformats "^9.9.0"
tslib "^2.8.1"
uint8arrays "3.0.0"
unicode-segmenter "^0.14.0"
"@atproto/lex-document@0.0.11": "@atproto/lex-document@0.0.11":
version "0.0.11" version "0.0.11"
resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.11.tgz#8cfdd6ab5b5befac4d1409c76e2d5a310845c1dc" resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.11.tgz#8cfdd6ab5b5befac4d1409c76e2d5a310845c1dc"
@@ -344,6 +378,14 @@
"@atproto/lex-data" "0.0.9" "@atproto/lex-data" "0.0.9"
tslib "^2.8.1" tslib "^2.8.1"
"@atproto/lex-json@^0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.10.tgz#18de1c8cc3ba564e5412bce1f8e22c198c956e64"
integrity sha512-L6MyXU17C5ODMeob8myQ2F3xvgCTvJUtM0ew8qSApnN//iDasB/FDGgd7ty4UVNmx4NQ/rtvz8xV94YpG6kneQ==
dependencies:
"@atproto/lex-data" "^0.0.10"
tslib "^2.8.1"
"@atproto/lex-resolver@0.0.12": "@atproto/lex-resolver@0.0.12":
version "0.0.12" version "0.0.12"
resolved "https://registry.yarnpkg.com/@atproto/lex-resolver/-/lex-resolver-0.0.12.tgz#fb6cd78c78c0acfc9a92d9e42abe7ff18b4c3a41" resolved "https://registry.yarnpkg.com/@atproto/lex-resolver/-/lex-resolver-0.0.12.tgz#fb6cd78c78c0acfc9a92d9e42abe7ff18b4c3a41"