@linktr.ee/messaging-react 3.3.10-rc-1781610191 → 3.3.10-rc-1781645401

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/{Card-DiSXLhzw.cjs → Card-BmQYVGMq.cjs} +2 -2
  2. package/dist/{Card-DiSXLhzw.cjs.map → Card-BmQYVGMq.cjs.map} +1 -1
  3. package/dist/{Card-giUAe8IB.js → Card-BokhtsDD.js} +2 -2
  4. package/dist/{Card-giUAe8IB.js.map → Card-BokhtsDD.js.map} +1 -1
  5. package/dist/{Card-DndbBigk.js → Card-CG8SMxkM.js} +2 -2
  6. package/dist/{Card-DndbBigk.js.map → Card-CG8SMxkM.js.map} +1 -1
  7. package/dist/{Card-qFB_y7k3.js → Card-DaXkK4uS.js} +3 -3
  8. package/dist/{Card-qFB_y7k3.js.map → Card-DaXkK4uS.js.map} +1 -1
  9. package/dist/{Card-Ch3cTL6G.cjs → Card-qF1fO3J4.cjs} +2 -2
  10. package/dist/{Card-Ch3cTL6G.cjs.map → Card-qF1fO3J4.cjs.map} +1 -1
  11. package/dist/{Card-DezN34Sz.cjs → Card-xNHR_bKY.cjs} +2 -2
  12. package/dist/{Card-DezN34Sz.cjs.map → Card-xNHR_bKY.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-DkdVuMZM.cjs → LockedThumbnail-BZMu174W.cjs} +2 -2
  14. package/dist/{LockedThumbnail-DkdVuMZM.cjs.map → LockedThumbnail-BZMu174W.cjs.map} +1 -1
  15. package/dist/{LockedThumbnail-C9Y0oCjv.js → LockedThumbnail-_5g6bxXW.js} +2 -2
  16. package/dist/{LockedThumbnail-C9Y0oCjv.js.map → LockedThumbnail-_5g6bxXW.js.map} +1 -1
  17. package/dist/{index-B_8de68T.cjs → index-3GF2ZDV8.cjs} +2 -2
  18. package/dist/index-3GF2ZDV8.cjs.map +1 -0
  19. package/dist/{index-DGVCJzwy.js → index-XXoPu3F3.js} +436 -404
  20. package/dist/index-XXoPu3F3.js.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.d.ts +54 -0
  23. package/dist/index.js +24 -20
  24. package/package.json +1 -1
  25. package/src/index.ts +10 -0
  26. package/src/utils/linkAttachmentFromStream.test.ts +99 -0
  27. package/src/utils/linkAttachmentFromStream.ts +112 -0
  28. package/dist/index-B_8de68T.cjs.map +0 -1
  29. package/dist/index-DGVCJzwy.js.map +0 -1
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-B_8de68T.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-3GF2ZDV8.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.captionDuplicatesLinkUrl=e.captionDuplicatesLinkUrl;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.linkAttachmentPropsFromStreamAttachment=e.linkAttachmentPropsFromStreamAttachment;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.normalizeLinkUrl=e.normalizeLinkUrl;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.resolveStreamLinkUrl=e.resolveStreamLinkUrl;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
2
2
  //# sourceMappingURL=index.cjs.map
