ChatProvider.tsx raw

   1  import chatService, {
   2    TAccessMode,
   3    TChannel,
   4    TChannelMessage,
   5    TMemberEntry
   6  } from '@/services/chat.service'
   7  import client from '@/services/client.service'
   8  import { useNostr } from '@/providers/NostrProvider'
   9  import {
  10    createContext,
  11    useCallback,
  12    useContext,
  13    useEffect,
  14    useMemo,
  15    useRef,
  16    useState
  17  } from 'react'
  18  
  19  // --- localStorage helpers ---
  20  
  21  function loadJsonMap(key: string): Record<string, number> {
  22    try {
  23      return JSON.parse(localStorage.getItem(key) || '{}')
  24    } catch {
  25      return {}
  26    }
  27  }
  28  
  29  function saveJsonMap(key: string, map: Record<string, number>) {
  30    localStorage.setItem(key, JSON.stringify(map))
  31  }
  32  
  33  function loadStringSet(key: string): Set<string> {
  34    try {
  35      return new Set(JSON.parse(localStorage.getItem(key) || '[]'))
  36    } catch {
  37      return new Set()
  38    }
  39  }
  40  
  41  function saveStringSet(key: string, set: Set<string>) {
  42    localStorage.setItem(key, JSON.stringify([...set]))
  43  }
  44  
  45  // --- Types ---
  46  
  47  type TChatContext = {
  48    // Channel state
  49    channels: TChannel[]
  50    currentChannel: TChannel | null
  51    messages: TChannelMessage[]
  52    isLoadingChannels: boolean
  53    isLoadingMessages: boolean
  54    relayUrl: string
  55    setRelayUrl: (url: string) => void
  56    selectChannel: (channel: TChannel | null) => void
  57    selectChannelById: (channelId: string | null) => void
  58    sendMessage: (content: string) => Promise<void>
  59    createChannel: (name: string, about: string, accessMode?: TAccessMode) => Promise<void>
  60    refreshChannels: () => Promise<void>
  61    loadMoreMessages: () => Promise<void>
  62    // Notifications
  63    unreadCounts: Record<string, number>
  64    hasUnreadChannels: boolean
  65    mutedChannels: Set<string>
  66    markChannelAsSeen: (channelId: string) => void
  67    toggleMuteChannel: (channelId: string) => void
  68    // Moderation
  69    channelMods: string[]
  70    channelMembers: TMemberEntry[]
  71    channelBlocked: TMemberEntry[]
  72    channelInvited: TMemberEntry[]
  73    channelRequested: string[]
  74    channelRejected: string[]
  75    channelAccessMode: TAccessMode
  76    hiddenMessages: Set<string>
  77    isOwnerOrMod: boolean
  78    isMember: boolean
  79    addMod: (pubkey: string) => Promise<void>
  80    removeMod: (pubkey: string) => Promise<void>
  81    approveMember: (pubkey: string) => Promise<void>
  82    removeMember: (pubkey: string) => Promise<void>
  83    hideMessage: (messageId: string) => Promise<void>
  84    blockUser: (pubkey: string) => Promise<void>
  85    unblockUser: (pubkey: string) => Promise<void>
  86    updateAccessMode: (mode: TAccessMode) => Promise<void>
  87    updateMessageExpiry: (expirySecs: number) => Promise<void>
  88    sendInvite: (pubkey: string) => Promise<void>
  89    revokeInvite: (pubkey: string) => Promise<void>
  90    acceptRequest: (pubkey: string) => Promise<void>
  91    rejectRequest: (pubkey: string) => Promise<void>
  92    revokeRejection: (pubkey: string) => Promise<void>
  93    // Participants (for @ mentions and member list)
  94    channelParticipants: string[]
  95  }
  96  
  97  const ChatContext = createContext<TChatContext | undefined>(undefined)
  98  
  99  export function useChat() {
 100    const ctx = useContext(ChatContext)
 101    if (!ctx) throw new Error('useChat must be used within ChatProvider')
 102    return ctx
 103  }
 104  
 105  const DEFAULT_RELAY = 'wss://relay.orly.dev/'
 106  
 107  export function ChatProvider({ children }: { children: React.ReactNode }) {
 108    const { pubkey, signEvent } = useNostr()
 109    const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY)
 110    const [channels, setChannels] = useState<TChannel[]>([])
 111    const [currentChannel, setCurrentChannel] = useState<TChannel | null>(null)
 112    const [messages, setMessages] = useState<TChannelMessage[]>([])
 113    const [isLoadingChannels, setIsLoadingChannels] = useState(false)
 114    const [isLoadingMessages, setIsLoadingMessages] = useState(false)
 115    const subCloserRef = useRef<{ close: () => void } | null>(null)
 116    const seenIdsRef = useRef(new Set<string>())
 117  
 118    // Notification state
 119    const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({})
 120    const [mutedChannels, setMutedChannels] = useState<Set<string>>(new Set())
 121    const [, setLastSeenTimestamps] = useState<Record<string, number>>({})
 122    const currentChannelRef = useRef<TChannel | null>(null)
 123  
 124    // Moderation state (for current channel)
 125    const [channelMods, setChannelMods] = useState<string[]>([])
 126    const [channelMembers, setChannelMembers] = useState<TMemberEntry[]>([])
 127    const [channelBlocked, setChannelBlocked] = useState<TMemberEntry[]>([])
 128    const [channelInvited, setChannelInvited] = useState<TMemberEntry[]>([])
 129    const [channelRequested, setChannelRequested] = useState<string[]>([])
 130    const [channelRejected, setChannelRejected] = useState<string[]>([])
 131    const [channelAccessMode, setChannelAccessMode] = useState<TAccessMode>('whitelist')
 132    const [hiddenMessages, setHiddenMessages] = useState<Set<string>>(new Set())
 133  
 134    // Keep ref in sync
 135    useEffect(() => {
 136      currentChannelRef.current = currentChannel
 137    }, [currentChannel])
 138  
 139    // Load notification prefs from localStorage on login
 140    useEffect(() => {
 141      if (!pubkey) return
 142      loadJsonMap(`nirc:lastSeen:${pubkey}`)
 143      setMutedChannels(loadStringSet(`nirc:muted:${pubkey}`))
 144    }, [pubkey])
 145  
 146    const isOwnerOrMod = useMemo(() => {
 147      if (!pubkey || !currentChannel) return false
 148      if (currentChannel.creator === pubkey) return true
 149      return channelMods.includes(pubkey)
 150    }, [pubkey, currentChannel, channelMods])
 151  
 152    const isMember = useMemo(() => {
 153      if (!pubkey || !currentChannel) return false
 154      if (channelAccessMode === 'open') return true
 155      if (currentChannel.creator === pubkey) return true
 156      if (channelMods.includes(pubkey)) return true
 157      if (channelAccessMode === 'whitelist') {
 158        return (
 159          channelMembers.some((m) => m.pubkey === pubkey) ||
 160          channelInvited.some((m) => m.pubkey === pubkey)
 161        )
 162      }
 163      if (channelAccessMode === 'blacklist') {
 164        return !channelBlocked.some((m) => m.pubkey === pubkey)
 165      }
 166      return false
 167    }, [pubkey, currentChannel, channelAccessMode, channelMods, channelMembers, channelInvited, channelBlocked])
 168  
 169    // Collect unique participants from messages + member list for @ mentions
 170    const channelParticipants = useMemo(() => {
 171      const pks = new Set<string>()
 172      for (const msg of messages) pks.add(msg.pubkey)
 173      for (const m of channelMembers) pks.add(m.pubkey)
 174      for (const m of channelInvited) pks.add(m.pubkey)
 175      for (const pk of channelMods) pks.add(pk)
 176      if (currentChannel) pks.add(currentChannel.creator)
 177      return [...pks]
 178    }, [messages, channelMembers, channelInvited, channelMods, currentChannel])
 179  
 180    const hasUnreadChannels = useMemo(() => {
 181      return Object.entries(unreadCounts).some(
 182        ([chId, count]) => count > 0 && !mutedChannels.has(chId)
 183      )
 184    }, [unreadCounts, mutedChannels])
 185  
 186    const markChannelAsSeen = useCallback(
 187      (channelId: string) => {
 188        setUnreadCounts((prev) => {
 189          if (!prev[channelId]) return prev
 190          const next = { ...prev }
 191          delete next[channelId]
 192          return next
 193        })
 194        const now = Math.floor(Date.now() / 1000)
 195        setLastSeenTimestamps((prev) => {
 196          const next = { ...prev, [channelId]: now }
 197          if (pubkey) saveJsonMap(`nirc:lastSeen:${pubkey}`, next)
 198          return next
 199        })
 200      },
 201      [pubkey]
 202    )
 203  
 204    const toggleMuteChannel = useCallback(
 205      (channelId: string) => {
 206        setMutedChannels((prev) => {
 207          const next = new Set(prev)
 208          if (next.has(channelId)) {
 209            next.delete(channelId)
 210          } else {
 211            next.add(channelId)
 212          }
 213          if (pubkey) saveStringSet(`nirc:muted:${pubkey}`, next)
 214          return next
 215        })
 216      },
 217      [pubkey]
 218    )
 219  
 220    const refreshChannels = useCallback(async () => {
 221      setIsLoadingChannels(true)
 222      try {
 223        const chs = await chatService.fetchChannels(relayUrl)
 224        setChannels(chs)
 225      } finally {
 226        setIsLoadingChannels(false)
 227      }
 228    }, [relayUrl])
 229  
 230    // Load channels on mount and relay change
 231    useEffect(() => {
 232      refreshChannels()
 233    }, [refreshChannels])
 234  
 235    // Fetch moderation state for a channel
 236    const loadModState = useCallback(
 237      async (channel: TChannel) => {
 238        const meta = await chatService.fetchChannelMeta(relayUrl, channel.id)
 239        const ownerPk = channel.creator
 240        let mods: string[] = []
 241        let members: TMemberEntry[] = []
 242        let blocked: TMemberEntry[] = []
 243        let invited: TMemberEntry[] = []
 244        let requested: string[] = []
 245        let rejected: string[] = []
 246        let accessMode: TAccessMode = channel.accessMode
 247        if (meta) {
 248          mods = meta.mods
 249          members = meta.members
 250          blocked = meta.blocked
 251          invited = meta.invited
 252          requested = meta.requested
 253          rejected = meta.rejected
 254          accessMode = meta.accessMode
 255          // Update channel's accessMode and messageExpiry from latest metadata
 256          channel.accessMode = accessMode
 257          if (meta.messageExpiry !== undefined) {
 258            channel.messageExpiry = meta.messageExpiry
 259          }
 260        }
 261        // Owner is always a mod
 262        if (!mods.includes(ownerPk)) mods = [ownerPk, ...mods]
 263        setChannelMods(mods)
 264        setChannelMembers(members)
 265        setChannelBlocked(blocked)
 266        setChannelInvited(invited)
 267        setChannelRequested(requested)
 268        setChannelRejected(rejected)
 269        setChannelAccessMode(accessMode)
 270  
 271        // Fetch hidden messages and blocked users from mod actions
 272        const allMods = mods
 273        const hidden = await chatService.fetchHiddenMessageIds(relayUrl, channel.id, allMods)
 274        setHiddenMessages(hidden)
 275  
 276        const blockedFromActions = await chatService.fetchBlockedUsers(relayUrl, channel.id, allMods)
 277        if (blockedFromActions.size > 0) {
 278          setChannelBlocked((prev) => {
 279            const existingPks = new Set(prev.map((e) => e.pubkey))
 280            const newEntries = [...blockedFromActions]
 281              .filter((pk) => !existingPks.has(pk))
 282              .map((pk) => ({ pubkey: pk, addedBy: '' }))
 283            return [...prev, ...newEntries]
 284          })
 285        }
 286      },
 287      [relayUrl]
 288    )
 289  
 290    const selectChannel = useCallback(
 291      async (channel: TChannel | null) => {
 292        subCloserRef.current?.close()
 293        subCloserRef.current = null
 294        seenIdsRef.current.clear()
 295  
 296        setCurrentChannel(channel)
 297        setMessages([])
 298        setChannelMods([])
 299        setChannelMembers([])
 300        setChannelBlocked([])
 301        setChannelInvited([])
 302        setChannelRequested([])
 303        setChannelRejected([])
 304        setChannelAccessMode('whitelist')
 305        setHiddenMessages(new Set())
 306  
 307        if (!channel) return
 308  
 309        markChannelAsSeen(channel.id)
 310  
 311        setIsLoadingMessages(true)
 312        try {
 313          const [msgs] = await Promise.all([
 314            chatService.fetchMessages(relayUrl, channel.id),
 315            loadModState(channel)
 316          ])
 317          setMessages(msgs)
 318          msgs.forEach((m) => seenIdsRef.current.add(m.id))
 319        } finally {
 320          setIsLoadingMessages(false)
 321        }
 322  
 323        subCloserRef.current = chatService.subscribeMessages(
 324          relayUrl,
 325          channel.id,
 326          (msg) => {
 327            if (seenIdsRef.current.has(msg.id)) return
 328            seenIdsRef.current.add(msg.id)
 329            setMessages((prev) => [...prev, msg])
 330          }
 331        )
 332      },
 333      [relayUrl, markChannelAsSeen, loadModState]
 334    )
 335  
 336    const pendingChannelIdRef = useRef<string | null>(null)
 337  
 338    const selectChannelById = useCallback(
 339      (channelId: string | null) => {
 340        if (!channelId) {
 341          pendingChannelIdRef.current = null
 342          selectChannel(null)
 343          return
 344        }
 345        const ch = channels.find((c) => c.id === channelId)
 346        if (ch) {
 347          pendingChannelIdRef.current = null
 348          selectChannel(ch)
 349        } else {
 350          pendingChannelIdRef.current = channelId
 351        }
 352      },
 353      [channels, selectChannel]
 354    )
 355  
 356    useEffect(() => {
 357      if (pendingChannelIdRef.current && channels.length > 0) {
 358        const ch = channels.find((c) => c.id === pendingChannelIdRef.current)
 359        if (ch) {
 360          pendingChannelIdRef.current = null
 361          selectChannel(ch)
 362        }
 363      }
 364    }, [channels, selectChannel])
 365  
 366    // Cleanup subscription on unmount
 367    useEffect(() => {
 368      return () => {
 369        subCloserRef.current?.close()
 370      }
 371    }, [])
 372  
 373    // Global subscription for unread tracking across all channels
 374    useEffect(() => {
 375      if (!pubkey || channels.length === 0) return
 376  
 377      const channelIds = channels.map((ch) => ch.id)
 378  
 379      const globalSub = client.subscribe(
 380        [relayUrl],
 381        {
 382          kinds: [42],
 383          '#e': channelIds,
 384          since: Math.floor(Date.now() / 1000)
 385        },
 386        {
 387          onevent: (event: any) => {
 388            if (event.pubkey === pubkey) return
 389            const eTag = event.tags?.find(
 390              (t: string[]) => t[0] === 'e' && (t[3] === 'root' || t.length === 2)
 391            )
 392            if (!eTag) return
 393            const chId = eTag[1]
 394            if (currentChannelRef.current?.id === chId) return
 395            if (mutedChannels.has(chId)) return
 396  
 397            setUnreadCounts((prev) => ({
 398              ...prev,
 399              [chId]: (prev[chId] || 0) + 1
 400            }))
 401          }
 402        }
 403      )
 404  
 405      return () => {
 406        globalSub.close()
 407      }
 408    }, [pubkey, channels, relayUrl, mutedChannels])
 409  
 410    const sendMessage = useCallback(
 411      async (content: string) => {
 412        if (!currentChannel || !pubkey) return
 413        const draft = chatService.createMessageDraft(
 414          currentChannel.id, relayUrl, content, currentChannel.messageExpiry
 415        )
 416        const signed = await signEvent(draft)
 417        await client.publishEvent([relayUrl], signed)
 418      },
 419      [currentChannel, relayUrl, pubkey, signEvent]
 420    )
 421  
 422    const createChannel = useCallback(
 423      async (name: string, about: string, accessMode: TAccessMode = 'whitelist') => {
 424        if (!pubkey) return
 425        const draft = chatService.createChannelDraft(name, about, accessMode)
 426        const signed = await signEvent(draft)
 427        await client.publishEvent([relayUrl], signed)
 428        await refreshChannels()
 429      },
 430      [relayUrl, pubkey, signEvent, refreshChannels]
 431    )
 432  
 433    const loadMoreMessages = useCallback(async () => {
 434      if (!currentChannel || messages.length === 0) return
 435      const oldest = messages[0]
 436      const older = await chatService.fetchMessages(
 437        relayUrl,
 438        currentChannel.id,
 439        50,
 440        oldest.createdAt - 1
 441      )
 442      older.forEach((m) => seenIdsRef.current.add(m.id))
 443      setMessages((prev) => [...older, ...prev])
 444    }, [currentChannel, messages, relayUrl])
 445  
 446    // --- Moderation actions ---
 447  
 448    const publishMetadataUpdate = useCallback(
 449      async (
 450        mods: string[],
 451        members: TMemberEntry[],
 452        blocked: TMemberEntry[],
 453        invited: TMemberEntry[],
 454        requested: string[],
 455        rejected: string[],
 456        accessMode?: TAccessMode,
 457        messageExpiry?: number
 458      ) => {
 459        if (!currentChannel || !pubkey) return
 460        const meta: Record<string, unknown> = {
 461          name: currentChannel.name,
 462          about: currentChannel.about,
 463          access_mode: accessMode ?? channelAccessMode
 464        }
 465        const expiry = messageExpiry ?? currentChannel.messageExpiry
 466        if (expiry !== undefined) {
 467          meta.message_expiry = expiry
 468        }
 469  
 470        const draft = chatService.createMetadataUpdateDraft(
 471          currentChannel.id,
 472          relayUrl,
 473          meta as any,
 474          mods.filter((pk) => pk !== currentChannel.creator),
 475          members,
 476          blocked,
 477          invited,
 478          requested,
 479          rejected
 480        )
 481        const signed = await signEvent(draft)
 482        await client.publishEvent([relayUrl], signed)
 483      },
 484      [currentChannel, relayUrl, pubkey, signEvent, channelAccessMode]
 485    )
 486  
 487    const addMod = useCallback(
 488      async (pk: string) => {
 489        const newMods = [...channelMods, pk]
 490        setChannelMods(newMods)
 491        await publishMetadataUpdate(newMods, channelMembers, channelBlocked, channelInvited, channelRequested, channelRejected)
 492      },
 493      [channelMods, channelMembers, channelBlocked, channelInvited, channelRequested, channelRejected, publishMetadataUpdate]
 494    )
 495  
 496    const removeMod = useCallback(
 497      async (pk: string) => {
 498        // Cascade: remove all members/blocked/invited that this mod added
 499        const newMods = channelMods.filter((m) => m !== pk)
 500        const newMembers = channelMembers.filter((m) => m.addedBy !== pk)
 501        const newBlocked = channelBlocked.filter((m) => m.addedBy !== pk)
 502        const newInvited = channelInvited.filter((m) => m.addedBy !== pk)
 503        setChannelMods(newMods)
 504        setChannelMembers(newMembers)
 505        setChannelBlocked(newBlocked)
 506        setChannelInvited(newInvited)
 507        await publishMetadataUpdate(newMods, newMembers, newBlocked, newInvited, channelRequested, channelRejected)
 508      },
 509      [channelMods, channelMembers, channelBlocked, channelInvited, channelRequested, channelRejected, publishMetadataUpdate]
 510    )
 511  
 512    const approveMember = useCallback(
 513      async (pk: string) => {
 514        if (!pubkey) return
 515        const entry: TMemberEntry = { pubkey: pk, addedBy: pubkey }
 516        const newMembers = [...channelMembers, entry]
 517        // Remove from requested if present
 518        const newRequested = channelRequested.filter((r) => r !== pk)
 519        setChannelMembers(newMembers)
 520        setChannelRequested(newRequested)
 521        await publishMetadataUpdate(channelMods, newMembers, channelBlocked, channelInvited, newRequested, channelRejected)
 522      },
 523      [pubkey, channelMods, channelMembers, channelBlocked, channelInvited, channelRequested, channelRejected, publishMetadataUpdate]
 524    )
 525  
 526    const removeMember = useCallback(
 527      async (pk: string) => {
 528        const newMembers = channelMembers.filter((m) => m.pubkey !== pk)
 529        setChannelMembers(newMembers)
 530        await publishMetadataUpdate(channelMods, newMembers, channelBlocked, channelInvited, channelRequested, channelRejected)
 531      },
 532      [channelMods, channelMembers, channelBlocked, channelInvited, channelRequested, channelRejected, publishMetadataUpdate]
 533    )
 534  
 535    const hideMessage = useCallback(
 536      async (messageId: string) => {
 537        if (!pubkey) return
 538        const draft = chatService.createHideMessageDraft(messageId, relayUrl)
 539        const signed = await signEvent(draft)
 540        await client.publishEvent([relayUrl], signed)
 541        setHiddenMessages((prev) => new Set([...prev, messageId]))
 542      },
 543      [relayUrl, pubkey, signEvent]
 544    )
 545  
 546    const blockUser = useCallback(
 547      async (targetPubkey: string) => {
 548        if (!currentChannel || !pubkey) return
 549        const draft = chatService.createBlockUserDraft(
 550          currentChannel.id,
 551          targetPubkey,
 552          relayUrl
 553        )
 554        const signed = await signEvent(draft)
 555        await client.publishEvent([relayUrl], signed)
 556        const entry: TMemberEntry = { pubkey: targetPubkey, addedBy: pubkey }
 557        setChannelBlocked((prev) => [...prev, entry])
 558      },
 559      [currentChannel, relayUrl, pubkey, signEvent]
 560    )
 561  
 562    const unblockUser = useCallback(
 563      async (targetPubkey: string) => {
 564        const newBlocked = channelBlocked.filter((e) => e.pubkey !== targetPubkey)
 565        setChannelBlocked(newBlocked)
 566        await publishMetadataUpdate(channelMods, channelMembers, newBlocked, channelInvited, channelRequested, channelRejected)
 567      },
 568      [channelMods, channelMembers, channelBlocked, channelInvited, channelRequested, channelRejected, publishMetadataUpdate]
 569    )
 570  
 571    const updateAccessMode = useCallback(
 572      async (mode: TAccessMode) => {
 573        setChannelAccessMode(mode)
 574        setCurrentChannel((prev) => (prev ? { ...prev, accessMode: mode } : null))
 575        await publishMetadataUpdate(channelMods, channelMembers, channelBlocked, channelInvited, channelRequested, channelRejected, mode)
 576      },
 577      [channelMods, channelMembers, channelBlocked, channelInvited, channelRequested, channelRejected, publishMetadataUpdate]
 578    )
 579  
 580    const updateMessageExpiry = useCallback(
 581      async (expirySecs: number) => {
 582        setCurrentChannel((prev) => (prev ? { ...prev, messageExpiry: expirySecs } : null))
 583        await publishMetadataUpdate(channelMods, channelMembers, channelBlocked, channelInvited, channelRequested, channelRejected, undefined, expirySecs)
 584      },
 585      [channelMods, channelMembers, channelBlocked, channelInvited, channelRequested, channelRejected, publishMetadataUpdate]
 586    )
 587  
 588    const sendInvite = useCallback(
 589      async (targetPubkey: string) => {
 590        if (!currentChannel || !pubkey) return
 591        const entry: TMemberEntry = { pubkey: targetPubkey, addedBy: pubkey }
 592        const newInvited = [...channelInvited, entry]
 593        setChannelInvited(newInvited)
 594        await publishMetadataUpdate(channelMods, channelMembers, channelBlocked, newInvited, channelRequested, channelRejected)
 595  
 596        // Send DM with channel link
 597        const link = `https://smesh.mleku.dev/#/chat/${currentChannel.id}`
 598        const dmContent = `You've been invited to #${currentChannel.name} on NIRC:\n${link}`
 599        const dmDraft = {
 600          kind: 4,
 601          created_at: Math.floor(Date.now() / 1000),
 602          tags: [['p', targetPubkey]],
 603          content: dmContent
 604        }
 605        try {
 606          const signed = await signEvent(dmDraft)
 607          await client.publishEvent([relayUrl], signed)
 608        } catch {
 609          // DM send failure is non-fatal — invite is already in metadata
 610        }
 611      },
 612      [currentChannel, pubkey, channelMods, channelMembers, channelBlocked, channelInvited, channelRequested, channelRejected, publishMetadataUpdate, signEvent, relayUrl]
 613    )
 614  
 615    const revokeInvite = useCallback(
 616      async (targetPubkey: string) => {
 617        const newInvited = channelInvited.filter((e) => e.pubkey !== targetPubkey)
 618        setChannelInvited(newInvited)
 619        await publishMetadataUpdate(channelMods, channelMembers, channelBlocked, newInvited, channelRequested, channelRejected)
 620      },
 621      [channelMods, channelMembers, channelBlocked, channelInvited, channelRequested, channelRejected, publishMetadataUpdate]
 622    )
 623  
 624    const acceptRequest = useCallback(
 625      async (pk: string) => {
 626        // Move from requested to member
 627        await approveMember(pk)
 628      },
 629      [approveMember]
 630    )
 631  
 632    const rejectRequest = useCallback(
 633      async (pk: string) => {
 634        const newRequested = channelRequested.filter((r) => r !== pk)
 635        const newRejected = [...channelRejected, pk]
 636        setChannelRequested(newRequested)
 637        setChannelRejected(newRejected)
 638        await publishMetadataUpdate(channelMods, channelMembers, channelBlocked, channelInvited, newRequested, newRejected)
 639      },
 640      [channelMods, channelMembers, channelBlocked, channelInvited, channelRequested, channelRejected, publishMetadataUpdate]
 641    )
 642  
 643    const revokeRejection = useCallback(
 644      async (pk: string) => {
 645        const newRejected = channelRejected.filter((r) => r !== pk)
 646        setChannelRejected(newRejected)
 647        await publishMetadataUpdate(channelMods, channelMembers, channelBlocked, channelInvited, channelRequested, newRejected)
 648      },
 649      [channelMods, channelMembers, channelBlocked, channelInvited, channelRequested, channelRejected, publishMetadataUpdate]
 650    )
 651  
 652    // Filter messages: hide hidden messages and blocked users
 653    const filteredMessages = useMemo(() => {
 654      const blockedSet = new Set(channelBlocked.map((e) => e.pubkey))
 655      return messages.filter(
 656        (msg) => !hiddenMessages.has(msg.id) && !blockedSet.has(msg.pubkey)
 657      )
 658    }, [messages, hiddenMessages, channelBlocked])
 659  
 660    return (
 661      <ChatContext.Provider
 662        value={{
 663          channels,
 664          currentChannel,
 665          messages: filteredMessages,
 666          isLoadingChannels,
 667          isLoadingMessages,
 668          relayUrl,
 669          setRelayUrl,
 670          selectChannel,
 671          selectChannelById,
 672          sendMessage,
 673          createChannel,
 674          refreshChannels,
 675          loadMoreMessages,
 676          // Notifications
 677          unreadCounts,
 678          hasUnreadChannels,
 679          mutedChannels,
 680          markChannelAsSeen,
 681          toggleMuteChannel,
 682          // Moderation
 683          channelMods,
 684          channelMembers,
 685          channelBlocked,
 686          channelInvited,
 687          channelRequested,
 688          channelRejected,
 689          channelAccessMode,
 690          hiddenMessages,
 691          isOwnerOrMod,
 692          isMember,
 693          addMod,
 694          removeMod,
 695          approveMember,
 696          removeMember,
 697          hideMessage,
 698          blockUser,
 699          unblockUser,
 700          updateAccessMode,
 701          updateMessageExpiry,
 702          sendInvite,
 703          revokeInvite,
 704          acceptRequest,
 705          rejectRequest,
 706          revokeRejection,
 707          channelParticipants
 708        }}
 709      >
 710        {children}
 711      </ChatContext.Provider>
 712    )
 713  }
 714