refactor(posts): simplify the link-preview composable and card
Replace the hand-rolled debounce + watch + lastAttemptedUrl bookkeeping in useLinkCard with vueuse's watchDebounced over a computed URL — the watch only fires when the detected URL changes, so dedup and unmount cleanup come for free. Make firstUrl declarative (regex match + a single trim helper) and drop the www-strip regex in LinkCard for a plain startsWith/slice. Behavior unchanged: URL detection still mirrors the backend UrlDetector (verified against the same cases).
This commit is contained in:
parent
2815a45de8
commit
ec3f251401
2 changed files with 43 additions and 47 deletions
|
|
@ -7,7 +7,9 @@ const props = defineProps<{ card: LinkCard }>();
|
|||
|
||||
const domain = computed(() => {
|
||||
try {
|
||||
return new URL(props.card.uri).hostname.replace(/^www\./, '');
|
||||
const { hostname } = new URL(props.card.uri);
|
||||
|
||||
return hostname.startsWith('www.') ? hostname.slice(4) : hostname;
|
||||
} catch {
|
||||
return props.card.uri;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useHttp } from '@inertiajs/vue3';
|
||||
import { onBeforeUnmount, ref, watch, type Ref } from 'vue';
|
||||
import { watchDebounced } from '@vueuse/core';
|
||||
import { computed, ref, watch, type Ref } from 'vue';
|
||||
|
||||
import debounce from '@/debounce';
|
||||
import { linkPreview } from '@/routes/app/posts';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
|
|
@ -12,37 +12,44 @@ export interface LinkCard {
|
|||
image: string | null;
|
||||
}
|
||||
|
||||
const URL_RE = /(https?:\/\/[^\s]+)/;
|
||||
|
||||
/**
|
||||
* The first http(s) URL in `text`, or null. Trailing sentence punctuation and an
|
||||
* unmatched closing paren are trimmed so the detected URL matches the link's
|
||||
* rendered span — this mirrors the backend `UrlDetector`, keeping the preview
|
||||
* card's URL identical to the one the post actually links to.
|
||||
*/
|
||||
const firstUrl = (text: string): string | null => {
|
||||
const match = text.match(URL_RE);
|
||||
const match = text.match(/https?:\/\/[^\s]+/);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let url = match[0];
|
||||
if (/[.,;:!?]$/.test(url)) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
if (url.endsWith(')') && !url.includes('(')) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
return url;
|
||||
const url = match[0].replace(/[.,;:!?]$/, '');
|
||||
|
||||
return url.endsWith(')') && !url.includes('(') ? url.slice(0, -1) : url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Live link-preview card for the composer. Resolves the first link in `content`
|
||||
* to its OpenGraph card, but only when there is no attached media (media
|
||||
* suppresses the link card on every platform). Returns the card and a loading
|
||||
* flag for the editor previews to render.
|
||||
*/
|
||||
export const useLinkCard = (content: Ref<string>, media: Ref<MediaItem[]>) => {
|
||||
const card = ref<LinkCard | null>(null);
|
||||
const loading = ref(false);
|
||||
const lastAttemptedUrl = ref<string | null>(null);
|
||||
const http = useHttp<{ url: string }, LinkCard | null>({ url: '' });
|
||||
|
||||
const fetchCard = async (url: string): Promise<void> => {
|
||||
lastAttemptedUrl.value = url;
|
||||
const url = computed(() => (media.value.length > 0 ? null : firstUrl(content.value)));
|
||||
|
||||
const fetchCard = async (target: string): Promise<void> => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
http.url = url;
|
||||
http.url = target;
|
||||
const data = await http.post(linkPreview.url());
|
||||
card.value = data && data.uri ? data : null;
|
||||
card.value = data?.uri ? data : null;
|
||||
} catch {
|
||||
card.value = null;
|
||||
} finally {
|
||||
|
|
@ -50,38 +57,25 @@ export const useLinkCard = (content: Ref<string>, media: Ref<MediaItem[]>) => {
|
|||
}
|
||||
};
|
||||
|
||||
const debounced = debounce((url: string) => {
|
||||
void fetchCard(url);
|
||||
}, 400);
|
||||
// The link went away (media attached, or URL removed) — drop the card at once.
|
||||
watch(url, (next) => {
|
||||
if (!next) {
|
||||
card.value = null;
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
[content, media],
|
||||
() => {
|
||||
if (media.value.length > 0) {
|
||||
card.value = null;
|
||||
loading.value = false;
|
||||
lastAttemptedUrl.value = null;
|
||||
return;
|
||||
// A new link appeared — fetch it. `watch` only fires on change, so the same
|
||||
// URL is never re-requested; the debounce keeps it off every keystroke.
|
||||
watchDebounced(
|
||||
url,
|
||||
(next) => {
|
||||
if (next) {
|
||||
void fetchCard(next);
|
||||
}
|
||||
|
||||
const url = firstUrl(content.value);
|
||||
if (!url) {
|
||||
card.value = null;
|
||||
loading.value = false;
|
||||
lastAttemptedUrl.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (url === lastAttemptedUrl.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
debounced(url);
|
||||
},
|
||||
{ immediate: true },
|
||||
{ debounce: 400, immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => debounced.cancel());
|
||||
|
||||
return { card, loading };
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue