diff --git a/resources/js/components/posts/previews/LinkCard.vue b/resources/js/components/posts/previews/LinkCard.vue index d64a2a4e..bd759aae 100644 --- a/resources/js/components/posts/previews/LinkCard.vue +++ b/resources/js/components/posts/previews/LinkCard.vue @@ -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; } diff --git a/resources/js/composables/useLinkCard.ts b/resources/js/composables/useLinkCard.ts index 162d6e4e..17f9b541 100644 --- a/resources/js/composables/useLinkCard.ts +++ b/resources/js/composables/useLinkCard.ts @@ -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, media: Ref) => { const card = ref(null); const loading = ref(false); - const lastAttemptedUrl = ref(null); const http = useHttp<{ url: string }, LinkCard | null>({ url: '' }); - const fetchCard = async (url: string): Promise => { - lastAttemptedUrl.value = url; + const url = computed(() => (media.value.length > 0 ? null : firstUrl(content.value))); + + const fetchCard = async (target: string): Promise => { 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, media: Ref) => { } }; - 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 }; };