diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx
index 6845bdd278..322fb9ebd9 100644
--- a/src/components/RichText.tsx
+++ b/src/components/RichText.tsx
@@ -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
) {
diff --git a/src/lib/deviceName.ts b/src/lib/deviceName.ts
index 7ae4c845a5..0ca4a0f386 100644
--- a/src/lib/deviceName.ts
+++ b/src/lib/deviceName.ts
@@ -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
}
}
diff --git a/src/view/com/composer/drafts/DraftItem.tsx b/src/view/com/composer/drafts/DraftItem.tsx
index b0bf2d312e..afdd38c97e 100644
--- a/src/view/com/composer/drafts/DraftItem.tsx
+++ b/src/view/com/composer/drafts/DraftItem.tsx
@@ -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,
]}>
+
+ {!mediaExistsOnOtherDevice && }
+
+ {hasMetadata && (
+
+ {mediaExistsOnOtherDevice && (
+
+ )}
+ {mediaIsMissing && (
+
+ )}
+ {draft.meta.hasQuotes && (
+
+ )}
+ {draft.meta.replyCount > 0 && (
+
+ )}
+
+ )}
+ {/* Timestamp */}
(
{timeElapsed}
@@ -96,6 +166,7 @@ export function DraftItem({
+ {/* Menu button */}
- >
- )
-}
-
-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 (
- <>
- 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,
- ]}>
-
- {draft.hasMissingMedia && (
-
-
- Some media unavailable (saved on another device)
-
-
- )}
-
- {draft.posts.map((post, index) => (
-
- ))}
-
-
+ 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 (
-
-
-
- {!isLast && (
-
- )}
-
-
-
-
-
- {profile && (
- <>
-
- {createSanitizedDisplayName(profile)}
-
-
- {sanitizeHandle(profile.handle)}
-
-
- ·
-
- >
- )}
-
- {({timeElapsed}) => (
-
- {timeElapsed}
-
- )}
-
-
-
- {isFirst && (
-
- )}
-
-
- {post.text ? (
-
- ) : (
-
- (No text)
-
- )}
-
-
-
+
+
+ {text}
)
}
@@ -409,7 +315,7 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
}
return (
-
+
{loadedImages.map((image, i) => (
))}
diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts
index d45f573cea..a4040bb053 100644
--- a/src/view/com/composer/drafts/state/api.ts
+++ b/src/view/com/composer/drafts/state/api.ts
@@ -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,
}
}
diff --git a/src/view/com/composer/drafts/state/queries.ts b/src/view/com/composer/drafts/state/queries.ts
index 106c12ab5e..436e16317f 100644
--- a/src/view/com/composer/drafts/state/queries.ts
+++ b/src/view/com/composer/drafts/state/queries.ts
@@ -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,
+ }),
),
}
},
diff --git a/src/view/com/composer/drafts/state/schema.ts b/src/view/com/composer/drafts/state/schema.ts
index 0199a9b4da..9f88aa07da 100644
--- a/src/view/com/composer/drafts/state/schema.ts
+++ b/src/view/com/composer/drafts/state/schema.ts
@@ -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
+ }
}