···11+ Apache License
22+ Version 2.0, January 2004
33+ http://www.apache.org/licenses/
44+55+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
66+77+ 1. Definitions.
88+99+ "License" shall mean the terms and conditions for use, reproduction,
1010+ and distribution as defined by Sections 1 through 9 of this document.
1111+1212+ "Licensor" shall mean the copyright owner or entity authorized by
1313+ the copyright owner that is granting the License.
1414+1515+ "Legal Entity" shall mean the union of the acting entity and all
1616+ other entities that control, are controlled by, or are under common
1717+ control with that entity. For the purposes of this definition,
1818+ "control" means (i) the power, direct or indirect, to cause the
1919+ direction or management of such entity, whether by contract or
2020+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
2121+ outstanding shares, or (iii) beneficial ownership of such entity.
2222+2323+ "You" (or "Your") shall mean an individual or Legal Entity
2424+ exercising permissions granted by this License.
2525+2626+ "Source" form shall mean the preferred form for making modifications,
2727+ including but not limited to software source code, documentation
2828+ source, and configuration files.
2929+3030+ "Object" form shall mean any form resulting from mechanical
3131+ transformation or translation of a Source form, including but
3232+ not limited to compiled object code, generated documentation,
3333+ and conversions to other media types.
3434+3535+ "Work" shall mean the work of authorship, whether in Source or
3636+ Object form, made available under the License, as indicated by a
3737+ copyright notice that is included in or attached to the work
3838+ (an example is provided in the Appendix below).
3939+4040+ "Derivative Works" shall mean any work, whether in Source or Object
4141+ form, that is based on (or derived from) the Work and for which the
4242+ editorial revisions, annotations, elaborations, or other modifications
4343+ represent, as a whole, an original work of authorship. For the purposes
4444+ of this License, Derivative Works shall not include works that remain
4545+ separable from, or merely link (or bind by name) to the interfaces of,
4646+ the Work and Derivative Works thereof.
4747+4848+ "Contribution" shall mean any work of authorship, including
4949+ the original version of the Work and any modifications or additions
5050+ to that Work or Derivative Works thereof, that is intentionally
5151+ submitted to Licensor for inclusion in the Work by the copyright owner
5252+ or by an individual or Legal Entity authorized to submit on behalf of
5353+ the copyright owner. For the purposes of this definition, "submitted"
5454+ means any form of electronic, verbal, or written communication sent
5555+ to the Licensor or its representatives, including but not limited to
5656+ communication on electronic mailing lists, source code control systems,
5757+ and issue tracking systems that are managed by, or on behalf of, the
5858+ Licensor for the purpose of discussing and improving the Work, but
5959+ excluding communication that is conspicuously marked or otherwise
6060+ designated in writing by the copyright owner as "Not a Contribution."
6161+6262+ "Contributor" shall mean Licensor and any individual or Legal Entity
6363+ on behalf of whom a Contribution has been received by Licensor and
6464+ subsequently incorporated within the Work.
6565+6666+ 2. Grant of Copyright License. Subject to the terms and conditions of
6767+ this License, each Contributor hereby grants to You a perpetual,
6868+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
6969+ copyright license to reproduce, prepare Derivative Works of,
7070+ publicly display, publicly perform, sublicense, and distribute the
7171+ Work and such Derivative Works in Source or Object form.
7272+7373+ 3. Grant of Patent License. Subject to the terms and conditions of
7474+ this License, each Contributor hereby grants to You a perpetual,
7575+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
7676+ (except as stated in this section) patent license to make, have made,
7777+ use, offer to sell, sell, import, and otherwise transfer the Work,
7878+ where such license applies only to those patent claims licensable
7979+ by such Contributor that are necessarily infringed by their
8080+ Contribution(s) alone or by combination of their Contribution(s)
8181+ with the Work to which such Contribution(s) was submitted. If You
8282+ institute patent litigation against any entity (including a
8383+ cross-claim or counterclaim in a lawsuit) alleging that the Work
8484+ or a Contribution incorporated within the Work constitutes direct
8585+ or contributory patent infringement, then any patent licenses
8686+ granted to You under this License for that Work shall terminate
8787+ as of the date such litigation is filed.
8888+8989+ 4. Redistribution. You may reproduce and distribute copies of the
9090+ Work or Derivative Works thereof in any medium, with or without
9191+ modifications, and in Source or Object form, provided that You
9292+ meet the following conditions:
9393+9494+ (a) You must give any other recipients of the Work or
9595+ Derivative Works a copy of this License; and
9696+9797+ (b) You must cause any modified files to carry prominent notices
9898+ stating that You changed the files; and
9999+100100+ (c) You must retain, in the Source form of any Derivative Works
101101+ that You distribute, all copyright, patent, trademark, and
102102+ attribution notices from the Source form of the Work,
103103+ excluding those notices that do not pertain to any part of
104104+ the Derivative Works; and
105105+106106+ (d) If the Work includes a "NOTICE" text file as part of its
107107+ distribution, then any Derivative Works that You distribute must
108108+ include a readable copy of the attribution notices contained
109109+ within such NOTICE file, excluding those notices that do not
110110+ pertain to any part of the Derivative Works, in at least one
111111+ of the following places: within a NOTICE text file distributed
112112+ as part of the Derivative Works; within the Source form or
113113+ documentation, if provided along with the Derivative Works; or,
114114+ within a display generated by the Derivative Works, if and
115115+ wherever such third-party notices normally appear. The contents
116116+ of the NOTICE file are for informational purposes only and
117117+ do not modify the License. You may add Your own attribution
118118+ notices within Derivative Works that You distribute, alongside
119119+ or as an addendum to the NOTICE text from the Work, provided
120120+ that such additional attribution notices cannot be construed
121121+ as modifying the License.
122122+123123+ You may add Your own copyright statement to Your modifications and
124124+ may provide additional or different license terms and conditions
125125+ for use, reproduction, or distribution of Your modifications, or
126126+ for any such Derivative Works as a whole, provided Your use,
127127+ reproduction, and distribution of the Work otherwise complies with
128128+ the conditions stated in this License.
129129+130130+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131131+ any Contribution intentionally submitted for inclusion in the Work
132132+ by You to the Licensor shall be under the terms and conditions of
133133+ this License, without any additional terms or conditions.
134134+ Notwithstanding the above, nothing herein shall supersede or modify
135135+ the terms of any separate license agreement you may have executed
136136+ with Licensor regarding such Contributions.
137137+138138+ 6. Trademarks. This License does not grant permission to use the trade
139139+ names, trademarks, service marks, or product names of the Licensor,
140140+ except as required for reasonable and customary use in describing the
141141+ origin of the Work and reproducing the content of the NOTICE file.
142142+143143+ 7. Disclaimer of Warranty. Unless required by applicable law or
144144+ agreed to in writing, Licensor provides the Work (and each
145145+ Contributor provides its Contributions) on an "AS IS" BASIS,
146146+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147147+ implied, including, without limitation, any warranties or conditions
148148+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149149+ PARTICULAR PURPOSE. You are solely responsible for determining the
150150+ appropriateness of using or redistributing the Work and assume any
151151+ risks associated with Your exercise of permissions under this License.
152152+153153+ 8. Limitation of Liability. In no event and under no legal theory,
154154+ whether in tort (including negligence), contract, or otherwise,
155155+ unless required by applicable law (such as deliberate and grossly
156156+ negligent acts) or agreed to in writing, shall any Contributor be
157157+ liable to You for damages, including any direct, indirect, special,
158158+ incidental, or consequential damages of any character arising as a
159159+ result of this License or out of the use or inability to use the
160160+ Work (including but not limited to damages for loss of goodwill,
161161+ work stoppage, computer failure or malfunction, or any and all
162162+ other commercial damages or losses), even if such Contributor
163163+ has been advised of the possibility of such damages.
164164+165165+ 9. Accepting Warranty or Additional Liability. While redistributing
166166+ the Work or Derivative Works thereof, You may choose to offer,
167167+ and charge a fee for, acceptance of support, warranty, indemnity,
168168+ or other liability obligations and/or rights consistent with this
169169+ License. However, in accepting such obligations, You may act only
170170+ on Your own behalf and on Your sole responsibility, not on behalf
171171+ of any other Contributor, and only if You agree to indemnify,
172172+ defend, and hold each Contributor harmless for any liability
173173+ incurred by, or claims asserted against, such Contributor by reason
174174+ of your accepting any such warranty or additional liability.
175175+176176+ END OF TERMS AND CONDITIONS
177177+178178+ APPENDIX: How to apply the Apache License to your work.
179179+180180+ To apply the Apache License to your work, attach the following
181181+ boilerplate notice, with the fields enclosed by brackets "[]"
182182+ replaced with your own identifying information. (Don't include
183183+ the brackets!) The text should be enclosed in the appropriate
184184+ comment syntax for the file format. We also recommend that a
185185+ file or class name and description of purpose be included on the
186186+ same "printed page" as the copyright notice for easier
187187+ identification within third-party archives.
188188+189189+ Copyright [yyyy] [name of copyright owner]
190190+191191+ Licensed under the Apache License, Version 2.0 (the "License");
192192+ you may not use this file except in compliance with the License.
193193+ You may obtain a copy of the License at
194194+195195+ http://www.apache.org/licenses/LICENSE-2.0
196196+197197+ Unless required by applicable law or agreed to in writing, software
198198+ distributed under the License is distributed on an "AS IS" BASIS,
199199+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200200+ See the License for the specific language governing permissions and
201201+ limitations under the License.
···11+package main
22+33+import (
44+ "log"
55+ "net/http"
66+77+ "github.com/bluesky-social/indigo/api/atproto"
88+ bsky "github.com/bluesky-social/indigo/api/bsky"
99+ "github.com/bluesky-social/indigo/lex/util"
1010+)
1111+1212+func handleTimeline(w http.ResponseWriter, r *http.Request) {
1313+ log.Printf("DEBUG: handleTimeline called - Method: %s, URL: %s", r.Method, r.URL.Path)
1414+ c, didStr, err := getClientFromSession(r.Context(), r)
1515+ if err != nil {
1616+ http.Redirect(w, r, "/signin", http.StatusFound)
1717+ return
1818+ }
1919+2020+ profile, err := fetchProfile(r.Context(), c, didStr)
2121+ if err != nil {
2222+ http.Error(w, err.Error(), http.StatusInternalServerError)
2323+ return
2424+ }
2525+2626+ timeline, err := bsky.FeedGetTimeline(r.Context(), c, "", "", 50)
2727+ if err != nil {
2828+ http.Error(w, err.Error(), http.StatusInternalServerError)
2929+ return
3030+ }
3131+3232+ followsList := fetchFollows(r.Context(), c, didStr, 50)
3333+3434+ // Collect all unique parent URIs referenced in the timeline so we can batch-fetch them once
3535+ parentURIsSet := map[string]struct{}{}
3636+ if timeline != nil && timeline.Feed != nil {
3737+ for _, fv := range timeline.Feed {
3838+ if fv == nil || fv.Post == nil {
3939+ continue
4040+ }
4141+ if uri := extractReplyParentURI(fv.Post); uri != "" {
4242+ parentURIsSet[uri] = struct{}{}
4343+ }
4444+ // also include any root refs from the post record if present
4545+ chain := GetReplyChainInfos(fv.Post)
4646+ for _, pi := range chain {
4747+ if pi.Uri != "" {
4848+ parentURIsSet[pi.Uri] = struct{}{}
4949+ }
5050+ }
5151+ }
5252+ }
5353+5454+ var parentURIs []string
5555+ for u := range parentURIsSet {
5656+ parentURIs = append(parentURIs, u)
5757+ }
5858+5959+ // Batch fetch parent posts (API limits 25 URIs per request)
6060+ parentPreviews := map[string]ParentInfo{}
6161+ if len(parentURIs) > 0 {
6262+ const batchSize = 25
6363+ for i := 0; i < len(parentURIs); i += batchSize {
6464+ end := i + batchSize
6565+ if end > len(parentURIs) {
6666+ end = len(parentURIs)
6767+ }
6868+ batch := parentURIs[i:end]
6969+ postsMap, err := fetchPostsBatch(r.Context(), c, batch)
7070+ if err != nil {
7171+ log.Printf("DEBUG: handleTimeline - fetchPostsBatch error: %v", err)
7272+ continue
7373+ }
7474+ for uri, pv := range postsMap {
7575+ if pv == nil {
7676+ continue
7777+ }
7878+ pi := ParentInfo{Uri: uri}
7979+ // fill author handle/name if present
8080+ if pv.Author != nil {
8181+ if pv.Author.DisplayName != nil && *pv.Author.DisplayName != "" {
8282+ pi.AuthorName = *pv.Author.DisplayName
8383+ } else if pv.Author.Handle != "" {
8484+ pi.AuthorName = pv.Author.Handle
8585+ }
8686+ if pv.Author.Handle != "" {
8787+ pi.AuthorHandle = pv.Author.Handle
8888+ }
8989+ // populate avatar if available
9090+ if pv.Author.Avatar != nil {
9191+ pi.Avatar = *pv.Author.Avatar
9292+ }
9393+ }
9494+ // fill text
9595+ pi.Text = getPostText(pv.Record)
9696+ // link URL and posted time if available
9797+ if pv.Uri != "" {
9898+ pi.PostURL = getPostURL(pv)
9999+ }
100100+ pi.IndexedAt = pv.IndexedAt
101101+ if m := GetPostMedia(pv); m != nil {
102102+ pi.Media = m
103103+ }
104104+ // like count if available
105105+ if pv.LikeCount != nil {
106106+ pi.LikeCount = int(*pv.LikeCount)
107107+ }
108108+ // viewer state: whether the signed-in viewer liked this post
109109+ pi.IsFav = getIsFav(pv)
110110+ parentPreviews[uri] = pi
111111+ }
112112+ }
113113+ }
114114+115115+ postsList := PostsList{Items: timeline.Feed, Cursor: getCursorFromTimeline(timeline), ParentPreviews: parentPreviews}
116116+117117+ data := TimelinePageData{
118118+ Title: "Timeline - Tuiter 2006",
119119+ CurrentUser: profile,
120120+ Profile: profile,
121121+ Timeline: timeline,
122122+ Follows: followsList,
123123+ Posts: postsList,
124124+ PostBoxHandle: "",
125125+ // SignedIn should point to the logged-in profile
126126+ SignedIn: profile,
127127+ }
128128+129129+ executeTemplate(w, "timeline.html", data)
130130+}
131131+132132+func handleReply(w http.ResponseWriter, r *http.Request) {
133133+ log.Printf("DEBUG: handleReply called - Method: %s, URL: %s", r.Method, r.URL.Path)
134134+ if r.Method != http.MethodPost {
135135+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
136136+ return
137137+ }
138138+139139+ c, didStr, err := getClientFromSession(r.Context(), r)
140140+ if err != nil {
141141+ http.Redirect(w, r, "/signin", http.StatusFound)
142142+ return
143143+ }
144144+145145+ if err := r.ParseForm(); err != nil {
146146+ http.Error(w, "Failed to parse form data", http.StatusInternalServerError)
147147+ return
148148+ }
149149+150150+ replyTo := r.FormValue("reply-to")
151151+ status := r.FormValue("status")
152152+ if replyTo == "" || status == "" {
153153+ http.Error(w, "reply-to and status are required", http.StatusBadRequest)
154154+ return
155155+ }
156156+157157+ postsView, err := bsky.FeedGetPosts(r.Context(), c, []string{replyTo})
158158+ if err != nil || len(postsView.Posts) == 0 {
159159+ log.Printf("DEBUG: handleReply - Error fetching original post: %v", err)
160160+ http.Error(w, "Original post not found", http.StatusNotFound)
161161+ return
162162+ }
163163+164164+ postView := postsView.Posts[0]
165165+ post := &bsky.FeedPost{
166166+ Text: status,
167167+ CreatedAt: "",
168168+ Reply: &bsky.FeedPost_ReplyRef{
169169+ Root: &atproto.RepoStrongRef{Uri: postView.Uri, Cid: postView.Cid},
170170+ Parent: &atproto.RepoStrongRef{Uri: postView.Uri, Cid: postView.Cid},
171171+ },
172172+ }
173173+174174+ resp, err := atproto.RepoCreateRecord(r.Context(), c, &atproto.RepoCreateRecord_Input{
175175+ Collection: "app.bsky.feed.post",
176176+ Repo: didStr,
177177+ Record: &util.LexiconTypeDecoder{Val: post},
178178+ })
179179+ if err != nil {
180180+ http.Error(w, "Failed to create reply: "+err.Error(), http.StatusInternalServerError)
181181+ return
182182+ }
183183+184184+ log.Println("Created reply:", resp.Uri)
185185+ http.Redirect(w, r, "/post?uri="+replyTo, http.StatusFound)
186186+}
+156
handlers_view.go
···11+package main
22+33+import (
44+ "log"
55+ "net/http"
66+ "strings"
77+88+ bsky "github.com/bluesky-social/indigo/api/bsky"
99+)
1010+1111+func handlePost(w http.ResponseWriter, r *http.Request) {
1212+ c, didStr, err := getClientFromSession(r.Context(), r)
1313+ if err != nil {
1414+ http.Redirect(w, r, "/signin", http.StatusFound)
1515+ return
1616+ }
1717+1818+ data, err := preparePostPageData(r.Context(), r, c, didStr)
1919+ if err != nil {
2020+ http.Error(w, "Failed to prepare post page: "+err.Error(), http.StatusInternalServerError)
2121+ return
2222+ }
2323+2424+ executeTemplate(w, "post.html", data)
2525+}
2626+2727+func handleProfile(w http.ResponseWriter, r *http.Request) {
2828+ c, myDid, err := getClientFromSession(r.Context(), r)
2929+ if err != nil {
3030+ http.Redirect(w, r, "/signin", http.StatusFound)
3131+ return
3232+ }
3333+3434+ path := strings.TrimPrefix(r.URL.Path, "/profile/")
3535+ profileHandle := path
3636+ if profileHandle == "" {
3737+ profileHandle = myDid
3838+ }
3939+4040+ profileView, err := fetchProfile(r.Context(), c, profileHandle)
4141+ if err != nil {
4242+ http.Error(w, err.Error(), http.StatusInternalServerError)
4343+ return
4444+ }
4545+ if profileView == nil {
4646+ http.Error(w, "Profile not found", http.StatusNotFound)
4747+ return
4848+ }
4949+5050+ authorFeed, err := bsky.FeedGetAuthorFeed(r.Context(), c, profileView.Did, "", "", false, 50)
5151+ if err != nil {
5252+ http.Error(w, err.Error(), http.StatusInternalServerError)
5353+ return
5454+ }
5555+5656+ myProfile, err := fetchProfile(r.Context(), c, myDid)
5757+ if err != nil {
5858+ http.Error(w, err.Error(), http.StatusInternalServerError)
5959+ return
6060+ }
6161+6262+ followsList := fetchFollows(r.Context(), c, profileView.Did, 50)
6363+6464+ postBoxHandle := ""
6565+ if profileView.Handle != "" {
6666+ postBoxHandle = profileView.Handle
6767+ }
6868+6969+ // Collect parent URIs from the author's feed and batch-fetch previews
7070+ parentURIsSet := map[string]struct{}{}
7171+ if authorFeed != nil && authorFeed.Feed != nil {
7272+ for _, fv := range authorFeed.Feed {
7373+ if fv == nil || fv.Post == nil {
7474+ continue
7575+ }
7676+ if uri := extractReplyParentURI(fv.Post); uri != "" {
7777+ parentURIsSet[uri] = struct{}{}
7878+ }
7979+ chain := GetReplyChainInfos(fv.Post)
8080+ for _, pi := range chain {
8181+ if pi.Uri != "" {
8282+ parentURIsSet[pi.Uri] = struct{}{}
8383+ }
8484+ }
8585+ }
8686+ }
8787+8888+ var parentURIs []string
8989+ for u := range parentURIsSet {
9090+ parentURIs = append(parentURIs, u)
9191+ }
9292+9393+ parentPreviews := map[string]ParentInfo{}
9494+ if len(parentURIs) > 0 {
9595+ const batchSize = 25
9696+ for i := 0; i < len(parentURIs); i += batchSize {
9797+ end := i + batchSize
9898+ if end > len(parentURIs) {
9999+ end = len(parentURIs)
100100+ }
101101+ batch := parentURIs[i:end]
102102+ postsMap, err := fetchPostsBatch(r.Context(), c, batch)
103103+ if err != nil {
104104+ log.Printf("DEBUG: handleProfile - fetchPostsBatch error: %v", err)
105105+ continue
106106+ }
107107+ for uri, pv := range postsMap {
108108+ if pv == nil {
109109+ continue
110110+ }
111111+ pi := ParentInfo{Uri: uri}
112112+ if pv.Author != nil {
113113+ if pv.Author.DisplayName != nil && *pv.Author.DisplayName != "" {
114114+ pi.AuthorName = *pv.Author.DisplayName
115115+ } else if pv.Author.Handle != "" {
116116+ pi.AuthorName = pv.Author.Handle
117117+ }
118118+ if pv.Author.Handle != "" {
119119+ pi.AuthorHandle = pv.Author.Handle
120120+ }
121121+ if pv.Author.Avatar != nil {
122122+ pi.Avatar = *pv.Author.Avatar
123123+ }
124124+ }
125125+ pi.Text = getPostText(pv.Record)
126126+ if pv.Uri != "" {
127127+ pi.PostURL = getPostURL(pv)
128128+ }
129129+ pi.IndexedAt = pv.IndexedAt
130130+ // populate media preview if present
131131+ if m := GetPostMedia(pv); m != nil {
132132+ pi.Media = m
133133+ }
134134+ // like count if available
135135+ if pv.LikeCount != nil {
136136+ pi.LikeCount = int(*pv.LikeCount)
137137+ }
138138+ pi.IsFav = getIsFav(pv)
139139+ parentPreviews[uri] = pi
140140+ }
141141+ }
142142+ }
143143+144144+ data := ProfilePageData{
145145+ Title: "Profile - Tuiter 2006",
146146+ Profile: profileView,
147147+ Feed: authorFeed,
148148+ Follows: followsList,
149149+ Posts: PostsList{Items: authorFeed.Feed, Cursor: getCursorFromAuthorFeed(authorFeed), ParentPreviews: parentPreviews},
150150+ PostBoxHandle: postBoxHandle,
151151+ // provide the signed-in profile explicitly
152152+ SignedIn: myProfile,
153153+ }
154154+155155+ executeTemplate(w, "profile.html", data)
156156+}
+840
helpers.go
···11+package main
22+33+import (
44+ "context"
55+ "fmt"
66+ "log"
77+ "net/http"
88+ "regexp"
99+ "strings"
1010+1111+ "github.com/bluesky-social/indigo/api/atproto"
1212+ bsky "github.com/bluesky-social/indigo/api/bsky"
1313+ "github.com/bluesky-social/indigo/atproto/client"
1414+ "github.com/bluesky-social/indigo/atproto/syntax"
1515+ "github.com/bluesky-social/indigo/lex/util"
1616+)
1717+1818+// PostsList is a small, type-safe wrapper passed to templates to render lists of posts
1919+// and provide optional pagination cursor in a single place.
2020+type PostsList struct {
2121+ Items []*bsky.FeedDefs_FeedViewPost
2222+ Cursor string
2323+ // ParentPreviews holds pre-fetched ParentInfo keyed by parent URI. Handlers should populate
2424+ // this map by collecting all reply-ref URIs and calling fetchPostsBatch once.
2525+ ParentPreviews map[string]ParentInfo
2626+}
2727+2828+func getPostText(record *util.LexiconTypeDecoder) string {
2929+ if record == nil || record.Val == nil {
3030+ log.Printf("DEBUG: getPostText - record or record.Val is nil")
3131+ return "[Post content unavailable]"
3232+ }
3333+ if post, ok := record.Val.(*bsky.FeedPost); ok && post != nil {
3434+ return post.Text
3535+ }
3636+ log.Printf("DEBUG: getPostText - unable to extract text from record type: %T", record.Val)
3737+ return "[Post content unavailable]"
3838+}
3939+4040+func resolveHandleToDID(ctx context.Context, c *client.APIClient, identifier string) (string, error) {
4141+ if strings.HasPrefix(identifier, "did:") {
4242+ return identifier, nil
4343+ }
4444+ profile, err := bsky.ActorGetProfile(ctx, c, identifier)
4545+ if err != nil {
4646+ log.Printf("DEBUG: resolveHandleToDID - error resolving handle %s: %v", identifier, err)
4747+ return "", err
4848+ }
4949+ return profile.Did, nil
5050+}
5151+5252+func executeTemplate(w http.ResponseWriter, templateName string, data interface{}) {
5353+ // Ensure templates that reference .SignedIn won't panic when handlers pass nil
5454+ if data == nil {
5555+ // minimal typed wrapper with SignedIn nil
5656+ data = struct {
5757+ SignedIn *bsky.ActorDefs_ProfileViewDetailed
5858+ }{}
5959+ }
6060+ if err := tpl.ExecuteTemplate(w, templateName, data); err != nil {
6161+ log.Printf("Template execution error for %s: %v", templateName, err)
6262+ http.Error(w, "Internal server error", http.StatusInternalServerError)
6363+ }
6464+}
6565+6666+func getIntFromProfile(obj interface{}, keys []string) int {
6767+ if obj == nil {
6868+ return 0
6969+ }
7070+ switch p := obj.(type) {
7171+ case *bsky.ActorDefs_ProfileViewDetailed:
7272+ if p == nil {
7373+ return 0
7474+ }
7575+ for _, k := range keys {
7676+ switch k {
7777+ case "followersCount", "followers_count", "followers":
7878+ if p.FollowersCount != nil {
7979+ return int(*p.FollowersCount)
8080+ }
8181+ case "followsCount", "follows_count", "following", "follows":
8282+ if p.FollowsCount != nil {
8383+ return int(*p.FollowsCount)
8484+ }
8585+ case "postsCount", "posts_count", "posts":
8686+ if p.PostsCount != nil {
8787+ return int(*p.PostsCount)
8888+ }
8989+ }
9090+ }
9191+ default:
9292+ return 0
9393+ }
9494+ return 0
9595+}
9696+9797+func getDisplayNameFromProfile(obj interface{}) string {
9898+ if obj == nil {
9999+ return ""
100100+ }
101101+ switch p := obj.(type) {
102102+ case *bsky.ActorDefs_ProfileViewDetailed:
103103+ if p == nil {
104104+ return ""
105105+ }
106106+ if p.DisplayName != nil && *p.DisplayName != "" {
107107+ return *p.DisplayName
108108+ }
109109+ if p.Handle != "" {
110110+ return p.Handle
111111+ }
112112+ return ""
113113+ case *bsky.ActorDefs_ProfileView:
114114+ if p == nil {
115115+ return ""
116116+ }
117117+ if p.DisplayName != nil && *p.DisplayName != "" {
118118+ return *p.DisplayName
119119+ }
120120+ if p.Handle != "" {
121121+ return p.Handle
122122+ }
123123+ return ""
124124+ case *bsky.ActorDefs_ProfileViewBasic:
125125+ if p == nil {
126126+ return ""
127127+ }
128128+ if p.DisplayName != nil && *p.DisplayName != "" {
129129+ return *p.DisplayName
130130+ }
131131+ if p.Handle != "" {
132132+ return p.Handle
133133+ }
134134+ return ""
135135+ default:
136136+ return ""
137137+ }
138138+}
139139+140140+func getClientFromSession(ctx context.Context, r *http.Request) (*client.APIClient, string, error) {
141141+ session, _ := store.Get(r, sessionName)
142142+ didStr, ok := session.Values["did"].(string)
143143+ if !ok || didStr == "" {
144144+ return nil, "", fmt.Errorf("not logged in")
145145+ }
146146+ sessionID, ok := session.Values["session_id"].(string)
147147+ if !ok || sessionID == "" {
148148+ return nil, "", fmt.Errorf("not logged in")
149149+ }
150150+ did, err := syntax.ParseDID(didStr)
151151+ if err != nil {
152152+ return nil, "", err
153153+ }
154154+ sess, err := oauthApp.ResumeSession(ctx, did, sessionID)
155155+ if err != nil {
156156+ return nil, "", err
157157+ }
158158+ return sess.APIClient(), didStr, nil
159159+}
160160+161161+func fetchFollows(ctx context.Context, c *client.APIClient, did string, limit int64) []*bsky.ActorDefs_ProfileView {
162162+ follows, err := bsky.GraphGetFollows(ctx, c, did, "", limit)
163163+ if err != nil {
164164+ log.Printf("DEBUG: fetchFollows - error fetching follows for %s: %v", did, err)
165165+ return nil
166166+ }
167167+ if follows == nil || follows.Follows == nil {
168168+ return nil
169169+ }
170170+ return follows.Follows
171171+}
172172+173173+func fetchProfile(ctx context.Context, c *client.APIClient, idOrHandle string) (*bsky.ActorDefs_ProfileViewDetailed, error) {
174174+ if idOrHandle == "" {
175175+ return nil, fmt.Errorf("empty identifier")
176176+ }
177177+ if !strings.HasPrefix(idOrHandle, "did:") {
178178+ resolved, err := resolveHandleToDID(ctx, c, idOrHandle)
179179+ if err != nil {
180180+ return nil, err
181181+ }
182182+ idOrHandle = resolved
183183+ }
184184+ profile, err := bsky.ActorGetProfile(ctx, c, idOrHandle)
185185+ if err != nil {
186186+ return nil, err
187187+ }
188188+ return profile, nil
189189+}
190190+191191+func getCursorFromTimeline(t *bsky.FeedGetTimeline_Output) string {
192192+ if t == nil {
193193+ return ""
194194+ }
195195+ if t.Cursor != nil {
196196+ return *t.Cursor
197197+ }
198198+ return ""
199199+}
200200+201201+func getCursorFromAuthorFeed(f *bsky.FeedGetAuthorFeed_Output) string {
202202+ if f == nil {
203203+ return ""
204204+ }
205205+ if f.Cursor != nil {
206206+ return *f.Cursor
207207+ }
208208+ return ""
209209+}
210210+211211+func getCursorFromAny(v interface{}) string {
212212+ switch t := v.(type) {
213213+ case *bsky.FeedGetTimeline_Output:
214214+ return getCursorFromTimeline(t)
215215+ case *bsky.FeedGetAuthorFeed_Output:
216216+ return getCursorFromAuthorFeed(t)
217217+ default:
218218+ return ""
219219+ }
220220+}
221221+222222+func getProfileURL(actor interface{}) string {
223223+ switch a := actor.(type) {
224224+ case *bsky.ActorDefs_ProfileView:
225225+ if a != nil && a.Handle != "" {
226226+ return "/profile/" + a.Handle
227227+ }
228228+ case *bsky.ActorDefs_ProfileViewBasic:
229229+ if a != nil && a.Handle != "" {
230230+ return "/profile/" + a.Handle
231231+ }
232232+ case *bsky.ActorDefs_ProfileViewDetailed:
233233+ if a != nil && a.Handle != "" {
234234+ return "/profile/" + a.Handle
235235+ }
236236+ }
237237+ return "#"
238238+}
239239+240240+func getPostURL(post *bsky.FeedDefs_PostView) string {
241241+ if post != nil && post.Author != nil && post.Author.Handle != "" && post.Uri != "" {
242242+ uriParts := strings.Split(post.Uri, "/")
243243+ if len(uriParts) >= 4 {
244244+ postID := uriParts[len(uriParts)-1]
245245+ return "/post/" + post.Author.Handle + "/" + postID
246246+ }
247247+ }
248248+ return "#"
249249+}
250250+251251+func getFollowingCount(actor interface{}) int {
252252+ return getIntFromProfile(actor, []string{"followsCount", "follows_count", "following", "follows"})
253253+}
254254+func getFollowersCount(actor interface{}) int {
255255+ return getIntFromProfile(actor, []string{"followersCount", "followers_count", "followers"})
256256+}
257257+func getPostsCount(actor interface{}) int {
258258+ return getIntFromProfile(actor, []string{"postsCount", "posts_count", "posts"})
259259+}
260260+261261+// Post type helpers
262262+263263+type PostType int
264264+265265+const (
266266+ PostTypeAuthored PostType = iota
267267+ PostTypeRetweet
268268+ PostTypeQuote
269269+)
270270+271271+func GetPostType(fvp *bsky.FeedDefs_FeedViewPost) PostType {
272272+ if fvp == nil || fvp.Post == nil {
273273+ return PostTypeAuthored
274274+ }
275275+ if fvp.Reason != nil && fvp.Reason.FeedDefs_ReasonRepost != nil {
276276+ return PostTypeRetweet
277277+ }
278278+ if fvp.Post.Embed != nil && fvp.Post.Embed.EmbedRecord_View != nil {
279279+ return PostTypeQuote
280280+ }
281281+ return PostTypeAuthored
282282+}
283283+284284+func GetPostPrefix(fvp *bsky.FeedDefs_FeedViewPost) string {
285285+ switch GetPostType(fvp) {
286286+ case PostTypeRetweet:
287287+ return "RT"
288288+ case PostTypeQuote:
289289+ return "QT"
290290+ default:
291291+ return ""
292292+ }
293293+}
294294+295295+// Convenience boolean helpers for templates
296296+func IsPostRetweet(item interface{}) bool {
297297+ switch it := item.(type) {
298298+ case *bsky.FeedDefs_FeedViewPost:
299299+ return GetPostType(it) == PostTypeRetweet
300300+ default:
301301+ return false
302302+ }
303303+}
304304+305305+func IsPostQuote(item interface{}) bool {
306306+ switch it := item.(type) {
307307+ case *bsky.FeedDefs_FeedViewPost:
308308+ return GetPostType(it) == PostTypeQuote
309309+ default:
310310+ return false
311311+ }
312312+}
313313+314314+// Embed helpers
315315+316316+type EmbedRecordViewRecord struct {
317317+ Author interface{}
318318+ Value *util.LexiconTypeDecoder
319319+}
320320+321321+func GetEmbedRecord(post *bsky.FeedDefs_PostView) *EmbedRecordViewRecord {
322322+ if post == nil || post.Embed == nil || post.Embed.EmbedRecord_View == nil || post.Embed.EmbedRecord_View.Record == nil {
323323+ return nil
324324+ }
325325+ recordWrapper := post.Embed.EmbedRecord_View.Record
326326+ if recordWrapper.EmbedRecord_ViewRecord == nil {
327327+ return nil
328328+ }
329329+ rr := recordWrapper.EmbedRecord_ViewRecord
330330+ return &EmbedRecordViewRecord{Author: rr.Author, Value: rr.Value}
331331+}
332332+333333+type EmbedTemplateContext struct {
334334+ Parent *bsky.FeedDefs_PostView
335335+ Embed *EmbedRecordViewRecord
336336+}
337337+338338+func embedContext(parent *bsky.FeedDefs_PostView, embed *EmbedRecordViewRecord) *EmbedTemplateContext {
339339+ return &EmbedTemplateContext{Parent: parent, Embed: embed}
340340+}
341341+342342+// Small avatar/banner helpers to keep templates simple and avoid repeating conditionals.
343343+func AvatarURL(actor interface{}) string {
344344+ switch a := actor.(type) {
345345+ case *bsky.ActorDefs_ProfileView:
346346+ if a != nil && a.Avatar != nil {
347347+ return *a.Avatar
348348+ }
349349+ case *bsky.ActorDefs_ProfileViewBasic:
350350+ if a != nil && a.Avatar != nil {
351351+ return *a.Avatar
352352+ }
353353+ case *bsky.ActorDefs_ProfileViewDetailed:
354354+ if a != nil && a.Avatar != nil {
355355+ return *a.Avatar
356356+ }
357357+ case *bsky.FeedDefs_PostView:
358358+ // allow passing a PostView directly
359359+ if a != nil && a.Author != nil && a.Author.Avatar != nil {
360360+ return *a.Author.Avatar
361361+ }
362362+ }
363363+ return ""
364364+}
365365+366366+func HasAvatar(actor interface{}) bool {
367367+ return AvatarURL(actor) != ""
368368+}
369369+370370+func BannerURL(actor interface{}) string {
371371+ switch a := actor.(type) {
372372+ case *bsky.ActorDefs_ProfileViewDetailed:
373373+ if a != nil && a.Banner != nil {
374374+ return *a.Banner
375375+ }
376376+ }
377377+ return ""
378378+}
379379+380380+// Post box helpers
381381+func PostBoxInitial(handle string) string {
382382+ if handle == "" {
383383+ return ""
384384+ }
385385+ return "@" + handle + " "
386386+}
387387+388388+func PostBoxPlaceholder(handle string) string {
389389+ if handle == "" {
390390+ return "What are you doing?"
391391+ }
392392+ return "Mention " + handle
393393+}
394394+395395+// PostVM is a small, template-friendly view model for posts.
396396+type PostVM struct {
397397+ AuthorDisplayName string
398398+ AuthorHandle string
399399+ AuthorAvatar string
400400+ Text string
401401+ PostURL string
402402+ IndexedAt string
403403+ ReplyCount int
404404+ IsQuote bool
405405+ IsRetweet bool
406406+ ParentPost *bsky.FeedDefs_PostView
407407+ EmbedRecord *EmbedRecordViewRecord
408408+ Raw *bsky.FeedDefs_FeedViewPost // keep raw for advanced helpers if needed
409409+}
410410+411411+// BuildPostVM converts a typed feed view post into a PostVM for templates.
412412+func BuildPostVM(ctx context.Context, item *bsky.FeedDefs_FeedViewPost) *PostVM {
413413+ if item == nil || item.Post == nil {
414414+ return nil
415415+ }
416416+ post := item.Post
417417+ vm := &PostVM{Raw: item}
418418+ // author
419419+ if post.Author != nil {
420420+ if post.Author.Handle != "" {
421421+ vm.AuthorHandle = post.Author.Handle
422422+ }
423423+ if post.Author.DisplayName != nil && *post.Author.DisplayName != "" {
424424+ vm.AuthorDisplayName = *post.Author.DisplayName
425425+ } else if post.Author.Handle != "" {
426426+ vm.AuthorDisplayName = post.Author.Handle
427427+ }
428428+ if post.Author.Avatar != nil && *post.Author.Avatar != "" {
429429+ vm.AuthorAvatar = *post.Author.Avatar
430430+ }
431431+ }
432432+ // text and metadata
433433+ vm.Text = getPostText(post.Record)
434434+ vm.PostURL = getPostURL(post)
435435+ vm.IndexedAt = post.IndexedAt
436436+ if post.ReplyCount != nil {
437437+ vm.ReplyCount = int(*post.ReplyCount)
438438+ }
439439+ // embed / type
440440+ vm.IsRetweet = item.Reason != nil && item.Reason.FeedDefs_ReasonRepost != nil
441441+ vm.IsQuote = post.Embed != nil && post.Embed.EmbedRecord_View != nil
442442+ // parent and embed record
443443+ if post != nil {
444444+ vm.ParentPost = post
445445+ }
446446+ if vm.IsQuote {
447447+ vm.EmbedRecord = GetEmbedRecord(post)
448448+ }
449449+ return vm
450450+}
451451+452452+// Helper wrapper exposed to templates: convert interface{} (feed item) to *PostVM
453453+func buildPostVMForTemplate(item interface{}) *PostVM {
454454+ switch it := item.(type) {
455455+ case *bsky.FeedDefs_FeedViewPost:
456456+ return BuildPostVM(context.Background(), it)
457457+ default:
458458+ log.Printf("DEBUG: buildPostVMForTemplate - unexpected type %T", item)
459459+ return nil
460460+ }
461461+}
462462+463463+// Add helper to expose LikeCount safely to templates.
464464+func getLikeCount(post *bsky.FeedDefs_PostView) int {
465465+ if post == nil || post.LikeCount == nil {
466466+ return 0
467467+ }
468468+ return int(*post.LikeCount)
469469+}
470470+471471+// MakeElementID converts an at:// URI into a safe DOM id (alphanumeric and dashes)
472472+func MakeElementID(uri string) string {
473473+ if uri == "" {
474474+ return ""
475475+ }
476476+ // remove scheme prefix if present
477477+ uri = strings.TrimPrefix(uri, "at://")
478478+ // replace non-alphanumeric characters with dash
479479+ re := regexp.MustCompile(`[^a-zA-Z0-9]+`)
480480+ id := re.ReplaceAllString(uri, "-")
481481+ // ensure doesn't start with digit-only? keep as-is
482482+ return "post-" + strings.Trim(id, "-")
483483+}
484484+485485+// ThreadNodeWrapper bundles a ThreadViewPost with the ViewedURI so templates can access both typed values safely.
486486+type ThreadNodeWrapper struct {
487487+ Post *bsky.FeedDefs_ThreadViewPost
488488+ ViewedURI string
489489+}
490490+491491+// wrapThread is a template helper that wraps a ThreadViewPost with the current viewed URI.
492492+func wrapThread(n *bsky.FeedDefs_ThreadViewPost, viewedURI string) ThreadNodeWrapper {
493493+ return ThreadNodeWrapper{Post: n, ViewedURI: viewedURI}
494494+}
495495+496496+// HasItems is a tiny helper to ask if a PostsList has items; keeps templates readable.
497497+func HasItems(pl *PostsList) bool {
498498+ return pl != nil && len(pl.Items) > 0
499499+}
500500+501501+// Media view models for templates
502502+type ImageVM struct {
503503+ Thumb string
504504+ Full string
505505+ Alt string
506506+}
507507+508508+type VideoVM struct {
509509+ Thumb string
510510+ Cid string
511511+ Playlist string
512512+ OwnerDid string
513513+}
514514+515515+type ExternalVM struct {
516516+ Title string
517517+ Description string
518518+ Thumb string
519519+ Uri string
520520+}
521521+522522+type MediaVM struct {
523523+ Images []ImageVM
524524+ Video *VideoVM
525525+ External *ExternalVM
526526+}
527527+528528+// GetPostMedia inspects a post's embed fields and returns a small, typed
529529+// MediaVM suitable for templates. It supports images, videos (thumbnail only)
530530+// and external link previews. Returns nil if no media present.
531531+func GetPostMedia(post *bsky.FeedDefs_PostView) *MediaVM {
532532+ if post == nil || post.Embed == nil {
533533+ return nil
534534+ }
535535+ m := &MediaVM{}
536536+537537+ // top-level images
538538+ if post.Embed.EmbedImages_View != nil && post.Embed.EmbedImages_View.Images != nil {
539539+ for _, im := range post.Embed.EmbedImages_View.Images {
540540+ if im == nil {
541541+ continue
542542+ }
543543+ m.Images = append(m.Images, ImageVM{Thumb: im.Thumb, Full: im.Fullsize, Alt: im.Alt})
544544+ }
545545+ }
546546+547547+ // recordWithMedia (nested media inside an embedded record)
548548+ if post.Embed.EmbedRecordWithMedia_View != nil && post.Embed.EmbedRecordWithMedia_View.Media != nil {
549549+ mm := post.Embed.EmbedRecordWithMedia_View.Media
550550+ if mm.EmbedImages_View != nil && mm.EmbedImages_View.Images != nil {
551551+ for _, im := range mm.EmbedImages_View.Images {
552552+ if im == nil {
553553+ continue
554554+ }
555555+ m.Images = append(m.Images, ImageVM{Thumb: im.Thumb, Full: im.Fullsize, Alt: im.Alt})
556556+ }
557557+ }
558558+ if mm.EmbedVideo_View != nil {
559559+ v := mm.EmbedVideo_View
560560+ var thumb string
561561+ if v.Thumbnail != nil {
562562+ thumb = *v.Thumbnail
563563+ }
564564+ ownerDid := ""
565565+ if post.Author != nil {
566566+ ownerDid = post.Author.Did
567567+ }
568568+ m.Video = &VideoVM{Thumb: thumb, Cid: v.Cid, Playlist: v.Playlist, OwnerDid: ownerDid}
569569+ }
570570+ if mm.EmbedExternal_View != nil && mm.EmbedExternal_View.External != nil {
571571+ ex := mm.EmbedExternal_View.External
572572+ extVM := &ExternalVM{Title: ex.Title, Description: ex.Description, Uri: ex.Uri}
573573+ if ex.Thumb != nil {
574574+ extVM.Thumb = *ex.Thumb
575575+ }
576576+ m.External = extVM
577577+ }
578578+ }
579579+580580+ // top-level external
581581+ if post.Embed.EmbedExternal_View != nil && post.Embed.EmbedExternal_View.External != nil {
582582+ ex := post.Embed.EmbedExternal_View.External
583583+ extVM := &ExternalVM{Title: ex.Title, Description: ex.Description, Uri: ex.Uri}
584584+ if ex.Thumb != nil {
585585+ extVM.Thumb = *ex.Thumb
586586+ }
587587+ m.External = extVM
588588+ }
589589+590590+ // top-level video view
591591+ if post.Embed.EmbedVideo_View != nil {
592592+ v := post.Embed.EmbedVideo_View
593593+ var thumb string
594594+ if v.Thumbnail != nil {
595595+ thumb = *v.Thumbnail
596596+ }
597597+ ownerDid := ""
598598+ if post.Author != nil {
599599+ ownerDid = post.Author.Did
600600+ }
601601+ m.Video = &VideoVM{Thumb: thumb, Cid: v.Cid, Playlist: v.Playlist, OwnerDid: ownerDid}
602602+ }
603603+604604+ if len(m.Images) == 0 && m.Video == nil && m.External == nil {
605605+ return nil
606606+ }
607607+ return m
608608+}
609609+610610+// GetMediaForTemplate accepts either a *bsky.FeedDefs_PostView or a *MediaVM and returns a *MediaVM
611611+// This lets templates call a single helper when they may have either the full PostView or a precomputed MediaVM.
612612+func GetMediaForTemplate(v interface{}) *MediaVM {
613613+ if v == nil {
614614+ return nil
615615+ }
616616+ switch t := v.(type) {
617617+ case *bsky.FeedDefs_PostView:
618618+ return GetPostMedia(t)
619619+ case *MediaVM:
620620+ return t
621621+ default:
622622+ return nil
623623+ }
624624+}
625625+626626+// IsPostReply reports whether the given feed item is a reply (has a Reply ref).
627627+func IsPostReply(item interface{}) bool {
628628+ switch it := item.(type) {
629629+ case *bsky.FeedDefs_FeedViewPost:
630630+ if it == nil || it.Post == nil {
631631+ return false
632632+ }
633633+ // consider it a reply only if a parent/root URI is present
634634+ parentURI := extractReplyParentURI(it.Post)
635635+ return parentURI != ""
636636+ default:
637637+ return false
638638+ }
639639+}
640640+641641+// ReplyParentURI returns the parent URI for a post's reply reference, or empty string.
642642+func ReplyParentURI(pv *bsky.FeedDefs_PostView) string {
643643+ return extractReplyParentURI(pv)
644644+}
645645+646646+// ShortURI returns a compact representation of an at:// post URI (did/postid) or the original string.
647647+func ShortURI(uri string) string {
648648+ if uri == "" {
649649+ return ""
650650+ }
651651+ // expected form: at://did/app.bsky.feed.post/postid
652652+ if strings.HasPrefix(uri, "at://") {
653653+ parts := strings.Split(strings.TrimPrefix(uri, "at://"), "/")
654654+ if len(parts) >= 3 {
655655+ // parts[0]=did, parts[1]=app.bsky.feed.post, parts[2]=postid
656656+ return parts[0] + "/" + parts[len(parts)-1]
657657+ }
658658+ }
659659+ return uri
660660+}
661661+662662+// ParentInfo captures lightweight parent details available from a ReplyRef without fetching the parent post.
663663+type ParentInfo struct {
664664+ AuthorName string
665665+ AuthorHandle string
666666+ Text string
667667+ Uri string
668668+ Avatar string
669669+ PostURL string
670670+ IndexedAt string
671671+ Media *MediaVM
672672+ // whether the signed-in viewer has liked this post (from PostView.Viewer.Like)
673673+ IsFav bool
674674+ // like count for the parent post (populated by handlers from PostView.LikeCount)
675675+ LikeCount int
676676+ ReplyCount int
677677+ RepostCount int
678678+}
679679+680680+// GetParentInfo extracts whatever metadata is present in the ReplyRef.Parent or ReplyRef.Root
681681+// using concrete, type-safe assertions (no reflection). It prefers Parent over Root and
682682+// only extracts the Uri when available from known concrete types.
683683+func GetParentInfo(pv *bsky.FeedDefs_PostView) ParentInfo {
684684+ pi := ParentInfo{}
685685+ if pv == nil || pv.Record == nil || pv.Record.Val == nil {
686686+ return pi
687687+ }
688688+ post, ok := pv.Record.Val.(*bsky.FeedPost)
689689+ if !ok || post == nil || post.Reply == nil {
690690+ return pi
691691+ }
692692+ // prefer Parent over Root
693693+ var ref interface{}
694694+ if post.Reply.Parent != nil {
695695+ ref = post.Reply.Parent
696696+ } else if post.Reply.Root != nil {
697697+ ref = post.Reply.Root
698698+ }
699699+ if ref == nil {
700700+ return pi
701701+ }
702702+ // set Uri if available via existing helper
703703+ pi.Uri = extractReplyParentURI(pv)
704704+705705+ // Try known concrete types (atproto.RepoStrongRef) to extract Uri
706706+ if sr, ok := ref.(*atproto.RepoStrongRef); ok {
707707+ if sr.Uri != "" {
708708+ pi.Uri = sr.Uri
709709+ }
710710+ return pi
711711+ }
712712+713713+ // If other concrete types are introduced by the API, avoid reflection and return what we have.
714714+ return pi
715715+}
716716+717717+// GetReplyChainInfos extracts available reply-ref metadata from a PostView without performing network fetches.
718718+// It returns a slice of ParentInfo ordered from root (top-most ancestor) to immediate parent.
719719+// This implementation is type-safe and only uses concrete types; it will populate Uri when available.
720720+// NOTE: Reply refs carry only lightweight references (Uri/Cid). To display author handles, display names
721721+// and text previews for ancestors, handlers should collect all referenced URIs and call fetchPostsBatch
722722+// once to obtain full PostView objects, then populate a ParentInfo map passed into templates. Helpers
723723+// must not perform network I/O (per project rules), so this function intentionally avoids fetching.
724724+func GetReplyChainInfos(pv *bsky.FeedDefs_PostView) []ParentInfo {
725725+ var out []ParentInfo
726726+ if pv == nil || pv.Record == nil || pv.Record.Val == nil {
727727+ return out
728728+ }
729729+ post, ok := pv.Record.Val.(*bsky.FeedPost)
730730+ if !ok || post == nil || post.Reply == nil {
731731+ return out
732732+ }
733733+734734+ // helper to extract info from a reply-ref struct (root or parent)
735735+ extract := func(ref interface{}) ParentInfo {
736736+ pi := ParentInfo{}
737737+ if ref == nil {
738738+ return pi
739739+ }
740740+ // If the concrete type is a RepoStrongRef, extract Uri
741741+ if sr, ok := ref.(*atproto.RepoStrongRef); ok {
742742+ if sr.Uri != "" {
743743+ pi.Uri = sr.Uri
744744+ }
745745+ return pi
746746+ }
747747+ // Unknown concrete type: avoid reflection and return empty
748748+ return pi
749749+ }
750750+751751+ // prefer root then parent to produce top-down order
752752+ if post.Reply.Root != nil {
753753+ rootInfo := extract(post.Reply.Root)
754754+ out = append(out, rootInfo)
755755+ }
756756+ if post.Reply.Parent != nil {
757757+ parentInfo := extract(post.Reply.Parent)
758758+ // avoid duplicating the same Uri twice
759759+ if !(len(out) > 0 && out[len(out)-1].Uri != "" && parentInfo.Uri != "" && out[len(out)-1].Uri == parentInfo.Uri) {
760760+ out = append(out, parentInfo)
761761+ }
762762+ }
763763+ return out
764764+}
765765+766766+// GetEmbeddedParentInfo inspects a post's embed record (if it's an embedded record view)
767767+// and returns a ParentInfo constructed from the embedded record's author and value fields.
768768+// This is useful for rendering a quoted record as an ancestor in the chat-like UI when
769769+// a full ReplyRef chain isn't available.
770770+func GetEmbeddedParentInfo(pv *bsky.FeedDefs_PostView) ParentInfo {
771771+ pi := ParentInfo{}
772772+ if pv == nil || pv.Embed == nil {
773773+ return pi
774774+ }
775775+ // Prefer embedded record view
776776+ if pv.Embed.EmbedRecord_View != nil && pv.Embed.EmbedRecord_View.Record != nil {
777777+ rw := pv.Embed.EmbedRecord_View.Record
778778+ // the wrapped record may be an EmbedRecord_ViewRecord
779779+ if rw.EmbedRecord_ViewRecord != nil {
780780+ r := rw.EmbedRecord_ViewRecord
781781+ // author: use existing helper to get a friendly display name
782782+ if r.Author != nil {
783783+ pi.AuthorName = getDisplayNameFromProfile(r.Author)
784784+ // attempt to extract a handle from known concrete author types by converting to interface{}
785785+ switch a := interface{}(r.Author).(type) {
786786+ case *bsky.ActorDefs_ProfileView:
787787+ pi.AuthorHandle = a.Handle
788788+ if a.Avatar != nil {
789789+ pi.Avatar = *a.Avatar
790790+ }
791791+ case *bsky.ActorDefs_ProfileViewBasic:
792792+ pi.AuthorHandle = a.Handle
793793+ if a.Avatar != nil {
794794+ pi.Avatar = *a.Avatar
795795+ }
796796+ case *bsky.ActorDefs_ProfileViewDetailed:
797797+ pi.AuthorHandle = a.Handle
798798+ if a.Avatar != nil {
799799+ pi.Avatar = *a.Avatar
800800+ }
801801+ default:
802802+ // unknown author shape - leave handle/avatar empty
803803+ }
804804+ }
805805+ // text/value: use getPostText which accepts *util.LexiconTypeDecoder
806806+ if r.Value != nil {
807807+ pi.Text = getPostText(r.Value)
808808+ }
809809+ }
810810+ }
811811+ return pi
812812+}
813813+814814+// HasEmbedRecord reports whether the given FeedPost has an embedded record view
815815+func HasEmbedRecord(item *bsky.FeedDefs_FeedViewPost) bool {
816816+ if item == nil || item.Post == nil || item.Post.Embed == nil {
817817+ return false
818818+ }
819819+ if item.Post.Embed.EmbedRecord_View != nil {
820820+ return true
821821+ }
822822+ if item.Post.Embed.EmbedRecordWithMedia_View != nil {
823823+ return true
824824+ }
825825+ return false
826826+}
827827+828828+func IsReply(item *bsky.FeedDefs_FeedViewPost) bool {
829829+ if item == nil || item.Post == nil {
830830+ return false
831831+ }
832832+ return extractReplyParentURI(item.Post) != ""
833833+}
834834+835835+func getIsFav(post *bsky.FeedDefs_PostView) bool {
836836+ if post == nil || post.Viewer == nil || post.Viewer.Like == nil {
837837+ return false
838838+ }
839839+ return len(*post.Viewer.Like) > 0
840840+}
+8
main.go
···11+package main
22+33+// main is a tiny entrypoint that delegates initialization and server startup
44+// to Run() implemented in server.go. Keeping this file minimal avoids duplicate
55+// declarations and keeps responsibilities focused.
66+func main() {
77+ Run()
88+}
···11+# Tuiter 2006 base24 color scheme (default)
22+# This file follows the base24 layout used by tinted-theming/schemes (spec-0.11)
33+# Colors chosen to match the current Tuiter 2006 retro palette.
44+# Theme authors: supply a replacement YAML with same keys to generate alternate themes.
55+66+scheme: tuiter-2006-base24
77+author: auto-generated
88+99+# base00..base23 - semantic palette for theme tooling
1010+base00: "#9AE4E8" # page background (main body)
1111+base01: "#7DD0D5" # header accent / subtle border
1212+base02: "#C8E68A" # sidebar background
1313+base03: "#fffef6" # warm light surface (panels)
1414+base04: "#f6fbff" # cool light surface (alternate panels)
1515+base05: "#ffffff" # primary card background
1616+base06: "#fbfbfb" # subtle surface / form background
1717+base07: "#f8f9fa" # input / textarea background
1818+base08: "#00aced" # brand blue (primary brand color)
1919+base09: "#0066CC" # link / handle blue
2020+base0A: "#6BB6FF" # accent blue (buttons / highlights)
2121+base0B: "#FFCC33" # accent yellow (highlights, viewed post)
2222+base0C: "#E6E6E6" # subtle borders, dividers
2323+base0D: "#D0D0D0" # secondary border
2424+base0E: "#CCCCCC" # muted borders / disabled
2525+base0F: "#333333" # primary text color
2626+base10: "#666666" # muted text
2727+base11: "#C0392B" # error / danger red
2828+base12: "#EAF9FB" # pale cyan (decorative)
2929+base13: "#D6F4FB" # pale cyan 2 (decorative)
3030+base14: "#0B2B33" # dark teal (contrast text)
3131+base15: "#0A66C2" # secondary handle blue
3232+base16: "#99CCFF" # join-link background
3333+base17: "#88BBEE" # join-link hover
3434+base18: "#E8E8E8" # post divider
3535+base19: "#DDDDDD" # avatar / placeholder background
3636+base20: "#DFDFDF" # thread border
3737+base21: "#FFF4D6" # toggle active background
3838+base22: "#000000" # media/video black
3939+base23: "#FFFFFF" # pure white (utility)
4040+4141+# Notes for theme creators:
4242+# - Map your base16 colors to these base24 slots to approximate the Tuiter UI.
4343+# - base00..base07 typically control background and surface tones; base08..base0F control accents & text.
4444+# - Semantic mapping in the CSS uses these bases (brand, link, bg, muted, accent, card, border, etc.).
4545+# - See the styling guide: https://github.com/tinted-theming/home/blob/main/styling.md
static/tuiter1.png
This is a binary file and will not be displayed.
static/tuiter2.png
This is a binary file and will not be displayed.
static/tuiter3.png
This is a binary file and will not be displayed.
static/tuiter4.png
This is a binary file and will not be displayed.
+103
templates/about.html
···11+{{template "header.html" .}}
22+33+<div class="tuiter-about">
44+ <div class="main-content">
55+ <div class="content">
66+77+ <div class="about-hero post">
88+ <div class="post-content">
99+ <h2>About Tuiter 2006</h2>
1010+ <p class="lead">A small, text-first social place inspired by how the web used to feel: conversational, quick, and focused on people — not on attention optimization.</p>
1111+ </div>
1212+ </div>
1313+1414+ <div class="about-columns">
1515+ <div class="post about-column">
1616+ <div class="post-content">
1717+ <h3>Why this exists</h3>
1818+ <p>
1919+ Modern social platforms increasingly prize content that maximizes attention. Endless short-form feeds and attention-optimized placements make conversation noisy and transactional. Tuiter 2006 is a deliberate counterpoint: simple, text-first, and tuned for readable exchange where people come to speak and listen, not to be optimized for ad dollars.
2020+ </p>
2121+2222+ <h3>Text first, always</h3>
2323+ <p>
2424+ Text scales well: it’s quick to skim, easy to quote, and friendly to thoughtful replies. By keeping the interface lightweight and avoiding media-first mechanics we make it easy to follow conversations and participate without distraction.
2525+ </p>
2626+2727+ <h3>Obsolete by design</h3>
2828+ <p>
2929+ This project embraces minimalism. It is intentionally old-fashioned so the social experience — voices, replies, and threads — stays front and center. That obsolescence is the feature: fewer bells and whistles, more room for people.
3030+ </p>
3131+ </div>
3232+ </div>
3333+3434+ <div class="post about-column">
3535+ <div class="post-content">
3636+ <h3>What people do here</h3>
3737+ <p>
3838+ People use Tuiter 2006 to jot quick thoughts, follow conversations, reply, and collect small threads of discussion. It favors readable text over polished feeds and keeps interactions light and human.
3939+ </p>
4040+4141+ <h3>Share with friends</h3>
4242+ <p>
4343+ If this appeals to you, please tell a friend. Word-of-mouth sharing — a copied link, some screenshots of what you like or a short post on your other accounts, or an invitation to someone who loves old fashioned text conversation — is the best way to grow a calm, thoughtful community.
4444+ </p>
4545+4646+ <h3>Report bugs</h3>
4747+ <p>
4848+ Found a bug or something behaving oddly? Report issues on Bluesky to <a href="https://bsky.app/profile/oeiuwq.bsky.social"><strong>@oeiuwq.bsky.social</strong></a>. Remember that this site is obsolete by design, so most features will not be implemented, but clear bug reports help prioritize fixes and improve the experience for everyone.
4949+ </p>
5050+5151+5252+ </div>
5353+ </div>
5454+5555+ </div>
5656+ </div>
5757+5858+ <div class="sidebar">
5959+ <div class="donation-box">
6060+ <div class="donation-content">
6161+ <h3>Support ongoing development</h3>
6262+ <p>
6363+ This project is made and maintained by <a href="https://github.com/vic"><code>vic</code></a> <strong>out of love</strong>.
6464+ </p>
6565+ <p class="muted small">Any contribution helps: from a one-time donation to a monthly sponsorship. Or even better, tell someone that you love them, today.</p>
6666+ <div class="donate-buttons">
6767+ <a class="donate-link" href="https://ko-fi.com/oeiuwq" target="_blank" rel="noopener noreferrer">Donate on Ko‑fi</a>
6868+ <a class="donate-link" href="https://github.com/sponsors/vic" target="_blank" rel="noopener noreferrer">Sponsor on GitHub</a>
6969+ </div>
7070+ <p class="muted small">Why support? Sponsorships and donations offset time and infrastructure costs and keep small, non-profit projects alive.</p>
7171+ </div>
7272+ </div>
7373+7474+ <div class="donation-box">
7575+ <div class="donation-content">
7676+ <h3>
7777+ About the author
7878+ </h3>
7979+ <div>
8080+ <quote>
8181+ My name is Victor Borja. I'm not a designer as you can see, but I do try my best, I enjoy creating stuff for others.
8282+ And I really miss the old good days of twitter. Hope you like this site.
8383+ </quote>
8484+8585+ <br/>
8686+ <p>You can find me here:</p>
8787+8888+ <ul>
8989+ <li>https://github.com/vic</li>
9090+ <li>https://bsky.app/profile/oeiuwq.bsky.social</li>
9191+ <li>https://x.com/oeiuwq</li>
9292+ <li>vborja@apache.org</li>
9393+ <li>near your heart</li>
9494+ </ul>
9595+ </div>
9696+ </div>
9797+ </div>
9898+9999+100100+ </div>
101101+</div>
102102+103103+{{template "footer.html" .}}
···11+{{template "header.html" .}}
22+33+ <div class="main-content">
44+ <div class="content">
55+ <div class="welcome-message">
66+ <h2>Welcome to Tuiter 2006!</h2>
77+ <br />
88+ <p>Tuiter 2006 is a tribute to the early days of Twitter, using Bluesky's social protocol.</p>
99+ <p>It is totally <a href="/about">free</a>, and will always be. Made out of Love, like all the good things.</p>
1010+ </div>
1111+1212+ <div class="post">
1313+ <div class="post-content">
1414+ <h3>Early Alpha Preview</h3>
1515+ <br />
1616+ <p>You are most than welcome to try Tuiter 2006 right now!, just keep in mind it will evolve quickly. We still have to show a good looking fail whale on errors.</p>
1717+ <p><a href="/about">Feedback</a> is more than welcome, please share with your friends, let's get them out of X.</p>
1818+ <p>It currently looks like this:</p>
1919+ <div class="post-media">
2020+ <div class="media-images">
2121+ <a href="/static/tuiter1.png" target="_blank"><img class="post-image" src="/static/tuiter1.png"></img></a>
2222+ <a href="/static/tuiter2.png" target="_blank"><img class="post-image" src="/static/tuiter2.png"></img></a>
2323+ <a href="/static/tuiter3.png" target="_blank"><img class="post-image" src="/static/tuiter3.png"></img></a>
2424+ <a href="/static/tuiter4.png" target="_blank"><img class="post-image" src="/static/tuiter4.png"></img></a>
2525+ </div>
2626+ </div>
2727+ </div>
2828+ </div>
2929+3030+ <div class="post">
3131+ <div class="post-content">
3232+ <h5>Privacy Policy</h5>
3333+ <br />
3434+ <p>This site will NEVER ask you for your password. It uses Bluesky authentication and stores a cookie for keeping you signed.</p>
3535+ <p>No other data is saved, all your messages are sent directly to the Bluesky API.</p>
3636+ <p>The code for this site is <a href="https://tangled.sh/@oeiuwq.bsky.social/tuiter">opensource</a> under the Apache-2 license.</p>
3737+ <p>This service and its code is provided on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.</p>
3838+ </div>
3939+ </div>
4040+4141+ </div>
4242+4343+ <div class="sidebar">
4444+ <div class="signin-form">
4545+ <p>Sign in with your Bluesky account to get started!</p>
4646+ <form action="/login" method="post">
4747+ <div class="form-group">
4848+ <input type="text" id="identifier" name="identifier" placeholder="you.bsky.social">
4949+ <input type="submit" value="Log In" class="signin-btn">
5050+ </div>
5151+ </form>
5252+ </div>
5353+5454+ <div class="join-section">
5555+ <div class="note">💡 Uses Bluesky OAuth - we will never touch your password!</div>
5656+ <p>
5757+ If you don't already have a Bluesky account, create one <a href="https://bsky.app">HERE</a>.
5858+ </p>
5959+ </div>
6060+6161+ </div>
6262+ </div>
6363+6464+{{template "footer.html" .}}
···11+{{/* Partial that renders the list of timeline posts. Used by HTMX to refresh the feed. */}}
22+{{if .Posts.Items}}
33+ {{template "posts_list_partial.html" .Posts}}
44+{{else}}
55+ <div class="post">
66+ <div class="post-avatar">📱</div>
77+ <div class="post-content">
88+ <div class="post-text">No updates available in your timeline yet.</div>
99+ </div>
1010+ </div>
1111+{{end}}
+89
types.go
···11+package main
22+33+import (
44+ bsky "github.com/bluesky-social/indigo/api/bsky"
55+)
66+77+// Page data structs
88+99+type PostStatusPageData struct {
1010+ Title string
1111+ CurrentUser *bsky.ActorDefs_ProfileViewDetailed
1212+ Profile *bsky.ActorDefs_ProfileViewDetailed
1313+ Follows []*bsky.ActorDefs_ProfileView
1414+ // SignedIn is the currently signed-in profile (typed, may be nil)
1515+ SignedIn *bsky.ActorDefs_ProfileViewDetailed
1616+}
1717+1818+type TimelinePageData struct {
1919+ Title string
2020+ CurrentUser *bsky.ActorDefs_ProfileViewDetailed
2121+ Profile *bsky.ActorDefs_ProfileViewDetailed
2222+ Timeline *bsky.FeedGetTimeline_Output
2323+ Follows []*bsky.ActorDefs_ProfileView
2424+ Posts PostsList
2525+ PostBoxHandle string
2626+ // SignedIn is the currently signed-in profile (typed, may be nil)
2727+ SignedIn *bsky.ActorDefs_ProfileViewDetailed
2828+}
2929+3030+type TimelinePartialData struct {
3131+ Timeline *bsky.FeedGetTimeline_Output
3232+ Posts PostsList
3333+ // SignedIn may be present for partials that need header links
3434+ SignedIn *bsky.ActorDefs_ProfileViewDetailed
3535+}
3636+3737+type PostPageData struct {
3838+ Title string
3939+ Post *bsky.FeedDefs_PostView
4040+ Replies []*bsky.FeedDefs_PostView
4141+ ParentChain []*bsky.FeedDefs_PostView
4242+ ViewedURI string
4343+ ThreadRoot *bsky.FeedDefs_ThreadViewPost
4444+ CurrentUser *bsky.ActorDefs_ProfileViewDetailed
4545+ PostAuthor *bsky.ActorDefs_ProfileViewDetailed
4646+ PostAuthorFollows []*bsky.ActorDefs_ProfileView
4747+ // SignedIn is the currently signed-in profile (typed, may be nil)
4848+ SignedIn *bsky.ActorDefs_ProfileViewDetailed
4949+}
5050+5151+type ProfilePageData struct {
5252+ Title string
5353+ Profile *bsky.ActorDefs_ProfileViewDetailed
5454+ Feed *bsky.FeedGetAuthorFeed_Output
5555+ Follows []*bsky.ActorDefs_ProfileView
5656+ Posts PostsList
5757+ PostBoxHandle string
5858+ // SignedIn is the currently signed-in profile (typed, may be nil)
5959+ SignedIn *bsky.ActorDefs_ProfileViewDetailed
6060+}
6161+6262+type TimelineProvider struct{ T *bsky.FeedGetTimeline_Output }
6363+6464+func (p TimelineProvider) Posts() []*bsky.FeedDefs_FeedViewPost {
6565+ if p.T == nil || p.T.Feed == nil {
6666+ return nil
6767+ }
6868+ return p.T.Feed
6969+}
7070+7171+func (p TimelineProvider) Cursor() string {
7272+ if p.T == nil || p.T.Cursor == nil {
7373+ return ""
7474+ }
7575+ return *p.T.Cursor
7676+}
7777+7878+type AuthorProvider struct {
7979+ F *bsky.FeedGetAuthorFeed_Output
8080+}
8181+8282+func (p AuthorProvider) Posts() []*bsky.FeedDefs_FeedViewPost {
8383+ if p.F == nil || p.F.Feed == nil {
8484+ return nil
8585+ }
8686+ return p.F.Feed
8787+}
8888+8989+func (p AuthorProvider) Cursor() string { return "" }