Link Previews
Every http(s) URL in a post or comment is tappable and opens in the device's default browser. The first URL in a post without media also gets a preview card: large, small or text-only. Previews are unfurled server-side only. The app never fetches a third-party page, and never loads a third-party image: preview images are re-encoded by ogun and served from our storage.
One parser everywhere
packages/types/src/links.ts is shared by the composer, the API and the renderer, so all three agree on what a link is.
findLinks(body)— linkify-it, restricted tohttp/https. Fuzzy matches (example.com,www.example.com) becomehttps://. Trailing punctuation, closing parentheses, query strings, fragments and IDN hosts are linkify-it's job.- Never links:
javascript:,data:,file:,ftp:,mailto:; userinfo URLs (user:pass@host);me@site.com(an address, notsite.com). - Bare domains count for linkify-it's default TLDs (com, net, org, info, every two-letter ccTLD such as .gg, .io, .tv) plus
app, dev, xyz, live, gg, games, gaming, stream. Bare names on ccTLDs that are mostly file extensions (py, rs, sh, md, ts, ml, mk, so, cc, pm) only link with a path orwww., soscript.pyandmain.rsstay text whilefoo.rs/docslinks. tokenizePostText(body)— mentions and links in order; where they overlap the link wins (https://x.com/@useris one link).displayUrl(href)— the shortened form shown in text (domain plus path,…past 32 characters); the full URL stays the tap target.displayHost(href)— the domain shown to users. Unicode only when it can't impersonate a Latin domain; mixed or lookalike scripts (аррӏе.com) show as punycode (xn--…).normalizeUrlForCache(href)— the cache key: lower-cased scheme and host, punycode host, no default port, fragment or tracking params (utm_*,fbclid,gclid,mc_eid,igshid,si).
The app avoids the global URL for all of this: React Native's is an incomplete polyfill.
Lifecycle
Publishing
POST /posts takes linkPreviewId (the composer's preview) or linkPreviewDismissed: true. attachLinkPreview() in apps/api/src/services/linkPreview.ts:
- skips media posts (media wins, as on Twitter) and dismissed cards;
- reuses the composer's preview only if it is for the body's first link, re-resolving otherwise;
- for clients that didn't resolve one, resolves it now, waiting at most ~2s in total;
- sets
Post.linkPreviewIdand publishes. The post never waits longer and never fails because of a preview.
A preview that finishes after publishing reaches clients as a post:link-preview event (to the post's room and its author). The app patches it into every cached copy of the post.
Rendering
A card renders only when the preview is status: "ready" (text and image final), so its type and height never change after it appears. A failed unfurl means no card: the URL stays a plain tappable link and the post is unaffected.
On edit
Editing a post re-derives its card from the new body with the same rules (reattachLinkPreview() in services/linkPreview.ts):
- first link unchanged → the card is kept, no refetch;
- first link changed (or added) → the new one is resolved like at publish (composer preview reused, ~2s budget, late cards through
post:link-preview); - link removed, card dismissed, or media still on the post → no card.
Server-side fetching (packages/unfurl)
Node-only, used by the API (pages) and ogun (images). safeFetch() is the only way either reaches an arbitrary URL.
| Rule | |
|---|---|
| Schemes / ports | http, https; ports 80 and 443 only; no userinfo |
| Addresses | Every DNS answer must be public unicast (ipaddr.js), checked on every redirect hop. Blocks private, loopback, link-local (incl. 169.254.169.254 metadata), CGNAT, ULA, multicast, reserved, and IPv4-embedding IPv6 (mapped, NAT64, 6to4). Numeric host forms (2130706433, 0x7f.1) are normalized first. A name with any private answer is refused. |
| DNS rebinding | The connection is pinned to the address that was validated (an undici Agent whose connect.lookup returns it); there is no second lookup. Host header, SNI and certificate checks still use the hostname. |
| Redirects | Manual, at most 5, each re-validated; the final URL is recorded |
| Timeouts | connect 2s, headers 3s, whole request 5s (images 8s) |
| Size / type | HTML: text/html or application/xhtml+xml, read until </head>, at most 1.5 MB. Images: PNG, JPEG, WebP, GIF, AVIF (never SVG), at most 10 MB |
| Identity | User-Agent: PlayPalzBot/1.0 (+https://playpalz.com/bot); no cookies or credentials |
Extraction priority: Open Graph, then Twitter card tags, then <title> / meta description / rel=canonical. oEmbed discovery is fetched (with the same rules) only to fill a missing title or image. A large touch icon is the image of last resort. All text is sanitized: entities decoded, tags stripped, control and bidi-override characters removed, whitespace collapsed, capped (title 200, description 300, site name 80).
Card type: large when the site asks for summary_large_image or the image is at least 600px wide with aspect ≥ 1.3; small for any other image; text with no usable image (or one under 64px, which ogun discards).
Caching and limits
| Positive cache | Redis lp:url:<sha256(normalized url)> → preview id, 24h; DB snapshot fallback within the same window |
| Negative cache | fail:<code>: 30 min for timeouts, DNS/network errors, 5xx, 429; 6h for blocked, 4xx, non-HTML, nothing to preview |
| Single flight | Redis lock per URL; concurrent requests wait for the first unfurl instead of fetching again |
| Per user | POST /link-previews: 60 per 10 minutes (express-rate-limit, per pod) |
| Per domain | 30 outbound unfurls per host per minute across pods (Redis counter); over the limit, no preview (and no negative cache) |
LinkPreview rows are immutable snapshots. After the TTL the next request creates a new row; posts keep the card they were published with. Only linkPreviewPublicSelect (in @playpals/db) is ever sent to clients: the third-party imageSourceUrl stays server-side.
In the app
components/post/post-text.tsxrenders post and comment text: mentions open profiles, links open the system browser (Linking.openURL, never an in-app browser; universal and app links route natively). Long-press opens Open / Copy link / Share (hooks/useLinkActions.tsx, a global sheet mounted outermost inapp/(app)/_layout.tsxso it draws above the comment sheet and media viewer).components/link-preview/link-preview-card.tsxis the one card for feed, profile, detail and composer. Heights come fromlinkCardLayout()in@playpals/types(fixed line counts, system font scale capped at 1.3). Same inset and corner radius as post media.- When the card is shown and its link is the last thing in the text, that trailing URL is hidden (a post that is only the link renders as just the card).
- Links and the card are their own pressables, so tapping them never opens Post Detail.
hooks/useComposerLinkPreview.tsdrives the composer: skeleton while loading, X to dismiss, re-fetch when the first link changes, gone when it is removed or media is attached.
YouTube embeds
When the card's link is a YouTube video, the post and the composer show a player instead of the card (components/link-preview/youtube-embed.tsx). It's still the link preview underneath, so every card rule holds: only the first link, media wins, dismissing removes it, and a late preview arrives by the post:link-preview event.
- Which links:
youtubeVideo()in@playpals/types(packages/types/src/youtube.ts) acceptsyoutube.com/watch?v=,youtu.be/<id>,/shorts/,/live/,/embed/and/v/onyoutube.com,www.,m.,music.andyoutube-nocookie.com, matched exactly so lookalike hosts don't count.t/start(90,1m30s,1h2m3s) sets the start time. Channels, playlists and search pages keep the normal card. - Poster first: until tapped, the embed is our re-hosted preview image with a play button, so a feed never mounts a WebView per post and the client still never hotlinks YouTube's thumbnails.
- Player: a tap mounts a
react-native-webviewpage holding ayoutube-nocookie.comiframe (autoplay,playsinline,start). YouTube refuses embeds with no referrer or origin ("Error 153"), so the page is served withbaseUrl: https://www.playpalz.ggand passes that asorigin. - Locked down:
onShouldStartLoadWithRequestlets only the embed load. Anything else, such as "Watch on YouTube", opens outside the app, so a post can't navigate the WebView elsewhere. - Size: 16:9 at the media width; Shorts are 9:16, capped at 70% of the screen height.
- Playback: the feed passes viewability as
active, and going off screen unmounts the player. While it plays it claims the active-video slot (claimActiveVideoinlib/post-media/active-video.ts), so feed clips stay paused until it lets go.
