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; } $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 .= '
'; foreach ($node['replies'] as $reply) { if (isset($reply['post'])) { $html .= $this->renderThread($reply, false); } } $html .= '
'; } 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 !== '' ? '' : ''; $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 .= '' . $n . ' ' . $label . ($n !== 1 ? 's' : '') . ''; } } $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 <<
{$avatarHtml}
{$this->e($displayName)}
@{$this->e($handle)}
{$textHtml}
{$embedHtml}
{$stats} View on Bluesky ↗
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 .= "" . "\"{$alt}\"" . ''; } return "
{$inner}
"; } 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 !== '' ? "\"\"" : ''; return << {$thumbHtml}
{$uri}
{$title}
{$desc}
HTML; } private function renderQuotedRecord(array $record): string { if (empty($record)) { return ''; } if (!empty($record['notFound'])) { return '
[Post not found]
'; } if (!empty($record['blocked'])) { return '
[Post from blocked account]
'; } $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 <<
{$displayName} @{$this->e($handle)}
{$text}
{$embedsHtml}
View on Bluesky ↗
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 !== '' ? "\"{$alt}\"" : ''; $playlistHtml = $playlist !== '' ? "
▶ Watch video
" : ''; return "
{$thumbHtml}{$playlistHtml}
"; } // ------------------------------------------------------------------------- // 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 .= "{$escaped}"; } elseif ($ftype === 'app.bsky.richtext.facet#mention') { $did = $this->e($feature['did'] ?? ''); $result .= "{$escaped}"; } elseif ($ftype === 'app.bsky.richtext.facet#tag') { $tag = $this->e($feature['tag'] ?? ltrim($slice, '#')); $result .= "{$escaped}"; } 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'); } }