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