Skip to content

Commit 5baa16e

Browse files
feat: rich social embed cards (Bluesky / X) with size + collapse controls (#5429)
Render link previews as rich cards matching the Haven mobile app: a per-site accent stripe, author avatar + name/@handle, post text, sized media (with a play badge for videos), and engagement stats. server.js: /api/link-preview now also returns kind/author/handle/avatar/ text/accentColor/stats. Twitter/X switches from the bare oEmbed to the fxtwitter public JSON API (so we get avatars + counts); Bluesky is enriched the same way. The existing title/description/image/images fields are kept, so older clients and other callers are unaffected. client: the single _fetchLinkPreviews renderer (chat, DMs and the DM PiP) builds the unified card. The embed-size preference is consolidated into one system shared by the Settings picker and a new per-embed size toggle -- Off / Small / Medium / Full (legacy normal->medium, large->full) -- plus a per-embed collapse caret. YouTube keeps its inline iframe inside the same chrome. Co-authored-by: Amnibro <anthonymr7@gmail.com>
1 parent be989ac commit 5baa16e

5 files changed

Lines changed: 369 additions & 95 deletions

File tree

public/app.html

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,13 +1311,17 @@ <h5 class="settings-section-title">🔗 <span data-i18n="settings.embed_display.
13111311
<span class="density-icon">🚫</span>
13121312
<span class="density-label" data-i18n="settings.embed_display.off">Off</span>
13131313
</button>
1314-
<button type="button" class="density-btn active" data-embed-size="normal" title="Normal — compact preview card">
1314+
<button type="button" class="density-btn" data-embed-size="small" title="Small — compact card with small media">
13151315
<span class="density-icon">🔗</span>
1316-
<span class="density-label" data-i18n="settings.embed_display.normal">Normal</span>
1316+
<span class="density-label" data-i18n="settings.embed_display.small">Small</span>
13171317
</button>
1318-
<button type="button" class="density-btn" data-embed-size="large" title="Large — wider card with bigger thumbnail">
1318+
<button type="button" class="density-btn active" data-embed-size="medium" title="Medium — balanced card with medium media">
1319+
<span class="density-icon">🖼️</span>
1320+
<span class="density-label" data-i18n="settings.embed_display.medium">Medium</span>
1321+
</button>
1322+
<button type="button" class="density-btn" data-embed-size="full" title="Full — large media, author, and post stats">
13191323
<span class="density-icon">📰</span>
1320-
<span class="density-label" data-i18n="settings.embed_display.large">Large</span>
1324+
<span class="density-label" data-i18n="settings.embed_display.full">Full</span>
13211325
</button>
13221326
</div>
13231327
</div>

public/css/style.css

Lines changed: 182 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6426,19 +6426,30 @@ details.sound-section:not([open]) .sound-section-label::before {
64266426
object-fit: contain;
64276427
}
64286428

6429-
/* Embed / link preview size modes */
6430-
.embed-size-off .link-preview,
6431-
.embed-size-off .link-preview-yt { display: none !important; }
6432-
6433-
.embed-size-large .link-preview {
6434-
max-width: 600px;
6435-
}
6436-
.embed-size-large .link-preview-image {
6437-
width: 120px;
6438-
height: 120px;
6439-
}
6440-
.embed-size-large .link-preview-title { font-size: 14px; }
6441-
.embed-size-large .link-preview-desc { -webkit-line-clamp: 3; }
6429+
/* Embed / link preview size modes (shared by Settings picker + per-embed ⤢) */
6430+
.embed-size-off .link-preview { display: none !important; }
6431+
6432+
.embed-size-full .link-preview--rich .lp-image,
6433+
.embed-size-full .link-preview--rich .lp-video { max-height: 520px; }
6434+
.embed-size-full .link-preview--rich .lp-text { -webkit-line-clamp: 14; }
6435+
.embed-size-full .link-preview--yt .link-preview-yt { max-width: 480px; }
6436+
6437+
.embed-size-medium .link-preview--rich .lp-media,
6438+
.embed-size-medium .link-preview--rich .lp-video,
6439+
.embed-size-medium .link-preview--rich .link-preview-gallery { max-width: 340px; }
6440+
.embed-size-medium .link-preview--rich .lp-image,
6441+
.embed-size-medium .link-preview--rich .lp-video { max-height: 300px; }
6442+
.embed-size-medium .link-preview--rich .lp-text { -webkit-line-clamp: 8; }
6443+
.embed-size-medium .link-preview--yt .link-preview-yt { max-width: 380px; }
6444+
6445+
.embed-size-small .link-preview { max-width: 360px; }
6446+
.embed-size-small .link-preview--rich .lp-media,
6447+
.embed-size-small .link-preview--rich .lp-video,
6448+
.embed-size-small .link-preview--rich .link-preview-gallery { max-width: 220px; }
6449+
.embed-size-small .link-preview--rich .lp-image,
6450+
.embed-size-small .link-preview--rich .lp-video { max-height: 160px; }
6451+
.embed-size-small .link-preview--rich .lp-text { -webkit-line-clamp: 4; }
6452+
.embed-size-small .link-preview--yt .link-preview-yt { max-width: 280px; }
64426453

64436454
/* Image lightbox overlay */
64446455
.image-lightbox {
@@ -12739,6 +12750,164 @@ ul.chat-list { list-style-type: disc; }
1273912750
border: 0;
1274012751
}
1274112752

12753+
/* ── Rich embed card (Bluesky / X / generic) — mobile-parity layout ───── */
12754+
.link-preview--rich {
12755+
flex-direction: column;
12756+
gap: 6px;
12757+
align-items: stretch;
12758+
max-width: 480px;
12759+
padding: 8px 10px;
12760+
border-left-color: var(--lp-accent, var(--accent));
12761+
}
12762+
.lp-header {
12763+
display: flex;
12764+
align-items: center;
12765+
gap: 8px;
12766+
}
12767+
.lp-site {
12768+
flex: 1 1 auto;
12769+
min-width: 0;
12770+
font-size: 11px;
12771+
font-weight: 700;
12772+
text-transform: uppercase;
12773+
letter-spacing: 0.3px;
12774+
color: var(--lp-accent, var(--accent));
12775+
white-space: nowrap;
12776+
overflow: hidden;
12777+
text-overflow: ellipsis;
12778+
}
12779+
.lp-size,
12780+
.lp-collapse {
12781+
flex: 0 0 auto;
12782+
background: none;
12783+
border: 0;
12784+
cursor: pointer;
12785+
color: var(--text-muted);
12786+
font-size: 12px;
12787+
font-weight: 600;
12788+
line-height: 1;
12789+
padding: 2px 6px;
12790+
border-radius: 4px;
12791+
}
12792+
.lp-size:hover,
12793+
.lp-collapse:hover {
12794+
background: rgba(255, 255, 255, 0.07);
12795+
color: var(--text-secondary);
12796+
}
12797+
.lp-content {
12798+
display: flex;
12799+
flex-direction: column;
12800+
gap: 6px;
12801+
min-width: 0;
12802+
}
12803+
.link-preview.lp-collapsed .lp-content { display: none; }
12804+
.lp-meta {
12805+
display: flex;
12806+
flex-direction: column;
12807+
gap: 4px;
12808+
min-width: 0;
12809+
text-decoration: none;
12810+
color: inherit;
12811+
}
12812+
.lp-author {
12813+
display: flex;
12814+
align-items: center;
12815+
gap: 6px;
12816+
min-width: 0;
12817+
}
12818+
.lp-avatar {
12819+
width: 20px;
12820+
height: 20px;
12821+
border-radius: 50%;
12822+
object-fit: cover;
12823+
flex: 0 0 auto;
12824+
}
12825+
.lp-author-name {
12826+
font-size: 13px;
12827+
font-weight: 700;
12828+
color: var(--text-primary);
12829+
white-space: nowrap;
12830+
overflow: hidden;
12831+
text-overflow: ellipsis;
12832+
}
12833+
.lp-handle {
12834+
font-size: 12px;
12835+
color: var(--text-muted);
12836+
white-space: nowrap;
12837+
}
12838+
.lp-text {
12839+
font-size: 13px;
12840+
line-height: 1.45;
12841+
color: var(--text-primary);
12842+
white-space: pre-wrap;
12843+
word-break: break-word;
12844+
display: -webkit-box;
12845+
-webkit-box-orient: vertical;
12846+
-webkit-line-clamp: 14;
12847+
overflow: hidden;
12848+
}
12849+
.lp-media {
12850+
position: relative;
12851+
display: block;
12852+
line-height: 0;
12853+
border-radius: 8px;
12854+
overflow: hidden;
12855+
max-width: 100%;
12856+
}
12857+
.lp-image,
12858+
.lp-video {
12859+
display: block;
12860+
width: auto;
12861+
max-width: 100%;
12862+
height: auto;
12863+
max-height: 520px;
12864+
border-radius: 8px;
12865+
background: #000;
12866+
object-fit: contain;
12867+
}
12868+
.lp-play {
12869+
position: absolute;
12870+
top: 50%;
12871+
left: 50%;
12872+
width: 52px;
12873+
height: 52px;
12874+
transform: translate(-50%, -50%);
12875+
background: rgba(0, 0, 0, 0.55);
12876+
border-radius: 50%;
12877+
pointer-events: none;
12878+
}
12879+
.lp-play::after {
12880+
content: '';
12881+
position: absolute;
12882+
top: 50%;
12883+
left: 54%;
12884+
transform: translate(-50%, -50%);
12885+
border-style: solid;
12886+
border-width: 9px 0 9px 15px;
12887+
border-color: transparent transparent transparent #fff;
12888+
}
12889+
.lp-stats {
12890+
display: flex;
12891+
flex-wrap: wrap;
12892+
gap: 14px;
12893+
font-size: 12px;
12894+
color: var(--text-muted);
12895+
}
12896+
.lp-stats span { white-space: nowrap; }
12897+
/* YouTube card reuses the shared chrome; neutralise the legacy inner styling */
12898+
.link-preview--yt {
12899+
flex-direction: column;
12900+
gap: 6px;
12901+
padding: 8px 10px;
12902+
}
12903+
.link-preview--yt .link-preview-yt {
12904+
margin-top: 0;
12905+
border: 0;
12906+
border-radius: 6px;
12907+
max-width: 480px;
12908+
width: 100%;
12909+
}
12910+
1274212911
/* ═══════════════════════════════════════════════════════════
1274312912
DM auto-cleanup notice (#5340)
1274412913
═══════════════════════════════════════════════════════════ */

public/js/modules/app-media.js

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2450,28 +2450,33 @@ _applyImageMode(mode) {
24502450
_setupEmbedSizePicker() {
24512451
const picker = document.getElementById('embed-size-picker');
24522452
if (!picker) return;
2453-
2454-
const saved = localStorage.getItem('haven-embed-size') || 'normal';
2455-
this._applyEmbedSize(saved);
2456-
picker.querySelectorAll('[data-embed-size]').forEach(btn => {
2457-
btn.classList.toggle('active', btn.dataset.embedSize === saved);
2458-
});
2459-
2453+
this._applyEmbedSize(this._embedSize());
24602454
picker.addEventListener('click', (e) => {
24612455
const btn = e.target.closest('[data-embed-size]');
2462-
if (!btn) return;
2463-
const mode = btn.dataset.embedSize;
2464-
this._applyEmbedSize(mode);
2465-
localStorage.setItem('haven-embed-size', mode);
2466-
picker.querySelectorAll('[data-embed-size]').forEach(b => b.classList.remove('active'));
2467-
btn.classList.add('active');
2456+
if (btn) this._applyEmbedSize(btn.dataset.embedSize);
24682457
});
24692458
},
24702459

2460+
// Embed size is the single source of truth shared by the Settings picker and
2461+
// the per-embed ⤢ toggle (see app-messages.js). Legacy values are migrated.
2462+
_normalizeEmbedSize(mode) {
2463+
mode = ({ normal: 'medium', large: 'full' })[mode] || mode;
2464+
return ['full', 'medium', 'small', 'off'].includes(mode) ? mode : 'medium';
2465+
},
2466+
2467+
_embedSize() {
2468+
return this._normalizeEmbedSize(localStorage.getItem('haven-embed-size'));
2469+
},
2470+
24712471
_applyEmbedSize(mode) {
2472-
document.body.classList.remove('embed-size-off', 'embed-size-large');
2473-
if (mode === 'off') document.body.classList.add('embed-size-off');
2474-
if (mode === 'large') document.body.classList.add('embed-size-large');
2472+
mode = this._normalizeEmbedSize(mode);
2473+
localStorage.setItem('haven-embed-size', mode);
2474+
document.body.classList.remove('embed-size-off', 'embed-size-small', 'embed-size-medium', 'embed-size-full');
2475+
document.body.classList.add(`embed-size-${mode}`);
2476+
const label = `⤢ ${mode.charAt(0).toUpperCase() + mode.slice(1)}`;
2477+
document.querySelectorAll('.lp-size').forEach(b => { b.textContent = label; });
2478+
const picker = document.getElementById('embed-size-picker');
2479+
if (picker) picker.querySelectorAll('[data-embed-size]').forEach(b => b.classList.toggle('active', b.dataset.embedSize === mode));
24752480
},
24762481

24772482
// ── Role Display Picker ──

0 commit comments

Comments
 (0)