mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-02 01:38:17 +00:00
Cleaner social links
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user