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 unpin(&mut self, id: &str) {
112 self.chats.retain(|c| c != id);
113 }
114}
115
116pub fn load_local() -> PinnedChats {
119 crate::db::settings::get_sql_setting(LOCAL_PINNED_KEY.to_string())
120 .ok()
121 .flatten()
122 .map(|s| PinnedChats::from_json(&s))
123 .unwrap_or_default()
124}
125
126pub fn save_local(list: &PinnedChats) -> Result<(), String> {
127 crate::db::settings::set_sql_setting(LOCAL_PINNED_KEY.to_string(), list.to_json())
128}
129
130pub fn is_pinned(id: &str) -> bool {
134 load_local().contains(id)
135}
136
137pub fn pin_position(id: &str) -> Option<usize> {
139 load_local().chats.iter().position(|c| c == id)
140}
141
142fn our_last_publish() -> u64 {
143 crate::db::settings::get_sql_setting(PINNED_PUBLISHED_AT_KEY.to_string())
144 .ok()
145 .flatten()
146 .and_then(|s| s.parse().ok())
147 .unwrap_or(0)
148}
149
150fn mark_published() {
151 let now = std::time::SystemTime::now()
152 .duration_since(std::time::UNIX_EPOCH)
153 .map(|d| d.as_secs())
154 .unwrap_or(0);
155 let _ = crate::db::settings::set_sql_setting(PINNED_PUBLISHED_AT_KEY.to_string(), now.to_string());
156}
157
158async fn decrypt_event(my_pk: &PublicKey, event: &Event) -> PinnedChats {
159 if event.content.is_empty() {
160 return PinnedChats::default();
161 }
162 let signer = match crate::signer::active_signer() {
163 Ok(s) => s,
164 Err(e) => {
165 crate::log_warn!("[PinnedChats] signer unavailable for decrypt: {}", e);
166 return PinnedChats::default();
167 }
168 };
169 match signer.nip44_decrypt_async(my_pk, &event.content).await {
170 Ok(plaintext) => PinnedChats::from_json(&plaintext),
171 Err(e) => {
172 crate::log_warn!("[PinnedChats] decrypt failed: {}", e);
173 PinnedChats::default()
174 }
175 }
176}
177
178pub async fn fetch_pinned(client: &Client, my_pk: PublicKey) -> Result<PinnedChats, String> {
182 crate::db::scoped(async move {
183 let filter = Filter::new()
184 .author(my_pk)
185 .kind(Kind::Custom(event_kind::APPLICATION_SPECIFIC))
186 .identifier(PINNED_D_TAG)
187 .limit(1);
188 let events = client
189 .fetch_events(filter)
190 .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
191 .await
192 .map_err(|e| format!("fetch pinned chats (kind 30078): {}", e))?;
193
194 Ok(match events.into_iter().next() {
195 Some(ev) if ev.created_at.as_secs() < our_last_publish() => load_local(),
196 Some(ev) => decrypt_event(&my_pk, &ev).await,
197 None => load_local(),
198 })
199 })
200 .await
201}
202
203pub async fn publish(client: &Client, list: &PinnedChats) -> Result<(), String> {
205 save_local(list)?;
206 publish_only(client, list).await
207}
208
209async fn publish_only(client: &Client, list: &PinnedChats) -> Result<(), String> {
211 let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
212
213 let signer = crate::signer::active_signer().map_err(|e| format!("Signer unavailable: {}", e))?;
214 let content = signer
215 .nip44_encrypt_async(&my_pk, &list.to_json())
216 .await
217 .map_err(|e| format!("nip44 encrypt pinned chats: {}", e))?;
218
219 let builder = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), content)
220 .tag(Tag::identifier(PINNED_D_TAG));
221 crate::sign_and_send(client, builder)
222 .await
223 .map_err(|e| format!("Failed to publish pinned chats (kind 30078): {}", e))?;
224
225 mark_published();
226 crate::log_info!("[PinnedChats] published {} pin(s)", list.chats.len());
227 Ok(())
228}
229
230pub async fn pin_chat(client: &Client, id: &str) -> Result<PinnedChats, String> {
239 let mut list = load_local();
240 list.pin(id, effective_max_pinned())?;
241 save_local(&list)?;
242 publish_in_background(client, &list);
243 Ok(list)
244}
245
246pub async fn unpin_chat(client: &Client, id: &str) -> Result<PinnedChats, String> {
248 let mut list = load_local();
249 list.unpin(id);
250 save_local(&list)?;
251 publish_in_background(client, &list);
252 Ok(list)
253}
254
255fn publish_in_background(client: &Client, list: &PinnedChats) {
258 let client = client.clone();
259 let list = list.clone();
260 crate::db::spawn_bound(async move {
261 if let Err(e) = publish_only(&client, &list).await {
262 crate::log_warn!("[PinnedChats] background publish failed: {e}");
263 }
264 });
265}
266
267pub async fn ingest_remote_event(my_pk: &PublicKey, event: &Event) -> Result<PinnedChats, String> {
271 crate::db::scoped(async move {
272 if event.created_at.as_secs() < our_last_publish() {
275 return Ok(load_local());
276 }
277 let incoming = decrypt_event(my_pk, event).await;
278 save_local(&incoming)?;
279 Ok(incoming)
280 })
281 .await
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn pinning_is_capped_but_reading_over_the_cap_is_not() {
290 let mut l = PinnedChats::default();
291 for i in 0..MAX_PINNED {
292 l.pin(&format!("id{i}"), MAX_PINNED).unwrap();
293 }
294 let err = l.pin("one-too-many", MAX_PINNED).unwrap_err();
295 assert!(err.contains(&MAX_PINNED.to_string()), "the refusal names the cap: {err}");
296 assert_eq!(l.chats.len(), MAX_PINNED);
297
298 let over = PinnedChats::from_json("{\"v\":1,\"chats\":[\"a\",\"b\",\"c\",\"d\",\"e\"]}");
301 assert_eq!(over.chats.len(), 5, "read tolerates what write refuses");
302 }
303
304 #[test]
305 fn the_badge_tiers_step_the_cap_up_to_unlimited() {
306 assert_eq!(PINNED_BY_TIER, [3, 6, 9, usize::MAX]);
309 assert_eq!(MAX_PINNED, 3, "the frontend mirror is the free-tier cap");
310
311 let mut premium = PinnedChats::default();
314 for i in 0..MAX_PINNED + 5 {
315 premium
316 .pin(&format!("id{i}"), PINNED_BY_TIER[3])
317 .unwrap_or_else(|e| panic!("full premium refused pin {i}: {e}"));
318 }
319 assert_eq!(premium.chats.len(), MAX_PINNED + 5, "no ceiling at the top tier");
320
321 let mut free = PinnedChats::from_json(&premium.to_json());
324 assert_eq!(free.chats.len(), MAX_PINNED + 5, "a premium list survives a free-tier read");
325 assert!(free.pin("one-more", MAX_PINNED).is_err(), "but adding past the free cap is refused");
326 assert_eq!(free.chats.len(), MAX_PINNED + 5, "and the refusal changed nothing");
327 }
328
329 #[test]
330 fn pinning_is_idempotent_and_unpinning_an_absent_id_is_success() {
331 let mut l = PinnedChats::default();
332 l.pin("a", MAX_PINNED).unwrap();
333 l.pin("a", MAX_PINNED).unwrap();
334 assert_eq!(l.chats, vec!["a".to_string()], "a second pin is not a second entry");
335 l.unpin("never-pinned");
336 l.unpin("a");
337 assert!(l.chats.is_empty());
338 }
339
340 #[test]
341 fn order_is_preserved_across_a_round_trip() {
342 let mut l = PinnedChats::default();
343 l.pin("first", MAX_PINNED).unwrap();
344 l.pin("second", MAX_PINNED).unwrap();
345 let back = PinnedChats::from_json(&l.to_json());
346 assert_eq!(back.chats, vec!["first".to_string(), "second".to_string()], "order IS the display order");
347 }
348
349 #[test]
350 fn an_unknown_id_survives_a_parse_and_republish() {
351 let json = "{\"v\":1,\"chats\":[\"stranger\"]}";
354 let mut l = PinnedChats::from_json(json);
355 l.pin("mine", usize::MAX).unwrap();
356 let out = PinnedChats::from_json(&l.to_json());
357 assert!(out.contains("stranger"), "an id we cannot resolve is never dropped");
358 assert!(out.contains("mine"));
359 }
360
361 #[test]
362 fn a_malformed_or_future_payload_never_errors() {
363 assert!(PinnedChats::from_json("not json").chats.is_empty());
364 assert!(PinnedChats::from_json("{}").chats.is_empty());
365 let future = PinnedChats::from_json("{\"v\":99,\"chats\":[\"a\"],\"unknown\":true}");
367 assert_eq!(future.chats, vec!["a".to_string()]);
368 }
369}