Files
xExtension-BlueskyThreads/extension.php
James Griffin 13f7f12499
All checks were successful
CI / test (pull_request) Successful in 1m5s
CI / test (push) Successful in 52s
Fix thread post spacing: add reply padding and increase margins
Replies had no left padding from the border (padding-left:0) and too-tight
vertical spacing (margin:4px). Bump to padding-left:12px and margin:8px.

Closes #2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:59:09 -03:00

464 lines
15 KiB
PHP

<?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('entry_before_insert', [$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('entry_before_display', [$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;
}
$rootAuthorDid = $data['thread']['post']['author']['did'] ?? null;
$html = $this->renderThread($data['thread'], true, $rootAuthorDid);
$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 $rootAuthorDid = null): string {
if (!isset($node['post'])) {
return '';
}
$html = $this->renderPost($node['post'], $isRoot);
if (!empty($node['replies'])) {
$repliesHtml = '';
foreach ($node['replies'] as $reply) {
if (!isset($reply['post'])) {
continue;
}
if ($rootAuthorDid !== null && ($reply['post']['author']['did'] ?? null) !== $rootAuthorDid) {
continue;
}
$repliesHtml .= $this->renderThread($reply, false, $rootAuthorDid);
}
if ($repliesHtml !== '') {
$html .= '<div class="bsky-replies" style="border-left:2px solid #e1e8ed;margin-left:24px;padding-left:12px;">';
$html .= $repliesHtml;
$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'] ?? '';
$text = $record['text'] ?? '';
$facets = $record['facets'] ?? [];
$textHtml = nl2br($this->applyFacets($text, $facets));
$embedHtml = $embed !== null ? $this->renderEmbed($embed) : '';
$rootStyle = 'border:1px solid #cfd9de;border-radius:12px;padding:12px 16px;margin-bottom:12px;';
$replyStyle = 'padding:10px 12px;margin:8px 0;';
$style = $isRoot ? $rootStyle : $replyStyle;
return <<<HTML
<div class="bsky-post" style="{$style}">
<div class="bsky-text" style="white-space:pre-wrap;word-break:break-word;">{$textHtml}</div>
{$embedHtml}
</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
<blockquote style="border:1px solid #0085ff;border-radius:12px;padding:10px 12px;margin:8px 0 0 0;">
<div style="font-size:0.85em;margin-bottom:6px;">
<strong>{$displayName}</strong>
<a href="{$this->e($postUrl)}" style="color:#536471;margin-left:4px;text-decoration:none;">@{$this->e($handle)}</a>
</div>
<div style="white-space:pre-wrap;word-break:break-word;">{$text}</div>
{$embedsHtml}
</blockquote>
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');
}
}