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}}
-
+
{{if $hasSidebar}}
- {{if .Links}}
- {{end}}
-
- {{if .MeshKeys}}
-
-
-
- {{range $i, $mk := .MeshKeys}}
-
- {{if $mk.QR}}
{{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"}}{{end}}
+{{define "link-list"}}{{end}}