From 904edaf9ee213073d537fdf4691f2228a6adc123 Mon Sep 17 00:00:00 2001 From: Will Garrison Date: Fri, 29 May 2026 22:17:38 -0700 Subject: [PATCH] Bugfixes to make sure all connection flows work and routes properly --- features/auth/handlers.go | 15 +- features/auth/pages/signin.templ | 20 +- features/auth/pages/signin_atproto_templ.go | 10 +- features/auth/pages/signin_templ.go | 35 ++- features/connect/handlers.go | 53 ++-- features/connect/pages/connect.templ | 14 +- features/connect/pages/connect_local.templ | 2 +- features/connect/pages/connect_local_templ.go | 19 +- features/connect/pages/connect_templ.go | 71 ++--- features/connections/handlers.go | 259 +++++++++++++++++- features/connections/pages/profile.templ | 14 +- features/connections/pages/profile_templ.go | 99 +++---- features/index/handlers.go | 13 + features/profile/handlers.go | 2 +- features/profile/pages/profile.templ | 14 +- features/profile/pages/profile_templ.go | 121 ++++---- internal/connection/local.go | 2 +- .../020_drop_local_connections_fk.sql | 45 +++ web/resources/static/css/terminal.css | 2 + 19 files changed, 597 insertions(+), 213 deletions(-) create mode 100644 internal/db/migrations/020_drop_local_connections_fk.sql diff --git a/features/auth/handlers.go b/features/auth/handlers.go index 4488bae..7e11665 100644 --- a/features/auth/handlers.go +++ b/features/auth/handlers.go @@ -84,7 +84,7 @@ func (h *Handlers) HandleSearch(w http.ResponseWriter, r *http.Request) { // Signin renders the chooser page: sign in vs create new Atmosphere account. func (h *Handlers) Signin(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := pages.Signin().Render(r.Context(), w); err != nil { + if err := pages.Signin(r.URL.Query().Get("next")).Render(r.Context(), w); err != nil { slog.Error("render signin", "err", err) } } @@ -305,9 +305,14 @@ func sanitizeASCode(code string) string { // On subsequent visits (after sign-out), auto-recovers the previous local account // via the known-accounts cookie. func (h *Handlers) ContinueWithoutAccount(w http.ResponseWriter, r *http.Request) { + nextDest := r.URL.Query().Get("next") + if nextDest == "" { + nextDest = "/profile" + } + // Check if already authenticated (ATProto or local) if _, _, err := h.ResumeSession(r); err == nil { - http.Redirect(w, r, "/profile", http.StatusFound) + http.Redirect(w, r, nextDest, http.StatusFound) return } @@ -319,7 +324,7 @@ func (h *Handlers) ContinueWithoutAccount(w http.ResponseWriter, r *http.Request SELECT did FROM users WHERE did = ? `, existingLocalID).Scan(&exists) if err == nil { - http.Redirect(w, r, "/profile", http.StatusFound) + http.Redirect(w, r, nextDest, http.StatusFound) return } } @@ -342,7 +347,7 @@ func (h *Handlers) ContinueWithoutAccount(w http.ResponseWriter, r *http.Request http.Error(w, "failed to set cookie", http.StatusInternalServerError) return } - http.Redirect(w, r, "/profile", http.StatusFound) + http.Redirect(w, r, nextDest, http.StatusFound) return } } @@ -374,7 +379,7 @@ func (h *Handlers) ContinueWithoutAccount(w http.ResponseWriter, r *http.Request } h.Sessions.RememberLocal(w, localID) - http.Redirect(w, r, "/profile", http.StatusFound) + http.Redirect(w, r, nextDest, http.StatusFound) } // LocalLogout clears the local session cookie and redirects home. diff --git a/features/auth/pages/signin.templ b/features/auth/pages/signin.templ index 38f0cad..5c5be5c 100644 --- a/features/auth/pages/signin.templ +++ b/features/auth/pages/signin.templ @@ -1,10 +1,14 @@ package pages -import "atmoquest/features/common/layouts" +import ( + "net/url" + + "atmoquest/features/common/layouts" +) // Signin renders the chooser: existing Atmosphere account vs create a new one. -// The create path is a stub for now — we'll wire it later. -templ Signin() { +// next is an optional path to redirect to after sign-in. +templ Signin(next string) { @layouts.Base("sign in", "Sign in with your ATProto account.") {
@@ -33,7 +37,7 @@ templ Signin() {
already have a handle like you.bsky.social or any ATProto PDS? Go this way.
▸ continue ↵
- +
option b
Continue without an Atmosphere account
use atmo.quest locally — link an Atmosphere account later in Settings to claim your data.
@@ -47,3 +51,11 @@ templ Signin() {
} } + +// continueLocalURL builds the continue URL, preserving the next parameter. +func continueLocalURL(next string) string { + if next == "" { + return "/auth/local/continue" + } + return "/auth/local/continue?next=" + url.QueryEscape(next) +} diff --git a/features/auth/pages/signin_atproto_templ.go b/features/auth/pages/signin_atproto_templ.go index 0c1b3a0..d2a4786 100644 --- a/features/auth/pages/signin_atproto_templ.go +++ b/features/auth/pages/signin_atproto_templ.go @@ -5,12 +5,10 @@ package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. -import ( - "atmoquest/features/common/layouts" +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" -) +import "atmoquest/features/common/layouts" // SigninATProto renders the handle entry form. errorMsg is empty unless we're // re-rendering after a validation or OAuth failure. @@ -73,7 +71,7 @@ func SigninATProto(errorMsg string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
also accepts did:plc:… ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " also accepts did:plc:… or a PDS URL like https://pds.example.com ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/auth/pages/signin_templ.go b/features/auth/pages/signin_templ.go index bcf2501..10c41d8 100644 --- a/features/auth/pages/signin_templ.go +++ b/features/auth/pages/signin_templ.go @@ -8,11 +8,15 @@ package pages import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" -import "atmoquest/features/common/layouts" +import ( + "net/url" + + "atmoquest/features/common/layouts" +) // Signin renders the chooser: existing Atmosphere account vs create a new one. -// The create path is a stub for now — we'll wire it later. -func Signin() templ.Component { +// next is an optional path to redirect to after sign-in. +func Signin(next string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -52,13 +56,26 @@ func Signin() templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/signin.templ`, Line: 18, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/signin.templ`, Line: 22, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmo.quest:~$ auth --pick
choose how to start your quest.
step 1 of 1 · pick a path

sign in

atmo.quest is built on ATProto. Your records live in your repo, not ours. You'll sign in with the same identity you use anywhere on the open social web.

option a
sign in with your Atmosphere account
already have a handle like you.bsky.social or any ATProto PDS? Go this way.
▸ continue ↵
option b
Continue without an Atmosphere account
use atmo.quest locally — link an Atmosphere account later in Settings to claim your data.
▸ continue
atmo.quest
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmo.quest:~$ auth --pick
choose how to start your quest.
step 1 of 1 · pick a path

sign in

atmo.quest is built on ATProto. Your records live in your repo, not ours. You'll sign in with the same identity you use anywhere on the open social web.

option a
sign in with your Atmosphere account
already have a handle like you.bsky.social or any ATProto PDS? Go this way.
▸ continue ↵
option b
Continue without an Atmosphere account
use atmo.quest locally — link an Atmosphere account later in Settings to claim your data.
▸ continue
atmo.quest
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -72,4 +89,12 @@ func Signin() templ.Component { }) } +// continueLocalURL builds the continue URL, preserving the next parameter. +func continueLocalURL(next string) string { + if next == "" { + return "/auth/local/continue" + } + return "/auth/local/continue?next=" + url.QueryEscape(next) +} + var _ = templruntime.GeneratedTemplate diff --git a/features/connect/handlers.go b/features/connect/handlers.go index 0aa995e..ee026a2 100644 --- a/features/connect/handlers.go +++ b/features/connect/handlers.go @@ -260,14 +260,19 @@ func (h *Handlers) ConnectLocal(w http.ResponseWriter, r *http.Request) { return } - // Check if viewer is authenticated (ATProto or local) - viewerDID, _, ok := h.Auth.RequireSession(w, r) + // Check local session first (avoids RequireSession writing a redirect + // header that would prevent the local session from being used). viewerLocalID := h.Auth.Sessions.GetLocal(r) - if !ok && viewerLocalID == "" { - // Not logged in — render page with data-queue-local-id attribute - h.renderConnectLocalPage(w, r, targetLocalID, false) - return + var viewerDID syntax.DID + var ok bool + if viewerLocalID == "" { + viewerDID, _, ok = h.Auth.RequireSession(w, r) + if !ok { + // Not logged in — render page with data-queue-local-id attribute + h.renderConnectLocalPage(w, r, targetLocalID, false) + return + } } // Self-connect check @@ -276,15 +281,21 @@ func (h *Handlers) ConnectLocal(w http.ResponseWriter, r *http.Request) { return } - // Write connection record to local_connections table - viewerDIDStr := "" + // Determine the viewer's active identifier (used for badges/checkins). + viewerID := viewerLocalID if ok { - viewerDIDStr = viewerDID.String() + viewerID = viewerDID.String() + } + + // For writeLocalConnection, pass exactly one of viewerDID/viewerLocalID. + var connViewerDID, connViewerLocalID string + if ok { + connViewerDID = viewerDID.String() } else { - viewerDIDStr = viewerLocalID + connViewerLocalID = viewerLocalID } - err = h.writeLocalConnection(r.Context(), viewerDIDStr, viewerLocalID, "", targetLocalID, "") + err = h.writeLocalConnection(r.Context(), connViewerDID, connViewerLocalID, "", targetLocalID, "") if err != nil { slog.Warn("connect local: write connection", "err", err) } @@ -297,11 +308,11 @@ func (h *Handlers) ConnectLocal(w http.ResponseWriter, r *http.Request) { } } if eventURI != "" { - if err := checkin.PutLocal(r.Context(), h.DB, viewerDIDStr, eventURI, time.Time{}); err == nil { + if err := checkin.PutLocal(r.Context(), h.DB, viewerID, eventURI, time.Time{}); err == nil { _ = event.IncrementCheckins(r.Context(), h.DB, eventURI) _ = event.IncrementConnectors(r.Context(), h.DB, eventURI) } - _ = badge.AwardLocal(r.Context(), h.DB, viewerDIDStr, badge.AwardEventAttendee) + _ = badge.AwardLocal(r.Context(), h.DB, viewerID, badge.AwardEventAttendee) } // Besties badge: check if already connected at a different event @@ -312,24 +323,20 @@ func (h *Handlers) ConnectLocal(w http.ResponseWriter, r *http.Request) { WHERE ((viewer_did = ? AND viewer_local_id IS NULL) OR (viewer_local_id = ? AND viewer_did IS NULL)) AND ((target_did = ? AND target_local_id IS NULL) OR (target_local_id = ? AND target_did IS NULL)) AND event_uri != '' - `, viewerDIDStr, viewerDIDStr, targetLocalID, targetLocalID).Scan(&eventCount) + `, viewerID, viewerID, targetLocalID, targetLocalID).Scan(&eventCount) if eventCount >= 2 { - _ = badge.AwardLocal(r.Context(), h.DB, viewerDIDStr, badge.AwardBesties) + _ = badge.AwardLocal(r.Context(), h.DB, viewerID, badge.AwardBesties) } } // First-connect badge - _ = badge.AwardLocal(r.Context(), h.DB, viewerDIDStr, badge.AwardFirstConnect) + _ = badge.AwardLocal(r.Context(), h.DB, viewerID, badge.AwardFirstConnect) // Connection milestone badges - badge.AwardConnectionMilestonesLocal(r.Context(), h.DB, viewerDIDStr) + badge.AwardConnectionMilestonesLocal(r.Context(), h.DB, viewerID) - // Redirect to show connection - if viewerLocalID != "" { - http.Redirect(w, r, "/connections/l/"+targetLocalID, http.StatusSeeOther) - } else { - http.Redirect(w, r, "/connections/"+viewerDID.String(), http.StatusSeeOther) - } + // Redirect to the target's profile page + http.Redirect(w, r, "/connections/"+targetLocalID, http.StatusSeeOther) } // writeLocalConnection writes a connection involving at least one local user. diff --git a/features/connect/pages/connect.templ b/features/connect/pages/connect.templ index ab05543..3fb96d2 100644 --- a/features/connect/pages/connect.templ +++ b/features/connect/pages/connect.templ @@ -1,6 +1,11 @@ package pages -import "atmoquest/features/common/layouts" +import ( + "html" + "strings" + + "atmoquest/features/common/layouts" +) // ConnectView is the data the Connect handler renders. type ConnectView struct { @@ -60,7 +65,7 @@ templ Connect(v ConnectView) { }

