Make sure discord invite links are clickable

This commit is contained in:
Jonathon Leight committed 2026-07-03 20:05:59 -04:00
1 parent cc40ead28d
commit 1f68a4b047
4 files changed
+89 -13

No files matched your search

+13 -2
View File
@@ -328,15 +328,26 @@ func validMeshCoreKey(s string) bool {
// Signal enforces its username grammar; other text platforms (Discord) just need
// a non-empty, space-free handle.
func normalizeTextHandle(platform, val string) (string, string) {
v := strings.TrimPrefix(strings.TrimSpace(val), "@")
val = strings.TrimSpace(val)
if platform == store.SignalPlatform {
v := strings.TrimPrefix(val, "@")
if !validSignalUsername(v) {
return "", "Enter a valid Signal username (3–32 characters: letters, digits, . and _)."
}
return v, ""
}
// A text handle (Discord) also accepts an invite/profile URL, stored and
// rendered as a clickable link; a bare username stays plain text.
if store.LooksLikeURL(val) {
u := store.NormalizeLinkURL(val)
if !store.ValidLinkURL(u) {
return "", "Enter a valid username or invite link."
}
return u, ""
}
v := strings.TrimPrefix(val, "@")
if v == "" || len(v) > 64 || strings.ContainsAny(v, " \t\n\r") {
return "", "Enter a valid username (no spaces)."
return "", "Enter a valid username (no spaces) or an invite link."
}
return v, ""
}
+14 -6
View File
@@ -327,13 +327,21 @@ func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) {
}
// Validate/canonicalise per kind, leaving `u` as the value to persist.
switch p.Kind {
case store.KindText: // Discord — a handle shown as text.
v := strings.TrimPrefix(u, "@")
if v == "" || len(v) > 64 || strings.ContainsAny(v, " \t\n\r") {
orgErr(w, r, "Enter a valid username (no spaces).")
return
case store.KindText: // Discord — a username shown as text, or an invite link.
if store.LooksLikeURL(u) {
u = store.NormalizeLinkURL(u)
if !store.ValidLinkURL(u) {
orgErr(w, r, "Enter a valid username or invite link.")
return
}
} else {
v := strings.TrimPrefix(u, "@")
if v == "" || len(v) > 64 || strings.ContainsAny(v, " \t\n\r") {
orgErr(w, r, "Enter a valid username (no spaces) or an invite link.")
return
}
u = v
}
u = v
case store.KindHandle:
canon, ok := p.CanonicalHandleURL(u)
if !ok {
+22
View File
@@ -108,6 +108,10 @@ func TestHandleFromURLAndDisplay(t *testing.T) {
{UserLink{Platform: "github", Label: "My code", URL: "https://github.com/octocat"}, "My code", "https://github.com/octocat"},
// Text platform: display the handle text, no link.
{UserLink{Platform: "discord", URL: "cooluser"}, "cooluser", ""},
// A new-style Discord username may contain dots but is still just text.
{UserLink{Platform: "discord", URL: "cool.user"}, "cool.user", ""},
// A Discord invite URL is clickable and reads as the platform name.
{UserLink{Platform: "discord", URL: "https://discord.gg/abc123"}, "Discord", "https://discord.gg/abc123"},
{UserLink{Platform: SignalPlatform, URL: "alice.42"}, "alice.42", ""},
// 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"},
@@ -133,3 +137,21 @@ func TestHandleFromURLAndDisplay(t *testing.T) {
t.Errorf("org discord Href() = %q, want empty", h)
}
}
func TestLooksLikeURL(t *testing.T) {
cases := map[string]bool{
"cooluser": false, // bare handle
"cool.user": false, // dotted handle (new Discord usernames)
"@handle": false,
"discord.gg/abc": true, // bare host + path
"discord.com/invite/abc": true,
"https://discord.gg/abc": true,
"http://example.com": true,
"example.com": false, // dotted host but no path — treated as a handle
}
for in, want := range cases {
if got := LooksLikeURL(in); got != want {
t.Errorf("LooksLikeURL(%q) = %v, want %v", in, got, want)
}
}
}
+40 -5
View File
@@ -76,7 +76,7 @@ var linkPlatforms = []LinkPlatform{
{Key: "telegram", Name: "Telegram", Kind: KindHandle, Icon: "icon-brand-telegram", URLFmt: "https://t.me/%s", Hosts: []string{"t.me", "telegram.me"}, Placeholder: "@username or profile URL"},
{Key: mastodonKey, Name: "Mastodon", Kind: KindHandle, Icon: "icon-brand-mastodon", Placeholder: "@user@instance or profile URL"},
{Key: "bluesky", Name: "Bluesky", Kind: KindHandle, Icon: "icon-brand-bluesky", URLFmt: "https://bsky.app/profile/%s", Hosts: []string{"bsky.app"}, Placeholder: "@handle or profile URL"},
{Key: "discord", Name: "Discord", Kind: KindText, Icon: "icon-brand-discord", Placeholder: "username"},
{Key: "discord", Name: "Discord", Kind: KindText, Icon: "icon-brand-discord", Placeholder: "username or invite link"},
}
var linkPlatformByKey = func() map[string]LinkPlatform {
@@ -322,8 +322,15 @@ func linkDisplay(p LinkPlatform, key, value string) string {
return h
}
return p.Name
case KindText, KindEmail:
// Show the handle / email address itself rather than the platform name.
case KindText:
// A text handle (e.g. a Discord username) shows itself; but the field also
// accepts an invite/profile URL, which reads better as the platform name.
if isHTTPURL(value) {
return p.Name
}
return value
case KindEmail:
// Show the email address itself rather than the platform name.
return value
case "":
return key // unknown platform — show the raw key defensively
@@ -333,10 +340,17 @@ func linkDisplay(p LinkPlatform, key, value string) string {
}
// linkHref computes the hyperlink target for a link value given its platform
// descriptor. Text/key platforms aren't linkable; email becomes mailto:.
// descriptor. Key platforms aren't linkable; email becomes mailto:. A text handle
// isn't linkable either — except the field accepts a URL (e.g. a Discord invite),
// which we do link.
func linkHref(p LinkPlatform, value string) string {
switch p.Kind {
case KindText, KindKey:
case KindKey:
return ""
case KindText:
if isHTTPURL(value) {
return value
}
return ""
case KindEmail:
return "mailto:" + value
@@ -345,6 +359,27 @@ func linkHref(p LinkPlatform, value string) string {
}
}
// isHTTPURL reports whether value is already a stored http(s) URL. Stored values
// are normalized on save, so a scheme prefix is a reliable signal.
func isHTTPURL(value string) bool {
return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://")
}
// LooksLikeURL reports whether raw was entered as a web URL (a scheme, or a
// dotted host followed by a path like "discord.gg/abc") rather than a bare handle.
// A handle may contain dots (new Discord usernames do) but never a slash, so the
// path is the reliable discriminator.
func LooksLikeURL(raw string) bool {
raw = strings.TrimSpace(raw)
if strings.Contains(raw, "://") {
return true
}
if i := strings.IndexByte(raw, '/'); i > 0 {
return strings.Contains(raw[:i], ".")
}
return false
}
// ListOrgLinks returns an org's links in display order.
func (s *Store) ListOrgLinks(ctx context.Context, orgID int64) ([]OrgLink, error) {
rows, err := s.pool.Query(ctx,