Compare commits

..

5 Commits

Author SHA1 Message Date
13f7f12499 Fix thread post spacing: add reply padding and increase margins
All checks were successful
CI / test (pull_request) Successful in 1m5s
CI / test (push) Successful in 52s
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
bf72b74a01 Render embedded quoted posts as blockquotes with handle permalink
All checks were successful
CI / test (pull_request) Successful in 42s
CI / test (push) Successful in 40s
Fixes #1. Replaces the plain div wrapper and separate "View on Bluesky"
link with a <blockquote> element, and makes the @handle the permalink
to the quoted post.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 09:39:41 -03:00
3950a65783 Filter thread replies to original author only
All checks were successful
CI / test (push) Successful in 45s
Only replies from the root post's author are rendered; replies from other
users are skipped. Adds fixture and test for a real-world post with an
other-author reply (3mhypinzezo2l).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 12:16:02 -03:00
06067c0ff3 Update tests to reflect removal of reply authors and per-post permalinks
All checks were successful
CI / test (push) Successful in 49s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 12:05:24 -03:00
8a707f6c71 Add PostToolUse hook to run PHPUnit tests after every code edit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 11:08:02 -03:00
4 changed files with 154 additions and 19 deletions

View File

@@ -4,5 +4,20 @@
"WebFetch(domain:docs.bsky.app)", "WebFetch(domain:docs.bsky.app)",
"Bash(tea actions:*)" "Bash(tea actions:*)"
] ]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "vendor/bin/phpunit",
"timeout": 60,
"statusMessage": "Running PHPUnit tests..."
}
]
}
]
} }
} }

View File

