New draft preview UI

This commit is contained in:
Eric Bailey
2026-01-30 16:37:36 -06:00
parent 2f20e44cc8
commit 35e66a53c5
6 changed files with 179 additions and 242 deletions
+3 -3
View File
@@ -36,7 +36,7 @@ export type RichTextProps = TextStyleProp &
*
* Use with care - only use if you're rendering facets you're generating yourself.
*/
validateMentionFacets?: boolean
disableMentionFacetValidation?: true
}
export function RichText({
@@ -54,7 +54,7 @@ export function RichText({
onLayout,
onTextLayout,
shouldProxyLinks,
validateMentionFacets,
disableMentionFacetValidation,
}: RichTextProps) {
const richText = useMemo(() => {
if (value instanceof RichTextAPI) {
@@ -116,7 +116,7 @@ export function RichText({
if (
mention &&
(!validateMentionFacets ||
(disableMentionFacetValidation ||
AppBskyRichtextFacet.validateMention(mention).success) &&
!disableLinks
) {
+7 -5
View File
@@ -2,15 +2,17 @@ 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 || 'Android'
return deviceName || FALLBACK_ANDROID
} else if (env.IS_IOS) {
// we need an entitlement to get the real device name on iOS, so just
// return a generic name for now
return 'iOS'
return deviceName || FALLBACK_IOS
} else {
return 'Web' // could append browser info here
return FALLBACK_WEB // could append browser info here
}
}
+101 -195
View File
@@ -1,18 +1,19 @@
import {useCallback, useEffect, useState} from 'react'
import {useCallback, useEffect, useMemo, useState} from 'react'
import {Pressable, View} from 'react-native'
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 {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
import * as device from '#/lib/deviceName'
import {logger} from '#/view/com/composer/drafts/state/logger'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, select} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {atoms as a, select, useTheme} from '#/alf'
import {Button} 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 {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 Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
@@ -33,6 +34,29 @@ export function DraftItem({
const {_} = useLingui()
const t = useTheme()
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`another device`)
break
}
return name
}, [_, draft])
const handleDelete = useCallback(() => {
onDelete(draft)
@@ -48,7 +72,11 @@ export function DraftItem({
onPress={() => onSelect(draft)}
style={({pressed, hovered}) => [
a.rounded_md,
a.border,
t.atoms.shadow_sm,
pressed || hovered
? t.atoms.border_contrast_medium
: t.atoms.border_contrast_low,
{
backgroundColor: select(t.name, {
light: t.atoms.bg.backgroundColor,
@@ -56,21 +84,63 @@ export function DraftItem({
dim: t.atoms.bg_contrast_25.backgroundColor,
}),
},
(pressed || hovered) && t.atoms.bg_contrast_50,
]}>
<View
style={[
a.rounded_md,
a.overflow_hidden,
a.p_lg,
a.pb_md,
a.gap_sm,
{
paddingTop: 20 + a.pt_md.paddingTop,
},
]}>
<RichText
style={[a.text_md, a.leading_snug, a.pointer_events_none]}
value={post.text}
enableTags
disableMentionFacetValidation
/>
{!mediaExistsOnOtherDevice && <DraftMediaPreview post={post} />}
{hasMetadata && (
<View style={[a.gap_xs]}>
{mediaExistsOnOtherDevice && (
<DraftMetadataTag
icon={WarningIcon}
text={_(msg`Media stored on ${deviceName}`)}
/>
)}
{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={[
@@ -85,9 +155,9 @@ export function DraftItem({
{({timeElapsed}) => (
<Text
style={[
a.text_md,
a.text_sm,
t.atoms.text_contrast_medium,
a.leading_snug,
a.leading_tight,
]}
numberOfLines={1}>
{timeElapsed}
@@ -96,6 +166,7 @@ export function DraftItem({
</TimeElapsed>
</View>
{/* Menu button */}
<View
style={[
a.absolute,
@@ -151,71 +222,6 @@ export function DraftItem({
</Button>
</View>
</View>
</>
)
}
export function DraftItemOld({
draft,
onSelect,
onDelete,
}: {
draft: DraftSummary
onSelect: (draft: DraftSummary) => void
onDelete: (draft: DraftSummary) => void
}) {
const {_} = useLingui()
const t = useTheme()
const discardPromptControl = Prompt.usePromptControl()
const handleDelete = useCallback(() => {
onDelete(draft)
}, [onDelete, draft])
return (
<>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Open draft`)}
accessibilityHint={_(msg`Opens this draft in the composer`)}
onPress={() => onSelect(draft)}
style={({pressed, hovered}) => [
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.bg,
t.atoms.border_contrast_low,
t.atoms.shadow_sm,
(pressed || hovered) && t.atoms.bg_contrast_25,
]}>
<View style={[a.p_md, a.gap_sm]}>
{draft.hasMissingMedia && (
<View
style={[
a.rounded_sm,
a.px_sm,
a.py_xs,
a.mb_xs,
t.atoms.bg_contrast_50,
]}>
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
<Trans>Some media unavailable (saved on another device)</Trans>
</Text>
</View>
)}
{draft.posts.map((post, index) => (
<DraftPostRow
key={post.id}
post={post}
isFirst={index === 0}
isLast={index === draft.posts.length - 1}
timestamp={draft.updatedAt}
discardPromptControl={discardPromptControl}
/>
))}
</View>
</Pressable>
<Prompt.Basic
control={discardPromptControl}
@@ -229,128 +235,28 @@ export function DraftItemOld({
)
}
function DraftPostRow({
post,
isFirst,
isLast,
timestamp,
discardPromptControl,
function DraftMetadataTag({
display = 'info',
icon: Icon,
text,
}: {
post: DraftPostDisplay
isFirst: boolean
isLast: boolean
timestamp: string
discardPromptControl: Prompt.PromptControlProps
display?: 'info' | 'warning'
icon: React.ComponentType<SVGIconProps>
text: string
}) {
const {_} = useLingui()
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 (
<View style={[a.flex_row, a.gap_sm]}>
<View style={[a.align_center]}>
<UserAvatar type="user" size={42} avatar={profile?.avatar} />
{!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 ? (
<RichText
style={[a.text_md, a.leading_snug, a.pointer_events_none]}
value={post.text}
enableTags
validateMentionFacets={false}
/>
) : (
<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 style={[a.flex_row, a.align_center, a.gap_xs]}>
<Icon size="sm" fill={color} />
<Text style={[a.text_sm, a.leading_tight, {color}]}>{text}</Text>
</View>
)
}
@@ -409,7 +315,7 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
}
return (
<MediaPreview.Outer style={[a.pt_xs]}>
<MediaPreview.Outer>
{loadedImages.map((image, i) => (
<MediaPreview.ImageItem key={i} thumbnail={image.url} alt={image.alt} />
))}
+41 -26
View File
@@ -18,9 +18,11 @@ import {
type PostDraft,
} from '#/view/com/composer/state/composer'
import {type VideoState} from '#/view/com/composer/state/video'
import {type AnalyticsContextType} from '#/analytics'
import {getDeviceId} from '#/analytics/identifiers'
import {logger} from './logger'
import {type DraftPostDisplay, type DraftSummary} from './schema'
import * as storage from './storage'
const TENOR_HOSTNAME = 'media.tenor.com'
@@ -269,16 +271,24 @@ function serializeGif(gifMedia: {
* Convert server DraftView to DraftSummary for list display.
* Also checks which media files exist locally.
*/
export function draftViewToSummary(
view: AppBskyDraftDefs.DraftView,
localMediaExists: (path: string) => boolean,
): DraftSummary {
const firstPost = view.draft.posts[0]
const previewText = firstPost?.text?.slice(0, 100) || ''
let mediaCount = 0
let hasMedia = false
let hasMissingMedia = false
export function draftViewToSummary({
view,
analytics,
}: {
view: AppBskyDraftDefs.DraftView
analytics: AnalyticsContextType
}): DraftSummary {
const meta = {
isOriginatingDevice: view.draft.deviceId === getDeviceId(),
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 images: DraftPostDisplay['images'] = []
@@ -288,11 +298,11 @@ export function draftViewToSummary(
// Process images
if (post.embedImages) {
for (const img of post.embedImages) {
mediaCount++
hasMedia = true
const exists = localMediaExists(img.localRef.path)
meta.mediaCount++
meta.hasMedia = true
const exists = storage.mediaExists(img.localRef.path)
if (!exists) {
hasMissingMedia = true
meta.hasMissingMedia = true
}
images.push({
localPath: img.localRef.path,
@@ -305,11 +315,11 @@ export function draftViewToSummary(
// Process videos
if (post.embedVideos) {
for (const vid of post.embedVideos) {
mediaCount++
hasMedia = true
const exists = localMediaExists(vid.localRef.path)
meta.mediaCount++
meta.hasMedia = true
const exists = storage.mediaExists(vid.localRef.path)
if (!exists) {
hasMissingMedia = true
meta.hasMissingMedia = true
}
videos.push({
localPath: vid.localRef.path,
@@ -324,13 +334,18 @@ export function draftViewToSummary(
for (const ext of post.embedExternals) {
const gifData = parseGifFromUrl(ext.uri)
if (gifData) {
mediaCount++
hasMedia = true
meta.mediaCount++
meta.hasMedia = true
gif = gifData
}
}
}
if (post.embedRecords && post.embedRecords.length > 0) {
meta.quoteCount += post.embedRecords.length
meta.hasQuotes = true
}
return {
id: `post-${index}`,
text: post.text || '',
@@ -340,17 +355,17 @@ export function draftViewToSummary(
}
})
if (meta.isOriginatingDevice && meta.hasMissingMedia) {
analytics.logger.warn(`Draft is missing media on originating device`, {})
}
return {
id: view.id,
draft: view.draft,
previewText,
hasMedia,
hasMissingMedia,
mediaCount,
postCount: view.draft.posts.length,
createdAt: view.createdAt,
updatedAt: view.updatedAt,
draft: view.draft,
posts,
meta,
}
}
@@ -8,6 +8,7 @@ import {
import {isNetworkError} from '#/lib/strings/errors'
import {useAgent} from '#/state/session'
import {type ComposerState} from '#/view/com/composer/state/composer'
import {useAnalytics} from '#/analytics'
import {getDeviceId} from '#/analytics/identifiers'
import {composerStateToDraft, draftViewToSummary} from './api'
import {logger} from './logger'
@@ -20,6 +21,7 @@ const DRAFTS_QUERY_KEY = ['drafts']
*/
export function useDraftsQuery() {
const agent = useAgent()
const ax = useAnalytics()
return useInfiniteQuery({
queryKey: DRAFTS_QUERY_KEY,
@@ -30,7 +32,10 @@ export function useDraftsQuery() {
return {
cursor: res.data.cursor,
drafts: res.data.drafts.map(view =>
draftViewToSummary(view, path => storage.mediaExists(path)),
draftViewToSummary({
view,
analytics: ax,
}),
),
}
},
+21 -12
View File
@@ -50,22 +50,31 @@ export type DraftPostDisplay = {
*/
export type DraftSummary = {
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 */
createdAt: string
/** ISO timestamp of last update */
updatedAt: string
/** The full draft data from the server */
draft: AppBskyDraftDefs.Draft
/** All posts in the draft for full display */
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
}
}