1use nostr_sdk::prelude::*;
26use serde::{Deserialize, Serialize};
27
28use crate::stored_event::event_kind;
29
30pub const PINNED_D_TAG: &str = "vector/pinned";
32
33const LOCAL_PINNED_KEY: &str = "pinned_chats_local";
36
37const PINNED_PUBLISHED_AT_KEY: &str = "pinned_chats_published_at";
40
41const PINNED_BY_TIER: [usize; 4] = [3, 6, 9, usize::MAX];
49
50pub const MAX_PINNED: usize = PINNED_BY_TIER[0];
52
53pub fn effective_max_pinned() -> usize {
56 PINNED_BY_TIER[crate::badges::effective_tier() as usize]
57}
58
59const FETCH_TIMEOUT_SECS: u64 = 10;
60
61#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64pub struct PinnedChats {
65 #[serde(default = "one")]
66 pub v: u32,
67 #[serde(default)]
69 pub chats: Vec<String>,
70}
71
72fn one() -> u32 {
73 1
74}
75
76impl PinnedChats {
77 pub fn from_json(s: &str) -> Self {
80 serde_json::from_str(s).unwrap_or_default()
81 }
82
83 pub fn to_json(&self) -> String {
84 serde_json::to_string(self).unwrap_or_else(|_| "{\"v\":1,\"chats\":[]}".to_string())
85 }
86
87 pub fn contains(&self, id: &str) -> bool {
88 self.chats.iter().any(|c| c == id)
89 }
90
91 pub fn pin(&mut self, id: &str, max: usize) -> Result<(), String> {
96 if id.trim().is_empty() {
97 return Err("cannot pin a chat with no id".to_string());
98 }
99 if self.contains(id) {
100 return Ok(());
101 }
102 if self.chats.len() >= max {
103 return Err(format!("you can pin up to {max} chats — unpin one first"));
104 }
105 self.chats.push(id.to_string());
106 Ok(())
107 }
108
109 pub fn retire_community_pins(&mut self) {
115 self.chats.retain(|c| !is_community_id(c));
116 }
117
118 pub fn unpin(&mut self, id: &str) {
121 self.chats.retain(|c| c != id);
122 }
123}
124
125fn is_community_id(id: &str) -> bool {
126 id.len() == 64 && id.bytes().all(|b| b.is_ascii_hexdigit())
127}
128
129pub fn load_local() -> PinnedChats {
132 crate::db::settings::get_sql_setting(LOCAL_PINNED_KEY.to_string())
133 .ok()
134 .flatten()
135 .map(|s| PinnedChats::from_json(&s))
136 .unwrap_or_default()
137}
138
139pub fn save_local(list: &PinnedChats) -> Result<(), String> {
140 crate::db::settings::set_sql_setting(LOCAL_PINNED_KEY.to_string(), list.to_json())
141}
142
143pub fn is_pinned(id: &str) -> bool {
147 load_local().contains(id)
148}
149
150pub fn pin_position(id: &str) -> Option<usize> {
152 load_local().chats.iter().position(|c| c == id)
153}
154
155fn our_last_publish() -> u64 {
156 crate::db::settings::get_sql_setting(PINNED_PUBLISHED_AT_KEY.to_string())
157 .ok()
158 .flatten()
159 .and_then(|s| s.parse().ok())
160 .unwrap_or(0)
161}
162
163fn mark_published() {
164 let now = web_time::SystemTime::now()
165 .duration_since(web_time::UNIX_EPOCH)
166 .map(|d| d.as_secs())
167 .unwrap_or(0);
168 let _ = crate::db::settings::set_sql_setting(PINNED_PUBLISHED_AT_KEY.to_string(), now.to_string());
169}
170
171async fn decrypt_event(my_pk: &PublicKey, event: &Event) -> PinnedChats {
172 if event.content.is_empty() {
173 return PinnedChats::default();
174 }
175 let signer = match crate::signer::active_signer() {
176 Ok(s) => s,
177 Err(e) => {
178 crate::log_warn!("[PinnedChats] signer unavailable for decrypt: {}", e);
179 return PinnedChats::default();
180 }
181 };
182 match signer.nip44_decrypt_async(my_pk, &event.content).await {
183 Ok(plaintext) => PinnedChats::from_json(&plaintext),
184 Err(e) => {
185 crate::log_warn!("[PinnedChats] decrypt failed: {}", e);
186 PinnedChats::default()
187 }
188 }
189}
190
191pub async fn fetch_pinned(client: &Client, my_pk: PublicKey) -> Result<PinnedChats, String> {
195 crate::db::scoped(async move {
196 let filter = Filter::new()
197 .author(my_pk)
198 .kind(Kind::Custom(event_kind::APPLICATION_SPECIFIC))
199 .identifier(PINNED_D_TAG)
200 .limit(1);
201 let events = client
202 .fetch_events(filter)
203 .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
204 .await
205 .map_err(|e| format!("fetch pinned chats (kind 30078): {}", e))?;
206
207 Ok(match events.into_iter().next() {
208 Some(ev) if ev.created_at.as_secs() < our_last_publish() => load_local(),
209 Some(ev) => decrypt_event(&my_pk, &ev).await,
210 None => load_local(),
211 })
212 })
213 .await
214}
215
216pub async fn publish(client: &Client, list: &PinnedChats) -> Result<(), String> {
218 save_local(list)?;
219 publish_only(client, list).await
220}
221
222async fn publish_only(client: &Client, list: &PinnedChats) -> Result<(), String> {
224 let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
225
226 let signer = crate::signer::active_signer().map_err(|e| format!("Signer unavailable: {}", e))?;
227 let content = signer
228 .nip44_encrypt_async(&my_pk, &list.to_json())
229 .await
230 .map_err(|e| format!("nip44 encrypt pinned chats: {}", e))?;
231
232 let builder = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), content)
233 .tag(Tag::identifier(PINNED_D_TAG));
234 crate::sign_and_send(client, builder)
235 .await
236 .map_err(|e| format!("Failed to publish pinned chats (kind 30078): {}", e))?;
237
238 mark_published();
239 crate::log_info!("[PinnedChats] published {} pin(s)", list.chats.len());
240 Ok(())
241}
242
243pub async fn pin_chat(client: &Client, id: &str) -> Result<PinnedChats, String> {
252 let mut list = load_local();
253 list.retire_community_pins();
254 list.pin(id, effective_max_pinned())?;
255 save_local(&list)?;
256 publish_in_background(client, &list);
257 Ok(list)
258}
259
260pub async fn unpin_chat(client: &Client, id: &str) -> Result<PinnedChats, String> {
262 let mut list = load_local();
263 list.retire_community_pins();
264 list.unpin(id);
265 save_local(&list)?;
266 publish_in_background(client, &list);
267 Ok(list)
268}
269
270fn publish_in_background(client: &Client, list: &PinnedChats) {
273 let client = client.clone();
274 let list = list.clone();
275 crate::db::spawn_bound(async move {
276 if let Err(e) = publish_only(&client, &list).await {
277 crate::log_warn!("[PinnedChats] background publish failed: {e}");
278 }
279 });
280}
281
282pub async fn ingest_remote_event(my_pk: &PublicKey, event: &Event) -> Result<PinnedChats, String> {
286 crate::db::scoped(async move {
287 if event.created_at.as_secs() < our_last_publish() {
290 return Ok(load_local());
291 }
292 let incoming = decrypt_event(my_pk, event).await;
293 save_local(&incoming)?;
294 Ok(incoming)
295 })
296 .await
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn community_pins_are_shed_and_dm_pins_kept() {
305 let mut l = PinnedChats::default();
306 l.chats = vec![
307 "npub1alice".to_string(),
308 "c".repeat(64),
309 "some-future-id".to_string(),
310 ];
311 l.retire_community_pins();
312 assert_eq!(l.chats, vec!["npub1alice".to_string(), "some-future-id".to_string()]);
313 }
314
315 #[test]
316 fn pinning_is_capped_but_reading_over_the_cap_is_not() {
317 let mut l = PinnedChats::default();
318 for i in 0..MAX_PINNED {
319 l.pin(&format!("id{i}"), MAX_PINNED).unwrap();
320 }
321 let err = l.pin("one-too-many", MAX_PINNED).unwrap_err();
322 assert!(err.contains(&MAX_PINNED.to_string()), "the refusal names the cap: {err}");
323 assert_eq!(l.chats.len(), MAX_PINNED);
324
325 let over = PinnedChats::from_json("{\"v\":1,\"chats\":[\"a\",\"b\",\"c\",\"d\",\"e\"]}");
328 assert_eq!(over.chats.len(), 5, "read tolerates what write refuses");
329 }
330
331 #[test]
332 fn the_badge_tiers_step_the_cap_up_to_unlimited() {
333 assert_eq!(PINNED_BY_TIER, [3, 6, 9, usize::MAX]);
336 assert_eq!(MAX_PINNED, 3, "the frontend mirror is the free-tier cap");
337
338 let mut premium = PinnedChats::default();
341 for i in 0..MAX_PINNED + 5 {
342 premium
343 .pin(&format!("id{i}"), PINNED_BY_TIER[3])
344 .unwrap_or_else(|e| panic!("full premium refused pin {i}: {e}"));
345 }
346 assert_eq!(premium.chats.len(), MAX_PINNED + 5, "no ceiling at the top tier");
347
348 let mut free = PinnedChats::from_json(&premium.to_json());
351 assert_eq!(free.chats.len(), MAX_PINNED + 5, "a premium list survives a free-tier read");
352 assert!(free.pin("one-more", MAX_PINNED).is_err(), "but adding past the free cap is refused");
353 assert_eq!(free.chats.len(), MAX_PINNED + 5, "and the refusal changed nothing");
354 }
355
356 #[test]
357 fn pinning_is_idempotent_and_unpinning_an_absent_id_is_success() {
358 let mut l = PinnedChats::default();
359 l.pin("a", MAX_PINNED).unwrap();
360 l.pin("a", MAX_PINNED).unwrap();
361 assert_eq!(l.chats, vec!["a".to_string()], "a second pin is not a second entry");
362 l.unpin("never-pinned");
363 l.unpin("a");
364 assert!(l.chats.is_empty());
365 }
366
367 #[test]
368 fn order_is_preserved_across_a_round_trip() {
369 let mut l = PinnedChats::default();
370 l.pin("first", MAX_PINNED).unwrap();
371 l.pin("second", MAX_PINNED).unwrap();
372 let back = PinnedChats::from_json(&l.to_json());
373 assert_eq!(back.chats, vec!["first".to_string(), "second".to_string()], "order IS the display order");
374 }
375
376 #[test]
377 fn an_unknown_id_survives_a_parse_and_republish() {
378 let json = "{\"v\":1,\"chats\":[\"stranger\"]}";
381 let mut l = PinnedChats::from_json(json);
382 l.pin("mine", usize::MAX).unwrap();
383 let out = PinnedChats::from_json(&l.to_json());
384 assert!(out.contains("stranger"), "an id we cannot resolve is never dropped");
385 assert!(out.contains("mine"));
386 }
387
388 #[test]
389 fn a_malformed_or_future_payload_never_errors() {
390 assert!(PinnedChats::from_json("not json").chats.is_empty());
391 assert!(PinnedChats::from_json("{}").chats.is_empty());
392 let future = PinnedChats::from_json("{\"v\":99,\"chats\":[\"a\"],\"unknown\":true}");
394 assert_eq!(future.chats, vec!["a".to_string()]);
395 }
396}