profile.gno
4.21 Kb · 190 lines
1// Package profile stores lightweight user profiles for gnomemepad.
2// Independent of pad versions — upgrade this realm without redeploying markets.
3//
4// SetProfile(displayName, bio, uri) — EOA only, per caller address
5// GetProfile(addr) → name|bio|uri|updatedHeight
6package profile
7
8import (
9 "chain"
10 "chain/runtime"
11 "strconv"
12 "strings"
13
14 "gno.land/p/nt/avl/v0"
15)
16
17const (
18 maxNameLen = 48
19 maxBioLen = 280
20 maxURILen = 200
21)
22
23// Profile is one user's public card (all fields exported for LaunchInfo-style reads).
24type Profile struct {
25 Name string
26 Bio string
27 URI string
28 Updated int64 // block height
29}
30
31var (
32 // byAddr: address string -> *Profile
33 byAddr avl.Tree
34 inited bool
35)
36
37// Init is optional (no-op state). Kept for deploy symmetry with hub/pad.
38func Init(cur realm) {
39 if inited {
40 panic("profile: already initialized")
41 }
42 if !cur.Previous().IsUserCall() {
43 panic("profile: EOA only")
44 }
45 inited = true
46 byAddr = avl.Tree{}
47 chain.Emit("Init", "by", cur.Previous().Address().String())
48}
49
50func ensure() {
51 // Lazy: allow SetProfile before Init for simpler UX; Init still recommended.
52 if !inited {
53 byAddr = avl.Tree{}
54 inited = true
55 }
56}
57
58func sanitizeText(s string, max int) string {
59 s = strings.TrimSpace(s)
60 if len(s) > max {
61 s = s[:max]
62 }
63 // Strip control chars
64 out := ""
65 for _, c := range s {
66 if c >= 0x20 && c != 0x7f {
67 out += string(c)
68 }
69 }
70 return out
71}
72
73// SetProfile upserts the caller's profile.
74func SetProfile(cur realm, name, bio, uri string) {
75 if !cur.Previous().IsUserCall() {
76 panic("profile: EOA only")
77 }
78 ensure()
79 name = sanitizeText(name, maxNameLen)
80 bio = sanitizeText(bio, maxBioLen)
81 uri = sanitizeText(uri, maxURILen)
82 if name == "" {
83 panic("profile: name required")
84 }
85 if uri != "" && !(strings.HasPrefix(uri, "https://") || strings.HasPrefix(uri, "http://") || strings.HasPrefix(uri, "ipfs://")) {
86 panic("profile: uri must be http(s) or ipfs")
87 }
88 addr := cur.Previous().Address()
89 p := &Profile{
90 Name: name,
91 Bio: bio,
92 URI: uri,
93 Updated: runtime.ChainHeight(),
94 }
95 byAddr.Set(addr.String(), p)
96 chain.Emit("SetProfile", "addr", addr.String(), "name", name)
97}
98
99// ClearProfile removes the caller's profile.
100func ClearProfile(cur realm) {
101 if !cur.Previous().IsUserCall() {
102 panic("profile: EOA only")
103 }
104 ensure()
105 addr := cur.Previous().Address().String()
106 if byAddr.Has(addr) {
107 byAddr.Remove(addr)
108 chain.Emit("ClearProfile", "addr", addr)
109 }
110}
111
112// GetProfile returns pipe-delimited: name|bio|uri|updatedHeight
113// Empty string if none.
114func GetProfile(addr string) string {
115 ensure()
116 addr = strings.TrimSpace(addr)
117 if addr == "" {
118 return ""
119 }
120 v := byAddr.Get(addr)
121 if v == nil {
122 return ""
123 }
124 p, ok := v.(*Profile)
125 if !ok || p == nil {
126 return ""
127 }
128 return p.Name + "|" + p.Bio + "|" + p.URI + "|" + strconv.FormatInt(p.Updated, 10)
129}
130
131// HasProfile reports whether addr has a profile.
132func HasProfile(addr string) bool {
133 ensure()
134 return byAddr.Has(strings.TrimSpace(addr))
135}
136
137// ProfileCount returns number of profiles.
138func ProfileCount() int {
139 ensure()
140 return byAddr.Size()
141}
142
143func sanitizeMD(s string) string {
144 s = strings.ReplaceAll(s, "`", "'")
145 s = strings.ReplaceAll(s, "<", "")
146 s = strings.ReplaceAll(s, ">", "")
147 return s
148}
149
150// Render home or user/{addr}
151func Render(path string) string {
152 path = strings.Trim(path, "/")
153 ensure()
154 if path == "" {
155 out := "# gnomemepad profiles\n\n"
156 out += "- Profiles: **" + strconv.Itoa(byAddr.Size()) + "**\n\n"
157 out += "Call `SetProfile(name, bio, uri)` from your wallet.\n"
158 return out
159 }
160 if strings.HasPrefix(path, "user/") {
161 addr := strings.TrimPrefix(path, "user/")
162 raw := GetProfile(addr)
163 if raw == "" {
164 return "> [!WARNING]\n> No profile for `" + sanitizeMD(addr) + "`\n"
165 }
166 parts := strings.SplitN(raw, "|", 4)
167 name, bio, uri := parts[0], "", ""
168 if len(parts) > 1 {
169 bio = parts[1]
170 }
171 if len(parts) > 2 {
172 uri = parts[2]
173 }
174 out := "# " + sanitizeMD(name) + "\n\n"
175 out += "- Address: `" + sanitizeMD(addr) + "`\n"
176 if bio != "" {
177 out += "- Bio: " + sanitizeMD(bio) + "\n"
178 }
179 if uri != "" {
180 out += "- URI: " + sanitizeMD(uri) + "\n"
181 }
182 return out
183 }
184 return "> [!WARNING]\n> Path not found\n"
185}
186
187func resetForTest() {
188 byAddr = avl.Tree{}
189 inited = false
190}