diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index 2576f82a7..c1c6aa1b7 100644 --- a/src/components/PostControls/PostMenu/PostMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -8,7 +8,6 @@ import { import * as Clipboard from 'expo-clipboard' import { type AppBskyEmbedExternal, - type AppBskyEmbedImages, AppBskyEmbedRecord, type AppBskyEmbedRecordWithMedia, type AppBskyEmbedVideo, @@ -28,13 +27,14 @@ import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenLink} from '#/lib/hooks/useOpenLink' import {saveVideoToDevice} from '#/lib/media/saveVideoToDevice' +import {getRedraftImages} from '#/lib/redraft' import {getCurrentRoute} from '#/lib/routes/helpers' import {makeProfileLink} from '#/lib/routes/links' import { type CommonNavigatorParams, type NavigationProp, } from '#/lib/routes/types' -import {richTextToString} from '#/lib/strings/rich-text-helpers' +import {richTextToStringPreservingLinks} from '#/lib/strings/rich-text-helpers' import {toShareUrl} from '#/lib/strings/url-helpers' import {useTranslate} from '#/lib/translation' import {getPostLanguageTags} from '#/locale/helpers' @@ -247,47 +247,8 @@ let PostMenuItems = ({ } const onConfirmRedraft = async () => { - let imageUris: { - uri: string - width: number - height: number - altText?: string - blobRef?: AppBskyEmbedImages.Image['image'] - }[] = [] - const recordEmbed = record.embed - let recordImages: AppBskyEmbedImages.Image[] = [] - if (recordEmbed?.$type === 'app.bsky.embed.images') { - recordImages = (recordEmbed as AppBskyEmbedImages.Main).images - } else if (recordEmbed?.$type === 'app.bsky.embed.recordWithMedia') { - const media = (recordEmbed as AppBskyEmbedRecordWithMedia.Main).media - if (media.$type === 'app.bsky.embed.images') { - recordImages = (media as AppBskyEmbedImages.Main).images - } - } - - if (post.embed?.$type === 'app.bsky.embed.images#view') { - const embed = post.embed as AppBskyEmbedImages.View - imageUris = embed.images.map((img, i) => ({ - uri: img.fullsize, - width: img.aspectRatio?.width ?? 1000, - height: img.aspectRatio?.height ?? 1000, - altText: img.alt, - blobRef: recordImages[i]?.image, - })) - } else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') { - const embed = post.embed as AppBskyEmbedRecordWithMedia.View - if (embed.media.$type === 'app.bsky.embed.images#view') { - const images = embed.media as AppBskyEmbedImages.View - imageUris = images.images.map((img, i) => ({ - uri: img.fullsize, - width: img.aspectRatio?.width ?? 1000, - height: img.aspectRatio?.height ?? 1000, - altText: img.alt, - blobRef: recordImages[i]?.image, - })) - } - } + const imageUris = getRedraftImages(recordEmbed, post.embed) let quotePost: AppBskyFeedDefs.PostView | undefined @@ -397,7 +358,7 @@ let PostMenuItems = ({ } openComposer({ - text: richTextToString(richText, true), + text: richTextToStringPreservingLinks(richText), imageUris, videoUri, onPost: () => { @@ -451,7 +412,7 @@ let PostMenuItems = ({ } const onCopyPostText = () => { - const str = richTextToString(richText, true) + const str = richTextToStringPreservingLinks(richText) void Clipboard.setStringAsync(str) Toast.show(l`Copied to clipboard`, { diff --git a/src/lib/redraft.test.ts b/src/lib/redraft.test.ts new file mode 100644 index 000000000..57e7bcfa5 --- /dev/null +++ b/src/lib/redraft.test.ts @@ -0,0 +1,75 @@ +import { + type AppBskyEmbedGallery, + type AppBskyEmbedRecordWithMedia, + type AppBskyFeedDefs, + type AppBskyFeedPost, + type BlobRef, +} from '@atproto/api' + +import {getRedraftImages} from './redraft' + +const blobs = Array.from({length: 5}, (_, index) => + ({ref: {toString: () => `blob-${index}`}} as unknown as BlobRef), +) + +const recordGallery = { + $type: 'app.bsky.embed.gallery', + items: blobs.map((image, index) => ({ + $type: 'app.bsky.embed.gallery#image', + image, + alt: `alt ${index}`, + aspectRatio: {width: index + 1, height: index + 2}, + })), +} as AppBskyEmbedGallery.Main + +const viewGallery = { + $type: 'app.bsky.embed.gallery#view', + items: blobs.map((_, index) => ({ + $type: 'app.bsky.embed.gallery#viewImage', + thumbnail: `https://cdn.example/${index}/thumb`, + fullsize: `https://cdn.example/${index}/full`, + alt: `alt ${index}`, + aspectRatio: {width: index + 1, height: index + 2}, + })), +} as AppBskyEmbedGallery.View + +describe('getRedraftImages', () => { + it('restores galleries with more than four images', () => { + const images = getRedraftImages( + recordGallery as AppBskyFeedPost.Record['embed'], + viewGallery as AppBskyFeedDefs.PostView['embed'], + ) + + expect(images).toHaveLength(5) + expect(images[4]).toEqual({ + uri: 'https://cdn.example/4/full', + width: 5, + height: 6, + altText: 'alt 4', + blobRef: blobs[4], + }) + }) + + it('restores a gallery combined with a quote', () => { + const recordEmbed = { + $type: 'app.bsky.embed.recordWithMedia', + record: { + $type: 'app.bsky.embed.record', + record: {uri: 'at://did:plc:quoted/app.bsky.feed.post/1', cid: 'cid'}, + }, + media: recordGallery, + } as AppBskyEmbedRecordWithMedia.Main + const viewEmbed = { + $type: 'app.bsky.embed.recordWithMedia#view', + record: {$type: 'app.bsky.embed.record#view', record: {$type: 'unknown'}}, + media: viewGallery, + } as unknown as AppBskyEmbedRecordWithMedia.View + + expect( + getRedraftImages( + recordEmbed as AppBskyFeedPost.Record['embed'], + viewEmbed as AppBskyFeedDefs.PostView['embed'], + ), + ).toHaveLength(5) + }) +}) diff --git a/src/lib/redraft.ts b/src/lib/redraft.ts new file mode 100644 index 000000000..21f1c8bf4 --- /dev/null +++ b/src/lib/redraft.ts @@ -0,0 +1,69 @@ +import { + AppBskyEmbedGallery, + AppBskyEmbedImages, + AppBskyEmbedRecordWithMedia, + type AppBskyFeedDefs, + type AppBskyFeedPost, + type BlobRef, +} from '@atproto/api' + +export type RedraftImage = { + uri: string + width: number + height: number + altText?: string + blobRef?: BlobRef +} + +/** Restores image and gallery media from a post into composer image inputs. */ +export function getRedraftImages( + recordEmbed: AppBskyFeedPost.Record['embed'], + viewEmbed: AppBskyFeedDefs.PostView['embed'], +): RedraftImage[] { + const recordMedia = + recordEmbed?.$type === 'app.bsky.embed.recordWithMedia' + ? (recordEmbed as AppBskyEmbedRecordWithMedia.Main).media + : recordEmbed + const viewMedia = + viewEmbed?.$type === 'app.bsky.embed.recordWithMedia#view' + ? (viewEmbed as AppBskyEmbedRecordWithMedia.View).media + : viewEmbed + + if (viewMedia?.$type === 'app.bsky.embed.images#view') { + const viewImages = (viewMedia as AppBskyEmbedImages.View).images + const blobs = + recordMedia?.$type === 'app.bsky.embed.images' + ? (recordMedia as AppBskyEmbedImages.Main).images.map( + image => image.image, + ) + : [] + return viewImages.map((image, index) => ({ + uri: image.fullsize, + width: image.aspectRatio?.width ?? 1000, + height: image.aspectRatio?.height ?? 1000, + altText: image.alt, + blobRef: blobs[index], + })) + } + + if (viewMedia?.$type === 'app.bsky.embed.gallery#view') { + const viewItems = (viewMedia as AppBskyEmbedGallery.View).items.filter( + AppBskyEmbedGallery.isViewImage, + ) + const blobs = + recordMedia?.$type === 'app.bsky.embed.gallery' + ? (recordMedia as AppBskyEmbedGallery.Main).items + .filter(AppBskyEmbedGallery.isImage) + .map(image => image.image) + : [] + return viewItems.map((image, index) => ({ + uri: image.fullsize, + width: image.aspectRatio?.width ?? 1000, + height: image.aspectRatio?.height ?? 1000, + altText: image.alt, + blobRef: blobs[index], + })) + } + + return [] +} diff --git a/src/lib/strings/rich-text-helpers.test.ts b/src/lib/strings/rich-text-helpers.test.ts new file mode 100644 index 000000000..f2573b351 --- /dev/null +++ b/src/lib/strings/rich-text-helpers.test.ts @@ -0,0 +1,62 @@ +import {RichText} from '@atproto/api' + +import {richTextToStringPreservingLinks} from './rich-text-helpers' + +describe('richTextToStringPreservingLinks', () => { + it('preserves the visible labels of multiple masked links', () => { + const text = 'first and docs and third' + const richText = new RichText({ + text, + facets: [ + { + index: {byteStart: 0, byteEnd: 5}, + features: [ + { + $type: 'app.bsky.richtext.facet#link', + uri: 'https://one.example/destination', + }, + ], + }, + { + index: {byteStart: 10, byteEnd: 14}, + features: [ + { + $type: 'app.bsky.richtext.facet#link', + uri: 'https://docs.example/other-page', + }, + ], + }, + { + index: {byteStart: 19, byteEnd: 24}, + features: [ + { + $type: 'app.bsky.richtext.facet#link', + uri: 'https://three.example/destination', + }, + ], + }, + ], + }) + + expect(richTextToStringPreservingLinks(richText)).toBe( + '[first](https://one.example/destination) and [docs](https://docs.example/other-page) and [third](https://three.example/destination)', + ) + }) + + it('does not wrap a link whose text is already its destination', () => { + const uri = 'https://example.com/page' + const richText = new RichText({ + text: uri, + facets: [ + { + index: {byteStart: 0, byteEnd: uri.length}, + features: [ + {$type: 'app.bsky.richtext.facet#link', uri}, + ], + }, + ], + }) + + expect(richTextToStringPreservingLinks(richText)).toBe(uri) + }) +}) diff --git a/src/lib/strings/rich-text-helpers.ts b/src/lib/strings/rich-text-helpers.ts index c2b2ceac5..3731f7747 100644 --- a/src/lib/strings/rich-text-helpers.ts +++ b/src/lib/strings/rich-text-helpers.ts @@ -28,3 +28,30 @@ export function richTextToString(rt: RichText, loose: boolean): string { return result } + +/** + * Serializes rich text into markdown-link syntax without changing a link's + * visible label. This preserves even same-domain labels instead of replacing + * them with the destination URL. + */ +export function richTextToStringPreservingLinks(rt: RichText): string { + if (!rt.facets?.length) { + return rt.text + } + + let result = '' + + for (const segment of rt.segments()) { + const link = segment.link + if (link && AppBskyRichtextFacet.validateLink(link).success) { + result += + segment.text === link.uri + ? segment.text + : `[${segment.text}](${link.uri})` + } else { + result += segment.text + } + } + + return result +} diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index 2569fc4eb..00c07cdf3 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -80,7 +80,7 @@ import * as Layout from '#/components/Layout' import {ListFooter} from '#/components/Lists' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' -import {IS_NATIVE} from '#/env' +import {IS_IOS, IS_NATIVE} from '#/env' const PARENT_CHUNK_SIZE = IS_NATIVE ? 5 : 20 const CHILDREN_CHUNK_SIZE = 50 @@ -526,6 +526,23 @@ export function PostThread({ ) }, [thread.data.items, anchor, isRoot]) + /* + * Keep this sticky while a sort change temporarily replaces the response + * with anchor-only placeholder data. On iOS, maintaining a visible child + * while the reader transform or a sort reorders that child's surrounding + * native views can crash the list. Parent prepending still uses the normal + * behavior for threads whose item identities remain stable. + */ + const opThreadChainUri = useRef(null) + if (opThreadChainUri.current !== uri) { + opThreadChainUri.current = hasOpThreadChain ? uri : null + } else if (hasOpThreadChain) { + opThreadChainUri.current = uri + } + const disableIosVisiblePositionMaintenance = + IS_IOS && + (thread.state.view === 'reader' || opThreadChainUri.current === uri) + const onStartReached = () => { if (thread.state.isFetching) return // can be true after `prepareForParamsUpdate` is called @@ -967,7 +984,11 @@ export function PostThread({ * NATIVE ONLY * {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition} */ - maintainVisibleContentPosition={{minIndexForVisible: 0}} + maintainVisibleContentPosition={ + disableIosVisiblePositionMaintenance + ? undefined + : {minIndexForVisible: 0} + } desktopFixedHeight sideBorders={false} ListFooterComponent={ diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx index f3e971751..513be7395 100644 --- a/src/screens/Profile/Header/DisplayName.tsx +++ b/src/screens/Profile/Header/DisplayName.tsx @@ -5,8 +5,10 @@ import {getAuthorPrimaryName} from '#/lib/strings/display-names' import {type Shadow} from '#/state/cache/types' import {useHideDisplayNames} from '#/state/preferences/hide-display-names' import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf' +import {InlineLinkText} from '#/components/Link' import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' +import {useProfileHandleLink} from './useProfileHandleLink' export function ProfileHeaderDisplayName({ profile, @@ -18,6 +20,11 @@ export function ProfileHeaderDisplayName({ const t = useTheme() const {gtMobile} = useBreakpoints() const hideDisplayNames = useHideDisplayNames() + const shouldShowProfileLink = useProfileHandleLink(profile.handle) + const primaryName = getAuthorPrimaryName(profile, { + hideDisplayNames, + moderation: moderation.ui('displayName'), + }) return ( @@ -31,10 +38,21 @@ export function ProfileHeaderDisplayName({ a.font_bold, a.leading_tight, ]}> - {getAuthorPrimaryName(profile, { - hideDisplayNames, - moderation: moderation.ui('displayName'), - })} + {hideDisplayNames && shouldShowProfileLink ? ( + + {primaryName} + + ) : ( + primaryName + )} diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx index 97600c458..25246d693 100644 --- a/src/screens/Profile/Header/Handle.tsx +++ b/src/screens/Profile/Header/Handle.tsx @@ -9,14 +9,12 @@ import {sanitizePronouns} from '#/lib/strings/pronouns' import {type Shadow} from '#/state/cache/types' import {useHideDisplayNames} from '#/state/preferences/hide-display-names' import {useShowFollowsYouBadge} from '#/state/preferences/show-follows-you-badge' -import {useShowLinkInHandle} from '#/state/preferences/show-link-in-handle.tsx' -import {useShowLinkInHandleOnlyOnWorkingLinks} from '#/state/preferences/show-link-in-handle-only-on-working-links' -import {useHandleLinkQuery} from '#/state/queries/handle-link' import {atoms as a, useTheme, web} from '#/alf' import {InlineLinkText} from '#/components/Link.tsx' import {NewskieDialog} from '#/components/NewskieDialog' import {Text} from '#/components/Typography' import {IS_IOS, IS_NATIVE} from '#/env' +import {useProfileHandleLink} from './useProfileHandleLink' export function ProfileHeaderHandle({ profile, @@ -35,24 +33,8 @@ export function ProfileHeaderHandle({ const invalidHandle = isInvalidHandle(profile.handle) const pronouns = profile.pronouns const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy - const isBskySocialHandle = profile.handle.endsWith('.bsky.social') const showFollowsYouBadge = useShowFollowsYouBadge() - const showProfileInHandle = useShowLinkInHandle() - const showLinkInHandleOnlyOnWorkingLinks = - useShowLinkInHandleOnlyOnWorkingLinks() - const shouldCheckHandleLink = - showProfileInHandle && - showLinkInHandleOnlyOnWorkingLinks && - !invalidHandle && - !isBskySocialHandle - const {data: hasWorkingHandleLink = false} = useHandleLinkQuery( - profile.handle, - shouldCheckHandleLink, - ) - const shouldShowProfileLink = - showProfileInHandle && - !isBskySocialHandle && - (!showLinkInHandleOnlyOnWorkingLinks || hasWorkingHandleLink) + const shouldShowProfileLink = useProfileHandleLink(profile.handle) const disableNewskieDialog = disableTaps || disableAuxiliaryTaps const sanitized = sanitizeHandle( profile.handle, diff --git a/src/screens/Profile/Header/useProfileHandleLink.ts b/src/screens/Profile/Header/useProfileHandleLink.ts new file mode 100644 index 000000000..21862eed0 --- /dev/null +++ b/src/screens/Profile/Header/useProfileHandleLink.ts @@ -0,0 +1,28 @@ +import {isInvalidHandle} from '#/lib/strings/handles' +import {useShowLinkInHandle} from '#/state/preferences/show-link-in-handle' +import {useShowLinkInHandleOnlyOnWorkingLinks} from '#/state/preferences/show-link-in-handle-only-on-working-links' +import {useHandleLinkQuery} from '#/state/queries/handle-link' + +export function useProfileHandleLink(handle: string) { + const invalidHandle = isInvalidHandle(handle) + const isBskySocialHandle = handle.endsWith('.bsky.social') + const showProfileInHandle = useShowLinkInHandle() + const showLinkInHandleOnlyOnWorkingLinks = + useShowLinkInHandleOnlyOnWorkingLinks() + const shouldCheckHandleLink = + showProfileInHandle && + showLinkInHandleOnlyOnWorkingLinks && + !invalidHandle && + !isBskySocialHandle + const {data: hasWorkingHandleLink = false} = useHandleLinkQuery( + handle, + shouldCheckHandleLink, + ) + + return ( + !invalidHandle && + showProfileInHandle && + !isBskySocialHandle && + (!showLinkInHandleOnlyOnWorkingLinks || hasWorkingHandleLink) + ) +}