diff --git a/app.config.js b/app.config.js index 83fe9d7e86..c1ce5c7c76 100644 --- a/app.config.js +++ b/app.config.js @@ -119,6 +119,7 @@ module.exports = function (_config) { 'com.apple.developer.kernel.increased-memory-limit': true, 'com.apple.developer.kernel.extended-virtual-addressing': true, 'com.apple.security.application-groups': 'group.app.bsky', + 'com.apple.developer.device-information.user-assigned-device-name': true, }, privacyManifests: { NSPrivacyCollectedDataTypes: [ diff --git a/package.json b/package.json index e8baad5c50..6a1b9e89cf 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.18.18", + "@atproto/api": "^0.18.20", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.6", diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index eca0d7c635..322fb9ebd9 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import {useMemo} from 'react' import {type StyleProp, type TextStyle} from 'react-native' import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' @@ -27,6 +27,16 @@ export type RichTextProps = TextStyleProp & interactiveStyle?: StyleProp emojiMultiplier?: number 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({ @@ -44,12 +54,17 @@ export function RichText({ onLayout, onTextLayout, shouldProxyLinks, + disableMentionFacetValidation, }: RichTextProps) { - const richText = React.useMemo( - () => - value instanceof RichTextAPI ? value : new RichTextAPI({text: value}), - [value], - ) + const richText = useMemo(() => { + if (value instanceof RichTextAPI) { + return value + } else { + const rt = new RichTextAPI({text: value}) + rt.detectFacetsWithoutResolution() + return rt + } + }, [value]) const plainStyles = [a.leading_snug, style] const interactiveStyles = [plainStyles, interactiveStyle] @@ -98,9 +113,11 @@ export function RichText({ const link = segment.link const mention = segment.mention const tag = segment.tag + if ( mention && - AppBskyRichtextFacet.validateMention(mention).success && + (disableMentionFacetValidation || + AppBskyRichtextFacet.validateMention(mention).success) && !disableLinks ) { els.push( diff --git a/src/lib/deviceName.ts b/src/lib/deviceName.ts new file mode 100644 index 0000000000..0ca4a0f386 --- /dev/null +++ b/src/lib/deviceName.ts @@ -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 + } +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 148811bc15..d73d9c7b27 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -138,7 +138,7 @@ import { type RestoredVideo, } from './drafts/state/api' import { - loadDraft, + loadDraftMedia, useCleanupPublishedDraftMutation, useSaveDraftMutation, } from './drafts/state/queries' @@ -482,7 +482,7 @@ export const ComposePost = ({ }) // 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 const originalLocalRefs = extractLocalRefs(draftSummary.draft) diff --git a/src/view/com/composer/drafts/DraftItem.tsx b/src/view/com/composer/drafts/DraftItem.tsx index e647473300..7003d14cd5 100644 --- a/src/view/com/composer/drafts/DraftItem.tsx +++ b/src/view/com/composer/drafts/DraftItem.tsx @@ -1,20 +1,22 @@ -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} 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' import {Text} from '#/components/Typography' import {IS_WEB} from '#/env' import {type DraftPostDisplay, type DraftSummary} from './state/schema' @@ -32,6 +34,34 @@ 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({ + 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(() => { onDelete(draft) @@ -39,48 +69,169 @@ export function DraftItem({ 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) => ( - + 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, + dark: t.atoms.bg_contrast_25.backgroundColor, + dim: t.atoms.bg_contrast_25.backgroundColor, + }), + }, + ]}> + + - ))} + + {!mediaExistsOnOtherDevice && } + + {hasMetadata && ( + + {mediaExistsOnOtherDevice && ( + + )} + {mediaIsMissing && ( + + )} + {draft.meta.hasQuotes && ( + + )} + {draft.meta.replyCount > 0 && ( + + )} + + )} + + + + {/* Timestamp */} + + + {({timeElapsed}) => ( + + {timeElapsed} + + )} + - + + {/* Menu button */} + + + + + 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 ? ( - - {post.text} - - ) : ( - - (No text) - - )} - - - + + + {text} ) } @@ -271,7 +325,7 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) { } return ( - + {loadedImages.map((image, i) => ( ))} diff --git a/src/view/com/composer/drafts/DraftsListDialog.tsx b/src/view/com/composer/drafts/DraftsListDialog.tsx index 1a19bc5ead..834cd04cc7 100644 --- a/src/view/com/composer/drafts/DraftsListDialog.tsx +++ b/src/view/com/composer/drafts/DraftsListDialog.tsx @@ -5,7 +5,7 @@ import {useLingui} from '@lingui/react' import {useCallOnce} from '#/lib/once' 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 * as Dialog from '#/components/Dialog' import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX' @@ -26,6 +26,7 @@ export function DraftsListDialog({ }) { const {_} = useLingui() const t = useTheme() + const {gtPhone} = useBreakpoints() const ax = useAnalytics() const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} = useDraftsQuery() @@ -91,7 +92,7 @@ export function DraftsListDialog({ const renderItem = useCallback( ({item}: {item: DraftSummary}) => { return ( - + ) }, - [handleSelectDraft, handleDeleteDraft], + [handleSelectDraft, handleDeleteDraft, gtPhone], ) const header = useMemo( @@ -162,7 +163,17 @@ export function DraftsListDialog({ ListFooterComponent={footerComponent} onEndReached={onEndReached} 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]} contentContainerStyle={[a.pb_xl]} /> diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index ffcdb2b5db..a4040bb053 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -5,6 +5,7 @@ import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api' import {nanoid} from 'nanoid/non-secure' import {resolveLink} from '#/lib/api/resolve' +import {getDeviceName} from '#/lib/deviceName' import {getImageDim} from '#/lib/media/manip' import {mimeToExt} from '#/lib/media/video/util' import {type ComposerImage} from '#/state/gallery' @@ -17,8 +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' @@ -66,6 +70,8 @@ export async function composerStateToDraft(state: ComposerState): Promise<{ const draft: AppBskyDraftDefs.Draft = { $type: 'app.bsky.draft.defs#draft', + deviceId: getDeviceId(), + deviceName: getDeviceName().slice(0, 100), // max length of 100 in lex posts, threadgateAllow: threadgateAllowUISettingToAllowRecordValue( state.thread.threadgate, @@ -265,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'] = [] @@ -284,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, @@ -301,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, @@ -320,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 || '', @@ -336,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 2694c7ddab..436e16317f 100644 --- a/src/view/com/composer/drafts/state/queries.ts +++ b/src/view/com/composer/drafts/state/queries.ts @@ -8,6 +8,8 @@ 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' import * as storage from './storage' @@ -19,6 +21,7 @@ const DRAFTS_QUERY_KEY = ['drafts'] */ export function useDraftsQuery() { const agent = useAgent() + const ax = useAnalytics() return useInfiniteQuery({ queryKey: DRAFTS_QUERY_KEY, @@ -29,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, + }), ), } }, @@ -42,11 +48,17 @@ export function useDraftsQuery() { * Load a draft's local media for editing. * 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 }> { // Load local media files const loadedMedia = new Map() + + // can't load media from another device + if (draft.deviceId && draft.deviceId !== getDeviceId()) { + return {loadedMedia} + } + for (const post of draft.posts) { // Load images if (post.embedImages) { @@ -54,10 +66,10 @@ export async function loadDraft(draft: AppBskyDraftDefs.Draft): Promise<{ try { const url = await storage.loadMediaFromLocal(img.localRef.path) loadedMedia.set(img.localRef.path, url) - } catch (e) { - logger.debug('Failed to load draft image', { + } catch (e: any) { + logger.error('Failed to load draft image', { path: img.localRef.path, - error: e, + safeMessage: e.message, }) } } @@ -68,10 +80,10 @@ export async function loadDraft(draft: AppBskyDraftDefs.Draft): Promise<{ try { const url = await storage.loadMediaFromLocal(vid.localRef.path) loadedMedia.set(vid.localRef.path, url) - } catch (e) { - logger.debug('Failed to load draft video', { + } catch (e: any) { + logger.error('Failed to load draft video', { path: vid.localRef.path, - error: e, + safeMessage: e.message, }) } } 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 + } } diff --git a/yarn.lock b/yarn.lock index 830107d7f7..f50ea1d5c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -96,6 +96,20 @@ tlds "^1.234.0" 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": version "0.2.31" resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.31.tgz#e46d7db34ee57c4f9817269f1e73a7eddba2b9b8" @@ -190,6 +204,16 @@ "@atproto/syntax" "0.4.3" 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": version "0.1.0" resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210" @@ -327,6 +351,16 @@ uint8arrays "3.0.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": version "0.0.11" 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" 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": version "0.0.12" resolved "https://registry.yarnpkg.com/@atproto/lex-resolver/-/lex-resolver-0.0.12.tgz#fb6cd78c78c0acfc9a92d9e42abe7ff18b4c3a41"