{ v.TargetDID }

if v.TargetBio != "" { -

{ v.TargetBio }

+

@nl2br(v.TargetBio)

} if v.SelfConnect { @@ -149,3 +154,8 @@ func avatarAlt(displayName string) string { } return displayName + "'s profile picture" } + +func nl2br(s string) templ.Component { + s = strings.ReplaceAll(s, "\r\n", "\n") + return templ.Raw(strings.ReplaceAll(html.EscapeString(s), "\n", "
")) +} diff --git a/features/connect/pages/connect_local.templ b/features/connect/pages/connect_local.templ index 76caada..611143f 100644 --- a/features/connect/pages/connect_local.templ +++ b/features/connect/pages/connect_local.templ @@ -43,7 +43,7 @@ templ ConnectLocal(v ConnectLocalView) { }

{ v.TargetLocalID }

if v.TargetBio != "" { -

{ v.TargetBio }

+

@nl2br(v.TargetBio)

} if v.SelfConnect { diff --git a/features/connect/pages/connect_local_templ.go b/features/connect/pages/connect_local_templ.go index 8c653aa..fcb9c3a 100644 --- a/features/connect/pages/connect_local_templ.go +++ b/features/connect/pages/connect_local_templ.go @@ -178,12 +178,7 @@ func ConnectLocal(v ConnectLocalView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(v.TargetBio) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect_local.templ`, Line: 46, Col: 42} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + templ_7745c5c3_Err = nl2br(v.TargetBio).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -202,12 +197,12 @@ func ConnectLocal(v ConnectLocalView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 templ.SafeURL - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/c/l/" + v.TargetLocalID + "/confirm")) + var templ_7745c5c3_Var9 templ.SafeURL + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/c/l/" + v.TargetLocalID + "/confirm")) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect_local.templ`, Line: 58, Col: 88} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -230,12 +225,12 @@ func ConnectLocal(v ConnectLocalView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.TargetLocalID) + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.TargetLocalID) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect_local.templ`, Line: 85, Col: 45} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/connect/pages/connect_templ.go b/features/connect/pages/connect_templ.go index c344501..e850645 100644 --- a/features/connect/pages/connect_templ.go +++ b/features/connect/pages/connect_templ.go @@ -8,7 +8,12 @@ package pages import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" -import "atmoquest/features/common/layouts" +import ( + "html" + "strings" + + "atmoquest/features/common/layouts" +) // ConnectView is the data the Connect handler renders. type ConnectView struct { @@ -85,7 +90,7 @@ func Connect(v ConnectView) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 44, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 49, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -98,7 +103,7 @@ func Connect(v ConnectView) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(v.TargetDID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 45, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 50, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -116,7 +121,7 @@ func Connect(v ConnectView) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.TargetAvatarURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 51, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 56, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { @@ -129,7 +134,7 @@ func Connect(v ConnectView) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(avatarAlt(v.TargetDisplayName)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 51, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 56, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -157,7 +162,7 @@ func Connect(v ConnectView) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.TargetDisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 57, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 62, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -180,7 +185,7 @@ func Connect(v ConnectView) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.TargetDID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 61, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 66, Col: 67} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -195,12 +200,7 @@ func Connect(v ConnectView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(v.TargetBio) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 63, Col: 42} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + templ_7745c5c3_Err = nl2br(v.TargetBio).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -219,12 +219,12 @@ func Connect(v ConnectView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 templ.SafeURL - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/c/" + v.TargetDID + "/confirm")) + var templ_7745c5c3_Var9 templ.SafeURL + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/c/" + v.TargetDID + "/confirm")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 75, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 80, Col: 82} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -237,12 +237,12 @@ func Connect(v ConnectView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(v.TargetCurrentEvent.Name) + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(v.TargetCurrentEvent.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 86, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 91, Col: 65} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -255,12 +255,12 @@ func Connect(v ConnectView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(v.TargetCurrentEvent.Location) + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(v.TargetCurrentEvent.Location) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 88, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 93, Col: 66} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -273,12 +273,12 @@ func Connect(v ConnectView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.TargetCurrentEvent.EventURI) + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.TargetCurrentEvent.EventURI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 96, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 101, Col: 83} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -306,12 +306,12 @@ func Connect(v ConnectView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.TargetDID) + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.TargetDID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 129, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 134, Col: 36} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -355,4 +355,9 @@ func avatarAlt(displayName string) string { return displayName + "'s profile picture" } +func nl2br(s string) templ.Component { + s = strings.ReplaceAll(s, "\r\n", "\n") + return templ.Raw(strings.ReplaceAll(html.EscapeString(s), "\n", "
")) +} + var _ = templruntime.GeneratedTemplate diff --git a/features/connections/handlers.go b/features/connections/handlers.go index 712c96c..2030cad 100644 --- a/features/connections/handlers.go +++ b/features/connections/handlers.go @@ -293,6 +293,24 @@ func (h *Handlers) lookupPDSForDID(r *http.Request, did syntax.DID) string { return "https://bsky.social" } +// pdsHostForDID looks up the PDS host for a DID from oauth_sessions. +// Falls back to bsky.social. Context-based variant for use outside request handlers. +func pdsHostForDID(ctx context.Context, db *sql.DB, did syntax.DID) string { + if db == nil { + return "https://bsky.social" + } + var host string + err := db.QueryRowContext(ctx, ` + SELECT data ->> 'host_url' FROM oauth_sessions + WHERE did = ? + ORDER BY updated_at DESC LIMIT 1 + `, did.String()).Scan(&host) + if err == nil && host != "" { + return host + } + return "https://bsky.social" +} + // View renders the detailed profile page for a connected person, including // private notes and follow-up flag (backed by localStorage on the client). func (h *Handlers) View(w http.ResponseWriter, r *http.Request) { @@ -301,8 +319,17 @@ func (h *Handlers) View(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/signin", http.StatusFound) return } + + targetStr := chi.URLParam(r, "did") + if identity.IsLocal { - http.Redirect(w, r, "/profile", http.StatusFound) + h.viewLocalConnection(w, r, identity.DID, targetStr) + return + } + + // ATProto viewer viewing a local target → handle specially. + if strings.HasPrefix(targetStr, "local_") { + h.viewATProtoViewingLocal(w, r, identity.DID, targetStr) return } @@ -317,7 +344,6 @@ func (h *Handlers) View(w http.ResponseWriter, r *http.Request) { return } - targetStr := chi.URLParam(r, "did") target, err := syntax.ParseDID(targetStr) if err != nil { http.NotFound(w, r) @@ -375,9 +401,7 @@ func (h *Handlers) View(w http.ResponseWriter, r *http.Request) { } } - // Find the connection date from the viewer's PDS records. We scan - // through the viewer's connection list for this target — the list is - // already loaded for the page anyway and is typically small. + // Find the connection date from the viewer's PDS records. viewerPDS := h.lookupPDSForDID(r, viewerDID) entries, err := connection.List(r.Context(), viewerPDS, viewerDID) if err != nil { @@ -391,7 +415,7 @@ func (h *Handlers) View(w http.ResponseWriter, r *http.Request) { view.EventName = ev.Name } } - break // use the first (most recent) match + break } } @@ -409,13 +433,210 @@ func (h *Handlers) View(w http.ResponseWriter, r *http.Request) { } } +// viewLocalConnection renders the connection profile page for a local user +// viewing a connection. Fetches profile data from public PDS API (for ATProto +// targets) or the users table (for local targets), connection date from +// local_connections, and notes from SQLite. +func (h *Handlers) viewLocalConnection(w http.ResponseWriter, r *http.Request, viewerLocalID, targetStr string) { + // Self-view → redirect to own profile. + if viewerLocalID == targetStr { + http.Redirect(w, r, "/profile", http.StatusSeeOther) + return + } + + isLocalTarget := strings.HasPrefix(targetStr, "local_") + + view := pages.ProfileView{ + ViewerDID: viewerLocalID, + TargetDID: targetStr, + } + + if isLocalTarget { + // Local target: read all profile fields from users table. + var displayName, bio, location, worksAt, contactMethod string + var hiring, looking bool + var linksJSON, interestsJSON string + _ = h.DB.QueryRowContext(r.Context(), ` + SELECT display_name, bio, location, works_at, contact_method, + hiring, looking, links, interests + FROM users WHERE did = ? + `, targetStr).Scan(&displayName, &bio, &location, &worksAt, &contactMethod, + &hiring, &looking, &linksJSON, &interestsJSON) + view.DisplayName = displayName + view.Bio = bio + view.Location = location + view.WorksAt = worksAt + view.ContactMethod = contactMethod + view.Hiring = hiring + view.Looking = looking + if linksJSON != "" { + json.Unmarshal([]byte(linksJSON), &view.Links) + } + if interestsJSON != "" { + json.Unmarshal([]byte(interestsJSON), &view.Interests) + } + + // Connection date from local_connections. + var connectedAt time.Time + var eventURI sql.NullString + err := h.DB.QueryRowContext(r.Context(), ` + SELECT connected_at, event_uri FROM local_connections + WHERE viewer_local_id = ? AND target_local_id = ? + `, viewerLocalID, targetStr).Scan(&connectedAt, &eventURI) + if err == nil { + view.ConnectedAt = connectedAt.Format("Jan 2, 2006") + if eventURI.Valid && eventURI.String != "" { + if ev, err := event.Get(r.Context(), h.DB, eventURI.String); err == nil { + view.EventName = ev.Name + } + } + } + } else { + // ATProto target: fetch profiles via public PDS API. + target, err := syntax.ParseDID(targetStr) + if err != nil { + http.NotFound(w, r) + return + } + + targetPDS := h.lookupPDSForDID(r, target) + + bsky, err := profile.FetchBluesky(r.Context(), targetPDS, target) + if err != nil && !errors.Is(err, profile.ErrNotFound) { + slog.Debug("connections/view: bluesky fetch", "target", target.String(), "err", err) + } + if bsky != nil { + view.DisplayName = bsky.DisplayName + view.Bio = bsky.Description + if bsky.Avatar != nil { + view.AvatarURL = profile.AvatarURL(targetPDS, target, bsky.Avatar.CID()) + } + } + + quest, err := profile.FetchQuest(r.Context(), targetPDS, target) + if err != nil && !errors.Is(err, profile.ErrNotFound) { + slog.Debug("connections/view: quest fetch", "target", target.String(), "err", err) + } + if quest != nil { + view.Location = quest.Location + view.WorksAt = quest.WorksAt + view.ContactMethod = quest.ContactMethod + if quest.Hiring != nil && *quest.Hiring { + view.Hiring = true + } + if quest.Looking != nil && *quest.Looking { + view.Looking = true + } + for _, l := range quest.Links { + view.Links = append(view.Links, pages.ProfileLink{Label: l.Label, URL: l.URL}) + } + view.Interests = quest.Interests + if b := strings.TrimSpace(quest.Bio); b != "" { + view.Bio = b + } + } + + // Connection date from local_connections. + var connectedAt time.Time + var eventURI sql.NullString + err = h.DB.QueryRowContext(r.Context(), ` + SELECT connected_at, event_uri FROM local_connections + WHERE viewer_local_id = ? AND target_did = ? + `, viewerLocalID, target.String()).Scan(&connectedAt, &eventURI) + if err == nil { + view.ConnectedAt = connectedAt.Format("Jan 2, 2006") + if eventURI.Valid && eventURI.String != "" { + if ev, err := event.Get(r.Context(), h.DB, eventURI.String); err == nil { + view.EventName = ev.Name + } + } + } + } + + // Load private notes from SQLite. + note, err := notes.Get(r.Context(), h.DB, viewerLocalID, targetStr) + if err != nil { + slog.Debug("connections/view: load notes", "err", err) + } + view.Notes = note.Notes + view.FollowUp = note.FollowUp + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.ProfileViewPage(view).Render(r.Context(), w); err != nil { + slog.Error("render connection profile", "err", err) + } +} + +// viewATProtoViewingLocal renders a local user's profile page for an ATProto +// viewer. Reads from the users table and local_connections (no PDS calls). +func (h *Handlers) viewATProtoViewingLocal(w http.ResponseWriter, r *http.Request, viewerDID, targetLocalID string) { + view := pages.ProfileView{ + ViewerDID: viewerDID, + TargetDID: targetLocalID, + } + + var displayName, bio, location, worksAt, contactMethod string + var hiring, looking bool + var linksJSON, interestsJSON string + _ = h.DB.QueryRowContext(r.Context(), ` + SELECT display_name, bio, location, works_at, contact_method, + hiring, looking, links, interests + FROM users WHERE did = ? + `, targetLocalID).Scan(&displayName, &bio, &location, &worksAt, &contactMethod, + &hiring, &looking, &linksJSON, &interestsJSON) + view.DisplayName = displayName + view.Bio = bio + view.Location = location + view.WorksAt = worksAt + view.ContactMethod = contactMethod + view.Hiring = hiring + view.Looking = looking + if linksJSON != "" { + json.Unmarshal([]byte(linksJSON), &view.Links) + } + if interestsJSON != "" { + json.Unmarshal([]byte(interestsJSON), &view.Interests) + } + + // Connection date from local_connections (ATProto viewer → local target). + var connectedAt time.Time + var eventURI sql.NullString + err := h.DB.QueryRowContext(r.Context(), ` + SELECT connected_at, event_uri FROM local_connections + WHERE viewer_did = ? AND target_local_id = ? + `, viewerDID, targetLocalID).Scan(&connectedAt, &eventURI) + if err == nil { + view.ConnectedAt = connectedAt.Format("Jan 2, 2006") + if eventURI.Valid && eventURI.String != "" { + if ev, err := event.Get(r.Context(), h.DB, eventURI.String); err == nil { + view.EventName = ev.Name + } + } + } + + // Load private notes from SQLite. + note, err := notes.Get(r.Context(), h.DB, viewerDID, targetLocalID) + if err != nil { + slog.Debug("connections/view: load notes", "err", err) + } + view.Notes = note.Notes + view.FollowUp = note.FollowUp + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.ProfileViewPage(view).Render(r.Context(), w); err != nil { + slog.Error("render connection profile", "err", err) + } +} + // SaveNotes handles POST /connections/{did}/notes — persists the viewer's // private notes and follow-up flag for the given target to SQLite. func (h *Handlers) SaveNotes(w http.ResponseWriter, r *http.Request) { // Notes are local-only (SQLite), so we only need the viewer's DID from - // the session cookie — no OAuth token resume required. This avoids - // token-refresh races with the drain middleware and is much faster. + // the session cookie — no OAuth token resume required. didStr, _ := h.Auth.Sessions.Get(r) + if didStr == "" { + didStr = h.Auth.Sessions.GetLocal(r) + } if didStr == "" { http.Error(w, "not authenticated", http.StatusUnauthorized) return @@ -426,9 +647,12 @@ func (h *Handlers) SaveNotes(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid DID", http.StatusBadRequest) return } - if _, err := syntax.ParseDID(targetStr); err != nil { - http.Error(w, "invalid DID", http.StatusBadRequest) - return + // Accept both ATProto DIDs and local IDs (local_xxx). + if !strings.HasPrefix(targetStr, "local_") { + if _, err := syntax.ParseDID(targetStr); err != nil { + http.Error(w, "invalid DID", http.StatusBadRequest) + return + } } var body struct { @@ -524,6 +748,19 @@ func (h *Handlers) fetchLocalConnections(ctx context.Context, viewerID string) ( item.Handle = handle } + // For ATProto targets not in the users table, try Bluesky profile fetch. + if item.DisplayName == "" && !strings.HasPrefix(targetID, "local_") { + if parsed, pErr := syntax.ParseDID(targetID); pErr == nil { + pds := pdsHostForDID(ctx, h.DB, parsed) + if bsky, bErr := profile.FetchBluesky(ctx, pds, parsed); bErr == nil && bsky != nil { + item.DisplayName = bsky.DisplayName + if bsky.Avatar != nil { + item.AvatarURL = profile.AvatarURL(pds, parsed, bsky.Avatar.CID()) + } + } + } + } + // Load follow-up flag from SQLite note, err := notes.Get(ctx, h.DB, viewerID, targetID) if err == nil { diff --git a/features/connections/pages/profile.templ b/features/connections/pages/profile.templ index cb337e4..ba441bf 100644 --- a/features/connections/pages/profile.templ +++ b/features/connections/pages/profile.templ @@ -1,6 +1,11 @@ package pages -import "atmoquest/features/common/layouts" +import ( + "html" + "strings" + + "atmoquest/features/common/layouts" +) // ProfileView is the data for rendering another person's profile page. type ProfileView struct { @@ -85,7 +90,7 @@ templ ProfileViewPage(v ProfileView) { if v.Bio != "" || v.WorksAt != "" || v.Location != "" || v.ContactMethod != "" {
if v.Bio != "" { -

{ v.Bio }

+

@nl2br(v.Bio)

}
if v.WorksAt != "" { @@ -182,3 +187,8 @@ func profileTitle(v ProfileView) string { } return "profile" } + +func nl2br(s string) templ.Component { + s = strings.ReplaceAll(s, "\r\n", "\n") + return templ.Raw(strings.ReplaceAll(html.EscapeString(s), "\n", "
")) +} diff --git a/features/connections/pages/profile_templ.go b/features/connections/pages/profile_templ.go index daa9619..078b2c3 100644 --- a/features/connections/pages/profile_templ.go +++ b/features/connections/pages/profile_templ.go @@ -8,7 +8,12 @@ package pages import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" -import "atmoquest/features/common/layouts" +import ( + "html" + "strings" + + "atmoquest/features/common/layouts" +) // ProfileView is the data for rendering another person's profile page. type ProfileView struct { @@ -84,7 +89,7 @@ func ProfileViewPage(v ProfileView) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 43, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 48, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -97,7 +102,7 @@ func ProfileViewPage(v ProfileView) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(v.TargetDID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 44, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 49, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -115,7 +120,7 @@ func ProfileViewPage(v ProfileView) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.AvatarURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 51, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 56, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { @@ -128,7 +133,7 @@ func ProfileViewPage(v ProfileView) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(avatarAlt(v.DisplayName)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 51, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 56, Col: 81} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -156,7 +161,7 @@ func ProfileViewPage(v ProfileView) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 58, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 63, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -179,7 +184,7 @@ func ProfileViewPage(v ProfileView) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.TargetDID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 62, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 67, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -197,7 +202,7 @@ func ProfileViewPage(v ProfileView) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(v.ConnectedAt) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 70, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 75, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -216,7 +221,7 @@ func ProfileViewPage(v ProfileView) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 74, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 79, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -253,12 +258,7 @@ func ProfileViewPage(v ProfileView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(v.Bio) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 88, Col: 33} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + templ_7745c5c3_Err = nl2br(v.Bio).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -276,12 +276,12 @@ func ProfileViewPage(v ProfileView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(v.WorksAt) + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(v.WorksAt) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 94, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 99, Col: 49} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -295,12 +295,12 @@ func ProfileViewPage(v ProfileView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(v.Location) + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(v.Location) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 100, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 105, Col: 50} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -314,12 +314,12 @@ func ProfileViewPage(v ProfileView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(v.ContactMethod) + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(v.ContactMethod) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 106, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 111, Col: 55} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -343,12 +343,12 @@ func ProfileViewPage(v ProfileView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var15 templ.SafeURL - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(link.URL)) + var templ_7745c5c3_Var14 templ.SafeURL + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(link.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 119, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 124, Col: 42} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -356,12 +356,12 @@ func ProfileViewPage(v ProfileView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(link.Label) + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(link.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 119, Col: 128} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 124, Col: 128} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -385,12 +385,12 @@ func ProfileViewPage(v ProfileView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(interest) + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(interest) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 131, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 136, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -408,12 +408,12 @@ func ProfileViewPage(v ProfileView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.TargetDID) + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.TargetDID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 138, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 143, Col: 64} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -421,12 +421,12 @@ func ProfileViewPage(v ProfileView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(v.Notes) + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(v.Notes) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 150, Col: 16} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 155, Col: 16} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -474,4 +474,9 @@ func profileTitle(v ProfileView) string { return "profile" } +func nl2br(s string) templ.Component { + s = strings.ReplaceAll(s, "\r\n", "\n") + return templ.Raw(strings.ReplaceAll(html.EscapeString(s), "\n", "
")) +} + var _ = templruntime.GeneratedTemplate diff --git a/features/index/handlers.go b/features/index/handlers.go index d813f65..7c947cf 100644 --- a/features/index/handlers.go +++ b/features/index/handlers.go @@ -87,6 +87,19 @@ func (h *Handlers) IndexPage(w http.ResponseWriter, r *http.Request) { h.fillAuthedView(r, did, &view) h.fillRecentConnections(r, did, &view) } + } else if h.DB != nil { + // Local user: fetch display name + handle from users table. + var displayName, handle string + _ = h.DB.QueryRowContext(r.Context(), + `SELECT display_name, handle FROM users WHERE did = ?`, identity.DID).Scan(&displayName, &handle) + view.DisplayName = displayName + view.Handle = handle + + connectURL := strings.TrimRight(config.Global.PublicURL, "/") + "/c/l/" + identity.DID + view.QRConnectURL = connectURL + if config.Global.Environment == config.Dev { + view.ConnectURL = connectURL + } } } } diff --git a/features/profile/handlers.go b/features/profile/handlers.go index 0dae8d8..553330e 100644 --- a/features/profile/handlers.go +++ b/features/profile/handlers.go @@ -422,7 +422,7 @@ func (h *Handlers) saveLocalProfile(w http.ResponseWriter, r *http.Request, loca // Parse form data displayName := strings.TrimSpace(r.FormValue("display_name")) - bio := r.FormValue("bio") + bio := strings.ReplaceAll(r.FormValue("bio"), "\r\n", "\n") interestsStr := r.FormValue("interests") location := r.FormValue("location") worksAt := r.FormValue("works_at") diff --git a/features/profile/pages/profile.templ b/features/profile/pages/profile.templ index 15053c8..1dfff62 100644 --- a/features/profile/pages/profile.templ +++ b/features/profile/pages/profile.templ @@ -1,6 +1,11 @@ package pages -import "atmoquest/features/common/layouts" +import ( + "html" + "strings" + + "atmoquest/features/common/layouts" +) // ProfileLink mirrors profile.Link for the view layer. type ProfileLink struct { @@ -140,7 +145,7 @@ templ Profile(v ProfileView) { if v.Bio != "" {
bio
-
{ v.Bio }
+
@nl2br(v.Bio)
} if v.WorksAt != "" { @@ -243,6 +248,11 @@ func avatarAlt(displayName string) string { return displayName + "'s profile picture" } +func nl2br(s string) templ.Component { + s = strings.ReplaceAll(s, "\r\n", "\n") + return templ.Raw(strings.ReplaceAll(html.EscapeString(s), "\n", "
")) +} + templ profileBadge(b ProfileBadge) {