Skip to main content

vector_core/
pinned_chats.rs

1//! Pinned Chats — the account's favourite conversations, synced across its own
2//! devices.
3//!
4//! A pin is just an id: a DM's npub, or a Community's id (pinning a Community
5//! hoists the chat row of its primary channel). The list is ORDERED, and that
6//! order is the display order.
7//!
8//! On the wire: a parameterized-replaceable kind 30078 with the d tag
9//! `vector/pinned`, content NIP-44 self-encrypted — the same shape and the same
10//! self-sync subscription as the Community and Invite lists, so it inherits boot
11//! sync, reconnect re-sync and live cross-device edits without new plumbing.
12//!
13//! ```text
14//!   kind:    30078 (APPLICATION_SPECIFIC)
15//!   tags:    ["d", "vector/pinned"]
16//!   content: nip44(self, {"v":1,"chats":["npub1…","<community-id-hex>"]})
17//! ```
18//!
19//! **Ids are opaque here.** A pin for a chat this device has not synced yet is
20//! carried through every read and republish untouched: dropping "unknown" ids
21//! would let the device that knows least erase the others' pins. Nothing
22//! resolves an id to a chat in this module — [`is_pinned`] is consulted at
23//! render time, so a chat that syncs in later is pinned the moment it paints.
24
25use nostr_sdk::prelude::*;
26use serde::{Deserialize, Serialize};
27
28use crate::stored_event::event_kind;
29
30/// The d tag identifying this list among our other kind-30078 self-lists.
31pub const PINNED_D_TAG: &str = "vector/pinned";
32
33/// Settings key for the local mirror — the list paints from here at boot,
34/// before any relay answers, and keeps working offline.
35const LOCAL_PINNED_KEY: &str = "pinned_chats_local";
36
37/// UNIX-seconds of our most recent publish. An arriving copy older than this is
38/// our own echo racing a newer local edit, so it must not overwrite it.
39const PINNED_PUBLISHED_AT_KEY: &str = "pinned_chats_published_at";
40
41/// Per-effective-tier pin caps (index = `badges::effective_tier()`, 0-3). Tier 3
42/// is the Bug Hunter badge's full-premium grade, which unlocks unlimited pins.
43///
44/// Enforced on WRITE only: a list that already exceeds the cap — a premium
45/// account's list read on a free one, or another client's — is read and
46/// republished intact rather than truncated. Losing a premium user's pins
47/// because they opened a second account would be the worst possible reading.
48const PINNED_BY_TIER: [usize; 4] = [3, 6, 9, usize::MAX];
49
50/// Base (free, tier-0) pin cap. Named const for the frontend mirror.
51pub const MAX_PINNED: usize = PINNED_BY_TIER[0];
52
53/// Pin cap for the current account, scaled by effective tier. Gate the pin
54/// ACTION on this — never the read or render path.
55pub 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/// The synced list. `v` is a forward-compat marker, not a gate: an unknown
62/// version still round-trips its ids rather than being discarded.
63#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64pub struct PinnedChats {
65    #[serde(default = "one")]
66    pub v: u32,
67    /// Ordered ids. Order IS the display order.
68    #[serde(default)]
69    pub chats: Vec<String>,
70}
71
72fn one() -> u32 {
73    1
74}
75
76impl PinnedChats {
77    /// Tolerant parse: a malformed payload degrades to an empty list, never an
78    /// error that aborts a sync.
79    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    /// Append `id` if absent, up to `max`. `Err` when the cap is already met —
92    /// the caller is a user action, so a refusal is a message, not a silent
93    /// no-op. `max` is passed rather than read so this stays pure and testable;
94    /// callers use [`effective_max_pinned`].
95    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    /// Drop `id`. Absent is success: unpinning something already gone is the
110    /// state the caller asked for.
111    pub fn unpin(&mut self, id: &str) {
112        self.chats.retain(|c| c != id);
113    }
114}
115
116/// The local mirror. Every read path goes through here so the UI never waits on
117/// a relay to know what is pinned.
118pub 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
130/// Is this chat id pinned? The render-time question — deliberately a lookup
131/// against the list rather than a flag stamped onto chats when they sync, so a
132/// chat that arrives after its pin is pinned on its first paint.
133pub fn is_pinned(id: &str) -> bool {
134    load_local().contains(id)
135}
136
137/// Pin order for `id`, or `None` when unpinned. Sorts the chat list.
138pub 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
178/// Fetch the relay copy. A copy older than our last publish loses to the local
179/// mirror — otherwise a relay still serving the pre-edit event would undo the
180/// pin the user just made.
181pub 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
203/// Persist `list` locally and publish it self-encrypted.
204pub async fn publish(client: &Client, list: &PinnedChats) -> Result<(), String> {
205    save_local(list)?;
206    publish_only(client, list).await
207}
208
209/// The network half of [`publish`], for callers that already committed locally.
210async 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
230/// Pin a chat: commit locally and RETURN, then sync in the background.
231///
232/// A pin is a UI gesture, so it must land at click speed. The local mirror is
233/// already current — the self-sync subscription streams sibling-device edits
234/// into it — so there is nothing to re-read from a relay first, and the cap
235/// check is local anyway. Publishing behind the return keeps a slow or
236/// unreachable relay from stalling the list; the next mutation or boot
237/// republishes if it failed.
238pub 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
246/// Unpin a chat. Same commit-then-sync shape as [`pin_chat`].
247pub 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
255/// Publish off the caller's path. Bound to the account it started under, so a
256/// swap mid-publish cannot write this list into the next account's storage.
257fn 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
267/// Consume a remotely-received list event (the live cross-device path). Does
268/// NOT republish: the relay echoes our own publishes back on the same
269/// subscription, and answering an echo with a publish loops forever.
270pub async fn ingest_remote_event(my_pk: &PublicKey, event: &Event) -> Result<PinnedChats, String> {
271    crate::db::scoped(async move {
272        // Our own newer edit is still in flight to this relay; its older stored
273        // copy must not roll the user's pin back.
274        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        // A list that arrives OVER the cap is kept whole: truncating here would
299        // silently drop a pin made by a build whose cap is higher.
300        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        // Free pins 3, Bug Hunter grades step 6 -> 9, and the top grade IS
307        // full premium: unlimited.
308        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        // The cap is a parameter, so the premium path is exercised directly
312        // rather than by faking a badge.
313        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        // Downgrading is not destructive: a free-tier read keeps every pin, and
322        // only the next ADD is refused.
323        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        // The whole point of opaque ids: a device that has never synced the chat
352        // behind "stranger" must still carry its pin forward.
353        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        // An unknown version still yields its ids rather than being discarded.
366        let future = PinnedChats::from_json("{\"v\":99,\"chats\":[\"a\"],\"unknown\":true}");
367        assert_eq!(future.chats, vec!["a".to_string()]);
368    }
369}