First commit
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:docs.bsky.app)"
|
||||
]
|
||||
}
|
||||
}
|
||||
48
CLAUDE.md
Normal file
48
CLAUDE.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
A FreshRSS extension that enriches Bluesky posts in RSS feeds by fetching the full reply thread and embedded post content via the Bluesky public API, collapsing everything into a single article.
|
||||
|
||||
## Extension structure
|
||||
|
||||
FreshRSS expects the extension directory name to start with `x`. Required files:
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `metadata.json` | Extension metadata (`name`, `entrypoint` are required) |
|
||||
| `extension.php` | Main class — must be named `{entrypoint}Extension extends Minz_Extension` |
|
||||
| `configure.phtml` | Optional settings form; submitted values handled by `handleConfigureAction()` |
|
||||
|
||||
The extension is installed by dropping this directory into FreshRSS's `extensions/` folder.
|
||||
|
||||
## How it works
|
||||
|
||||
Two hooks work in tandem so both the web UI and API sync clients (Fever, GReader, etc.) receive enriched content:
|
||||
|
||||
1. **`EntryBeforeInsert`** (`fetchThread`) — fires once when a new entry is first saved to the DB. Fetches the thread immediately and stores it, so API clients get enriched content from the very first sync.
|
||||
2. **`EntryBeforeDisplay`** (`refreshThread`) — fires on every web render. Checks the file cache for staleness; if stale, re-fetches and calls `FreshRSS_Factory::createEntryDao()->updateEntry($entry)` to write the refreshed HTML back to the DB, keeping API clients up to date.
|
||||
3. **Staleness rules** (`needsRefetch`): posts ≥ 7 days old are frozen. Fresher posts use progressively tighter refresh windows — 10 min (< 1 h old), 1 h (< 24 h old), 12 h (< 7 d old).
|
||||
4. **Cache:** JSON files in `DATA_PATH/BlueskyThreads/{md5(url)}.json`, each containing `{html, fetched_at}`. On API failure, the stale cache is served as a fallback.
|
||||
5. **Handle → DID:** `GET https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={handle}` (skipped if the handle is already a `did:` URI).
|
||||
6. **Thread fetch:** `GET https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://{did}/app.bsky.feed.post/{rkey}&depth={depth}&parentHeight=0`
|
||||
7. **Rendering:** `renderThread()` walks the recursive `threadViewPost` structure. Root post uses a bordered card style; replies are indented under a left border. Each post renders its richtext facets (links, mentions, hashtags) and embeds (images, external links, quoted posts, video).
|
||||
|
||||
## Key implementation details
|
||||
|
||||
- **Facets** — Bluesky richtext uses UTF-8 *byte* offsets (`byteStart`/`byteEnd`). `applyFacets()` walks the raw PHP byte string directly rather than converting to characters first.
|
||||
- **Embed type normalisation** — the API returns `$type` values like `app.bsky.embed.images#view`; the `#view` suffix is stripped before the switch statement.
|
||||
- **User config** — `depth` (int, 1–1000, default 10) is stored via `setUserConfigurationValue`/`getUserConfigurationValue` and read in `handleConfigureAction()` on POST.
|
||||
- **No auth required** — all requests go to `public.api.bsky.app` and need no credentials.
|
||||
|
||||
## Bluesky API reference
|
||||
|
||||
- Thread endpoint: `app.bsky.feed.getPostThread` — params: `uri` (AT-URI), `depth` (0–1000), `parentHeight` (0–1000)
|
||||
- Handle resolution: `com.atproto.identity.resolveHandle` — param: `handle`
|
||||
- Docs: https://docs.bsky.app/docs/api/app-bsky-feed-get-post-thread
|
||||
|
||||
## FreshRSS extension docs
|
||||
|
||||
https://freshrss.github.io/FreshRSS/en/developers/03_Backend/05_Extensions.html
|
||||
49
README.md
Normal file
49
README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Bluesky Threads — FreshRSS Extension
|
||||
|
||||
A FreshRSS extension that enriches Bluesky posts in your RSS feeds by fetching the full reply thread and embedded content via the Bluesky public API, collapsing everything into a single article.
|
||||
|
||||
## Features
|
||||
|
||||
- Fetches full reply threads when a post is first saved, so API sync clients (Fever, GReader, etc.) receive enriched content immediately
|
||||
- Automatically refreshes thread content as it ages, with progressively relaxed refresh intervals
|
||||
- Renders rich text with working links, mentions, and hashtags
|
||||
- Renders all embed types: images, external link cards, quoted posts, and videos
|
||||
- No Bluesky account or API credentials required
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download or clone this repository into FreshRSS's `extensions/` directory. The folder must be named `xExtension-BlueskyThreads`.
|
||||
2. In FreshRSS, go to **Extensions** and enable **Bluesky Threads**.
|
||||
|
||||
## Configuration
|
||||
|
||||
In the extension settings, you can set **Thread depth** (default: 10, max: 1000) — how many levels of replies to fetch per post.
|
||||
|
||||
## How it works
|
||||
|
||||
### Hooks
|
||||
|
||||
Two hooks work in tandem so both the web UI and API sync clients receive enriched content:
|
||||
|
||||
- **`EntryBeforeInsert`** — fires once when a new entry is first saved. Fetches the thread immediately and stores the rendered HTML.
|
||||
- **`EntryBeforeDisplay`** — fires on every web render. Checks if the cached thread is stale; if so, re-fetches and writes the updated HTML back to the database.
|
||||
|
||||
### Cache & staleness
|
||||
|
||||
Thread HTML is cached as JSON files in `DATA_PATH/BlueskyThreads/{md5(url)}.json`. Staleness is determined by the post's age:
|
||||
|
||||
| Post age | Cache refresh interval |
|
||||
|--------------|------------------------|
|
||||
| < 1 hour | Every 10 minutes |
|
||||
| < 24 hours | Every 1 hour |
|
||||
| < 7 days | Every 12 hours |
|
||||
| 7+ days | Never (frozen) |
|
||||
|
||||
On API failure, the stale cache is served as a fallback.
|
||||
|
||||
### API endpoints used
|
||||
|
||||
All requests go to `public.api.bsky.app` — no authentication required.
|
||||
|
||||
- **Handle resolution:** `com.atproto.identity.resolveHandle`
|
||||
- **Thread fetch:** `app.bsky.feed.getPostThread`
|
||||
23
configure.phtml
Normal file
23
configure.phtml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
if (!defined('FRESHRSS')) {
|
||||
die();
|
||||
}
|
||||
/** @var BlueskyThreadsExtension $this */
|
||||
$depth = (int) ($this->getUserConfigurationValue('depth') ?: 10);
|
||||
?>
|
||||
<table class="table-striped">
|
||||
<tr>
|
||||
<th>
|
||||
<label for="depth">Thread depth</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="number" id="depth" name="depth"
|
||||
value="<?= $depth ?>" min="1" max="1000"
|
||||
class="short">
|
||||
<p class="help-block">
|
||||
How many levels of replies to fetch (default: 10).
|
||||
The Bluesky API allows up to 1000.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
491
extension.php
Normal file
491
extension.php
Normal file
@@ -0,0 +1,491 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class BlueskyThreadsExtension extends Minz_Extension {
|
||||
|
||||
private const API_BASE = 'https://public.api.bsky.app/xrpc';
|
||||
private const POST_URL_PATTERN = '#bsky\.app/profile/([^/]+)/post/([^/?#\s]+)#';
|
||||
|
||||
// How long a cached thread is considered fresh, based on the post's age.
|
||||
// Posts older than FREEZE_AGE are never re-fetched.
|
||||
private const FREEZE_AGE = 7 * 86400; // 7 days
|
||||
private const REFRESH_AGES = [
|
||||
3600 => 600, // post < 1 h old → refresh cache after 10 min
|
||||
86400 => 3600, // post < 24 h old → refresh cache after 1 h
|
||||
self::FREEZE_AGE => 43200, // post < 7 d old → refresh cache after 12 h
|
||||
];
|
||||
|
||||
#[\Override]
|
||||
public function init(): void {
|
||||
parent::init();
|
||||
// EntryBeforeInsert stores the initial thread in the DB so API sync
|
||||
// clients (Fever, GReader, etc.) receive enriched content immediately.
|
||||
$this->registerHook(Minz_HookType::EntryBeforeInsert, [$this, 'fetchThread']);
|
||||
// EntryBeforeDisplay re-checks staleness on every web render and writes
|
||||
// refreshed content back to the DB so API clients also get updates.
|
||||
$this->registerHook(Minz_HookType::EntryBeforeDisplay, [$this, 'refreshThread']);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handleConfigureAction(): void {
|
||||
if (Minz_Request::isPost()) {
|
||||
$depth = max(1, min(1000, (int) Minz_Request::paramString('depth') ?: 10));
|
||||
$this->setUserConfigurationValue('depth', $depth);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hook callbacks
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* EntryBeforeInsert — runs once when a new entry is first saved to the DB.
|
||||
* Fetches the thread and stores enriched HTML so API sync clients get it.
|
||||
*/
|
||||
public function fetchThread(FreshRSS_Entry $entry): FreshRSS_Entry {
|
||||
$url = $entry->link();
|
||||
if (!preg_match(self::POST_URL_PATTERN, $url, $m)) {
|
||||
return $entry;
|
||||
}
|
||||
|
||||
[, $handle, $rkey] = $m;
|
||||
|
||||
$html = $this->loadThread($handle, $rkey, $url);
|
||||
if ($html !== null) {
|
||||
$entry->_content($html);
|
||||
}
|
||||
return $entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* EntryBeforeDisplay — runs on every web render.
|
||||
* Re-fetches if the cached thread is stale and writes the result back to
|
||||
* the DB so API sync clients also receive the updated content.
|
||||
*/
|
||||
public function refreshThread(FreshRSS_Entry $entry): FreshRSS_Entry {
|
||||
$url = $entry->link();
|
||||
if (!preg_match(self::POST_URL_PATTERN, $url, $m)) {
|
||||
return $entry;
|
||||
}
|
||||
|
||||
[, $handle, $rkey] = $m;
|
||||
|
||||
$cache = $this->readCache($url);
|
||||
$publishedAt = (int) $entry->date(true);
|
||||
|
||||
if (!$this->needsRefetch($publishedAt, $cache['fetched_at'] ?? null)) {
|
||||
// Cache is fresh — serve without an API call.
|
||||
if ($cache !== null) {
|
||||
$entry->_content($cache['html']);
|
||||
}
|
||||
return $entry;
|
||||
}
|
||||
|
||||
$html = $this->loadThread($handle, $rkey, $url, $cache['html'] ?? null);
|
||||
if ($html !== null) {
|
||||
$entry->_content($html);
|
||||
// Write back to DB so API sync clients receive the refreshed thread.
|
||||
try {
|
||||
FreshRSS_Factory::createEntryDao()->updateEntry($entry);
|
||||
} catch (Throwable) {
|
||||
// Non-fatal: the web UI still shows fresh content; DB update
|
||||
// will be retried on the next stale render.
|
||||
}
|
||||
}
|
||||
return $entry;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Shared fetch logic
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves the handle, calls the API, renders the thread, updates the cache,
|
||||
* and returns the HTML. Returns $fallback (null by default) on any failure.
|
||||
*/
|
||||
private function loadThread(string $handle, string $rkey, string $url, ?string $fallback = null): ?string {
|
||||
$did = str_starts_with($handle, 'did:') ? $handle : $this->resolveHandle($handle);
|
||||
if ($did === null) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$atUri = "at://{$did}/app.bsky.feed.post/{$rkey}";
|
||||
$depth = (int) ($this->getUserConfigurationValue('depth') ?: 10);
|
||||
|
||||
$data = $this->apiGet('app.bsky.feed.getPostThread', [
|
||||
'uri' => $atUri,
|
||||
'depth' => $depth,
|
||||
'parentHeight' => 0,
|
||||
]);
|
||||
|
||||
if ($data === null || !isset($data['thread'])) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$html = $this->renderThread($data['thread'], true);
|
||||
$this->writeCache($url, $html);
|
||||
return $html;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cache helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function cacheDir(): string {
|
||||
$base = defined('DATA_PATH') ? DATA_PATH : sys_get_temp_dir();
|
||||
return rtrim((string) $base, '/') . '/BlueskyThreads';
|
||||
}
|
||||
|
||||
private function cacheFile(string $url): string {
|
||||
return $this->cacheDir() . '/' . md5($url) . '.json';
|
||||
}
|
||||
|
||||
/** @return array{html:string,fetched_at:int}|null */
|
||||
private function readCache(string $url): ?array {
|
||||
$file = $this->cacheFile($url);
|
||||
if (!is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
$raw = file_get_contents($file);
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) && isset($data['html'], $data['fetched_at']) ? $data : null;
|
||||
}
|
||||
|
||||
private function writeCache(string $url, string $html): void {
|
||||
$dir = $this->cacheDir();
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
file_put_contents(
|
||||
$this->cacheFile($url),
|
||||
json_encode(['html' => $html, 'fetched_at' => time()], JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if we should call the API again.
|
||||
*
|
||||
* @param int $publishedAt Unix timestamp of the post.
|
||||
* @param int|null $fetchedAt Unix timestamp of the last cache write, or null if never cached.
|
||||
*/
|
||||
private function needsRefetch(int $publishedAt, ?int $fetchedAt): bool {
|
||||
if ($fetchedAt === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$postAge = time() - $publishedAt;
|
||||
$cacheAge = time() - $fetchedAt;
|
||||
|
||||
if ($postAge >= self::FREEZE_AGE) {
|
||||
return false; // Thread old enough to be considered complete.
|
||||
}
|
||||
|
||||
foreach (self::REFRESH_AGES as $maxPostAge => $minCacheAge) {
|
||||
if ($postAge < $maxPostAge) {
|
||||
return $cacheAge >= $minCacheAge;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Bluesky API helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function resolveHandle(string $handle): ?string {
|
||||
$data = $this->apiGet('com.atproto.identity.resolveHandle', ['handle' => $handle]);
|
||||
return isset($data['did']) ? (string) $data['did'] : null;
|
||||
}
|
||||
|
||||
private function apiGet(string $method, array $params): ?array {
|
||||
$url = self::API_BASE . '/' . $method . '?' . http_build_query($params);
|
||||
$ctx = stream_context_create(['http' => [
|
||||
'timeout' => 10,
|
||||
'ignore_errors' => true,
|
||||
'header' => "User-Agent: FreshRSS-BlueskyThreads/0.1\r\nAccept: application/json\r\n",
|
||||
]]);
|
||||
$body = @file_get_contents($url, false, $ctx);
|
||||
if ($body === false) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($body, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Thread rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function renderThread(array $node, bool $isRoot): string {
|
||||
if (!isset($node['post'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = $this->renderPost($node['post'], $isRoot);
|
||||
|
||||
if (!empty($node['replies'])) {
|
||||
$html .= '<div class="bsky-replies" style="border-left:2px solid #e1e8ed;margin-left:24px;padding-left:0;">';
|
||||
foreach ($node['replies'] as $reply) {
|
||||
if (isset($reply['post'])) {
|
||||
$html .= $this->renderThread($reply, false);
|
||||
}
|
||||
}
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function renderPost(array $post, bool $isRoot): string {
|
||||
$author = $post['author'] ?? [];
|
||||
$record = $post['record'] ?? [];
|
||||
$embed = $post['embed'] ?? null;
|
||||
|
||||
$handle = $author['handle'] ?? '';
|
||||
$displayName = $author['displayName'] ?? $handle;
|
||||
$avatar = $author['avatar'] ?? '';
|
||||
$text = $record['text'] ?? '';
|
||||
$facets = $record['facets'] ?? [];
|
||||
|
||||
$rkey = basename(rtrim($post['uri'] ?? '', '/'));
|
||||
$postUrl = 'https://bsky.app/profile/' . rawurlencode($handle) . '/post/' . $rkey;
|
||||
|
||||
$avatarHtml = $avatar !== ''
|
||||
? '<img src="' . $this->e($avatar) . '" alt="" style="width:40px;height:40px;border-radius:50%;flex-shrink:0;">'
|
||||
: '<span style="width:40px;height:40px;border-radius:50%;background:#ccc;display:inline-block;flex-shrink:0;"></span>';
|
||||
|
||||
$textHtml = nl2br($this->applyFacets($text, $facets));
|
||||
$embedHtml = $embed !== null ? $this->renderEmbed($embed) : '';
|
||||
|
||||
$stats = '';
|
||||
foreach ([
|
||||
'replyCount' => 'Reply',
|
||||
'repostCount' => 'Repost',
|
||||
'likeCount' => 'Like',
|
||||
] as $key => $label) {
|
||||
if (isset($post[$key]) && (int) $post[$key] > 0) {
|
||||
$n = (int) $post[$key];
|
||||
$stats .= '<span style="margin-right:12px;">' . $n . ' ' . $label . ($n !== 1 ? 's' : '') . '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
$rootStyle = 'border:1px solid #cfd9de;border-radius:12px;padding:12px 16px;margin-bottom:12px;';
|
||||
$replyStyle = 'padding:10px 12px;margin:4px 0;';
|
||||
$style = $isRoot ? $rootStyle : $replyStyle;
|
||||
|
||||
return <<<HTML
|
||||
<div class="bsky-post" style="{$style}">
|
||||
<div style="display:flex;align-items:flex-start;gap:10px;margin-bottom:8px;">
|
||||
<a href="https://bsky.app/profile/{$this->e($handle)}" style="text-decoration:none;">{$avatarHtml}</a>
|
||||
<div>
|
||||
<a href="https://bsky.app/profile/{$this->e($handle)}" style="text-decoration:none;color:inherit;">
|
||||
<strong>{$this->e($displayName)}</strong>
|
||||
</a><br>
|
||||
<span style="color:#536471;font-size:0.85em;">@{$this->e($handle)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bsky-text" style="margin-bottom:8px;white-space:pre-wrap;word-break:break-word;">{$textHtml}</div>
|
||||
{$embedHtml}
|
||||
<div style="margin-top:8px;font-size:0.8em;color:#536471;">
|
||||
{$stats}
|
||||
<a href="{$this->e($postUrl)}" style="color:#536471;">View on Bluesky ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Embed rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function renderEmbed(array $embed): string {
|
||||
$type = preg_replace('/#view$/', '', $embed['$type'] ?? '');
|
||||
|
||||
switch ($type) {
|
||||
case 'app.bsky.embed.images':
|
||||
return $this->renderImages($embed['images'] ?? []);
|
||||
|
||||
case 'app.bsky.embed.external':
|
||||
return $this->renderExternal($embed['external'] ?? []);
|
||||
|
||||
case 'app.bsky.embed.record':
|
||||
return $this->renderQuotedRecord($embed['record'] ?? []);
|
||||
|
||||
case 'app.bsky.embed.recordWithMedia':
|
||||
return $this->renderEmbed($embed['media'] ?? [])
|
||||
. $this->renderQuotedRecord($embed['record']['record'] ?? []);
|
||||
|
||||
case 'app.bsky.embed.video':
|
||||
return $this->renderVideo($embed);
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private function renderImages(array $images): string {
|
||||
if (empty($images)) {
|
||||
return '';
|
||||
}
|
||||
$cols = count($images) === 1 ? '1fr' : '1fr 1fr';
|
||||
$inner = '';
|
||||
foreach ($images as $img) {
|
||||
$thumb = $this->e($img['thumb'] ?? '');
|
||||
$full = $this->e($img['fullsize'] ?? $img['thumb'] ?? '');
|
||||
$alt = $this->e($img['alt'] ?? '');
|
||||
if ($thumb === '') {
|
||||
continue;
|
||||
}
|
||||
$inner .= "<a href=\"{$full}\" target=\"_blank\" rel=\"noopener\">"
|
||||
. "<img src=\"{$thumb}\" alt=\"{$alt}\" style=\"width:100%;height:180px;object-fit:cover;border-radius:8px;display:block;\">"
|
||||
. '</a>';
|
||||
}
|
||||
return "<div style=\"display:grid;grid-template-columns:{$cols};gap:4px;margin-top:8px;\">{$inner}</div>";
|
||||
}
|
||||
|
||||
private function renderExternal(array $ext): string {
|
||||
$uri = $this->e($ext['uri'] ?? '');
|
||||
$title = $this->e($ext['title'] ?? $ext['uri'] ?? '');
|
||||
$desc = $this->e($ext['description'] ?? '');
|
||||
$thumb = $this->e($ext['thumb'] ?? '');
|
||||
|
||||
if ($uri === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$thumbHtml = $thumb !== ''
|
||||
? "<img src=\"{$thumb}\" alt=\"\" style=\"width:100%;height:140px;object-fit:cover;border-radius:8px 8px 0 0;display:block;\">"
|
||||
: '';
|
||||
|
||||
return <<<HTML
|
||||
<div style="border:1px solid #cfd9de;border-radius:12px;overflow:hidden;margin-top:8px;">
|
||||
{$thumbHtml}
|
||||
<div style="padding:10px 12px;">
|
||||
<div style="font-size:0.8em;color:#536471;margin-bottom:2px;">{$uri}</div>
|
||||
<a href="{$uri}" target="_blank" rel="noopener" style="font-weight:600;text-decoration:none;">{$title}</a>
|
||||
<div style="font-size:0.85em;color:#536471;margin-top:2px;">{$desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private function renderQuotedRecord(array $record): string {
|
||||
if (empty($record)) {
|
||||
return '';
|
||||
}
|
||||
if (!empty($record['notFound'])) {
|
||||
return '<div style="border:1px solid #cfd9de;border-radius:12px;padding:10px 12px;margin-top:8px;color:#536471;">[Post not found]</div>';
|
||||
}
|
||||
if (!empty($record['blocked'])) {
|
||||
return '<div style="border:1px solid #cfd9de;border-radius:12px;padding:10px 12px;margin-top:8px;color:#536471;">[Post from blocked account]</div>';
|
||||
}
|
||||
|
||||
$author = $record['author'] ?? [];
|
||||
$value = $record['value'] ?? [];
|
||||
$embeds = $record['embeds'] ?? [];
|
||||
|
||||
$handle = $author['handle'] ?? '';
|
||||
$displayName = $this->e($author['displayName'] ?? $handle);
|
||||
$text = nl2br($this->applyFacets($value['text'] ?? '', $value['facets'] ?? []));
|
||||
$rkey = basename(rtrim($record['uri'] ?? '', '/'));
|
||||
$postUrl = 'https://bsky.app/profile/' . rawurlencode($handle) . '/post/' . $rkey;
|
||||
|
||||
$embedsHtml = '';
|
||||
foreach ($embeds as $embed) {
|
||||
$embedsHtml .= $this->renderEmbed($embed);
|
||||
}
|
||||
|
||||
return <<<HTML
|
||||
<div style="border:1px solid #0085ff;border-radius:12px;padding:10px 12px;margin-top:8px;">
|
||||
<div style="font-size:0.85em;margin-bottom:6px;">
|
||||
<strong>{$displayName}</strong>
|
||||
<span style="color:#536471;margin-left:4px;">@{$this->e($handle)}</span>
|
||||
</div>
|
||||
<div style="white-space:pre-wrap;word-break:break-word;">{$text}</div>
|
||||
{$embedsHtml}
|
||||
<div style="margin-top:6px;font-size:0.75em;">
|
||||
<a href="{$this->e($postUrl)}" style="color:#536471;">View on Bluesky ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private function renderVideo(array $embed): string {
|
||||
$thumb = $this->e($embed['thumbnail'] ?? '');
|
||||
$playlist = $this->e($embed['playlist'] ?? '');
|
||||
$alt = $this->e($embed['alt'] ?? 'Video');
|
||||
$thumbHtml = $thumb !== ''
|
||||
? "<img src=\"{$thumb}\" alt=\"{$alt}\" style=\"max-width:100%;border-radius:8px;\">"
|
||||
: '';
|
||||
$playlistHtml = $playlist !== ''
|
||||
? "<div style=\"margin-top:4px;font-size:0.8em;\"><a href=\"{$playlist}\" target=\"_blank\" rel=\"noopener\">▶ Watch video</a></div>"
|
||||
: '';
|
||||
return "<div style=\"margin-top:8px;\">{$thumbHtml}{$playlistHtml}</div>";
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rich text: apply facets (mentions, links, hashtags)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Converts a Bluesky post's plain text + facets into HTML with working links.
|
||||
* Facets use UTF-8 byte offsets, so we work on the raw bytes.
|
||||
*/
|
||||
private function applyFacets(string $text, array $facets): string {
|
||||
if (empty($facets)) {
|
||||
return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
usort($facets, static fn($a, $b) => ($a['index']['byteStart'] ?? 0) <=> ($b['index']['byteStart'] ?? 0));
|
||||
|
||||
$result = '';
|
||||
$bytePos = 0;
|
||||
|
||||
foreach ($facets as $facet) {
|
||||
$start = (int) ($facet['index']['byteStart'] ?? 0);
|
||||
$end = (int) ($facet['index']['byteEnd'] ?? 0);
|
||||
|
||||
if ($start < $bytePos || $end <= $start) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result .= htmlspecialchars(substr($text, $bytePos, $start - $bytePos), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
||||
$slice = substr($text, $start, $end - $start);
|
||||
$feature = $facet['features'][0] ?? [];
|
||||
$ftype = $feature['$type'] ?? '';
|
||||
$escaped = htmlspecialchars($slice, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
||||
if ($ftype === 'app.bsky.richtext.facet#link') {
|
||||
$href = $this->e($feature['uri'] ?? '#');
|
||||
$result .= "<a href=\"{$href}\" target=\"_blank\" rel=\"noopener\">{$escaped}</a>";
|
||||
} elseif ($ftype === 'app.bsky.richtext.facet#mention') {
|
||||
$did = $this->e($feature['did'] ?? '');
|
||||
$result .= "<a href=\"https://bsky.app/profile/{$did}\">{$escaped}</a>";
|
||||
} elseif ($ftype === 'app.bsky.richtext.facet#tag') {
|
||||
$tag = $this->e($feature['tag'] ?? ltrim($slice, '#'));
|
||||
$result .= "<a href=\"https://bsky.app/hashtag/{$tag}\">{$escaped}</a>";
|
||||
} else {
|
||||
$result .= $escaped;
|
||||
}
|
||||
|
||||
$bytePos = $end;
|
||||
}
|
||||
|
||||
$result .= htmlspecialchars(substr($text, $bytePos), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
return $result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Utility
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function e(string $s): string {
|
||||
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
8
metadata.json
Normal file
8
metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Bluesky Threads",
|
||||
"author": "thatguygriff",
|
||||
"description": "Fetches the full Bluesky reply thread and embedded post content into a single article.",
|
||||
"version": "0.1.0",
|
||||
"entrypoint": "BlueskyThreads",
|
||||
"type": "user"
|
||||
}
|
||||
Reference in New Issue
Block a user