package/dist/index.d.ts CHANGED
@@ -84,6 +84,15 @@ export declare interface AvatarProps {
84
84
  */
85
85
  export declare function buildCompactMetaLabel(mimeType?: string, filename?: string, fileSize?: number): string | undefined;
86
86
 
87
+ /**
88
+ * True when `caption` is *nothing but* the link URL (plus surrounding
89
+ * punctuation / whitespace), so it can be dropped as redundant with the card's
90
+ * own URL footer. A caption that adds real words — e.g. `"<url>\n\nhello
91
+ * there"` — returns false so that text is preserved rather than dropped along
92
+ * with the redundant URL.
93
+ */
94
+ export declare function captionDuplicatesLinkUrl(caption: string | undefined, url: string | undefined): boolean;
95
+
87
96
  /**
88
97
  * Empty state component shown when a channel has no messages
89
98
  * Returns null to show nothing - the LoadingIndicator handles the loading state
@@ -624,6 +633,24 @@ export declare interface LinkAttachmentCta {
624
633
  */
625
634
  export declare type LinkAttachmentLayout = 'featured' | 'classic';
626
635
 
636
+ /**
637
+ * Maps a Stream `Attachment` (a scraped link / OG preview) to the props for a
638
+ * `LinkAttachment.Sent` / `LinkAttachment.Received` card — the single source
639
+ * of truth so the admin and visitor surfaces render link shares identically.
640
+ */
641
+ export declare function linkAttachmentPropsFromStreamAttachment(attachment: Attachment, options?: LinkAttachmentPropsOptions): StreamLinkAttachmentProps;
642
+
643
+ export declare interface LinkAttachmentPropsOptions {
644
+ /**
645
+ * Hook to transform the raw hero image URL — e.g. apply a CDN size preset or
646
+ * drop unreliable hosts. Returning a falsy value drops the hero and the card
647
+ * falls back to the classic (text-only) layout. Defaults to passing the raw
648
+ * URL through unchanged. App-specific CDN logic belongs here, not in the
649
+ * shared mapping.
650
+ */
651
+ resolveThumbnailUrl?: (raw: string | undefined) => string | undefined;
652
+ }
653
+
627
654
  export declare interface LinkAttachmentReceivedCardProps extends LinkAttachmentBaseProps {
628
655
  /**
629
656
  * Fired when the recipient activates the card. Behavior depends on how
@@ -1222,6 +1249,9 @@ export declare interface MessagingShellProps extends ChannelViewPassthroughProps
1222
1249
 
1223
1250
  export declare function normalizeLanguageCode(language?: string): string | undefined;
1224
1251
 
1252
+ /** Normalize a URL for caption comparison: strip protocol + trailing slash, lowercase. */
1253
+ export declare function normalizeLinkUrl(url: string): string;
1254
+
1225
1255
  /**
1226
1256
  * Action intents supported by Linktree official CTA attachments.
1227
1257
  */
@@ -1334,6 +1364,14 @@ export declare function resolveMediaFromMessage(message: LocalMessage): MediaMes
1334
1364
  */
1335
1365
  export declare function resolveParticipantDisplayName(user?: ParticipantDisplayUser | null): string;
1336
1366
 
1367
+ /**
1368
+ * Navigation target for a scraped-link attachment. Stream exposes both
1369
+ * `og_scrape_url` (the enriched URL) and `title_link`; prefer the former and
1370
+ * fall back to the latter, ignoring empty / whitespace-only values so they
1371
+ * never produce an empty `href`.
1372
+ */
1373
+ export declare function resolveStreamLinkUrl(attachment: Attachment): string | undefined;
1374
+
1337
1375
  export declare interface SentCardProps extends LockedAttachmentBaseProps {
1338
1376
  /** Placeholder shown in the title slot when no title is set. */
1339
1377
  placeholderTitle?: string;
@@ -1347,6 +1385,22 @@ export declare interface SentCardProps extends LockedAttachmentBaseProps {
1347
1385
  onFetchSource?: () => Promise<LockedAttachmentSource | void>;
1348
1386
  }
1349
1387
 
1388
+ /**
1389
+ * Props derived from a Stream `Attachment` for rendering a
1390
+ * `LinkAttachment.Sent` / `LinkAttachment.Received` card. The shared mapping
1391
+ * both the admin (mfe-messaging) and visitor (profiles) surfaces use so a
1392
+ * scraped-link attachment renders identically across them, rather than each
1393
+ * app re-deriving these props and drifting apart.
1394
+ */
1395
+ export declare interface StreamLinkAttachmentProps {
1396
+ title?: string;
1397
+ description?: string;
1398
+ url?: string;
1399
+ thumbnailUrl?: string;
1400
+ mimeType?: string;
1401
+ layout: LinkAttachmentLayout;
1402
+ }
1403
+
1350
1404
  export declare function useCustomMessage<K extends keyof CustomMessageRegistry>(key: K): CustomMessageRegistry[K];
1351
1405
 
1352
1406
  /**
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as e, b as t, C as i, c as n, d as o, e as m, F as g, f as l, L as r, h as M, M as u, i as L, j as c, k as h, l as d, m as p, n as v, o as A, p as C, q as F, s as k, t as b, u as f, v as x, w as y, x as P, y as S, z as q, B as z, D as B } from "./index-DGVCJzwy.js";
1
+ import { a as e, b as t, C as i, c as n, d as o, e as m, F as r, f as l, L as g, h as c, M as L, i as M, j as h, k as u, l as p, m as k, n as A, o as d, p as v, q as C, s as F, t as S, u as P, v as b, w as f, x, y, z, B as D, D as U, E as q, G as B, H as E, I as V } from "./index-XXoPu3F3.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
@@ -6,29 +6,33 @@ export {
6
6
  n as ChannelList,
7
7
  o as ChannelView,
8
8
  m as CustomMessageProvider,
9
- g as FaqList,
9
+ r as FaqList,
10
10
  l as FaqListItem,
11
- r as LinkAttachment,
12
- M as LockedAttachment,
13
- u as MediaMessage,
14
- L as MessageAttachment,
15
- c as MessageVoteButtons,
16
- h as MessagingProvider,
17
- d as MessagingShell,
18
- p as buildCompactMetaLabel,
19
- v as formatFileSize,
20
- A as formatRelativeTime,
11
+ g as LinkAttachment,
12
+ c as LockedAttachment,
13
+ L as MediaMessage,
14
+ M as MessageAttachment,
15
+ h as MessageVoteButtons,
16
+ u as MessagingProvider,
17
+ p as MessagingShell,
18
+ k as buildCompactMetaLabel,
19
+ A as captionDuplicatesLinkUrl,
20
+ d as formatFileSize,
21
+ v as formatRelativeTime,
21
22
  C as getFileExtensionLabel,
22
23
  F as getMessageDisplayText,
23
- k as isLinkAttachment,
24
- b as isUuidLike,
24
+ S as isLinkAttachment,
25
+ P as isUuidLike,
26
+ b as linkAttachmentPropsFromStreamAttachment,
25
27
  f as messageAttachmentGroupPositionFromStream,
26
28
  x as normalizeLanguageCode,
27
- y as resolveLinkAttachment,
28
- P as resolveMediaFromMessage,
29
- S as resolveParticipantDisplayName,
30
- q as useCustomMessage,
31
- z as useMessageVote,
32
- B as useMessaging
29
+ y as normalizeLinkUrl,
30
+ z as resolveLinkAttachment,
31
+ D as resolveMediaFromMessage,
32
+ U as resolveParticipantDisplayName,
33
+ q as resolveStreamLinkUrl,
34
+ B as useCustomMessage,
35
+ E as useMessageVote,
36
+ V as useMessaging
33
37
  };
34
38
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "3.3.10-rc-1781610191",
3
+ "version": "3.3.10-rc-1781645401",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
package/src/index.ts CHANGED
@@ -45,6 +45,16 @@ export {
45
45
  isUuidLike,
46
46
  } from './utils/resolveParticipantDisplayName'
47
47
  export type { ParticipantDisplayUser } from './utils/resolveParticipantDisplayName'
48
+ export {
49
+ linkAttachmentPropsFromStreamAttachment,
50
+ resolveStreamLinkUrl,
51
+ captionDuplicatesLinkUrl,
52
+ normalizeLinkUrl,
53
+ } from './utils/linkAttachmentFromStream'
54
+ export type {
55
+ StreamLinkAttachmentProps,
56
+ LinkAttachmentPropsOptions,
57
+ } from './utils/linkAttachmentFromStream'
48
58
 
49
59
  // Types
50
60
  export type {
@@ -0,0 +1,99 @@
1
+ import type { Attachment } from 'stream-chat'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import {
5
+ captionDuplicatesLinkUrl,
6
+ linkAttachmentPropsFromStreamAttachment,
7
+ normalizeLinkUrl,
8
+ resolveStreamLinkUrl,
9
+ } from './linkAttachmentFromStream'
10
+
11
+ const scraped = {
12
+ title: 'Classic Jazz Albums',
13
+ text: 'A handpicked set',
14
+ og_scrape_url: 'https://tr.ee/2DKKNv',
15
+ image_url: 'https://example.test/cover.jpg',
16
+ thumb_url: 'https://example.test/thumb.jpg',
17
+ mime_type: 'text/html',
18
+ } as unknown as Attachment
19
+
20
+ describe('resolveStreamLinkUrl', () => {
21
+ it('prefers og_scrape_url over title_link', () => {
22
+ expect(
23
+ resolveStreamLinkUrl({
24
+ og_scrape_url: 'https://tr.ee/a',
25
+ title_link: 'https://tr.ee/b',
26
+ } as unknown as Attachment)
27
+ ).toBe('https://tr.ee/a')
28
+ })
29
+
30
+ it('falls back to title_link', () => {
31
+ expect(
32
+ resolveStreamLinkUrl({ title_link: 'https://tr.ee/b' } as unknown as Attachment)
33
+ ).toBe('https://tr.ee/b')
34
+ })
35
+
36
+ it('ignores empty / whitespace-only values', () => {
37
+ expect(
38
+ resolveStreamLinkUrl({ og_scrape_url: ' ' } as unknown as Attachment)
39
+ ).toBeUndefined()
40
+ })
41
+ })
42
+
43
+ describe('linkAttachmentPropsFromStreamAttachment', () => {
44
+ it('maps title, description, url and a featured layout', () => {
45
+ expect(linkAttachmentPropsFromStreamAttachment(scraped)).toEqual({
46
+ title: 'Classic Jazz Albums',
47
+ description: 'A handpicked set',
48
+ url: 'https://tr.ee/2DKKNv',
49
+ thumbnailUrl: 'https://example.test/cover.jpg',
50
+ mimeType: 'text/html',
51
+ layout: 'featured',
52
+ })
53
+ })
54
+
55
+ it('uses the classic layout when there is no hero image', () => {
56
+ const props = linkAttachmentPropsFromStreamAttachment({
57
+ title: 'No image',
58
+ og_scrape_url: 'https://tr.ee/x',
59
+ } as unknown as Attachment)
60
+ expect(props.thumbnailUrl).toBeUndefined()
61
+ expect(props.layout).toBe('classic')
62
+ })
63
+
64
+ it('applies resolveThumbnailUrl and drops the hero when it returns falsy', () => {
65
+ const props = linkAttachmentPropsFromStreamAttachment(scraped, {
66
+ resolveThumbnailUrl: () => undefined,
67
+ })
68
+ expect(props.thumbnailUrl).toBeUndefined()
69
+ expect(props.layout).toBe('classic')
70
+ })
71
+ })
72
+
73
+ describe('captionDuplicatesLinkUrl', () => {
74
+ it('is true when the caption is just the URL (any protocol/slash form)', () => {
75
+ expect(captionDuplicatesLinkUrl('tr.ee/abc', 'https://tr.ee/abc/')).toBe(true)
76
+ })
77
+
78
+ it('is true for a URL-only caption with surrounding punctuation', () => {
79
+ expect(captionDuplicatesLinkUrl('https://tr.ee/abc !', 'https://tr.ee/abc')).toBe(true)
80
+ })
81
+
82
+ it('keeps a multi-line caption that contains the URL plus real text', () => {
83
+ expect(
84
+ captionDuplicatesLinkUrl('https://getsewn.com/\n\nhello there', 'https://getsewn.com/')
85
+ ).toBe(false)
86
+ })
87
+
88
+ it('is false for an unrelated caption and for empty inputs', () => {
89
+ expect(captionDuplicatesLinkUrl('Check this out', 'https://tr.ee/abc')).toBe(false)
90
+ expect(captionDuplicatesLinkUrl('', 'https://tr.ee/abc')).toBe(false)
91
+ expect(captionDuplicatesLinkUrl('hi', undefined)).toBe(false)
92
+ })
93
+ })
94
+
95
+ describe('normalizeLinkUrl', () => {
96
+ it('strips protocol + trailing slash and lowercases', () => {
97
+ expect(normalizeLinkUrl('HTTPS://Tr.ee/ABC/')).toBe('tr.ee/abc')
98
+ })
99
+ })
@@ -0,0 +1,112 @@
1
+ import type { Attachment } from 'stream-chat'
2
+
3
+ import type { LinkAttachmentLayout } from '../components/LinkAttachment/types'
4
+
5
+ /**
6
+ * Props derived from a Stream `Attachment` for rendering a
7
+ * `LinkAttachment.Sent` / `LinkAttachment.Received` card. The shared mapping
8
+ * both the admin (mfe-messaging) and visitor (profiles) surfaces use so a
9
+ * scraped-link attachment renders identically across them, rather than each
10
+ * app re-deriving these props and drifting apart.
11
+ */
12
+ export interface StreamLinkAttachmentProps {
13
+ title?: string
14
+ description?: string
15
+ url?: string
16
+ thumbnailUrl?: string
17
+ mimeType?: string
18
+ layout: LinkAttachmentLayout
19
+ }
20
+
21
+ type ScrapedAttachment = Attachment & {
22
+ title?: string
23
+ text?: string
24
+ og_scrape_url?: string
25
+ title_link?: string
26
+ image_url?: string
27
+ thumb_url?: string
28
+ mime_type?: string
29
+ }
30
+
31
+ /**
32
+ * Navigation target for a scraped-link attachment. Stream exposes both
33
+ * `og_scrape_url` (the enriched URL) and `title_link`; prefer the former and
34
+ * fall back to the latter, ignoring empty / whitespace-only values so they
35
+ * never produce an empty `href`.
36
+ */
37
+ export function resolveStreamLinkUrl(attachment: Attachment): string | undefined {
38
+ const a = attachment as ScrapedAttachment
39
+ const raw = [a.og_scrape_url, a.title_link].find(
40
+ (value): value is string => typeof value === 'string'
41
+ )
42
+ return raw && raw.trim() !== '' ? raw : undefined
43
+ }
44
+
45
+ export interface LinkAttachmentPropsOptions {
46
+ /**
47
+ * Hook to transform the raw hero image URL — e.g. apply a CDN size preset or
48
+ * drop unreliable hosts. Returning a falsy value drops the hero and the card
49
+ * falls back to the classic (text-only) layout. Defaults to passing the raw
50
+ * URL through unchanged. App-specific CDN logic belongs here, not in the
51
+ * shared mapping.
52
+ */
53
+ resolveThumbnailUrl?: (raw: string | undefined) => string | undefined
54
+ }
55
+
56
+ /**
57
+ * Maps a Stream `Attachment` (a scraped link / OG preview) to the props for a
58
+ * `LinkAttachment.Sent` / `LinkAttachment.Received` card — the single source
59
+ * of truth so the admin and visitor surfaces render link shares identically.
60
+ */
61
+ export function linkAttachmentPropsFromStreamAttachment(
62
+ attachment: Attachment,
63
+ options?: LinkAttachmentPropsOptions
64
+ ): StreamLinkAttachmentProps {
65
+ const a = attachment as ScrapedAttachment
66
+ const rawThumbnail = a.image_url ?? a.thumb_url
67
+ const thumbnailUrl =
68
+ (options?.resolveThumbnailUrl
69
+ ? options.resolveThumbnailUrl(rawThumbnail)
70
+ : rawThumbnail) || undefined
71
+
72
+ return {
73
+ title: a.title,
74
+ description: a.text,
75
+ url: resolveStreamLinkUrl(attachment),
76
+ thumbnailUrl,
77
+ mimeType: a.mime_type,
78
+ layout: thumbnailUrl ? 'featured' : 'classic',
79
+ }
80
+ }
81
+
82
+ /** Normalize a URL for caption comparison: strip protocol + trailing slash, lowercase. */
83
+ export function normalizeLinkUrl(url: string): string {
84
+ return url
85
+ .trim()
86
+ .replace(/^https?:\/\//i, '')
87
+ .replace(/\/+$/, '')
88
+ .toLowerCase()
89
+ }
90
+
91
+ /**
92
+ * True when `caption` is *nothing but* the link URL (plus surrounding
93
+ * punctuation / whitespace), so it can be dropped as redundant with the card's
94
+ * own URL footer. A caption that adds real words — e.g. `"<url>\n\nhello
95
+ * there"` — returns false so that text is preserved rather than dropped along
96
+ * with the redundant URL.
97
+ */
98
+ export function captionDuplicatesLinkUrl(
99
+ caption: string | undefined,
100
+ url: string | undefined
101
+ ): boolean {
102
+ if (!caption?.trim() || !url?.trim()) return false
103
+
104
+ const normalizedCaption = normalizeLinkUrl(caption)
105
+ const normalizedUrl = normalizeLinkUrl(url)
106
+
107
+ if (normalizedCaption === normalizedUrl) return true
108
+ if (!normalizedCaption.includes(normalizedUrl)) return false
109
+
110
+ const remainder = normalizedCaption.split(normalizedUrl).join('')
111
+ return !/[\p{L}\p{N}]/u.test(remainder)
112
+ }