Skip to content

Commit d5225ef

Browse files
simonwclaude
andauthored
Add thread view tabs to Bluesky thread viewer (#117)
Add tabs to toggle between Thread View and Most Recent First: - Thread View shows posts nested by reply structure (original behavior) - Most Recent First shows all posts sorted chronologically with newest at top - Switching tabs reorders existing posts without making new API calls - In chronological view, replies show "in reply to username" link - Clicking reply link scrolls to parent post with highlight animation Co-authored-by: Claude <noreply@anthropic.com>
1 parent 98281d5 commit d5225ef

File tree

1 file changed

+187
-26
lines changed

1 file changed

+187
-26
lines changed

bluesky-thread.html

Lines changed: 187 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,45 @@
5858
cursor: pointer;
5959
margin-right: 0.5em;
6060
}
61+
.tabs {
62+
display: none;
63+
margin-bottom: 1em;
64+
border-bottom: 2px solid #ddd;
65+
}
66+
.tab {
67+
background: none;
68+
border: none;
69+
padding: 0.75em 1.5em;
70+
font-size: 1rem;
71+
cursor: pointer;
72+
border-bottom: 3px solid transparent;
73+
margin-bottom: -2px;
74+
color: #666;
75+
}
76+
.tab:hover {
77+
color: #333;
78+
}
79+
.tab.active {
80+
color: #007bff;
81+
border-bottom-color: #007bff;
82+
font-weight: bold;
83+
}
84+
.reply-to {
85+
font-size: 0.8rem;
86+
color: #888;
87+
margin-bottom: 0.25em;
88+
}
89+
.reply-to a {
90+
color: #007bff;
91+
text-decoration: none;
92+
}
93+
.reply-to a:hover {
94+
text-decoration: underline;
95+
}
96+
.post.highlighted {
97+
background-color: #fffde7;
98+
transition: background-color 0.3s;
99+
}
61100
.post {
62101
position: relative;
63102
border: 1px solid #ccc;
@@ -124,6 +163,10 @@ <h1>Bluesky Thread Viewer</h1>
124163
<button id="copyBtn">Copy</button>
125164
<button id="copyJsonBtn">Copy JSON</button>
126165
</div>
166+
<div class="tabs" id="viewTabs">
167+
<button class="tab active" data-view="thread">Thread View</button>
168+
<button class="tab" data-view="recent">Most Recent First</button>
169+
</div>
127170
</header>
128171
<div id="threadContainer"></div>
129172

@@ -134,33 +177,164 @@ <h1>Bluesky Thread Viewer</h1>
134177
const copyBtn = document.getElementById('copyBtn');
135178
const copyJsonBtn = document.getElementById('copyJsonBtn');
136179
const copyContainer = document.querySelector('.copy-container');
180+
const viewTabs = document.getElementById('viewTabs');
137181
const postUrl = document.getElementById('postUrl');
138182
let lastThread = null;
183+
let allPosts = []; // Flat array of all posts with metadata
184+
let currentView = 'thread';
139185

140186
postUrl.addEventListener('keydown', (e) => {
141187
if (e.key === 'Enter') { e.preventDefault(); form.requestSubmit(); }
142188
});
143189

190+
// Extract post ID from URI for use as element ID
191+
function getPostId(uri) {
192+
return 'post-' + uri.split('/').pop();
193+
}
194+
195+
// Flatten thread into array of posts with parent info
196+
function flattenThread(item, parentUri = null, parentAuthor = null) {
197+
const posts = [];
198+
posts.push({
199+
item,
200+
parentUri,
201+
parentAuthor,
202+
uri: item.post.uri,
203+
createdAt: new Date(item.post.record.createdAt)
204+
});
205+
if (item.replies && item.replies.length) {
206+
const authorName = item.post.author.displayName || item.post.author.handle;
207+
item.replies.forEach(reply => {
208+
posts.push(...flattenThread(reply, item.post.uri, authorName));
209+
});
210+
}
211+
return posts;
212+
}
213+
144214
// Fixed function to generate thread text in a readable format
145215
function generateThreadText(thread) {
146216
const lines = [];
147-
217+
148218
function processPost(item, prefix = '1') {
149219
const author = item.post.author.displayName || item.post.author.handle;
150220
const text = item.post.record.text.replace(/\n/g, ' ');
151221
lines.push(`[${prefix}] ${author}: ${text}`);
152-
222+
153223
if (item.replies && item.replies.length > 0) {
154224
item.replies.forEach((reply, i) => {
155225
processPost(reply, `${prefix}.${i+1}`);
156226
});
157227
}
158228
}
159-
229+
160230
processPost(thread);
161231
return lines.join('\n\n');
162232
}
163233

234+
// Display post in thread view (nested)
235+
function displayPostThread(item, parent, depth = 1) {
236+
const el = document.createElement('div');
237+
el.className = `post depth-${Math.min(depth, 8)}`;
238+
el.id = getPostId(item.post.uri);
239+
const authorEl = document.createElement('div');
240+
authorEl.className = 'author';
241+
authorEl.textContent = `${item.post.author.displayName} (@${item.post.author.handle})`;
242+
const metaEl = document.createElement('div');
243+
metaEl.className = 'meta';
244+
metaEl.textContent = new Date(item.post.record.createdAt).toLocaleString();
245+
const link = document.createElement('a');
246+
link.href = `https://bsky.app/profile/${item.post.author.handle}/post/${item.post.uri.split('/').pop()}`;
247+
link.textContent = 'View';
248+
link.target = '_blank';
249+
metaEl.appendChild(link);
250+
const textEl = document.createElement('div');
251+
textEl.className = 'text';
252+
textEl.textContent = item.post.record.text;
253+
el.append(authorEl, metaEl, textEl);
254+
parent.appendChild(el);
255+
if (item.replies && item.replies.length) item.replies.forEach(reply => displayPostThread(reply, el, depth+1));
256+
}
257+
258+
// Display posts in chronological order (most recent first)
259+
function displayPostsChronological() {
260+
container.innerHTML = '';
261+
const sorted = [...allPosts].sort((a, b) => b.createdAt - a.createdAt);
262+
263+
sorted.forEach(postData => {
264+
const item = postData.item;
265+
const el = document.createElement('div');
266+
el.className = 'post depth-1';
267+
el.id = getPostId(item.post.uri);
268+
269+
// Add "in reply to" if this is a reply
270+
if (postData.parentUri) {
271+
const replyToEl = document.createElement('div');
272+
replyToEl.className = 'reply-to';
273+
const replyLink = document.createElement('a');
274+
replyLink.href = '#' + getPostId(postData.parentUri);
275+
replyLink.textContent = `in reply to ${postData.parentAuthor}`;
276+
replyLink.addEventListener('click', (e) => {
277+
e.preventDefault();
278+
const targetEl = document.getElementById(getPostId(postData.parentUri));
279+
if (targetEl) {
280+
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
281+
targetEl.classList.add('highlighted');
282+
setTimeout(() => targetEl.classList.remove('highlighted'), 2000);
283+
}
284+
});
285+
replyToEl.appendChild(replyLink);
286+
el.appendChild(replyToEl);
287+
}
288+
289+
const authorEl = document.createElement('div');
290+
authorEl.className = 'author';
291+
authorEl.textContent = `${item.post.author.displayName} (@${item.post.author.handle})`;
292+
const metaEl = document.createElement('div');
293+
metaEl.className = 'meta';
294+
metaEl.textContent = new Date(item.post.record.createdAt).toLocaleString();
295+
const link = document.createElement('a');
296+
link.href = `https://bsky.app/profile/${item.post.author.handle}/post/${item.post.uri.split('/').pop()}`;
297+
link.textContent = 'View';
298+
link.target = '_blank';
299+
metaEl.appendChild(link);
300+
const textEl = document.createElement('div');
301+
textEl.className = 'text';
302+
textEl.textContent = item.post.record.text;
303+
el.append(authorEl, metaEl, textEl);
304+
container.appendChild(el);
305+
});
306+
}
307+
308+
// Display thread view
309+
function displayThreadView() {
310+
container.innerHTML = '';
311+
if (lastThread) {
312+
displayPostThread(lastThread, container);
313+
}
314+
}
315+
316+
// Render current view
317+
function renderCurrentView() {
318+
if (currentView === 'thread') {
319+
displayThreadView();
320+
} else {
321+
displayPostsChronological();
322+
}
323+
}
324+
325+
// Tab click handlers
326+
viewTabs.addEventListener('click', (e) => {
327+
if (e.target.classList.contains('tab')) {
328+
const view = e.target.dataset.view;
329+
if (view !== currentView) {
330+
currentView = view;
331+
viewTabs.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
332+
e.target.classList.add('active');
333+
renderCurrentView();
334+
}
335+
}
336+
});
337+
164338
copyBtn.addEventListener('click', async () => {
165339
if (!lastThread) return;
166340
try {
@@ -191,7 +365,14 @@ <h1>Bluesky Thread Viewer</h1>
191365
e.preventDefault();
192366
container.innerHTML = '';
193367
copyContainer.style.display = 'none';
368+
viewTabs.style.display = 'none';
194369
lastThread = null;
370+
allPosts = [];
371+
currentView = 'thread';
372+
// Reset tabs to default
373+
viewTabs.querySelectorAll('.tab').forEach(t => {
374+
t.classList.toggle('active', t.dataset.view === 'thread');
375+
});
195376
// Update URL with the submitted post URL
196377
const newUrl = new URL(window.location);
197378
newUrl.searchParams.set('url', postUrl.value.trim());
@@ -221,31 +402,11 @@ <h1>Bluesky Thread Viewer</h1>
221402
const threadJson = await threadRes.json();
222403
if (threadJson.thread.$type === 'app.bsky.feed.defs#notFoundPost') throw new Error('Post not found');
223404

224-
function displayPost(item, parent, depth = 1) {
225-
const el = document.createElement('div');
226-
el.className = `post depth-${Math.min(depth, 8)}`;
227-
const authorEl = document.createElement('div');
228-
authorEl.className = 'author';
229-
authorEl.textContent = `${item.post.author.displayName} (@${item.post.author.handle})`;
230-
const metaEl = document.createElement('div');
231-
metaEl.className = 'meta';
232-
metaEl.textContent = new Date(item.post.record.createdAt).toLocaleString();
233-
const link = document.createElement('a');
234-
link.href = `https://bsky.app/profile/${item.post.author.handle}/post/${item.post.uri.split('/').pop()}`;
235-
link.textContent = 'View';
236-
link.target = '_blank';
237-
metaEl.appendChild(link);
238-
const textEl = document.createElement('div');
239-
textEl.className = 'text';
240-
textEl.textContent = item.post.record.text;
241-
el.append(authorEl, metaEl, textEl);
242-
parent.appendChild(el);
243-
if (item.replies && item.replies.length) item.replies.forEach(reply => displayPost(reply, el, depth+1));
244-
}
245-
246-
displayPost(threadJson.thread, container);
247405
lastThread = threadJson.thread;
406+
allPosts = flattenThread(threadJson.thread);
407+
renderCurrentView();
248408
copyContainer.style.display = 'block';
409+
viewTabs.style.display = 'block';
249410
} catch (err) {
250411
console.error(err);
251412
container.textContent = 'Error: ' + err.message;

0 commit comments

Comments
 (0)