Cleaner social links

This commit is contained in:
Jonathon Leight
2026-07-03 07:57:45 -04:00
parent 17c9e00925
commit 41bd03f026
9 changed files with 171 additions and 62 deletions
+7
View File
@@ -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).
+96
View File
@@ -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)
}
+25 -35
View File
@@ -9,7 +9,7 @@
</div>
{{end}}
{{define "content"}}
{{$hasSidebar := or .Links .MeshKeys}}
{{$hasSidebar := .Links}}
<div class="row row-cards">
<!-- Left: about -->
<div class="{{if $hasSidebar}}col-lg-8{{else}}col-12{{end}}">
@@ -30,53 +30,43 @@
</div>
</div>
<!-- Right: contact links + MeshCore -->
<!-- Right: contact links (MeshCore keys expand inline to a QR code) -->
{{if $hasSidebar}}
<div class="col-lg-4">
{{if .Links}}
<div class="card">
<div class="card-header"><h3 class="card-title">Links</h3></div>
<div class="list-group list-group-flush">
{{range .Links}}
{{if .Href}}
<a class="list-group-item list-group-item-action d-flex align-items-center gap-2" href="{{.Href}}" target="_blank" rel="noopener noreferrer nofollow">
<span class="text-secondary d-flex">{{template "link-icon" .Platform}}</span>
<span class="flex-fill text-truncate">{{.Display}}</span>
{{if .IsPrimary}}<span class="badge bg-primary-lt">Primary</span>{{end}}
{{range $i, $l := .Links}}
{{if $l.IsMeshCore}}
<div class="list-group-item">
<a class="d-flex align-items-center gap-2 text-reset" data-bs-toggle="collapse" href="#mesh-{{$i}}" role="button" aria-expanded="false" aria-controls="mesh-{{$i}}">
<span class="text-secondary d-flex" title="{{$l.PlatformName}}">{{template "link-icon" $l.Platform}}</span>
<span class="flex-fill text-truncate">{{$l.Display}}</span>
<span class="text-secondary d-flex">{{template "icon-qrcode" ""}}</span>
</a>
<div class="collapse" id="mesh-{{$i}}">
{{if $l.QR}}<div class="text-center mt-3"><img class="rounded border" src="{{$l.QR}}" width="200" height="200" alt="MeshCore contact QR for {{$l.Display}}"></div>{{end}}
<code class="pk d-block mt-2">{{$l.URL}}</code>
<div class="text-secondary small mt-1">Scan to add this contact in the MeshCore app.</div>
</div>
</div>
{{else if $l.Href}}
<a class="list-group-item list-group-item-action d-flex align-items-center gap-2" href="{{$l.Href}}" target="_blank" rel="noopener noreferrer nofollow">
<span class="text-secondary d-flex" title="{{$l.PlatformName}}">{{template "link-icon" $l.Platform}}</span>
<span class="flex-fill text-truncate">{{$l.Display}}</span>
{{if $l.IsPrimary}}<span class="badge bg-primary-lt">Primary</span>{{end}}
<span class="text-secondary d-flex">{{template "icon-arrow-right" ""}}</span>
</a>
{{else}}
<div class="list-group-item">
<div class="d-flex align-items-center gap-2">
<span class="text-secondary d-flex">{{template "link-icon" .Platform}}</span>
<span class="flex-fill">{{.Display}}</span>
{{if .IsPrimary}}<span class="badge bg-primary-lt">Primary</span>{{end}}
</div>
<div class="text-secondary font-monospace text-truncate mt-1">{{.URL}}</div>
<div class="list-group-item d-flex align-items-center gap-2">
<span class="text-secondary d-flex" title="{{$l.PlatformName}}">{{template "link-icon" $l.Platform}}</span>
<span class="flex-fill text-truncate">{{$l.Display}}</span>
{{if $l.IsPrimary}}<span class="badge bg-primary-lt">Primary</span>{{end}}
</div>
{{end}}
{{end}}
</div>
</div>
{{end}}
{{if .MeshKeys}}
<div class="card {{if .Links}}mt-3{{end}}">
<div class="card-header">
<h3 class="card-title">MeshCore</h3>
<div class="card-subtitle">Scan to add this contact in the MeshCore app.</div>
</div>
<div class="card-body">
{{range $i, $mk := .MeshKeys}}
<div class="{{if $i}}mt-3{{end}}">
{{if $mk.QR}}<div class="text-center mb-2"><img class="rounded border" src="{{$mk.QR}}" width="200" height="200" alt="MeshCore contact QR for {{$mk.Label}}"></div>{{end}}
<div class="form-label mb-1">{{$mk.Label}}</div>
<code class="pk d-block">{{$mk.Key}}</code>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
</div>
+16 -21
View File
@@ -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,
})
}
+2 -2
View File
@@ -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.
+12 -2
View File
@@ -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
}
}
+9
View File
@@ -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
+3 -1
View File
@@ -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)
+1 -1
View File
@@ -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"}}<div class="btn-list">{{range .}}{{if .Href}}<a class="btn" href="{{.Href}}" target="_blank" rel="noopener noreferrer nofollow">{{template "link-icon" .Platform}}<span class="ms-1">{{.Display}}</span></a>{{else}}<span class="btn disabled">{{template "link-icon" .Platform}}<span class="ms-1">{{.Display}}</span></span>{{end}}{{end}}</div>{{end}}
{{define "link-list"}}<div class="btn-list">{{range .}}{{if .Href}}<a class="btn" href="{{.Href}}" target="_blank" rel="noopener noreferrer nofollow"><span class="d-flex" title="{{.PlatformName}}">{{template "link-icon" .Platform}}</span><span class="ms-1">{{.Display}}</span></a>{{else}}<span class="btn disabled"><span class="d-flex" title="{{.PlatformName}}">{{template "link-icon" .Platform}}</span><span class="ms-1">{{.Display}}</span></span>{{end}}{{end}}</div>{{end}}