@@ -123,7 +123,8 @@ final class BlueskyThreadsExtension extends Minz_Extension {
return $fallback; return $fallback;
} }
$html = $this->renderThread($data['thread'], true); $rootAuthorDid = $data['thread']['post']['author']['did'] ?? null;
$html = $this->renderThread($data['thread'], true, $rootAuthorDid);
$this->writeCache($url, $html); $this->writeCache($url, $html);
return $html; return $html;
} }
@@ -221,7 +222,7 @@ final class BlueskyThreadsExtension extends Minz_Extension {
// Thread rendering // Thread rendering
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
private function renderThread(array $node, bool $isRoot): string { private function renderThread(array $node, bool $isRoot, ?string $rootAuthorDid = null): string {
if (!isset($node['post'])) { if (!isset($node['post'])) {
return ''; return '';
} }
@@ -229,14 +230,22 @@ final class BlueskyThreadsExtension extends Minz_Extension {
$html = $this->renderPost($node['post'], $isRoot); $html = $this->renderPost($node['post'], $isRoot);
if (!empty($node['replies'])) { if (!empty($node['replies'])) {
$html .= '<div class="bsky-replies" style="border-left:2px solid #e1e8ed;margin-left:24px;padding-left:0;">'; $repliesHtml = '';
foreach ($node['replies'] as $reply) { foreach ($node['replies'] as $reply) {
if (isset($reply['post'])) { if (!isset($reply['post'])) {
$html .= $this->renderThread($reply, false); 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>'; $html .= '</div>';
} }
}
return $html; return $html;
} }
@@ -254,7 +263,7 @@ final class BlueskyThreadsExtension extends Minz_Extension {
$embedHtml = $embed !== null ? $this->renderEmbed($embed) : ''; $embedHtml = $embed !== null ? $this->renderEmbed($embed) : '';
$rootStyle = 'border:1px solid #cfd9de;border-radius:12px;padding:12px 16px;margin-bottom:12px;'; $rootStyle = 'border:1px solid #cfd9de;border-radius:12px;padding:12px 16px;margin-bottom:12px;';
$replyStyle = 'padding:10px 12px;margin:4px 0;'; $replyStyle = 'padding:10px 12px;margin:8px 0;';
$style = $isRoot ? $rootStyle : $replyStyle; $style = $isRoot ? $rootStyle : $replyStyle;
return <<<HTML return <<<HTML
@@ -367,17 +376,14 @@ final class BlueskyThreadsExtension extends Minz_Extension {
} }
return <<<HTML return <<<HTML
<div style="border:1px solid #0085ff;border-radius:12px;padding:10px 12px;margin-top:8px;"> <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;"> <div style="font-size:0.85em;margin-bottom:6px;">
<strong>{$displayName}</strong> <strong>{$displayName}</strong>
<span style="color:#536471;margin-left:4px;">@{$this->e($handle)}</span> <a href="{$this->e($postUrl)}" style="color:#536471;margin-left:4px;text-decoration:none;">@{$this->e($handle)}</a>
</div> </div>
<div style="white-space:pre-wrap;word-break:break-word;">{$text}</div> <div style="white-space:pre-wrap;word-break:break-word;">{$text}</div>
{$embedsHtml} {$embedsHtml}
<div style="margin-top:6px;font-size:0.75em;"> </blockquote>
<a href="{$this->e($postUrl)}" style="color:#536471;">View on Bluesky ↗</a>
</div>
</div>
HTML; HTML;
} }

View File

@@ -145,13 +145,13 @@ class BlueskyThreadsTest extends TestCase {
$this->assertStringContainsString('hockeyviz.com', $html); $this->assertStringContainsString('hockeyviz.com', $html);
} }
/** Both reply authors must appear in the rendered HTML. */ /** Reply post authors are not rendered; only quoted post authors are. */
public function testReplyAuthorsAreRendered(): void { public function testReplyAuthorsAreRendered(): void {
$data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true); $data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true);
$html = $this->call('renderThread', $data['thread'], true); $html = $this->call('renderThread', $data['thread'], true);
$this->assertStringContainsString('example.bsky.social', $html, 'First reply author must be present'); $this->assertStringNotContainsString('example.bsky.social', $html, 'Reply author must not appear in thread posts');
$this->assertStringContainsString('hockeyfan.bsky.social', $html, 'Second reply author must be present'); $this->assertStringNotContainsString('hockeyfan.bsky.social', $html, 'Reply author must not appear in thread posts');
} }
/** The quoted/embedded post inside the root is rendered via renderQuotedRecord. */ /** The quoted/embedded post inside the root is rendered via renderQuotedRecord. */
@@ -164,13 +164,34 @@ class BlueskyThreadsTest extends TestCase {
'Quoted post text must be rendered as an embed'); 'Quoted post text must be rendered as an embed');
} }
/** Every post must include a "View on Bluesky" link pointing to the right URL. */ /** Quoted posts use a blockquote with a handle permalink; thread posts have no permalink. */
public function testViewOnBskyLinksArePresent(): void { public function testViewOnBskyLinksArePresent(): void {
$data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true); $data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true);
$html = $this->call('renderThread', $data['thread'], true); $html = $this->call('renderThread', $data['thread'], true);
$this->assertStringContainsString('3mhtk7awhrp26', $html, 'Root post rkey must appear in a Bluesky link'); $this->assertStringNotContainsString('3mhtk7awhrp26', $html, 'Thread post rkey must not appear as a permalink');
$this->assertStringContainsString('View on Bluesky', $html); $this->assertStringContainsString('3mhtjo3rtkn26', $html, 'Quoted post rkey must appear in the handle permalink');
$this->assertStringContainsString('<blockquote', $html, 'Quoted post must be wrapped in a blockquote');
$this->assertStringNotContainsString('View on Bluesky', $html);
}
/**
* A post with only a reply from another user (3mhypinzezo2l fixture) must
* render just the root post — the other-author reply is filtered out.
*/
public function testOtherAuthorRepliesAreExcluded(): void {
$data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhypinzezo2l.json'), true);
$thread = $data['thread'];
$rootAuthorDid = $thread['post']['author']['did'];
$html = $this->call('renderThread', $thread, true, $rootAuthorDid);
$this->assertSame(1, substr_count($html, 'bsky-post'),
'Only the root post should be rendered; other-author reply must be excluded');
$this->assertSame(0, substr_count($html, 'bsky-replies'),
'No replies wrapper should be rendered when all replies are from other authors');
$this->assertStringNotContainsString('lasagnazero.bsky.social', $html,
'Reply author handle must not appear in output');
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -0,0 +1,93 @@
{
"thread": {
"$type": "app.bsky.feed.defs#threadViewPost",
"post": {
"uri": "at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhypinzezo2l",
"cid": "bafyreialfdaqkwykiinxzy3xb6qhcenuwsaxi7d6qwzx4zfdyhprzo4s2e",
"author": {
"did": "did:plc:d4324t32vfi5xzydqbh2qdj3",
"handle": "hockeyviz.com",
"displayName": "Micah McCurdy ",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:d4324t32vfi5xzydqbh2qdj3/bafkreiddyjdi3hty4z73vdoyxja3hj5n7izrsdmeoif2ppkqidbt4nsluq",
"associated": {
"chat": {
"allowIncoming": "all"
},
"activitySubscription": {
"allowSubscriptions": "followers"
}
},
"labels": [],
"createdAt": "2023-05-28T17:07:32.578Z"
},
"record": {
"$type": "app.bsky.feed.post",
"createdAt": "2026-03-26T23:01:26.959Z",
"facets": [],
"langs": [
"en"
],
"tags": [],
"text": "Any good musician can rerecord somebody else's song but what really makes a cover worth doing is that little note of jealousy nestled in the blanket of admiration. It's the same with reproving somebody else's result or redoing a viz. Shoulda been me, the grudgingist yet in fact highest respect."
},
"bookmarkCount": 0,
"replyCount": 1,
"repostCount": 0,
"likeCount": 21,
"quoteCount": 0,
"indexedAt": "2026-03-26T23:01:27.160Z",
"labels": []
},
"replies": [
{
"$type": "app.bsky.feed.defs#threadViewPost",
"post": {
"uri": "at://did:plc:vfi77nom5cwpnhurnxhy7ltj/app.bsky.feed.post/3mhyw2ijjc22a",
"cid": "bafyreif6evbxbqcf5t3t6i4apm6pertxy4mylleet6a2d2fgzwqnz6inly",
"author": {
"did": "did:plc:vfi77nom5cwpnhurnxhy7ltj",
"handle": "lasagnazero.bsky.social",
"displayName": "L0",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:vfi77nom5cwpnhurnxhy7ltj/bafkreigjurbghqtq3iogsqarnxldhl54zqdmi657pflwrrqwa2wisteq6i",
"associated": {
"activitySubscription": {
"allowSubscriptions": "followers"
}
},
"labels": [],
"createdAt": "2024-11-07T14:26:50.985Z"
},
"record": {
"$type": "app.bsky.feed.post",
"createdAt": "2026-03-27T00:58:47.713Z",
"langs": [
"en",
"ru"
],
"reply": {
"parent": {
"cid": "bafyreialfdaqkwykiinxzy3xb6qhcenuwsaxi7d6qwzx4zfdyhprzo4s2e",
"uri": "at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhypinzezo2l"
},
"root": {
"cid": "bafyreialfdaqkwykiinxzy3xb6qhcenuwsaxi7d6qwzx4zfdyhprzo4s2e",
"uri": "at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhypinzezo2l"
}
},
"text": "Something about imitation and flattery\u2026"
},
"bookmarkCount": 0,
"replyCount": 0,
"repostCount": 0,
"likeCount": 0,
"quoteCount": 0,
"indexedAt": "2026-03-27T00:58:48.952Z",
"labels": []
},
"replies": [],
"threadContext": {}
}
],
"threadContext": {}
}
}