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 the Community pins older builds wrote. Communities are arranged on
110    /// the rail now, so a pin there means nothing; it is shed on the next write
111    /// rather than by a write of its own. Keyed on FORM (a community id is 64
112    /// hex, a DM is an npub), so a pin for a chat this device has never synced
113    /// still survives.
114    pub fn retire_community_pins(&mut self) {
115        self.chats.retain(|c| !is_community_id(c));
116    }
117
118    /// Drop `id`. Absent is success: unpinning something already gone is the
119    /// state the caller asked for.
120    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
129/// The local mirror. Every read path goes through here so the UI never waits on
130/// a relay to know what is pinned.
131pub 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
143/// Is this chat id pinned? The render-time question — deliberately a lookup
144/// against the list rather than a flag stamped onto chats when they sync, so a
145/// chat that arrives after its pin is pinned on its first paint.
146pub fn is_pinned(id: &str) -> bool {
147    load_local().contains(id)
148}
149
150/// Pin order for `id`, or `None` when unpinned. Sorts the chat list.
151pub 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
191/// Fetch the relay copy. A copy older than our last publish loses to the local
192/// mirror — otherwise a relay still serving the pre-edit event would undo the
193/// pin the user just made.
194pub 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
216/// Persist `list` locally and publish it self-encrypted.
217pub async fn publish(client: &Client, list: &PinnedChats) -> Result<(), String> {
218    save_local(list)?;
219    publish_only(client, list).await
220}
221
222/// The network half of [`publish`], for callers that already committed locally.
223async 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
243/// Pin a chat: commit locally and RETURN, then sync in the background.
244///
245/// A pin is a UI gesture, so it must land at click speed. The local mirror is
246/// already current — the self-sync subscription streams sibling-device edits
247/// into it — so there is nothing to re-read from a relay first, and the cap
248/// check is local anyway. Publishing behind the return keeps a slow or
249/// unreachable relay from stalling the list; the next mutation or boot
250/// republishes if it failed.
251pub 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
260/// Unpin a chat. Same commit-then-sync shape as [`pin_chat`].
261pub 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
270/// Publish off the caller's path. Bound to the account it started under, so a
271/// swap mid-publish cannot write this list into the next account's storage.
272fn 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
282/// Consume a remotely-received list event (the live cross-device path). Does
283/// NOT republish: the relay echoes our own publishes back on the same
284/// subscription, and answering an echo with a publish loops forever.
285pub async fn ingest_remote_event(my_pk: &PublicKey, event: &Event) -> Result<PinnedChats, String> {
286    crate::db::scoped(async move {
287        // Our own newer edit is still in flight to this relay; its older stored
288        // copy must not roll the user's pin back.
289        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        // A list that arrives OVER the cap is kept whole: truncating here would
326        // silently drop a pin made by a build whose cap is higher.
327        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        // Free pins 3, Bug Hunter grades step 6 -> 9, and the top grade IS
334        // full premium: unlimited.
335        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        // The cap is a parameter, so the premium path is exercised directly
339        // rather than by faking a badge.
340        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        // Downgrading is not destructive: a free-tier read keeps every pin, and
349        // only the next ADD is refused.
350        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        // The whole point of opaque ids: a device that has never synced the chat
379        // behind "stranger" must still carry its pin forward.
380        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        // An unknown version still yields its ids rather than being discarded.
393        let future = PinnedChats::from_json("{\"v\":99,\"chats\":[\"a\"],\"unknown\":true}");
394        assert_eq!(future.chats, vec!["a".to_string()]);
395    }
396}