Skip to content

Commit 3dd8a88

Browse files
pfuto21454markharding
authored andcommitted
Link previews can be remove [#139]
Changelog: enhancement
1 parent 83698f7 commit 3dd8a88

7 files changed

Lines changed: 743 additions & 80 deletions

File tree

apps/polycentric/src/features/composer/ComposeSheet.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export function ComposeSheet({
110110
onRemoveAttachment={composer.handleRemoveAttachment}
111111
linkPreview={composer.linkPreview}
112112
linkPreviewLoading={composer.linkPreviewLoading}
113+
onRemoveLinkPreview={composer.handleRemoveLinkPreview}
113114
autoFocus={autoFocus}
114115
/>
115116
</Sheet.Content>

apps/polycentric/src/features/composer/ComposeTabScreen.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export default function ComposeTabScreen() {
7474
onRemoveAttachment={composer.handleRemoveAttachment}
7575
linkPreview={composer.linkPreview}
7676
linkPreviewLoading={composer.linkPreviewLoading}
77+
onRemoveLinkPreview={composer.handleRemoveLinkPreview}
7778
/>
7879
</View>
7980
<ComposeSheetFooterBar

apps/polycentric/src/features/composer/ComposerFields.tsx

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ type ComposerFieldsProps = {
4444
linkPreview: v2.Link | null;
4545
/** True while the link preview is being fetched. */
4646
linkPreviewLoading: boolean;
47+
/**
48+
* Remove the link preview (X button).
49+
*/
50+
onRemoveLinkPreview: () => void;
4751
/** Auto-focus the text field.**/
4852
autoFocus?: boolean;
4953
};
@@ -67,6 +71,7 @@ export function ComposerFields({
6771
onRemoveAttachment,
6872
linkPreview,
6973
linkPreviewLoading,
74+
onRemoveLinkPreview,
7075
autoFocus = true,
7176
}: ComposerFieldsProps) {
7277
const { theme } = useTheme();
@@ -136,6 +141,8 @@ export function ComposerFields({
136141
<ComposerLinkPreview
137142
link={linkPreview}
138143
loading={linkPreviewLoading}
144+
disabled={submitting}
145+
onRemove={onRemoveLinkPreview}
139146
/>
140147
{/* Quote preview */}
141148
{!!quote && <ComposerPostEmbed post={quote} intentText="Quoting" />}
@@ -148,39 +155,71 @@ export function ComposerFields({
148155
/**
149156
* Live link preview shown while composing: a loading row until the unfurl
150157
* resolves, then the same `LinkPreviewCard` used in the feed (so the composer
151-
* preview matches what the post will look like).
158+
* preview matches what the post will look like). Both states carry an X
159+
* button that removes the preview from the draft.
152160
*/
153161
function ComposerLinkPreview({
154162
link,
155163
loading,
164+
disabled,
165+
onRemove,
156166
}: {
157167
link: v2.Link | null;
158168
loading: boolean;
169+
disabled: boolean;
170+
onRemove: () => void;
159171
}) {
160172
const { theme } = useTheme();
161173

162-
if (link) return <LinkPreviewCard link={link} />;
163-
if (!loading) return null;
174+
if (!link && !loading) return null;
164175

165176
return (
166-
<View
167-
style={[
168-
Atoms.flex_row,
169-
Atoms.align_center,
170-
Atoms.gap_sm,
171-
Atoms.p_md,
172-
Atoms.rounded_md,
173-
Atoms.mt_md,
174-
{
175-
borderWidth: 1,
176-
borderColor: withHexOpacity(theme.palette.neutral_500, '30'),
177-
},
178-
]}
179-
>
180-
<ActivityIndicator size="small" color={theme.palette.neutral_500} />
181-
<Text variant="secondary" color="neutral_500">
182-
Loading preview…
183-
</Text>
177+
<View>
178+
{link ? (
179+
<LinkPreviewCard link={link} />
180+
) : (
181+
<View
182+
style={[
183+
Atoms.flex_row,
184+
Atoms.align_center,
185+
Atoms.gap_sm,
186+
Atoms.p_md,
187+
Atoms.rounded_md,
188+
Atoms.mt_md,
189+
{
190+
borderWidth: 1,
191+
borderColor: withHexOpacity(theme.palette.neutral_500, '30'),
192+
},
193+
]}
194+
>
195+
<ActivityIndicator size="small" color={theme.palette.neutral_500} />
196+
<Text variant="secondary" color="neutral_500">
197+
Loading preview…
198+
</Text>
199+
</View>
200+
)}
201+
<Pressable
202+
onPress={onRemove}
203+
disabled={disabled}
204+
accessibilityLabel="Remove link preview"
205+
hitSlop={6}
206+
style={{
207+
position: 'absolute',
208+
// Below the card's own mt_md top margin (margins stay inside the
209+
// wrapper, so the card's top edge is Spacing.md down from ours).
210+
top: Spacing.md + 4,
211+
right: 4,
212+
width: 22,
213+
height: 22,
214+
borderRadius: 11,
215+
alignItems: 'center',
216+
justifyContent: 'center',
217+
backgroundColor: withHexOpacity(theme.palette.black, 'b0'),
218+
opacity: disabled ? 0.4 : 1,
219+
}}
220+
>
221+
<Icon name="close" size={14} color="white" />
222+
</Pressable>
184223
</View>
185224
);
186225
}

apps/polycentric/src/features/composer/hooks/useComposer.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,162 @@ describe('useComposer link previews', () => {
417417
expect(built.post.links).toHaveLength(1);
418418
});
419419

420+
it('drops the stale card as soon as the url is swapped', async () => {
421+
jest.useFakeTimers();
422+
try {
423+
const { result } = await renderComposer();
424+
act(() => result.current.setText(draftWithUrl));
425+
// The loading state waits for the debounced fetch to actually start.
426+
expect(result.current.linkPreviewLoading).toBe(false);
427+
428+
await act(async () => {
429+
jest.advanceTimersByTime(1000);
430+
});
431+
expect(result.current.linkPreview).not.toBeNull();
432+
expect(result.current.linkPreviewLoading).toBe(false);
433+
434+
// Swap the url: the stale card disappears right away, before the new
435+
// fetch (and its loading state) kicks in.
436+
act(() => result.current.setText('now https://other.example instead'));
437+
expect(result.current.linkPreview).toBeNull();
438+
expect(result.current.linkPreviewLoading).toBe(false);
439+
440+
await act(async () => {
441+
jest.advanceTimersByTime(1000);
442+
});
443+
expect(result.current.linkPreview).toMatchObject({
444+
url: 'https://other.example',
445+
});
446+
expect(result.current.linkPreviewLoading).toBe(false);
447+
} finally {
448+
jest.useRealTimers();
449+
}
450+
});
451+
452+
it('disables posting until the preview finishes loading', async () => {
453+
jest.useFakeTimers();
454+
try {
455+
const unfurl = deferred<Awaited<ReturnType<typeof mockClient.urlInfo>>>();
456+
mockClient.urlInfo.mockReturnValueOnce(unfurl.promise);
457+
const { result } = await renderComposer();
458+
act(() => result.current.setText(draftWithUrl));
459+
460+
// Debounce elapses, the fetch starts: posting is held.
461+
await act(async () => {
462+
jest.advanceTimersByTime(1000);
463+
});
464+
expect(result.current.linkPreviewLoading).toBe(true);
465+
expect(result.current.canPost).toBe(false);
466+
467+
await act(async () => {
468+
unfurl.resolve({
469+
url: 'https://example.com',
470+
title: 't',
471+
description: 'd',
472+
image: 'i',
473+
});
474+
});
475+
expect(result.current.linkPreviewLoading).toBe(false);
476+
expect(result.current.canPost).toBe(true);
477+
} finally {
478+
jest.useRealTimers();
479+
}
480+
});
481+
482+
it('re-enables posting when a loading preview is removed', async () => {
483+
jest.useFakeTimers();
484+
try {
485+
// Never resolves: the user removes the preview instead of waiting.
486+
mockClient.urlInfo.mockReturnValueOnce(new Promise(() => {}));
487+
const { result } = await renderComposer();
488+
act(() => result.current.setText(draftWithUrl));
489+
490+
await act(async () => {
491+
jest.advanceTimersByTime(1000);
492+
});
493+
expect(result.current.canPost).toBe(false);
494+
495+
act(() => result.current.handleRemoveLinkPreview());
496+
expect(result.current.linkPreviewLoading).toBe(false);
497+
expect(result.current.canPost).toBe(true);
498+
} finally {
499+
jest.useRealTimers();
500+
}
501+
});
502+
503+
it('revives previews when a different link is typed after dismissal', async () => {
504+
jest.useFakeTimers();
505+
try {
506+
const { result } = await renderComposer();
507+
act(() => result.current.setText(draftWithUrl));
508+
await act(async () => {
509+
jest.advanceTimersByTime(1000);
510+
});
511+
expect(result.current.linkPreview).not.toBeNull();
512+
513+
act(() => result.current.handleRemoveLinkPreview());
514+
expect(result.current.linkPreview).toBeNull();
515+
516+
act(() => result.current.setText('now https://other.example instead'));
517+
await act(async () => {
518+
jest.advanceTimersByTime(1000);
519+
});
520+
expect(result.current.linkPreview).toMatchObject({
521+
url: 'https://other.example',
522+
});
523+
} finally {
524+
jest.useRealTimers();
525+
}
526+
});
527+
528+
it('revives previews when the same link is deleted and retyped', async () => {
529+
jest.useFakeTimers();
530+
try {
531+
const { result } = await renderComposer();
532+
act(() => result.current.setText(draftWithUrl));
533+
await act(async () => {
534+
jest.advanceTimersByTime(1000);
535+
});
536+
act(() => result.current.handleRemoveLinkPreview());
537+
expect(result.current.linkPreview).toBeNull();
538+
539+
// Delete the link and let the draft settle: the removal enters the
540+
// diff baseline, so retyping the very same url counts as newly typed
541+
// and unfurls again. (Deleting and retyping within one debounce window
542+
// coalesces into "no change" — the settled diff never sees it.)
543+
act(() => result.current.setText('check out'));
544+
await act(async () => {
545+
jest.advanceTimersByTime(1000);
546+
});
547+
act(() => result.current.setText(draftWithUrl));
548+
await act(async () => {
549+
jest.advanceTimersByTime(1000);
550+
});
551+
expect(result.current.linkPreview).toMatchObject({
552+
url: 'https://example.com',
553+
});
554+
} finally {
555+
jest.useRealTimers();
556+
}
557+
});
558+
559+
it('drops the preview and stops embedding once dismissed', async () => {
560+
const { result } = await renderComposer();
561+
act(() => result.current.setText(draftWithUrl));
562+
563+
act(() => result.current.handleRemoveLinkPreview());
564+
expect(result.current.linkPreview).toBeNull();
565+
expect(result.current.linkPreviewLoading).toBe(false);
566+
567+
await act(async () => {
568+
await result.current.handlePost();
569+
});
570+
571+
expect(mockClient.urlInfo).not.toHaveBeenCalled();
572+
const built = mockClient.contentManager.build.mock.calls[0][0];
573+
expect(built.post.links).toHaveLength(0);
574+
});
575+
420576
it('skips link preview generation when disabled', async () => {
421577
mockLinkPreviewsEnabled = false;
422578
const { result } = await renderComposer();

0 commit comments

Comments
 (0)