state.js raw

   1  // state.js — reducer, dispatch, initial state
   2  
   3  import { DEFAULT_RELAYS } from './helpers.js'
   4  
   5  // ─── state ──────────────────────────────────────────────────────────
   6  
   7  export let state = null
   8  let _renderFn = null
   9  
  10  export function initState(renderFn) {
  11    _renderFn = renderFn
  12    const storedPubkey = localStorage.getItem('smesh2-pubkey')
  13    const storedMode = localStorage.getItem('smesh2-loginMode') || 'extension'
  14    const hasEncrypted = !!localStorage.getItem('smesh2-enc')
  15    const canAutoRestore = storedPubkey && (storedMode === 'extension' || (storedMode === 'nsec' && !hasEncrypted))
  16    const needsPasswordPrompt = storedPubkey && storedMode === 'nsec' && hasEncrypted
  17  
  18    state = {
  19      pubkey: canAutoRestore ? storedPubkey : null,
  20      loginMode: canAutoRestore ? storedMode : null,
  21      hasStoredSession: !!needsPasswordPrompt,
  22      profile: {},
  23      profileTs: 0,
  24      profiles: new Map(),
  25      contacts: [],
  26      relays: JSON.parse(localStorage.getItem('smesh2-relays') || 'null') || DEFAULT_RELAYS,
  27      feed: [],
  28      pendingNotes: [],
  29      feedReady: false,
  30      feedLoading: false,
  31      feedPage: 0,
  32      feedExhausted: false,
  33      feedLenBefore: 0,
  34      activeTab: 'feed',
  35      snackbar: null,
  36      threadEventId: null,
  37      threadRootId: null,
  38      threadRelayHints: [],
  39      threadEvents: [],
  40      threadQueriedIds: [],
  41      orlyRelays: [],
  42      embeddedNotes: new Map(),
  43      lightboxUrl: null,
  44      hashtagQuery: '',
  45      hashtagFeed: [],
  46      hashtagLoading: false,
  47      hashtagPage: 0,
  48      hashtagExhausted: false,
  49      hashtagLenBefore: 0,
  50      relayFeed: [],
  51      relayFeedLoading: false,
  52      relayFeedPage: 0,
  53      relayFeedExhausted: false,
  54      relayFeedLenBefore: 0,
  55      conversations: [],
  56      activeDM: null,
  57      dmMessages: [],
  58      dmTab: 'list',
  59      sidebarOpen: false,
  60    }
  61  }
  62  
  63  export function dispatch(action) {
  64    const prev = state
  65    state = reducer(state, action)
  66    if (state !== prev && _renderFn) _renderFn()
  67  }
  68  
  69  // ─── reducer ────────────────────────────────────────────────────────
  70  
  71  function reducer(state, action) {
  72    switch (action.type) {
  73      case 'LOGIN': {
  74        const mode = action.loginMode || 'extension'
  75        localStorage.setItem('smesh2-pubkey', action.pubkey)
  76        localStorage.setItem('smesh2-loginMode', mode)
  77        return { ...state, pubkey: action.pubkey, loginMode: mode, hasStoredSession: false, feedReady: false }
  78      }
  79      case 'CLEAR_STORED_SESSION':
  80        localStorage.removeItem('smesh2-pubkey')
  81        localStorage.removeItem('smesh2-loginMode')
  82        return { ...state, hasStoredSession: false }
  83      case 'SET_TAB':
  84        return { ...state, activeTab: action.tab }
  85      case 'SET_SIDEBAR':
  86        return { ...state, sidebarOpen: action.open }
  87      case 'SET_PROFILE': {
  88        if (state.profileTs && action.ts < state.profileTs) return state
  89        return { ...state, profile: action.profile, profileTs: action.ts }
  90      }
  91      case 'ADD_PROFILE': {
  92        const existing = state.profiles.get(action.pubkey)
  93        if (existing && existing._ts >= action.ts) return state
  94        const profiles = new Map(state.profiles)
  95        profiles.set(action.pubkey, { ...action.profile, _ts: action.ts })
  96        return { ...state, profiles }
  97      }
  98      case 'SET_CONTACTS':
  99        return { ...state, contacts: action.contacts }
 100      case 'SET_RELAYS': {
 101        localStorage.setItem('smesh2-relays', JSON.stringify(action.relays))
 102        return { ...state, relays: action.relays }
 103      }
 104      case 'ADD_RELAY': {
 105        if (state.relays.includes(action.url)) return state
 106        const relays = [...state.relays, action.url]
 107        localStorage.setItem('smesh2-relays', JSON.stringify(relays))
 108        return { ...state, relays }
 109      }
 110      case 'REMOVE_RELAY': {
 111        const relays = state.relays.filter((r) => r !== action.url)
 112        localStorage.setItem('smesh2-relays', JSON.stringify(relays))
 113        return { ...state, relays }
 114      }
 115      case 'ADD_EVENT': {
 116        if (state.feed.some((e) => e.id === action.event.id)) return state
 117        if (state.pendingNotes.some((e) => e.id === action.event.id)) return state
 118        return { ...state, feed: [...state.feed, action.event] }
 119      }
 120      case 'ADD_PENDING_NOTE': {
 121        if (state.feed.some((e) => e.id === action.event.id)) return state
 122        if (state.pendingNotes.some((e) => e.id === action.event.id)) return state
 123        return { ...state, pendingNotes: [...state.pendingNotes, action.event] }
 124      }
 125      case 'FLUSH_PENDING': {
 126        if (!state.pendingNotes.length) return state
 127        return { ...state, feed: [...state.feed, ...state.pendingNotes], pendingNotes: [] }
 128      }
 129      case 'SET_FEED_READY':
 130        return { ...state, feedReady: true }
 131      case 'SET_FEED_LOADING':
 132        return { ...state, feedLoading: true, feedLenBefore: state.feed.length }
 133      case 'FEED_LOADED_MORE': {
 134        const added = state.feed.length - state.feedLenBefore
 135        return { ...state, feedLoading: false, feedPage: state.feedPage + 1, feedExhausted: added === 0 }
 136      }
 137      case 'OPEN_THREAD': {
 138        const ev = action.event
 139        const eTags = (ev?.tags || []).filter((t) => t[0] === 'e')
 140        const rootTag = eTags.find((t) => t[3] === 'root') || (eTags.length > 0 ? eTags[0] : null)
 141        const rootId = rootTag ? rootTag[1] : action.eventId
 142        const hints = (ev?.tags || [])
 143          .filter((t) => t[0] === 'e' && t[2] && t[2].startsWith('wss://'))
 144          .map((t) => t[2])
 145        return { ...state, activeTab: 'thread', threadEventId: action.eventId, threadRootId: rootId, threadRelayHints: hints, threadEvents: ev ? [ev] : [], threadQueriedIds: [] }
 146      }
 147      case 'ADD_THREAD_EVENT': {
 148        if (state.threadEvents.some((e) => e.id === action.event.id)) return state
 149        return { ...state, threadEvents: [...state.threadEvents, action.event] }
 150      }
 151      case 'MARK_THREAD_QUERIED':
 152        return { ...state, threadQueriedIds: [...state.threadQueriedIds, ...action.ids] }
 153      case 'SET_ORLY_RELAYS':
 154        return { ...state, orlyRelays: action.relays }
 155      case 'SET_SNACKBAR':
 156        return { ...state, snackbar: action.message }
 157      case 'CACHE_EMBEDDED': {
 158        const embeddedNotes = new Map(state.embeddedNotes)
 159        embeddedNotes.set(action.eventId, action.event)
 160        return { ...state, embeddedNotes }
 161      }
 162      case 'OPEN_LIGHTBOX':
 163        return { ...state, lightboxUrl: action.url }
 164      case 'CLOSE_LIGHTBOX':
 165        return { ...state, lightboxUrl: null }
 166      case 'SET_DM_TAB':
 167        return { ...state, dmTab: action.tab }
 168      case 'OPEN_DM':
 169        return { ...state, activeTab: 'dms', dmTab: 'chat', activeDM: action.peer, dmMessages: [] }
 170      case 'SET_CONVERSATIONS':
 171        return { ...state, conversations: action.conversations }
 172      case 'SET_DM_MESSAGES':
 173        return { ...state, dmMessages: action.messages }
 174      case 'ADD_DM_MESSAGE': {
 175        const msg = action.message
 176        if (msg.peer !== state.activeDM) return state
 177        if (state.dmMessages.some((m) => m.id === msg.id)) return state
 178        return { ...state, dmMessages: [...state.dmMessages, msg] }
 179      }
 180      case 'ADD_CONVERSATION': {
 181        const c = action.conversation
 182        const existing = (state.conversations || []).filter((x) => x.peer !== c.peer)
 183        return { ...state, conversations: [c, ...existing].sort((a, b) => b.lastTs - a.lastTs) }
 184      }
 185      case 'SET_HASHTAG_QUERY':
 186        return { ...state, hashtagQuery: action.query, hashtagFeed: [], hashtagPage: 0, hashtagExhausted: false, hashtagLoading: false, hashtagLenBefore: 0 }
 187      case 'ADD_HASHTAG_EVENT': {
 188        if (state.hashtagFeed.some((e) => e.id === action.event.id)) return state
 189        return { ...state, hashtagFeed: [...state.hashtagFeed, action.event] }
 190      }
 191      case 'SET_HASHTAG_LOADING':
 192        return { ...state, hashtagLoading: true, hashtagLenBefore: state.hashtagFeed.length }
 193      case 'HASHTAG_LOADED_MORE': {
 194        const added = state.hashtagFeed.length - state.hashtagLenBefore
 195        return { ...state, hashtagLoading: false, hashtagPage: state.hashtagPage + 1, hashtagExhausted: added === 0 }
 196      }
 197      case 'ADD_RELAY_EVENT': {
 198        if (state.relayFeed.some((e) => e.id === action.event.id)) return state
 199        return { ...state, relayFeed: [...state.relayFeed, action.event] }
 200      }
 201      case 'SET_RELAY_LOADING':
 202        return { ...state, relayFeedLoading: true, relayFeedLenBefore: state.relayFeed.length }
 203      case 'RELAY_LOADED_MORE': {
 204        const added = state.relayFeed.length - state.relayFeedLenBefore
 205        return { ...state, relayFeedLoading: false, relayFeedPage: state.relayFeedPage + 1, relayFeedExhausted: added === 0 }
 206      }
 207      default:
 208        return state
 209    }
 210  }
 211