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:
Paulo Castellano 2026-07-17 15:57:51 -03:00
parent 2815a45de8
commit ec3f251401
2 changed files with 43 additions and 47 deletions

View file

@ -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;
}

View file

@ -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 };
};