Robert Birming

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,'&amp;')
  .replace(/</g,'&lt;')
  .replace(/>/g,'&gt;')
  .replace(/"/g,'&quot;');

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">&#8249;</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">&#8250;</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.

  1. Built for the Bearming theme. Using a different theme? Add the Bearming tokens to make them work with your setup.

  2. Requires JavaScript, available on Bear Blog's premium plan.