From 41bd03f026bcc0cbea0e930aa3d7bc0cb0ae444d Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Fri, 3 Jul 2026 07:57:45 -0400 Subject: [PATCH] Cleaner social links --- internal/e2e/harness_test.go | 7 ++ internal/e2e/user_public_test.go | 96 +++++++++++++++++++ internal/marketing/templates/user_public.html | 60 +++++------- internal/marketing/users.go | 37 ++++--- internal/store/link_platform_test.go | 4 +- internal/store/org_links.go | 14 ++- internal/store/user_links.go | 9 ++ internal/web/links.go | 4 +- internal/web/templates/icons.html | 2 +- 9 files changed, 171 insertions(+), 62 deletions(-) create mode 100644 internal/e2e/user_public_test.go diff --git a/internal/e2e/harness_test.go b/internal/e2e/harness_test.go index 061c2f7..4e635df 100644 --- a/internal/e2e/harness_test.go +++ b/internal/e2e/harness_test.go @@ -118,6 +118,13 @@ func authReachableHosts() hostLayout { return hostLayout{app: "app." + browserHost(), auth: browserHost(), root: "root." + browserHost()} } +// rootReachableHosts puts the ROOT (public marketing) surface on the +// browser-reachable host so a browser test can drive public pages like the +// /u/{username} profile. +func rootReachableHosts() hostLayout { + return hostLayout{app: "app." + browserHost(), auth: "auth." + browserHost(), root: browserHost()} +} + // newE2EServer stands up the app on a 0.0.0.0 listener so the browser container // can connect back to it, and returns both address forms. An optional hostLayout // selects which surface the browser can reach (defaults to the app surface). diff --git a/internal/e2e/user_public_test.go b/internal/e2e/user_public_test.go new file mode 100644 index 0000000..d05c55f --- /dev/null +++ b/internal/e2e/user_public_test.go @@ -0,0 +1,96 @@ +//go:build browser + +package e2e + +import ( + "crypto/rand" + "strings" + "testing" + + cdplog "github.com/chromedp/cdproto/log" + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" + meshcore "github.com/meshcore-go/meshcore-go" + + "github.com/jleight/meshtender/internal/store" +) + +// TestE2EUserPublicLinks renders a public profile in a real browser and checks +// the links list: email shows the address (not "Email"), text platforms show the +// handle on a single line, handle platforms show the "@handle", and a MeshCore +// key collapses to a row that expands to a QR code + the key — all under the +// strict CSP. +func TestE2EUserPublicLinks(t *testing.T) { + // The public profile is served by the root (marketing) surface. + srv := newE2EServer(t, rootReachableHosts()) + + u, err := srv.store.CreateUser(srv.ctx, "profileuser", "") + if err != nil { + t.Fatalf("create user: %v", err) + } + id, _ := meshcore.GenerateLocalIdentity(rand.Reader) + meshKey := id.String() + err = srv.store.ReplaceUserLinks(srv.ctx, u.ID, []store.UserLink{ + {Platform: store.EmailPlatform, URL: "person@example.com", IsPrimary: true}, + {Platform: store.SignalPlatform, URL: "jleight.07"}, + {Platform: "discord", URL: "xerofait"}, + {Platform: "github", URL: "https://github.com/jleight"}, + {Platform: store.MeshCorePlatform, Label: "Base Node", URL: meshKey}, + }) + if err != nil { + t.Fatalf("replace links: %v", err) + } + + bctx, cancel, watch := startBrowser(t) + defer cancel() + + profileURL := srv.browserURL + "/u/profileuser" + var listText, meshCodeText string + var qrShown, titlesShown bool + if err := chromedp.Run(bctx, + network.Enable(), + cdplog.Enable(), + chromedp.Navigate(profileURL), + chromedp.WaitVisible(`.list-group`, chromedp.ByQuery), + chromedp.Text(`.list-group`, &listText, chromedp.ByQuery), + // Each icon carries a hover title naming its platform. + chromedp.Evaluate(`['GitHub','Signal','Discord','Email','MeshCore'].every(function(n){return !!document.querySelector('.list-group [title="'+n+'"]');})`, &titlesShown), + // Expand the MeshCore row (Bootstrap collapse under the CSP) and read the key. + // Target the row's own toggle — the navbar also uses data-bs-toggle=collapse. + chromedp.Click(`.list-group a[href^="#mesh-"]`, chromedp.ByQuery), + chromedp.WaitVisible(`.collapse.show .pk`, chromedp.ByQuery), + chromedp.Text(`.collapse.show .pk`, &meshCodeText, chromedp.ByQuery), + chromedp.WaitVisible(`.collapse.show img`, chromedp.ByQuery), + chromedp.Evaluate(`!!document.querySelector('.collapse.show img[src^="data:image"]')`, &qrShown), + ); err != nil { + t.Fatalf("browser run against %s: %v", profileURL, err) + } + + // Email shows the address, not the platform name. + if !strings.Contains(listText, "person@example.com") { + t.Errorf("email address not shown; list text:\n%s", listText) + } + if strings.Contains(listText, "Email") { + t.Errorf("email row still shows the %q label; list text:\n%s", "Email", listText) + } + // Text handles appear (once each — the old duplicate mono line is gone). + for _, want := range []string{"jleight.07", "xerofait", "@jleight", "Base Node"} { + if !strings.Contains(listText, want) { + t.Errorf("missing %q in links list; text:\n%s", want, listText) + } + } + if strings.Count(listText, "jleight.07") != 1 { + t.Errorf("signal handle should appear exactly once, got %d; text:\n%s", strings.Count(listText, "jleight.07"), listText) + } + // The expanded MeshCore row reveals the key and a QR image. + if !strings.Contains(meshCodeText, meshKey) { + t.Errorf("expanded MeshCore key = %q, want %q", meshCodeText, meshKey) + } + if !qrShown { + t.Error("expanded MeshCore row has no QR image") + } + if !titlesShown { + t.Error("link icons are missing hover titles naming their platform") + } + watch.assertClean(t) +} diff --git a/internal/marketing/templates/user_public.html b/internal/marketing/templates/user_public.html index b464597..1e94d22 100644 --- a/internal/marketing/templates/user_public.html +++ b/internal/marketing/templates/user_public.html @@ -9,7 +9,7 @@ {{end}} {{define "content"}} -{{$hasSidebar := or .Links .MeshKeys}} +{{$hasSidebar := .Links}}
@@ -30,53 +30,43 @@
- + {{if $hasSidebar}}
- {{if .Links}}

