Robert Birming

Bluesky feed

Display your latest Bluesky posts as a scrollable feed of cards. Links, @mentions, and #hashtags are fully clickable. Supports images, videos, link cards, quote posts, and has built-in display support for Popfeed reviews and Beacon Bits check-ins.1 2

Preview

Head over to my status page to see a live demo.

How to use

Markup

Paste this where you want the feed to appear, and update data-handle to your Bluesky handle. Use data-limit to control how many posts to show (default: 10).

<div class="bsky-feed" data-handle="your-handle.bsky.social" data-limit="10" hidden></div>

<script>
/* Bluesky feed | robertbirming.com */
(async () => {
  const root = document.querySelector('.bsky-feed');
  const handle = root?.getAttribute('data-handle');
  const limit = parseInt(root?.getAttribute('data-limit') || '10', 10);
  if (!handle) return;
  try {
    const base = 'https://public.api.bsky.app/xrpc';
    const fetchLimit = Math.min(limit * 2, 100);
    const res = await fetch(`${base}/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(handle)}&limit=${fetchLimit}&filter=posts_no_replies`);
    if (!res.ok) return;
    const data = await res.json();
    const posts = data.feed?.filter(f => !f.reason).map(f => f.post)
      .slice(0, limit) || [];
    if (!posts.length) return;

    const esc = s => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');

    const enc = new TextEncoder();
    const dec = new TextDecoder();

    function isBeacon(text) {
      return /beaconbits\.app\/beacon\//i.test(text);
    }

    function 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 };
    }

    function 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();
    }

    function beaconUrl(text) {
      const m = text.match(/https?:\/\/beaconbits\.app\/\S+/i);
      return m ? m[0] : null;
    }

    function stars(n) {
      return '★'.repeat(n) + '☆'.repeat(5 - n);
    }

    function cleanText(text, embed) {
      let t = text;
      try {
        if (embed?.$type === 'app.bsky.embed.external#view' && embed.external?.uri) {
          const escaped = embed.external.uri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
          t = t.replace(new RegExp('\\n*' + escaped + '\\s*$'), '').trimEnd();
        }
      } catch {}
      t = t.replace(/\n+https?:\/\/\S+\s*$/, '').trimEnd();
      t = t.replace(/\n*View .+ on @\S+\s*$/i, '').trimEnd();
      t = t.replace(/\n*via @\S+\s*$/i, '').trimEnd();
      return t;
    }

    function applyFacets(text, facets) {
      if (!facets?.length) return '<p>' + esc(text).replace(/\n\n+/g,'</p><p>').replace(/\n/g,'<br>') + '</p>';
      const sorted = [...facets].sort((a, b) => b.index.byteStart - a.index.byteStart);
      let bytes = enc.encode(text);
      sorted.forEach(facet => {
        const { byteStart, byteEnd } = facet.index;
        if (byteStart < 0 || byteEnd > bytes.length || byteStart >= byteEnd) return;
        const chunk = dec.decode(bytes.slice(byteStart, byteEnd));
        const feature = facet.features?.[0];
        let replacement = esc(chunk);
        if (feature?.$type === 'app.bsky.richtext.facet#link') {
          replacement = `<a href="${esc(feature.uri)}" target="_blank" rel="noopener noreferrer">${esc(chunk)}</a>`;
        } else if (feature?.$type === 'app.bsky.richtext.facet#mention') {
          replacement = `<a href="https://bsky.app/profile/${esc(feature.did)}" target="_blank" rel="noopener noreferrer">${esc(chunk)}</a>`;
        } else if (feature?.$type === 'app.bsky.richtext.facet#tag') {
          replacement = `<a href="https://bsky.app/hashtag/${esc(feature.tag)}" target="_blank" rel="noopener noreferrer">${esc(chunk)}</a>`;
        }
        const repBytes = enc.encode(replacement);
        const merged = new Uint8Array(bytes.length - (byteEnd - byteStart) + repBytes.length);
        merged.set(bytes.slice(0, byteStart));
        merged.set(repBytes, byteStart);
        merged.set(bytes.slice(byteEnd), byteStart + repBytes.length);
        bytes = merged;
      });
      return '<p>' + dec.decode(bytes).replace(/\n\n+/g,'</p><p>').replace(/\n/g,'<br>') + '</p>';
    }

    const ago = (n, u) => n + ' ' + u + (n === 1 ? '' : 's') + ' ago';
    function relativeTime(dateStr, now) {
      const mins = Math.max(0, (now - new Date(dateStr)) / 60000) | 0;
      return 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');
    }

    const list = document.createElement('div');
    list.className = 'bsky-feed-list';

    const now = Date.now();

    posts.forEach(post => {
      const rkey = post.uri.split('/').pop();
      const postUrl = `https://bsky.app/profile/${handle}/post/${rkey}`;
      const when = relativeTime(post.record.createdAt, now);
      const embed = post.embed;
      const rawText = post.record.text;
      const beacon = isBeacon(rawText);

      let contentHTML = '';
      let embedHTML = '';

      if (beacon) {
        const { name, meta, rating, weather } = parseBeaconLines(rawText);
        const beaconLink = beaconUrl(rawText) || postUrl;
        const shout = cleanBeaconText(rawText);
        const thumb = 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;

        contentHTML =
          `<div class="bsky-feed-content">` +
          (name ? `<p class="bsky-feed-beacon-header">📍 ${esc(name)}${rating ? ' <span class="bsky-feed-beacon-stars">' + stars(rating) + '</span>' : ''}</p>` : '') +
          (shout ? `<p>${esc(shout)}</p>` : '') +
          `</div>`;

        embedHTML =
          `<a class="bsky-feed-card" href="${esc(beaconLink)}" target="_blank" rel="noopener noreferrer">` +
          (thumb ? `<img class="bsky-feed-card-thumb" src="${esc(thumb)}" alt="" loading="lazy" decoding="async">` : '') +
          `<div class="bsky-feed-card-text">` +
          (meta ? `<span class="bsky-feed-card-title">${esc(meta)}</span>` : '') +
          (weather ? `<span class="bsky-feed-card-desc">${esc(weather)}</span>` : '') +
          `</div></a>`;

      } else {
        contentHTML = `<div class="bsky-feed-content">${applyFacets(cleanText(rawText, embed), post.record.facets)}</div>`;

        if (embed?.$type === 'app.bsky.embed.images#view') {
          const imgs = embed.images;
          if (imgs.length === 1) {
            embedHTML = `<a class="bsky-feed-img-link" href="${postUrl}" target="_blank" rel="noopener noreferrer"><img class="bsky-feed-img" src="${imgs[0].fullsize}" alt="${esc(imgs[0].alt || '')}" loading="lazy" decoding="async"></a>`;
          } else {
            embedHTML = `<div class="bsky-feed-img-grid">${imgs.map(img => `<a class="bsky-feed-img-link" href="${postUrl}" target="_blank" rel="noopener noreferrer"><img class="bsky-feed-img" src="${img.thumb}" alt="${esc(img.alt || '')}" loading="lazy" decoding="async"></a>`).join('')}</div>`;
          }
        } else if (embed?.$type === 'app.bsky.embed.video#view') {
          if (embed.thumbnail) {
            embedHTML = `<a class="bsky-feed-video-link" href="${postUrl}" target="_blank" rel="noopener noreferrer"><img class="bsky-feed-img" src="${embed.thumbnail}" alt="" loading="lazy" decoding="async"><span class="bsky-feed-video-play" aria-hidden="true">▶</span></a>`;
          }
        } else if (embed?.$type === 'app.bsky.embed.external#view') {
          const ext = embed.external;
          embedHTML =
            `<a class="bsky-feed-card" href="${esc(ext.uri)}" target="_blank" rel="noopener noreferrer">` +
            (ext.thumb ? `<img class="bsky-feed-card-thumb" src="${esc(ext.thumb)}" alt="" loading="lazy" decoding="async">` : '') +
            `<div class="bsky-feed-card-text">` +
            `<span class="bsky-feed-card-title">${esc(ext.title || '')}</span>` +
            (ext.description ? `<span class="bsky-feed-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)}`;
            embedHTML =
              `<a class="bsky-feed-quote" href="${qUrl}" target="_blank" rel="noopener noreferrer">` +
              `<span class="bsky-feed-quote-author">@${esc(qAuthor)}</span>` +
              `<div class="bsky-feed-quote-text">${esc(qText)}</div>` +
              `</a>`;
          }
        } else if (embed?.$type === 'app.bsky.embed.recordWithMedia#view') {
          const media = embed.media;
          if (media?.$type === 'app.bsky.embed.images#view') {
            const imgs = media.images;
            if (imgs.length === 1) {
              embedHTML = `<a class="bsky-feed-img-link" href="${postUrl}" target="_blank" rel="noopener noreferrer"><img class="bsky-feed-img" src="${imgs[0].fullsize}" alt="${esc(imgs[0].alt || '')}" loading="lazy" decoding="async"></a>`;
            } else {
              embedHTML = `<div class="bsky-feed-img-grid">${imgs.map(img => `<a class="bsky-feed-img-link" href="${postUrl}" target="_blank" rel="noopener noreferrer"><img class="bsky-feed-img" src="${img.thumb}" alt="${esc(img.alt || '')}" loading="lazy" decoding="async"></a>`).join('')}</div>`;
            }
          }
          const rec = embed.record?.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)}`;
            embedHTML +=
              `<a class="bsky-feed-quote" href="${qUrl}" target="_blank" rel="noopener noreferrer">` +
              `<span class="bsky-feed-quote-author">@${esc(qAuthor)}</span>` +
              `<div class="bsky-feed-quote-text">${esc(qText)}</div>` +
              `</a>`;
          }
        }
      }

      const note = document.createElement('article');
      note.className = 'bsky-feed-note';
      note.innerHTML =
        contentHTML +
        (embedHTML ? `<div class="bsky-feed-embed">${embedHTML}</div>` : '') +
        `<div class="bsky-feed-meta">` +
          `<a class="bsky-feed-date" href="${postUrl}" target="_blank" rel="noopener noreferrer">${when}</a>` +
        `</div>`;
      list.appendChild(note);
    });

    root.appendChild(list);
    root.removeAttribute('hidden');
  } catch {}
})();
</script>

Styles

/* Bluesky feed | robertbirming.com */
.bsky-feed-list {
  display: flex;
  flex-direction: column;
  gap: calc(var(--space-block) * 0.75);
  margin-block: var(--space-block);
}

.bsky-feed-note {
  padding-block: 1.1rem;
  padding-inline: 1.5rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.bsky-feed-content > :first-child { margin-block-start: 0; }
.bsky-feed-content > :last-child { margin-block-end: 0; }

.bsky-feed-content p {
  margin-block: 0.4em;
}

.bsky-feed-beacon-stars {
  color: var(--text);
}

.bsky-feed-embed {
  margin-block: 1.2rem;
}

.bsky-feed-img-link {
  display: block;
}

.bsky-feed-img {
  width: 100%;
  border-radius: var(--radius);
}

.bsky-feed-img-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.3em;
}

.bsky-feed-img-grid .bsky-feed-img {
  height: 12rem;
  object-fit: cover;
}

.bsky-feed-video-link {
  display: block;
  position: relative;
}

.bsky-feed-video-play {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.5rem;
  color: #fff;
  background: color-mix(in srgb, var(--text) 20%, transparent);
  border-radius: var(--radius);
  pointer-events: none;
}

.bsky-feed-card {
  display: flex;
  gap: 0.6em;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  text-decoration: none;
  background: transparent;
}

.bsky-feed-card-thumb {
  width: 5rem;
  height: 5rem;
  object-fit: cover;
  flex-shrink: 0;
  margin: 0;
  align-self: center;
}

.bsky-feed-card-text {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 0.2em;
  padding: 0.5em 0.6em;
  min-width: 0;
}

.bsky-feed-card-title {
  font-size: var(--font-small);
  font-weight: 600;
  color: var(--text);
}

.bsky-feed-card-desc {
  font-size: calc(var(--font-small) * 0.9);
  color: var(--muted);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.bsky-feed-quote {
  display: block;
  margin-block-start: 0.6em;
  padding: 0.6em 0.8em;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  text-decoration: none;
  background: transparent;
}

.bsky-feed-quote-author {
  display: block;
  font-size: calc(var(--font-small) * 0.9);
  font-weight: 600;
  color: var(--muted);
  margin-block-end: 0.3em;
}

.bsky-feed-quote-text {
  font-size: var(--font-small);
  color: var(--text);
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.bsky-feed-meta {
  margin-block-start: 1.2em;
  font-size: var(--font-small);
  color: var(--muted);
}

.bsky-feed-date {
  color: inherit;
  text-decoration: none;
}

.bsky-feed-date:visited {
  color: inherit;
}

@media (hover: hover) {
  .bsky-feed-card:hover,
  .bsky-feed-quote:hover {
    background: color-mix(in srgb, var(--border) 30%, transparent);
    text-decoration: none;
  }
}

Happy blogging, and status posting.

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.