main.mx raw
1 package main
2
3 // Feed Worker - subscription management, dedup, filtering, and pagination for the note feed.
4 //
5 // Shell forwards verified feed events (subID "feed" / "feed-more") to this worker.
6 // This worker deduplicates, applies mute/follow filters, and sends render commands back to Shell.
7 //
8 // supervisor -> worker (from Shell via feed.Send):
9 // ["F_EVENT", subID, evJSON] -- feed or feed-more event arrived
10 // ["F_EOSE", subID] -- EOSE for feed or feed-more sub
11 // ["F_SET_MODE", mode] -- "follows", "relays", or relay URL
12 // ["F_SET_RELAYS", urlsJSON]
13 // ["F_SET_FOLLOWS", pksJSON] -- current follow list (pubkeys)
14 // ["F_SET_MUTES", pksJSON] -- current mute list (pubkeys)
15 // ["F_SET_PUBKEY", pk]
16 // ["F_LOAD_MORE"] -- user clicked "load more"
17 // ["F_REFRESH"] -- user triggered refresh
18 //
19 // worker -> supervisor (to Shell via F_* prefix routing):
20 // ["F_RENDER", evJSON, now] -- now=1: render immediately, 0: buffer
21 // ["F_NEW"] -- new buffered events available (pulse)
22 // ["F_EOSE_DONE", subID] -- signal Shell to update UI after EOSE
23 // ["F_STATUS", count, exhausted] -- event count and exhaustion state
24 // ["F_SUB", subID, filterJSON, urlsJSON]
25 // ["F_CLOSE", subID]
26
27 import (
28 "git.smesh.lol/smesh/web/common/helpers"
29 "git.smesh.lol/smesh/web/common/jsbridge/feed"
30 "git.smesh.lol/smesh/web/common/mw"
31 "git.smesh.lol/smesh/web/common/nostr"
32 )
33
34 var (
35 feedMode string
36 relayURLs []string
37 followList []string
38 followSet map[string]bool
39 muteSet map[string]bool
40 myPK string
41 seenEvents map[string]bool
42 oldestFeedTs int64
43 eventCount int32
44 feedInitialLoad bool
45 feedExhausted bool
46 feedEmptyStreak int32
47 feedLoading bool
48 feedMoreGot int32
49 feedMoreTimer int32
50 )
51
52 func main() {
53 seenEvents = map[string]bool{}
54 followSet = map[string]bool{}
55 muteSet = map[string]bool{}
56 feedInitialLoad = true
57 feed.WorkerOnMessage(handleMessage)
58 }
59
60 func handleMessage(msg string) {
61 w := mw.New(msg)
62 switch w.Str() {
63 case "F_EVENT":
64 subID := w.Str()
65 evJSON := w.Raw()
66 handleFeedEvent(subID, evJSON)
67 case "F_EOSE":
68 subID := w.Str()
69 handleEOSE(subID)
70 case "F_SET_MODE":
71 feedMode = w.Str()
72 case "F_SET_RELAYS":
73 relayURLs = parseStringArray(w.Raw())
74 case "F_SET_FOLLOWS":
75 followList = parseStringArray(w.Raw())
76 followSet = map[string]bool{}
77 for _, pk := range followList {
78 followSet[pk] = true
79 }
80 case "F_SET_MUTES":
81 pks := parseStringArray(w.Raw())
82 muteSet = map[string]bool{}
83 for _, pk := range pks {
84 muteSet[pk] = true
85 }
86 case "F_SET_PUBKEY":
87 myPK = w.Str()
88 case "F_LOAD_MORE":
89 handleLoadMore()
90 case "F_REFRESH":
91 handleRefresh()
92 }
93 }
94
95 func handleFeedEvent(subID, evJSON string) {
96 ev := nostr.ParseEvent(evJSON)
97 if ev == nil {
98 return
99 }
100 if seenEvents[ev.ID] {
101 return
102 }
103 seenEvents[ev.ID] = true
104 if !feedPassesFilter(ev) {
105 return
106 }
107 if muteSet[ev.PubKey] {
108 return
109 }
110 if repliesToMuted(ev) {
111 return
112 }
113 if (ev.Kind == 1 || ev.Kind == 1111) && looksLikeJSONSpam(ev.Content) {
114 return
115 }
116 if oldestFeedTs == 0 || ev.CreatedAt < oldestFeedTs {
117 oldestFeedTs = ev.CreatedAt
118 }
119 eventCount++
120 if subID == "feed-more" {
121 feedMoreGot++
122 feed.WorkerPost(`["F_RENDER",` | evJSON | `,1]`)
123 return
124 }
125 if feedInitialLoad {
126 feed.WorkerPost(`["F_RENDER",` | evJSON | `,1]`)
127 } else {
128 feed.WorkerPost(`["F_RENDER",` | evJSON | `,0]`)
129 }
130 }
131
132 func handleEOSE(subID string) {
133 if subID == "feed" {
134 feedInitialLoad = false
135 feed.WorkerPost(`["F_EOSE_DONE","feed"]`)
136 feed.WorkerPost(`["F_STATUS",` | helpers.Itoa(int64(eventCount)) | `,0]`)
137 } else if subID == "feed-more" {
138 if feedMoreTimer != 0 {
139 feed.WorkerClearTimeout(feedMoreTimer)
140 feedMoreTimer = 0
141 }
142 feedLoading = false
143 if feedMoreGot == 0 {
144 feedEmptyStreak++
145 if feedEmptyStreak >= 3 {
146 feedExhausted = true
147 }
148 } else {
149 feedEmptyStreak = 0
150 }
151 ex := int64(0)
152 if feedExhausted {
153 ex = 1
154 }
155 feed.WorkerPost(`["F_STATUS",` | helpers.Itoa(int64(eventCount)) | `,` | helpers.Itoa(ex) | `]`)
156 feed.WorkerPost(`["F_EOSE_DONE","feed-more"]`)
157 feed.WorkerPost(`["F_CLOSE","feed-more"]`)
158 }
159 }
160
161 func handleRefresh() {
162 seenEvents = map[string]bool{}
163 oldestFeedTs = 0
164 eventCount = 0
165 feedInitialLoad = true
166 feedExhausted = false
167 feedEmptyStreak = 0
168 feed.WorkerPost(`["F_CLOSE","feed"]`)
169 feed.WorkerPost(`["F_CLOSE","feed-more"]`)
170 subscribe()
171 }
172
173 func handleLoadMore() {
174 if feedLoading || feedExhausted || oldestFeedTs == 0 {
175 return
176 }
177 feedLoading = true
178 feedMoreGot = 0
179 filter := buildFeedFilter(20)
180 until := `,"until":` | helpers.Itoa(oldestFeedTs) | `}`
181 filter = filter[:len(filter)-1] | until
182 urls := feedRelays()
183 feed.WorkerPost(`["F_SUB","feed-more",` | filter | `,` | buildURLsJSON(urls) | `]`)
184 feedMoreTimer = feed.WorkerSetTimeout(10000, func() {
185 feedMoreTimer = 0
186 feedLoading = false
187 feed.WorkerPost(`["F_EOSE_DONE","feed-more"]`)
188 })
189 }
190
191 func subscribe() {
192 if myPK == "" || len(relayURLs) == 0 {
193 return
194 }
195 filter := buildFeedFilter(20)
196 urls := feedRelays()
197 feed.WorkerPost(`["F_SUB","feed",` | filter | `,` | buildURLsJSON(urls) | `]`)
198 }
199
200 func buildFeedFilter(limit int32) (s string) {
201 if feedMode == "follows" && len(followList) > 0 {
202 authors := jstr(myPK)
203 for _, pk := range followList {
204 if pk != myPK {
205 authors = authors | "," | jstr(pk)
206 }
207 }
208 return `{"kinds":[1,6,7,1111],"authors":[` | authors | `],"limit":` | helpers.Itoa(int64(limit)) | `}`
209 }
210 return `{"kinds":[1,6,7,1111],"limit":` | helpers.Itoa(int64(limit)) | `}`
211 }
212
213 func feedPassesFilter(ev *nostr.Event) (ok bool) {
214 if feedMode != "follows" {
215 return true
216 }
217 if len(followList) == 0 {
218 return true
219 }
220 if ev.PubKey == myPK {
221 return true
222 }
223 return followSet[ev.PubKey]
224 }
225
226 func feedRelays() (ss []string) {
227 if feedMode != "" && feedMode != "follows" && feedMode != "relays" {
228 return []string{feedMode}
229 }
230 return relayURLs
231 }
232
233 func repliesToMuted(ev *nostr.Event) (ok bool) {
234 for _, tag := range ev.Tags.GetAll("p") {
235 if v := string(tag.Value()); v != "" && muteSet[v] {
236 return true
237 }
238 }
239 return false
240 }
241
242 func looksLikeJSONSpam(content string) (ok bool) {
243 i := 0
244 for i < len(content) {
245 c := content[i]
246 if c == ' ' || c == '\n' || c == '\r' || c == '\t' {
247 i++
248 } else {
249 break
250 }
251 }
252 if i >= len(content) {
253 return false
254 }
255 open := content[i]
256 return open == '{' || open == '['
257 }
258
259 func buildURLsJSON(urls []string) (s string) {
260 s := "["
261 for i, u := range urls {
262 if i > 0 { s = s | "," }
263 s = s | jstr(u)
264 }
265 return s | "]"
266 }
267
268 func parseStringArray(json string) (ss []string) {
269 var out []string
270 i := 0
271 for i < len(json) && json[i] != '[' { i++ }
272 if i >= len(json) { return nil }
273 i++
274 for {
275 for i < len(json) && (json[i] == ' ' || json[i] == ',' || json[i] == '\n') { i++ }
276 if i >= len(json) || json[i] == ']' { break }
277 if json[i] != '"' { break }
278 i++
279 start := i
280 for i < len(json) && json[i] != '"' {
281 if json[i] == '\\' { i++ }
282 i++
283 }
284 if i >= len(json) { break }
285 out = append(out, json[start:i])
286 i++
287 }
288 return out
289 }
290
291 func jstr(s string) (sv string) { return helpers.JsonString(s) }
292