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