chat.service.ts raw
1 import client from './client.service'
2 import { Event as NEvent, EventTemplate } from 'nostr-tools'
3
4 // --- Access modes ---
5
6 export type TAccessMode = 'open' | 'whitelist' | 'blacklist'
7
8 // --- Message expiry ---
9
10 /** Default message expiry: 4 weeks in seconds */
11 export const DEFAULT_MESSAGE_EXPIRY = 4 * 7 * 24 * 60 * 60
12
13 /** Configurable expiry options for channel settings */
14 export const EXPIRY_OPTIONS = [
15 { label: '1 week', value: 7 * 24 * 60 * 60 },
16 { label: '2 weeks', value: 2 * 7 * 24 * 60 * 60 },
17 { label: '4 weeks', value: 4 * 7 * 24 * 60 * 60 },
18 { label: '2 months', value: 2 * 30 * 24 * 60 * 60 },
19 { label: '3 months', value: 3 * 30 * 24 * 60 * 60 },
20 { label: '6 months', value: 6 * 30 * 24 * 60 * 60 },
21 { label: '9 months', value: 9 * 30 * 24 * 60 * 60 },
22 { label: '12 months', value: 12 * 30 * 24 * 60 * 60 },
23 ] as const
24
25 // --- Member entry with provenance tracking ---
26
27 export type TMemberEntry = {
28 pubkey: string
29 addedBy: string // pubkey of who added them (owner or mod)
30 }
31
32 // --- Channel types ---
33
34 export type TChannel = {
35 id: string
36 name: string
37 about: string
38 picture?: string
39 creator: string
40 createdAt: number
41 accessMode: TAccessMode
42 messageExpiry?: number // seconds until messages expire (NIP-40)
43 mods: string[]
44 members: TMemberEntry[]
45 blocked: TMemberEntry[]
46 invited: TMemberEntry[]
47 requested: string[]
48 rejected: string[]
49 }
50
51 export type TChannelMessage = {
52 id: string
53 channelId: string
54 content: string
55 pubkey: string
56 createdAt: number
57 event: NEvent
58 }
59
60 export type TModAction = {
61 id: string
62 kind: number
63 pubkey: string
64 channelId: string
65 targetEventId?: string
66 targetPubkey?: string
67 reason: string
68 createdAt: number
69 }
70
71 const CHANNEL_CREATE_KIND = 40
72 const CHANNEL_META_KIND = 41
73 const CHANNEL_MESSAGE_KIND = 42
74 const CHANNEL_HIDE_KIND = 43
75 const CHANNEL_MUTE_KIND = 44
76
77 /** Parse access_mode from kind 41 content, with backward compat for invite_only */
78 function parseAccessMode(meta: Record<string, unknown>): TAccessMode {
79 if (meta.access_mode === 'open' || meta.access_mode === 'whitelist' || meta.access_mode === 'blacklist') {
80 return meta.access_mode
81 }
82 // Backward compat: invite_only: true → whitelist, false → open
83 if (meta.invite_only === false) return 'open'
84 return 'whitelist'
85 }
86
87 function parseChannelFromEvent(event: NEvent): TChannel | null {
88 try {
89 const meta = JSON.parse(event.content)
90 return {
91 id: event.id,
92 name: meta.name || 'unnamed',
93 about: meta.about || '',
94 picture: meta.picture,
95 creator: event.pubkey,
96 createdAt: event.created_at,
97 accessMode: parseAccessMode(meta),
98 messageExpiry: typeof meta.message_expiry === 'number' ? meta.message_expiry : undefined,
99 mods: [],
100 members: [],
101 blocked: [],
102 invited: [],
103 requested: [],
104 rejected: []
105 }
106 } catch {
107 return null
108 }
109 }
110
111 function parseMessageFromEvent(event: NEvent): TChannelMessage | null {
112 const channelTag = event.tags.find(
113 (t) => t[0] === 'e' && (t[3] === 'root' || t.length === 2)
114 )
115 if (!channelTag) return null
116
117 return {
118 id: event.id,
119 channelId: channelTag[1],
120 content: event.content,
121 pubkey: event.pubkey,
122 createdAt: event.created_at,
123 event
124 }
125 }
126
127 export type TChannelMeta = {
128 mods: string[]
129 members: TMemberEntry[]
130 blocked: TMemberEntry[]
131 invited: TMemberEntry[]
132 requested: string[]
133 rejected: string[]
134 accessMode: TAccessMode
135 messageExpiry?: number
136 }
137
138 class ChatService {
139 async fetchChannels(relayUrl: string): Promise<TChannel[]> {
140 const events = await client.fetchEvents([relayUrl], {
141 kinds: [CHANNEL_CREATE_KIND],
142 limit: 100
143 })
144 return events
145 .map(parseChannelFromEvent)
146 .filter((ch): ch is TChannel => ch !== null)
147 .sort((a, b) => b.createdAt - a.createdAt)
148 }
149
150 async fetchMessages(
151 relayUrl: string,
152 channelId: string,
153 limit = 50,
154 until?: number
155 ): Promise<TChannelMessage[]> {
156 const filter: Record<string, unknown> = {
157 kinds: [CHANNEL_MESSAGE_KIND],
158 '#e': [channelId],
159 limit
160 }
161 if (until) filter.until = until
162
163 const events = await client.fetchEvents([relayUrl], filter as any)
164 return events
165 .map(parseMessageFromEvent)
166 .filter((m): m is TChannelMessage => m !== null)
167 .sort((a, b) => a.createdAt - b.createdAt)
168 }
169
170 async fetchChannelMeta(
171 relayUrl: string,
172 channelId: string
173 ): Promise<TChannelMeta | null> {
174 const events = await client.fetchEvents([relayUrl], {
175 kinds: [CHANNEL_META_KIND],
176 '#e': [channelId],
177 limit: 1
178 })
179 if (events.length === 0) return null
180 const ev = events[0]
181 const mods: string[] = []
182 const members: TMemberEntry[] = []
183 const blocked: TMemberEntry[] = []
184 const invited: TMemberEntry[] = []
185 const requested: string[] = []
186 const rejected: string[] = []
187 for (const tag of ev.tags) {
188 if (tag[0] !== 'p') continue
189 const pk = tag[1]
190 const role = tag[2]
191 const addedBy = tag[3] || ''
192 if (role === 'mod') mods.push(pk)
193 else if (role === 'member') members.push({ pubkey: pk, addedBy })
194 else if (role === 'blocked') blocked.push({ pubkey: pk, addedBy })
195 else if (role === 'invited') invited.push({ pubkey: pk, addedBy })
196 else if (role === 'requested') requested.push(pk)
197 else if (role === 'rejected') rejected.push(pk)
198 }
199 let accessMode: TAccessMode = 'whitelist'
200 let messageExpiry: number | undefined
201 try {
202 const meta = JSON.parse(ev.content)
203 accessMode = parseAccessMode(meta)
204 if (typeof meta.message_expiry === 'number') {
205 messageExpiry = meta.message_expiry
206 }
207 } catch { /* keep default */ }
208 return { mods, members, blocked, invited, requested, rejected, accessMode, messageExpiry }
209 }
210
211 async fetchHiddenMessageIds(
212 relayUrl: string,
213 _channelId: string,
214 modPubkeys: string[]
215 ): Promise<Set<string>> {
216 if (modPubkeys.length === 0) return new Set()
217 const events = await client.fetchEvents([relayUrl], {
218 kinds: [CHANNEL_HIDE_KIND],
219 authors: modPubkeys,
220 limit: 500
221 })
222 const hidden = new Set<string>()
223 for (const ev of events) {
224 const eTag = ev.tags.find((t) => t[0] === 'e')
225 if (eTag) hidden.add(eTag[1])
226 }
227 return hidden
228 }
229
230 async fetchBlockedUsers(
231 relayUrl: string,
232 channelId: string,
233 modPubkeys: string[]
234 ): Promise<Set<string>> {
235 if (modPubkeys.length === 0) return new Set()
236 const events = await client.fetchEvents([relayUrl], {
237 kinds: [CHANNEL_MUTE_KIND],
238 '#e': [channelId],
239 authors: modPubkeys,
240 limit: 500
241 })
242 const blocked = new Set<string>()
243 for (const ev of events) {
244 const pTag = ev.tags.find((t) => t[0] === 'p')
245 if (pTag) blocked.add(pTag[1])
246 }
247 return blocked
248 }
249
250 subscribeMessages(
251 relayUrl: string,
252 channelId: string,
253 onMessage: (msg: TChannelMessage) => void
254 ) {
255 return client.subscribe([relayUrl], {
256 kinds: [CHANNEL_MESSAGE_KIND],
257 '#e': [channelId],
258 since: Math.floor(Date.now() / 1000)
259 }, {
260 onevent: (event: NEvent) => {
261 const msg = parseMessageFromEvent(event)
262 if (msg) onMessage(msg)
263 }
264 })
265 }
266
267 createChannelDraft(name: string, about: string, accessMode: TAccessMode = 'whitelist'): EventTemplate {
268 return {
269 kind: CHANNEL_CREATE_KIND,
270 created_at: Math.floor(Date.now() / 1000),
271 tags: [],
272 content: JSON.stringify({ name, about, access_mode: accessMode })
273 }
274 }
275
276 createMessageDraft(channelId: string, relayUrl: string, content: string, expirySecs?: number): EventTemplate {
277 const now = Math.floor(Date.now() / 1000)
278 const expiry = expirySecs ?? DEFAULT_MESSAGE_EXPIRY
279 return {
280 kind: CHANNEL_MESSAGE_KIND,
281 created_at: now,
282 tags: [
283 ['e', channelId, relayUrl, 'root'],
284 ['expiration', String(now + expiry)]
285 ],
286 content
287 }
288 }
289
290 createMetadataUpdateDraft(
291 channelId: string,
292 relayUrl: string,
293 meta: { name?: string; about?: string; access_mode?: TAccessMode; message_expiry?: number },
294 mods: string[],
295 members: TMemberEntry[],
296 blocked: TMemberEntry[],
297 invited: TMemberEntry[],
298 requested: string[],
299 rejected: string[]
300 ): EventTemplate {
301 const tags: string[][] = [['e', channelId, relayUrl, 'root']]
302 for (const pk of mods) tags.push(['p', pk, 'mod'])
303 for (const m of members) tags.push(['p', m.pubkey, 'member', m.addedBy])
304 for (const b of blocked) tags.push(['p', b.pubkey, 'blocked', b.addedBy])
305 for (const inv of invited) tags.push(['p', inv.pubkey, 'invited', inv.addedBy])
306 for (const pk of requested) tags.push(['p', pk, 'requested'])
307 for (const pk of rejected) tags.push(['p', pk, 'rejected'])
308 return {
309 kind: CHANNEL_META_KIND,
310 created_at: Math.floor(Date.now() / 1000),
311 tags,
312 content: JSON.stringify(meta)
313 }
314 }
315
316 createHideMessageDraft(messageEventId: string, relayUrl: string, reason = ''): EventTemplate {
317 const now = Math.floor(Date.now() / 1000)
318 return {
319 kind: CHANNEL_HIDE_KIND,
320 created_at: now,
321 tags: [
322 ['e', messageEventId, relayUrl, 'root'],
323 ['expiration', String(now + DEFAULT_MESSAGE_EXPIRY)]
324 ],
325 content: reason
326 }
327 }
328
329 createBlockUserDraft(
330 channelId: string,
331 targetPubkey: string,
332 relayUrl: string,
333 reason = ''
334 ): EventTemplate {
335 const now = Math.floor(Date.now() / 1000)
336 return {
337 kind: CHANNEL_MUTE_KIND,
338 created_at: now,
339 tags: [
340 ['e', channelId, relayUrl, 'root'],
341 ['p', targetPubkey],
342 ['expiration', String(now + DEFAULT_MESSAGE_EXPIRY)]
343 ],
344 content: reason
345 }
346 }
347 }
348
349 const chatService = new ChatService()
350 export default chatService
351