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