main.mx raw
1 package main
2
3 // Notif Worker - notification subscription, dedup, filtering, watermark tracking.
4 //
5 // supervisor -> worker:
6 // ["N_EVENT", subID, evJSON]
7 // ["N_EOSE", subID]
8 // ["N_SET_PUBKEY", pk]
9 // ["N_SET_RELAYS", urlsJSON]
10 // ["N_SET_MUTES", pksJSON]
11 // ["N_LOAD_MORE"]
12 // ["N_MARK_READ"]
13 // ["N_SET_FILTER", filter] -- "all", "mentions", "reactions", "zaps"
14 // ["N_READ_TS", ts] -- initial notifReadTs from IDB
15 //
16 // worker -> supervisor (routed to Shell):
17 // ["N_RENDER", evJSON, mode] -- mode: "prepend" or "append"
18 // ["N_DOT", show] -- show=1 or 0
19 // ["N_EOSE_DONE", subID]
20 // ["N_STATUS", count, exhausted]
21 // ["N_SUB", subID, filterJSON, urlsJSON]
22 // ["N_CLOSE", subID]
23 // ["N_FETCH_REFS", idsJSON] -- Shell should fetch these referenced events
24 // ["N_STORE_READ_TS", ts] -- save notif-read-ts to IDB
25
26 import (
27 "git.smesh.lol/smesh/web/common/helpers"
28 "git.smesh.lol/smesh/web/common/jsbridge/notif"
29 "git.smesh.lol/smesh/web/common/mw"
30 "git.smesh.lol/smesh/web/common/nostr"
31 )
32
33 var (
34 myPK string
35 relayURLs []string
36 muteSet map[string]bool
37 notifFilter string
38 notifReadTs int64
39 notifSeen map[string]bool
40 oldestTs int64
41 eventCount int32
42 exhausted bool
43 emptyStreak int32
44 initLoad bool
45 loading bool
46 moreGot int32
47 moreTimer int32
48 notifMissing []string
49 refTimer int32
50 )
51
52 func main() {
53 muteSet = map[string]bool{}
54 notifSeen = map[string]bool{}
55 notifFilter = "all"
56 initLoad = true
57 notif.WorkerOnMessage(handleMessage)
58 }
59
60 func handleMessage(msg string) {
61 w := mw.New(msg)
62 switch w.Str() {
63 case "N_SET_PUBKEY":
64 myPK = w.Str()
65 case "N_SET_RELAYS":
66 relayURLs = parseStringArray(w.Raw())
67 case "N_SET_MUTES":
68 pks := parseStringArray(w.Raw())
69 muteSet = map[string]bool{}
70 for _, pk := range pks { muteSet[pk] = true }
71 case "N_SET_FILTER":
72 notifFilter = w.Str()
73 case "N_READ_TS":
74 notifReadTs = w.Num()
75 case "N_EVENT":
76 _ = w.Str() // subID consumed
77 evJSON := w.Raw()
78 handleNotifEvent(evJSON)
79 case "N_EOSE":
80 subID := w.Str()
81 handleEOSE(subID)
82 case "N_SUBSCRIBE":
83 handleSubscribe()
84 case "N_LOAD_MORE":
85 handleLoadMore()
86 case "N_MARK_READ":
87 handleMarkRead()
88 }
89 }
90
91 func handleNotifEvent(evJSON string) {
92 ev := nostr.ParseEvent(evJSON)
93 if ev == nil { return }
94 if notifSeen == nil { notifSeen = map[string]bool{} }
95 if notifSeen[ev.ID] { return }
96 notifSeen[ev.ID] = true
97 if oldestTs == 0 || ev.CreatedAt < oldestTs { oldestTs = ev.CreatedAt }
98 if ev.PubKey == myPK { return }
99 if muteSet[notifSenderPK(ev)] { return }
100 if repliesToMuted(ev) { return }
101
102 // Queue missing referenced events
103 if ev.Kind != 1 {
104 refID := notifRefID(ev)
105 if refID != "" {
106 notifMissing = append(notifMissing, refID)
107 }
108 }
109
110 // Check against read watermark for dot display
111 if ev.CreatedAt > notifReadTs {
112 notif.WorkerPost(`["N_DOT",1]`)
113 }
114
115 if !passesFilter(ev) { return }
116
117 mode := "append"
118 if !initLoad { mode = "prepend" }
119 notif.WorkerPost(`["N_RENDER",` | evJSON | `,"` | mode | `"]`)
120 eventCount++
121 }
122
123 func handleEOSE(subID string) {
124 if subID == "ntf" {
125 initLoad = false
126 if len(notifMissing) > 0 {
127 notif.WorkerPost(`["N_FETCH_REFS",` | buildIDsJSON(notifMissing) | `]`)
128 notifMissing = nil
129 }
130 notif.WorkerPost(`["N_EOSE_DONE","ntf"]`)
131 notif.WorkerPost(`["N_STATUS",` | helpers.Itoa(int64(eventCount)) | `,0]`)
132 } else if subID == "ntf-more" {
133 if moreTimer != 0 { notif.WorkerClearTimeout(moreTimer); moreTimer = 0 }
134 loading = false
135 if moreGot == 0 {
136 emptyStreak++
137 if emptyStreak >= 3 { exhausted = true }
138 } else {
139 emptyStreak = 0
140 }
141 ex := int64(0)
142 if exhausted { ex = 1 }
143 notif.WorkerPost(`["N_STATUS",` | helpers.Itoa(int64(eventCount)) | `,` | helpers.Itoa(ex) | `]`)
144 notif.WorkerPost(`["N_EOSE_DONE","ntf-more"]`)
145 notif.WorkerPost(`["N_CLOSE","ntf-more"]`)
146 }
147 }
148
149 func handleSubscribe() {
150 if myPK == "" || len(relayURLs) == 0 {
151 return
152 }
153 // Close any existing ntf sub, then open fresh.
154 notif.WorkerPost(`["N_CLOSE","ntf"]`)
155 filter := `{"kinds":[1,6,7,9735],"#p":[` | jstr(myPK) | `],"limit":20}`
156 notif.WorkerPost(`["N_SUB","ntf",` | filter | `,` | buildURLsJSON(relayURLs) | `]`)
157 }
158
159 func handleLoadMore() {
160 if loading || exhausted || oldestTs == 0 || myPK == "" { return }
161 loading = true
162 moreGot = 0
163 filter := `{"kinds":[1,6,7,9735],"#p":[` | jstr(myPK) | `],"until":` | helpers.Itoa(oldestTs) | `,"limit":20}`
164 notif.WorkerPost(`["N_SUB","ntf-more",` | filter | `,` | buildURLsJSON(relayURLs) | `]`)
165 moreTimer = notif.WorkerSetTimeout(10000, func() {
166 moreTimer = 0
167 loading = false
168 notif.WorkerPost(`["N_EOSE_DONE","ntf-more"]`)
169 })
170 }
171
172 func handleMarkRead() {
173 ts := notif.WorkerNowSeconds()
174 notifReadTs = ts
175 notif.WorkerPost(`["N_STORE_READ_TS",` | helpers.Itoa(ts) | `]`)
176 notif.WorkerPost(`["N_DOT",0]`)
177 }
178
179 func passesFilter(ev *nostr.Event) (ok bool) {
180 switch notifFilter {
181 case "mentions":
182 return ev.Kind == 1
183 case "reactions":
184 return ev.Kind == 7
185 case "zaps":
186 return ev.Kind == 9735
187 default:
188 return true
189 }
190 }
191
192 func notifRefID(ev *nostr.Event) (s string) {
193 switch ev.Kind {
194 case 7, 9735, 6:
195 tag := ev.Tags.GetFirst([]byte("e"))
196 if tag != nil { return string(tag.Value()) }
197 }
198 return ""
199 }
200
201 func notifSenderPK(ev *nostr.Event) (s string) {
202 if ev.Kind == 9735 {
203 pTag := ev.Tags.GetFirst([]byte("P"))
204 if pTag != nil { return string(pTag.Value()) }
205 }
206 return ev.PubKey
207 }
208
209 func repliesToMuted(ev *nostr.Event) (ok bool) {
210 for _, tag := range ev.Tags.GetAll("p") {
211 if v := string(tag.Value()); v != "" && muteSet[v] { return true }
212 }
213 return false
214 }
215
216 func buildIDsJSON(ids []string) (s string) {
217 s := "["
218 for i, id := range ids {
219 if i > 0 { s = s | "," }
220 s = s | jstr(id)
221 }
222 return s | "]"
223 }
224
225 func buildURLsJSON(urls []string) (s string) {
226 s := "["
227 for i, u := range urls {
228 if i > 0 { s = s | "," }
229 s = s | jstr(u)
230 }
231 return s | "]"
232 }
233
234 func parseStringArray(json string) (ss []string) {
235 var out []string
236 i := 0
237 for i < len(json) && json[i] != '[' { i++ }
238 if i >= len(json) { return nil }
239 i++
240 for {
241 for i < len(json) && (json[i] == ' ' || json[i] == ',' || json[i] == '\n') { i++ }
242 if i >= len(json) || json[i] == ']' { break }
243 if json[i] != '"' { break }
244 i++
245 start := i
246 for i < len(json) && json[i] != '"' {
247 if json[i] == '\\' { i++ }
248 i++
249 }
250 if i >= len(json) { break }
251 out = append(out, json[start:i])
252 i++
253 }
254 return out
255 }
256
257 func jstr(s string) (sv string) { return helpers.JsonString(s) }
258