Skip to content

Commit e48929e

Browse files
committed
support group nicknames, based on NIP-AD.
1 parent 8f17339 commit e48929e

9 files changed

Lines changed: 332 additions & 4 deletions

File tree

global/settings.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ type UserSettings struct {
108108
LiveKitServerURL string `json:"livekit_server_url"`
109109
LiveKitAPIKey string `json:"livekit_apikey"`
110110
LiveKitAPISecret string `json:"livekit_apisecret"`
111+
NIPAD struct {
112+
Enabled bool `json:"enabled"`
113+
Names map[string]string `json:"names"`
114+
} `json:"nipad"`
111115
} `json:"groups"`
112116

113117
Grasp struct {
@@ -398,6 +402,9 @@ func loadUserSettings() error {
398402
// nip05
399403
Settings.NIP05.Names = make(map[string]nostr.PubKey)
400404

405+
// nip-ad group nicknames
406+
Settings.Groups.NIPAD.Names = make(map[string]string)
407+
401408
path := getUserSettingsPath()
402409
os.MkdirAll(filepath.Dir(path), 0700)
403410

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
1313
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
1414
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
1515
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
16-
fiatjaf.com/nostr v0.0.0-20260814194801-ef0208a87a19 h1:NQdSEJo16tGr49FflxUNzqyqk/YZmrz3otC1AbeRgPw=
17-
fiatjaf.com/nostr v0.0.0-20260814194801-ef0208a87a19/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
1816
fiatjaf.com/nostr v0.0.0-20260815222433-1d52197d55a5 h1:HciPDdae8+LmcVe9EX5oZtg+1HE5dPjO9RMATFT4FJE=
1917
fiatjaf.com/nostr v0.0.0-20260815222433-1d52197d55a5/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
2018
fiatjaf.com/pomegranate v0.0.0-20260515185713-4cdb7e027855 h1:8Y7CD1yl3lYxMKFu0AoBfUAbZYLC1M6yYPd5evwnBsQ=

groups/ad.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ func WebAddressHandler(w http.ResponseWriter, r *http.Request) {
2121
}
2222

2323
groupId, ok := strings.CutPrefix(path, "/groups/")
24+
if ok && groupId == "" {
25+
ok = false
26+
}
27+
if !ok {
28+
// bare nickname resolution, e.g. /<nickname>
29+
if global.Settings.Groups.NIPAD.Enabled {
30+
groupId, ok = global.Settings.Groups.NIPAD.Names[strings.TrimPrefix(path, "/")]
31+
}
32+
}
2433
if !ok || groupId == "" || strings.Contains(groupId, "/") {
2534
http.NotFound(w, r)
2635
return

groups/group.templ

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55

66
"fiatjaf.com/nostr"
7+
"fiatjaf.com/nostr/nip19"
78

89
"github.com/fiatjaf/pyramid/global"
910
"github.com/fiatjaf/pyramid/layout"
@@ -123,10 +124,66 @@ templ groupDetailPage(loggedUser nostr.PubKey, group *Group, events []nostr.Even
123124
}
124125
</div>
125126
</div>
127+
// nip-ad nickname section
128+
if !deleted && global.Settings.Groups.NIPAD.Enabled && (group.IsPrimaryRole(loggedUser) || pyramid.IsRoot(loggedUser)) {
129+
<div>
130+
<h2 class="text-lg font-semibold mb-3 dark:text-stone-200">group nickname</h2>
131+
<p class="text-sm text-stone-600 dark:text-stone-400 mb-3">
132+
nicknames are used for NIP-AD web address resolution, e.g.
133+
<code class="px-1 rounded bg-stone-100 dark:bg-stone-800">{ "https://" + global.Settings.Domain + "/<nickname>" }</code>.
134+
must be a single word with only ascii letters, numbers and underscores, and can't be used by
135+
another group.
136+
</p>
137+
<form
138+
method="POST"
139+
action={ "/groups/nickname/" + group.Address.ID }
140+
class="flex items-center gap-3"
141+
>
142+
<input
143+
type="text"
144+
name="nickname"
145+
value={ groupNickname(group.Address.ID) }
146+
placeholder="e.g. quichelovers"
147+
class="w-full max-w-xs px-3 py-2 border border-stone-300 dark:border-stone-600 rounded-lg bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 focus:outline-none focus:ring-2 focus:ring-stone-500"
148+
/>
149+
<button
150+
type="submit"
151+
class="cursor-pointer px-4 py-2 rounded bg-stone-200 hover:bg-stone-300 dark:bg-stone-700 dark:hover:bg-stone-600 text-stone-700 dark:text-stone-300 font-medium"
152+
>
153+
save
154+
</button>
155+
<p class="text-xs text-stone-500 dark:text-stone-400">leave empty to remove the nickname</p>
156+
</form>
157+
</div>
158+
}
159+
// share group
160+
if !deleted {
161+
<div>
162+
<h2 class="text-lg font-semibold mb-3 dark:text-stone-200">share group</h2>
163+
<div class="space-y-4">
164+
if name := groupNickname(group.Address.ID); name != "" {
165+
<div class="space-y-1">
166+
<p class="text-sm text-stone-600 dark:text-stone-400">web address</p>
167+
<a
168+
href={ global.Settings.HTTPScheme() + global.Settings.Domain + "/" + name }
169+
target="_blank"
170+
class="block px-3 py-2 rounded-lg bg-stone-100 dark:bg-stone-800 text-emerald-600 dark:text-emerald-400 font-mono text-sm break-all hover:underline"
171+
>{ global.Settings.HTTPScheme() + global.Settings.Domain + "/" + name }</a>
172+
</div>
173+
}
174+
<div class="space-y-1">
175+
<p class="text-sm text-stone-600 dark:text-stone-400">nostr address pointer</p>
176+
<code
177+
class="block px-3 py-2 rounded-lg bg-stone-100 dark:bg-stone-800 text-stone-700 dark:text-stone-300 font-mono text-sm break-all"
178+
>{ "nostr:" + nip19.EncodeNaddr(global.Settings.RelayInternalSecretKey.Public(), nostr.KindSimpleGroupMetadata, group.Address.ID, []string{global.Settings.WSScheme() + global.Settings.Domain}) }</code>
179+
</div>
180+
</div>
181+
</div>
182+
}
126183
// external links
127184
if !deleted {
128185
<div>
129-
<h2 class="text-lg font-semibold mb-3 dark:text-stone-200">view on other clients</h2>
186+
<h2 class="text-lg font-semibold mb-3 dark:text-stone-200">open in dedicated clients</h2>
130187
<div class="flex gap-3">
131188
<a
132189
href={ "https://grimoire2.netlify.app/run?cmd=chat%20" + group.Address.Relay + "%27" + group.Address.ID }

groups/handler.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package groups
33
import (
44
"fmt"
55
"net/http"
6+
"regexp"
7+
"strings"
68
"time"
79

810
"fiatjaf.com/nostr"
@@ -47,6 +49,9 @@ func setupEnabled() {
4749
Handler.mux = http.NewServeMux()
4850

4951
Handler.mux.HandleFunc("POST /groups/disable", disableHandler)
52+
Handler.mux.HandleFunc("POST /groups/nipad/enable", nipadEnableHandler)
53+
Handler.mux.HandleFunc("POST /groups/nipad/disable", nipadDisableHandler)
54+
Handler.mux.HandleFunc("POST /groups/nickname/{groupId}", groupNicknameHandler)
5055
Handler.mux.HandleFunc("POST /groups/livekit/start", startEmbeddedLiveKitHandler)
5156
Handler.mux.HandleFunc("POST /groups/livekit/stop", stopEmbeddedLiveKitHandler)
5257
Handler.mux.HandleFunc("POST /groups/livekit/log", livekitLogHandler)
@@ -173,6 +178,104 @@ func disableHandler(w http.ResponseWriter, r *http.Request) {
173178
http.Redirect(w, r, "/groups/", 302)
174179
}
175180

181+
func nipadEnableHandler(w http.ResponseWriter, r *http.Request) {
182+
loggedUser, _ := global.GetLoggedUser(r)
183+
184+
if !pyramid.IsRoot(loggedUser) {
185+
http.Error(w, "unauthorized", 403)
186+
return
187+
}
188+
189+
global.Settings.Groups.NIPAD.Enabled = true
190+
191+
if err := global.SaveUserSettings(); err != nil {
192+
http.Error(w, "failed to save settings: "+err.Error(), 500)
193+
return
194+
}
195+
196+
http.Redirect(w, r, "/groups/", 302)
197+
}
198+
199+
func nipadDisableHandler(w http.ResponseWriter, r *http.Request) {
200+
loggedUser, _ := global.GetLoggedUser(r)
201+
202+
if !pyramid.IsRoot(loggedUser) {
203+
http.Error(w, "unauthorized", 403)
204+
return
205+
}
206+
207+
global.Settings.Groups.NIPAD.Enabled = false
208+
209+
if err := global.SaveUserSettings(); err != nil {
210+
http.Error(w, "failed to save settings: "+err.Error(), 500)
211+
return
212+
}
213+
214+
http.Redirect(w, r, "/groups/", 302)
215+
}
216+
217+
var groupNicknameRe = regexp.MustCompile(`^[a-z0-9_]+$`)
218+
219+
// groupNicknameHandler lets a group admin set or update the group's NIP-AD nickname.
220+
func groupNicknameHandler(w http.ResponseWriter, r *http.Request) {
221+
loggedUser, isLoggedIn := global.GetLoggedUser(r)
222+
if !isLoggedIn {
223+
http.Error(w, "auth-required: must be logged in", 401)
224+
return
225+
}
226+
227+
groupId := r.PathValue("groupId")
228+
group, exists := State.Groups.Load(groupId)
229+
if !exists || !group.IsPrimaryRole(loggedUser) {
230+
http.Error(w, "unauthorized: only group admins can set the nickname", 403)
231+
return
232+
}
233+
234+
nickname := strings.TrimSpace(r.FormValue("nickname"))
235+
nickname = strings.ToLower(nickname)
236+
237+
if nickname == "" {
238+
// clearing the nickname
239+
for name, gid := range global.Settings.Groups.NIPAD.Names {
240+
if gid == groupId {
241+
delete(global.Settings.Groups.NIPAD.Names, name)
242+
}
243+
}
244+
} else {
245+
if !groupNicknameRe.MatchString(nickname) {
246+
http.Error(w, "invalid nickname: must be a single word with only ascii letters, numbers and underscores", 400)
247+
return
248+
}
249+
250+
if isReservedNickname(nickname) {
251+
http.Error(w, "nickname already taken: this name is reserved for a relay path", 400)
252+
return
253+
}
254+
255+
// check no other group already has this nickname
256+
if existing, inUse := global.Settings.Groups.NIPAD.Names[nickname]; inUse && existing != groupId {
257+
http.Error(w, "nickname already taken", 400)
258+
return
259+
}
260+
261+
// clear any previous nickname for this group
262+
for name, gid := range global.Settings.Groups.NIPAD.Names {
263+
if gid == groupId {
264+
delete(global.Settings.Groups.NIPAD.Names, name)
265+
}
266+
}
267+
268+
global.Settings.Groups.NIPAD.Names[nickname] = groupId
269+
}
270+
271+
if err := global.SaveUserSettings(); err != nil {
272+
http.Error(w, "failed to save settings: "+err.Error(), 500)
273+
return
274+
}
275+
276+
http.Redirect(w, r, "/groups/"+groupId, 302)
277+
}
278+
176279
func wipeGroupHandler(w http.ResponseWriter, r *http.Request) {
177280
loggedUser, _ := global.GetLoggedUser(r)
178281

groups/home.templ

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ templ homeGroupsPage(loggedUser nostr.PubKey) {
157157
<thead class="bg-stone-50 dark:bg-stone-800">
158158
<tr>
159159
<th class="px-1 py-3 text-xs font-medium text-stone-500 dark:text-stone-400">group id</th>
160+
if global.Settings.Groups.NIPAD.Enabled {
161+
<th class="px-1 py-3 text-xs font-medium text-stone-500 dark:text-stone-400">nickname</th>
162+
}
160163
<th class="px-1 py-3 text-xs font-medium text-stone-500 dark:text-stone-400">group</th>
161164
<th class="px-1 py-3 text-xs font-medium text-stone-500 dark:text-stone-400">members</th>
162165
<th class="px-1 py-3 text-xs font-medium text-stone-500 dark:text-stone-400">moderators</th>
@@ -185,6 +188,19 @@ templ homeGroupsPage(loggedUser nostr.PubKey) {
185188
class="text-stone-600 dark:text-stone-400 hover:underline"
186189
>{ group.Address.ID }</a>
187190
</td>
191+
if global.Settings.Groups.NIPAD.Enabled {
192+
<td class="px-4 py-3 text-sm text-center">
193+
if name := groupNickname(group.Address.ID); name != "" {
194+
<a
195+
href={ global.Settings.HTTPScheme() + global.Settings.Domain + "/" + name }
196+
target="_blank"
197+
class="text-emerald-600 dark:text-emerald-400 hover:underline"
198+
>{ name }</a>
199+
} else {
200+
<span class="text-stone-400 dark:text-stone-600">—</span>
201+
}
202+
</td>
203+
}
188204
<td class="px-4 py-3 text-sm text-center">
189205
<a
190206
href={ "/groups/" + group.Address.ID }
@@ -274,6 +290,9 @@ templ homeGroupsPage(loggedUser nostr.PubKey) {
274290
<thead class="bg-stone-50 dark:bg-stone-800">
275291
<tr>
276292
<th class="px-1 py-3 text-xs font-medium text-stone-500 dark:text-stone-400">group id</th>
293+
if global.Settings.Groups.NIPAD.Enabled {
294+
<th class="px-1 py-3 text-xs font-medium text-stone-500 dark:text-stone-400">nickname</th>
295+
}
277296
<th class="px-1 py-3 text-xs font-medium text-stone-500 dark:text-stone-400">group</th>
278297
<th class="px-1 py-3 text-xs font-medium text-stone-500 dark:text-stone-400">members</th>
279298
<th class="px-1 py-3 text-xs font-medium text-stone-500 dark:text-stone-400">moderators</th>
@@ -295,6 +314,19 @@ templ homeGroupsPage(loggedUser nostr.PubKey) {
295314
class="text-stone-600 dark:text-stone-400 hover:underline"
296315
>{ group.Address.ID }</a>
297316
</td>
317+
if global.Settings.Groups.NIPAD.Enabled {
318+
<td class="px-4 py-3 text-sm text-center">
319+
if name := groupNickname(group.Address.ID); name != "" {
320+
<a
321+
href={ global.Settings.HTTPScheme() + global.Settings.Domain + "/" + name }
322+
target="_blank"
323+
class="text-emerald-600 dark:text-emerald-400 hover:underline"
324+
>{ name }</a>
325+
} else {
326+
<span class="text-stone-400 dark:text-stone-600">—</span>
327+
}
328+
</td>
329+
}
298330
<td class="px-4 py-3 text-sm text-center">
299331
<a
300332
href={ "/groups/" + group.Address.ID }
@@ -762,6 +794,32 @@ templ homeGroupsPage(loggedUser nostr.PubKey) {
762794
</button>
763795
</form>
764796
</details>
797+
<details>
798+
<summary class="mb-4 cursor-pointer text-sm font-medium text-stone-600 dark:text-stone-400 hover:text-stone-800 dark:hover:text-stone-200">NIP-AD group nicknames</summary>
799+
if !global.Settings.Groups.NIPAD.Enabled {
800+
<form class="my-4" method="POST" action="/groups/nipad/enable">
801+
<p class="text-sm text-stone-600 dark:text-stone-400 mb-3">
802+
enables group nicknames: each group admin can register a short word that identifies
803+
the group (e.g. <code class="px-1 rounded bg-stone-100 dark:bg-stone-800">/foo</code>).
804+
</p>
805+
<button
806+
type="submit"
807+
class="cursor-pointer px-4 py-2 rounded bg-stone-200 hover:bg-stone-300 dark:bg-stone-700 dark:hover:bg-stone-600 text-stone-700 dark:text-stone-300 font-medium"
808+
>
809+
enable
810+
</button>
811+
</form>
812+
} else {
813+
<form class="my-4" method="POST" action="/groups/nipad/disable">
814+
<button
815+
type="submit"
816+
class="cursor-pointer px-4 py-2 rounded bg-stone-200 hover:bg-stone-300 dark:bg-stone-700 dark:hover:bg-stone-600 text-stone-700 dark:text-stone-300 font-medium"
817+
>
818+
disable
819+
</button>
820+
</form>
821+
}
822+
</details>
765823
<details>
766824
<summary class="mb-4 cursor-pointer text-sm font-medium text-stone-600 dark:text-stone-400 hover:text-stone-800 dark:hover:text-stone-200">disable groups</summary>
767825
<form class="my-4" method="POST" action="/groups/disable">

0 commit comments

Comments
 (0)