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 + } }