Bluesky widget
Display your latest Bluesky post as a card with relative timestamp, thumbnail support for images and videos, and a lovely little π¦ link back to your profile. Links, @mentions, and #hashtags in posts are fully clickable.1 2
Preview
How to use
Markup
Paste this where you want the widget to appear, and update data-handle to your Bluesky handle:
<div class="bsky-status" data-handle="your-handle.bsky.social" hidden></div>
<script>
/* Bluesky status | robertbirming.com */
(async () => {
const root = document.querySelector('.bsky-status');
const handle = root?.getAttribute('data-handle');
if (!handle) return;
try {
const base = 'https://public.api.bsky.app/xrpc';
const res = await fetch(`${base}/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(handle)}&limit=10&filter=posts_no_replies`);
if (!res.ok) return;
const feed = await res.json();
const item = feed.feed?.find(item => !item.reason);
const post = item?.post;
if (!post?.record?.text) return;
const rkey = post.uri.split('/').pop();
const postUrl = `https://bsky.app/profile/${handle}/post/${rkey}`;
const profileUrl = `https://bsky.app/profile/${handle}`;
const esc = s => String(s ?? '')
.replace(/&/g,'&')
.replace(/</g,'<')
.replace(/>/g,'>')
.replace(/"/g,'"');
const safeUrl = url => {
try {
const u = new URL(url);
return ['http:', 'https:'].includes(u.protocol) ? u.href : '#';
} catch { return '#'; }
};
const enc = new TextEncoder(), dec = new TextDecoder();
const richText = (text, facets) => {
if (!facets?.length) return esc(text);
const bytes = enc.encode(text);
const sorted = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart);
let out = '', cursor = 0;
for (const f of sorted) {
const { byteStart: s, byteEnd: e } = f.index;
if (cursor < s) out += esc(dec.decode(bytes.slice(cursor, s)));
const chunk = esc(dec.decode(bytes.slice(s, e)));
const feat = f.features?.[0];
if (feat?.$type === 'app.bsky.richtext.facet#link')
out += `<a href="${esc(safeUrl(feat.uri))}" target="_blank" rel="noopener nofollow">${chunk}</a>`;
else if (feat?.$type === 'app.bsky.richtext.facet#mention')
out += `<a href="https://bsky.app/profile/${esc(feat.did)}" target="_blank" rel="noopener">${chunk}</a>`;
else if (feat?.$type === 'app.bsky.richtext.facet#tag')
out += `<a href="https://bsky.app/hashtag/${esc(feat.tag)}" target="_blank" rel="noopener">${chunk}</a>`;
else out += chunk;
cursor = e;
}
if (cursor < bytes.length) out += esc(dec.decode(bytes.slice(cursor)));
return out;
};
const isBeacon = text => /beaconbits\.app\/beacon\//i.test(text);
const parseBeaconLines = text => {
const lines = text.split('\n');
let name = '', meta = '', rating = 0, weather = '';
for (const line of lines) {
if (/^π/.test(line)) name = line.replace(/^π\s*/, '').trim();
if (/Β·/.test(line) && !meta) meta = line.trim();
if (/^[β
β]+$/.test(line.trim())) rating = (line.match(/β
/g) || []).length;
if (/^[\u{1F300}-\u{1F5FF}\u{2600}-\u{26FF}]/u.test(line) && /Β°[CF]/.test(line)) weather = line.trim();
}
return { name, meta, rating, weather };
};
const cleanBeaconText = text => {
let t = text;
t = t.replace(/^π[^\n]+\n?/m, '');
t = t.replace(/^[^\n]+Β·[^\n]+\n?/m, '');
t = t.replace(/^[\u{1F300}-\u{1F5FF}\u{2600}-\u{26FF}][^\n]*Β°[CF][^\n]*\n?/mu, '');
t = t.replace(/^[β
ββ¦]+\s*\n?/m, '');
t = t.replace(/\n+https?:\/\/beaconbits\.app\/\S+/gi, '').trimEnd();
t = t.replace(/\n*#BeaconBits\s*/gi, '').trimEnd();
return t.trim();
};
const beaconUrl = text => {
const m = text.match(/https?:\/\/beaconbits\.app\/\S+/i);
return m ? m[0] : null;
};
const stars = n => 'β
'.repeat(n) + 'β'.repeat(5 - n);
let rawText = post.record.text;
rawText = rawText.replace(/\n*View .+ on @\S+\s*$/i, '').trimEnd();
rawText = rawText.replace(/\n*via @\S+\s*$/i, '').trimEnd();
rawText = rawText.replace(/\n+https?:\/\/\S+\s*$/, '').trimEnd();
const mins = Math.max(0, (Date.now() - new Date(post.record.createdAt)) / 60000) | 0;
const ago = (n, u) => n + '\u00a0' + u + (n === 1 ? '' : 's') + ' ago';
const when = mins < 1 ? 'just now'
: mins < 60 ? ago(mins, 'minute')
: mins < 1440 ? ago(mins / 60 | 0, 'hour')
: mins < 43200 ? ago(mins / 1440 | 0, 'day')
: mins < 525600 ? ago(mins / 43200 | 0, 'month')
: ago(mins / 525600 | 0, 'year');
let embed = post.embed;
if (embed?.$type === 'app.bsky.embed.recordWithMedia#view') embed = embed.media;
let thumb = null, isVideo = false, images = [], cardHTML = '', textHtml = '';
if (isBeacon(rawText)) {
const { name, meta, rating, weather } = parseBeaconLines(rawText);
const beaconLink = beaconUrl(rawText) || postUrl;
const shout = cleanBeaconText(rawText);
const beaconThumb = embed?.$type === 'app.bsky.embed.images#view'
? embed.images[0]?.thumb
: embed?.media?.$type === 'app.bsky.embed.images#view'
? embed.media.images[0]?.thumb
: null;
textHtml =
(name ? `<p class="bsky-status-beacon-header">π ${esc(name)}${rating ? ' <span class="bsky-status-beacon-stars">' + stars(rating) + '</span>' : ''}</p>` : '') +
(shout ? `<p>${esc(shout)}</p>` : '');
cardHTML =
`<a class="bsky-status-card" href="${esc(beaconLink)}" target="_blank" rel="noopener">` +
(beaconThumb ? `<img class="bsky-status-card-thumb" src="${esc(beaconThumb)}" alt="" loading="lazy" decoding="async">` : '') +
`<div class="bsky-status-card-text">` +
(meta ? `<span class="bsky-status-card-title">${esc(meta)}</span>` : '') +
(weather ? `<span class="bsky-status-card-desc">${esc(weather)}</span>` : '') +
`</div></a>`;
} else {
textHtml = '<p>' + richText(rawText, post.record.facets)
.replace(/\n\n+/g,'</p><p>').replace(/\n/g,'<br>') + '</p>';
if (embed?.$type === 'app.bsky.embed.images#view') {
images = embed.images.map(i => ({ thumb: i.thumb, full: i.fullsize || i.thumb, alt: i.alt || '' }));
thumb = images[0]?.thumb;
} else if (embed?.$type === 'app.bsky.embed.video#view') {
thumb = embed.thumbnail;
isVideo = true;
} else if (embed?.$type === 'app.bsky.embed.external#view') {
const ext = embed.external;
cardHTML =
`<a class="bsky-status-card" href="${esc(safeUrl(ext.uri))}" target="_blank" rel="noopener">` +
(ext.thumb ? `<img class="bsky-status-card-thumb" src="${esc(ext.thumb)}" alt="" loading="lazy" decoding="async">` : '') +
`<div class="bsky-status-card-text">` +
`<span class="bsky-status-card-title">${esc(ext.title)}</span>` +
(ext.description ? `<span class="bsky-status-card-desc">${esc(ext.description)}</span>` : '') +
`</div></a>`;
} else if (embed?.$type === 'app.bsky.embed.record#view') {
const rec = embed.record;
if (rec?.$type === 'app.bsky.embed.record#viewRecord') {
const qAuthor = rec.author?.handle || '';
const qText = rec.value?.text || '';
const qRkey = rec.uri?.split('/').pop();
const qUrl = `https://bsky.app/profile/${esc(qAuthor)}/post/${esc(qRkey)}`;
cardHTML =
`<a class="bsky-status-card bsky-status-quote" href="${qUrl}" target="_blank" rel="noopener">` +
`<span class="bsky-status-quote-author">@${esc(qAuthor)}</span>` +
`<div class="bsky-status-quote-text">${esc(qText)}</div>` +
`</a>`;
}
}
}
const thumbHTML = thumb
? `<a class="bsky-status-thumb-link${isVideo ? ' bsky-status-thumb-link--video' : ''}" href="${postUrl}" target="_blank" rel="noopener">` +
`<img class="bsky-status-thumb" src="${esc(thumb)}" alt="" loading="lazy" decoding="async"></a>`
: '';
root.innerHTML =
thumbHTML +
`<div class="bsky-status-body">` +
`<div class="bsky-status-text">${textHtml}</div>` +
(cardHTML ? cardHTML : '') +
`<div class="bsky-status-meta">` +
`<a class="bsky-status-time" href="${postUrl}" target="_blank" rel="noopener">${when}</a>` +
`<a class="bsky-status-butterfly" href="${profileUrl}" target="_blank" rel="noopener" aria-label="View Bluesky profile">π¦</a>` +
`</div>` +
`</div>`;
root.removeAttribute('hidden');
if (images.length < 2) return;
// Lightbox (multiple images only)
const lb = document.createElement('div');
lb.className = 'bsky-status-lightbox';
lb.innerHTML =
`<button class="bsky-status-lb-nav bsky-status-lb-prev" aria-label="Previous image">‹</button>` +
`<figure class="bsky-status-lb-figure">` +
`<img class="bsky-status-lb-img" src="" alt="">` +
`<figcaption class="bsky-status-lb-caption"></figcaption>` +
`</figure>` +
`<button class="bsky-status-lb-nav bsky-status-lb-next" aria-label="Next image">›</button>`;
document.body.appendChild(lb);
const lbImg = lb.querySelector('.bsky-status-lb-img');
const lbCaption = lb.querySelector('.bsky-status-lb-caption');
const prevBtn = lb.querySelector('.bsky-status-lb-prev');
const nextBtn = lb.querySelector('.bsky-status-lb-next');
let current = 0;
const show = i => {
current = (i + images.length) % images.length;
lbImg.src = images[current].full;
lbImg.alt = images[current].alt;
lbCaption.textContent = images[current].alt;
lbCaption.hidden = !images[current].alt;
};
const openLb = i => {
show(i);
lb.classList.add('open');
document.addEventListener('keydown', onKey);
};
const closeLb = () => {
lb.classList.remove('open');
lbImg.src = '';
document.removeEventListener('keydown', onKey);
};
const onKey = e => {
if (e.key === 'Escape') closeLb();
else if (e.key === 'ArrowRight') show(current + 1);
else if (e.key === 'ArrowLeft') show(current - 1);
};
const thumbLink = root.querySelector('.bsky-status-thumb-link');
if (thumbLink && !isVideo) {
thumbLink.addEventListener('click', e => {
e.preventDefault();
openLb(0);
});
}
lb.addEventListener('click', closeLb);
lbImg.addEventListener('click', e => { e.stopPropagation(); show(current + 1); });
prevBtn.addEventListener('click', e => { e.stopPropagation(); show(current - 1); });
nextBtn.addEventListener('click', e => { e.stopPropagation(); show(current + 1); });
} catch {}
})();
</script>
To change the label, update the content value in .bsky-status::before in the styles below.
Styles
/* Bluesky widget | robertbirming.com */
.bsky-status {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.3em 1.1rem;
align-items: start;
max-inline-size: 34rem;
margin-inline: auto;
margin-block: var(--space-block);
padding: 1rem 1.1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
text-align: start;
}
.bsky-status::before {
content: "LATEST STATUS";
display: block;
grid-column: 1 / -1;
margin-block-end: 0.5em;
font-family: var(--font-mono);
font-size: calc(var(--font-small) * 0.85);
color: var(--muted);
}
.bsky-status:not(:has(.bsky-status-thumb-link)) .bsky-status-body {
grid-column: 1 / -1;
}
.bsky-status-thumb-link {
display: block;
position: relative;
margin-block-start: 0.2rem;
}
.bsky-status-thumb-link--video::after {
content: "βΆ";
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
color: #fff;
background: color-mix(in srgb, var(--text) 25%, transparent);
border-radius: var(--radius);
}
.bsky-status-thumb {
width: 4rem;
height: 4rem;
object-fit: cover;
border-radius: var(--radius);
margin: 0;
}
.bsky-status-body {
min-width: 0;
}
.bsky-status-text {
font-size: var(--font-small);
color: var(--text);
overflow-wrap: break-word;
}
.bsky-status-text p { margin-block: 0.4em; }
.bsky-status-text > :first-child { margin-block-start: 0; }
.bsky-status-text > :last-child { margin-block-end: 0; }
.bsky-status-beacon-header {
font-weight: 600;
color: var(--text);
}
.bsky-status-beacon-stars {
font-weight: 400;
color: var(--text);
}
.bsky-status-card {
display: flex;
gap: 0.5em;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
margin-block-start: 0.8em;
text-decoration: none;
background: transparent;
}
.bsky-status-card-thumb {
width: 4rem;
height: 4rem;
object-fit: cover;
flex-shrink: 0;
margin-block-start: 0.5em;
margin-inline-start: 0.5em;
border-radius: var(--radius);
align-self: flex-start;
}
.bsky-status-card-text {
display: flex;
flex-direction: column;
gap: 0.15em;
padding: 0.4em 0.5em;
min-width: 0;
}
.bsky-status-card-title {
display: block;
font-size: var(--font-small);
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bsky-status-card-desc {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: var(--font-small);
color: var(--text);
}
.bsky-status-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-block-start: 1em;
}
.bsky-status-time,
.bsky-status-butterfly {
font-family: var(--font-mono);
font-size: calc(var(--font-small) * 0.85);
color: var(--muted);
text-decoration: none;
}
.bsky-status-time:visited,
.bsky-status-butterfly:visited {
color: var(--muted);
}
.bsky-status-butterfly {
display: inline-block;
font-family: inherit;
font-size: 1rem;
}
/* Lightbox */
.bsky-status-lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.88);
z-index: 9999;
align-items: center;
justify-content: center;
cursor: zoom-out;
}
.bsky-status-lightbox.open {
display: flex;
}
.bsky-status-lb-figure {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
margin: 0;
}
.bsky-status-lb-img {
max-inline-size: min(82vi, 1200px);
max-block-size: 82vh;
object-fit: contain;
border-radius: var(--radius);
display: block;
margin: 0;
cursor: zoom-in;
}
.bsky-status-lb-caption {
color: rgba(255, 255, 255, 0.75);
font-size: var(--font-small);
font-style: italic;
text-align: center;
margin: 0;
}
.bsky-status-lb-nav {
all: unset;
color: #fff;
font-size: 3rem;
line-height: 1;
padding-inline: 1rem;
cursor: pointer;
opacity: 0.7;
user-select: none;
flex-shrink: 0;
}
.bsky-status-quote {
display: block;
padding: 0.6em 0.8em;
text-decoration: none;
}
.bsky-status-quote-author {
display: block;
font-size: var(--font-small);
font-weight: 600;
color: var(--muted);
margin-block-end: 0.3em;
}
.bsky-status-quote-text {
font-size: var(--font-small);
color: var(--text);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (hover: hover) {
.bsky-status-time:hover {
color: var(--link);
}
.bsky-status-butterfly:hover {
opacity: 0.7;
text-decoration: none;
transform: scale(1.15);
}
.bsky-status-card:hover {
background: color-mix(in srgb, var(--border) 30%, transparent);
text-decoration: none;
}
.bsky-status-lb-nav:hover {
opacity: 1;
}
}
Want more? Check out all available Bearming add-ons.
Built for the Bearming theme. Using a different theme? Add the Bearming tokens to make them work with your setup.↩
Requires JavaScript, available on Bear Blog's premium plan.↩