Links

- {{range .Links}} - {{if .Href}} - - {{template "link-icon" .Platform}} - {{.Display}} - {{if .IsPrimary}}Primary{{end}} + {{range $i, $l := .Links}} + {{if $l.IsMeshCore}} +
+ +
+ {{if $l.QR}}
MeshCore contact QR for {{$l.Display}}
{{end}} + {{$l.URL}} +
Scan to add this contact in the MeshCore app.
+
+
+ {{else if $l.Href}} + + {{template "link-icon" $l.Platform}} + {{$l.Display}} + {{if $l.IsPrimary}}Primary{{end}} {{template "icon-arrow-right" ""}} {{else}} -
-
- {{template "link-icon" .Platform}} - {{.Display}} - {{if .IsPrimary}}Primary{{end}} -
-
{{.URL}}
+
+ {{template "link-icon" $l.Platform}} + {{$l.Display}} + {{if $l.IsPrimary}}Primary{{end}}
{{end}} {{end}}
- {{end}} - - {{if .MeshKeys}} -
-
-

MeshCore

-
Scan to add this contact in the MeshCore app.
-
-
- {{range $i, $mk := .MeshKeys}} -
- {{if $mk.QR}}
MeshCore contact QR for {{$mk.Label}}
{{end}} -
{{$mk.Label}}
- {{$mk.Key}} -
- {{end}} -
-
- {{end}}
{{end}}
diff --git a/internal/marketing/users.go b/internal/marketing/users.go index 009a441..0501aa2 100644 --- a/internal/marketing/users.go +++ b/internal/marketing/users.go @@ -14,17 +14,17 @@ import ( "github.com/jleight/meshtender/internal/web" ) -// meshKeyView is a MeshCore public key rendered for the public profile: the -// label, the key text, and a QR code that adds the person as a MeshCore contact. -type meshKeyView struct { - Label string - Key string - QR template.URL +// linkView is one row in the public profile's links list. It embeds the stored +// link (so .Platform/.Display/.Href/.IsPrimary/.URL are available) and adds a QR +// code for MeshCore keys, which render as a collapsible row rather than a link. +type linkView struct { + store.UserLink + QR template.URL // non-empty only for MeshCore keys } // pageUserPublic renders a user's public profile (/u/{username}) for anyone. All // profile fields are optional; an unfilled profile just shows the display name. -// MeshCore-key links render as scannable QR codes; other links render as buttons. +// Every link renders in one list; a MeshCore key expands to a scannable QR code. func (s *Handlers) pageUserPublic(w http.ResponseWriter, r *http.Request) { username := auth.NormalizeUsername(chi.URLParam(r, "username")) u, err := s.Store.GetUserByUsername(r.Context(), username) @@ -41,34 +41,29 @@ func (s *Handlers) pageUserPublic(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not load profile", http.StatusInternalServerError) return } - // Ordinary links render as buttons; MeshCore keys render as QR codes. - var webLinks []store.UserLink - var meshKeys []meshKeyView + views := make([]linkView, 0, len(links)) for _, l := range links { + lv := linkView{UserLink: l} if l.IsMeshCore() { - mk := meshKeyView{Label: l.Display(), Key: l.URL} if qr, ok := web.QRDataURI(web.MeshCoreContactURI(u.Name(), l.URL, int(meshcore.AdvertTypeChat))); ok { - mk.QR = qr + lv.QR = qr } - meshKeys = append(meshKeys, mk) - continue } - webLinks = append(webLinks, l) + views = append(views, lv) } // Surface the primary contact first (it's how people are meant to reach this - // person); keep the editor's order otherwise. - sort.SliceStable(webLinks, func(i, j int) bool { - return webLinks[i].IsPrimary && !webLinks[j].IsPrimary + // person); keep the editor's order otherwise. MeshCore keys are never primary. + sort.SliceStable(views, func(i, j int) bool { + return views[i].IsPrimary && !views[j].IsPrimary }) // Whether there's anything beyond the name to show — drives an empty-state hint. - hasDetails := u.Bio != "" || u.Location != "" || u.Callsign != "" || len(webLinks) > 0 || len(meshKeys) > 0 + hasDetails := u.Bio != "" || u.Location != "" || u.Callsign != "" || len(views) > 0 s.Render(w, r, "user_public.html", map[string]any{ "ProfileUser": u, "Bio": u.Bio, "Location": u.Location, "Callsign": u.Callsign, - "Links": webLinks, - "MeshKeys": meshKeys, + "Links": views, "HasDetails": hasDetails, }) } diff --git a/internal/store/link_platform_test.go b/internal/store/link_platform_test.go index e708025..2826f8c 100644 --- a/internal/store/link_platform_test.go +++ b/internal/store/link_platform_test.go @@ -109,8 +109,8 @@ func TestHandleFromURLAndDisplay(t *testing.T) { // Text platform: display the handle text, no link. {UserLink{Platform: "discord", URL: "cooluser"}, "cooluser", ""}, {UserLink{Platform: SignalPlatform, URL: "alice.42"}, "alice.42", ""}, - // Email: mailto:, name as the default display. - {UserLink{Platform: EmailPlatform, URL: "a@b.com"}, "Email", "mailto:a@b.com"}, + // Email: mailto:, and the address itself as the display (not "Email"). + {UserLink{Platform: EmailPlatform, URL: "a@b.com"}, "a@b.com", "mailto:a@b.com"}, // URL platform with no label falls back to the platform name. {UserLink{Platform: "website", URL: "https://example.org"}, "Website", "https://example.org"}, // MeshCore key: no href. diff --git a/internal/store/org_links.go b/internal/store/org_links.go index e08b8a2..ce6a4bc 100644 --- a/internal/store/org_links.go +++ b/internal/store/org_links.go @@ -304,6 +304,15 @@ func (l OrgLink) Href() string { return linkHref(linkPlatformByKey[l.Platform], l.URL) } +// PlatformName is the platform's display name (e.g. "Discord"), used as the +// icon's hover title. Falls back to the raw key for an unknown platform. +func (l OrgLink) PlatformName() string { + if p, ok := linkPlatformByKey[l.Platform]; ok { + return p.Name + } + return l.Platform +} + // linkDisplay computes the default display text for a link value given its // platform descriptor (zero value if the key is unknown). func linkDisplay(p LinkPlatform, key, value string) string { @@ -313,11 +322,12 @@ func linkDisplay(p LinkPlatform, key, value string) string { return h } return p.Name - case KindText: + case KindText, KindEmail: + // Show the handle / email address itself rather than the platform name. return value case "": return key // unknown platform — show the raw key defensively - default: + default: // KindURL, KindKey — the platform name (or a custom label upstream) return p.Name } } diff --git a/internal/store/user_links.go b/internal/store/user_links.go index 86c8fb9..6b4ff65 100644 --- a/internal/store/user_links.go +++ b/internal/store/user_links.go @@ -73,6 +73,15 @@ func (l UserLink) Display() string { // a QR code) rather than an ordinary URL. func (l UserLink) IsMeshCore() bool { return l.Platform == MeshCorePlatform } +// PlatformName is the platform's display name (e.g. "GitHub"), used as the icon's +// hover title. Falls back to the raw key for an unknown platform. +func (l UserLink) PlatformName() string { + if p, ok := userLinkPlatformByKey[l.Platform]; ok { + return p.Name + } + return l.Platform +} + // Href is the hyperlink target for this link, or "" when it isn't directly // linkable (a MeshCore key renders as a QR; Signal/Discord handles as plain // text). An email becomes a mailto: link; handle/URL platforms use the stored diff --git a/internal/web/links.go b/internal/web/links.go index 3282a36..bbfe143 100644 --- a/internal/web/links.go +++ b/internal/web/links.go @@ -26,7 +26,9 @@ func LinkPlatformsJS(ps []store.LinkPlatform) template.JS { m[p.Key] = linkPlatformClient{ Kind: string(p.Kind), Placeholder: p.Placeholder, - Label: p.Kind == store.KindURL, + // Website takes a free-text label; MeshCore's label is the node name. + // Branded platforms don't — their name/handle is the label. + Label: p.Kind == store.KindURL || p.Kind == store.KindKey, } } b, err := json.Marshal(m) diff --git a/internal/web/templates/icons.html b/internal/web/templates/icons.html index bb6e957..6fd9538 100644 --- a/internal/web/templates/icons.html +++ b/internal/web/templates/icons.html @@ -53,4 +53,4 @@ {{define "link-icon"}}{{if eq . "email"}}{{template "icon-mail" ""}}{{else if eq . "signal"}}{{template "icon-brand-signal" ""}}{{else if eq . "meshcore"}}{{template "icon-key" ""}}{{else if eq . "discord"}}{{template "icon-brand-discord" ""}}{{else if eq . "facebook"}}{{template "icon-brand-facebook" ""}}{{else if eq . "instagram"}}{{template "icon-brand-instagram" ""}}{{else if eq . "x"}}{{template "icon-brand-x" ""}}{{else if eq . "youtube"}}{{template "icon-brand-youtube" ""}}{{else if eq . "tiktok"}}{{template "icon-brand-tiktok" ""}}{{else if eq . "twitch"}}{{template "icon-brand-twitch" ""}}{{else if eq . "github"}}{{template "icon-brand-github" ""}}{{else if eq . "telegram"}}{{template "icon-brand-telegram" ""}}{{else if eq . "reddit"}}{{template "icon-brand-reddit" ""}}{{else if eq . "linkedin"}}{{template "icon-brand-linkedin" ""}}{{else if eq . "mastodon"}}{{template "icon-brand-mastodon" ""}}{{else if eq . "bluesky"}}{{template "icon-brand-bluesky" ""}}{{else}}{{template "icon-link" ""}}{{end}}{{end}} {{/* link-list renders a wrapping row of org link buttons. . is a slice of links, each exposing .Platform, .URL, and .Display. */}} -{{define "link-list"}}
{{range .}}{{if .Href}}{{template "link-icon" .Platform}}{{.Display}}{{else}}{{template "link-icon" .Platform}}{{.Display}}{{end}}{{end}}
{{end}} +{{define "link-list"}}
{{range .}}{{if .Href}}{{template "link-icon" .Platform}}{{.Display}}{{else}}{{template "link-icon" .Platform}}{{.Display}}{{end}}{{end}}
{{end}}