use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::stored_event::event_kind;
pub const PINNED_D_TAG: &str = "vector/pinned";
const LOCAL_PINNED_KEY: &str = "pinned_chats_local";
const PINNED_PUBLISHED_AT_KEY: &str = "pinned_chats_published_at";
const PINNED_BY_TIER: [usize; 4] = [3, 6, 9, usize::MAX];
pub const MAX_PINNED: usize = PINNED_BY_TIER[0];
pub fn effective_max_pinned() -> usize {
PINNED_BY_TIER[crate::badges::effective_tier() as usize]
}
const FETCH_TIMEOUT_SECS: u64 = 10;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PinnedChats {
#[serde(default = "one")]
pub v: u32,
#[serde(default)]
pub chats: Vec<String>,
}
fn one() -> u32 {
1
}
impl PinnedChats {
pub fn from_json(s: &str) -> Self {
serde_json::from_str(s).unwrap_or_default()
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{\"v\":1,\"chats\":[]}".to_string())
}
pub fn contains(&self, id: &str) -> bool {
self.chats.iter().any(|c| c == id)
}
pub fn pin(&mut self, id: &str, max: usize) -> Result<(), String> {
if id.trim().is_empty() {
return Err("cannot pin a chat with no id".to_string());
}
if self.contains(id) {
return Ok(());
}
if self.chats.len() >= max {
return Err(format!("you can pin up to {max} chats — unpin one first"));
}
self.chats.push(id.to_string());
Ok(())
}
pub fn unpin(&mut self, id: &str) {
self.chats.retain(|c| c != id);
}
}
pub fn load_local() -> PinnedChats {
crate::db::settings::get_sql_setting(LOCAL_PINNED_KEY.to_string())
.ok()
.flatten()
.map(|s| PinnedChats::from_json(&s))
.unwrap_or_default()
}
pub fn save_local(list: &PinnedChats) -> Result<(), String> {
crate::db::settings::set_sql_setting(LOCAL_PINNED_KEY.to_string(), list.to_json())
}
pub fn is_pinned(id: &str) -> bool {
load_local().contains(id)
}
pub fn pin_position(id: &str) -> Option<usize> {
load_local().chats.iter().position(|c| c == id)
}
fn our_last_publish() -> u64 {
crate::db::settings::get_sql_setting(PINNED_PUBLISHED_AT_KEY.to_string())
.ok()
.flatten()
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
fn mark_published() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = crate::db::settings::set_sql_setting(PINNED_PUBLISHED_AT_KEY.to_string(), now.to_string());
}
async fn decrypt_event(my_pk: &PublicKey, event: &Event) -> PinnedChats {
if event.content.is_empty() {
return PinnedChats::default();
}
let signer = match crate::signer::active_signer() {
Ok(s) => s,
Err(e) => {
crate::log_warn!("[PinnedChats] signer unavailable for decrypt: {}", e);
return PinnedChats::default();
}
};
match signer.nip44_decrypt_async(my_pk, &event.content).await {
Ok(plaintext) => PinnedChats::from_json(&plaintext),
Err(e) => {
crate::log_warn!("[PinnedChats] decrypt failed: {}", e);
PinnedChats::default()
}
}
}
pub async fn fetch_pinned(client: &Client, my_pk: PublicKey) -> Result<PinnedChats, String> {
crate::db::scoped(async move {
let filter = Filter::new()
.author(my_pk)
.kind(Kind::Custom(event_kind::APPLICATION_SPECIFIC))
.identifier(PINNED_D_TAG)
.limit(1);
let events = client
.fetch_events(filter)
.timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
.await
.map_err(|e| format!("fetch pinned chats (kind 30078): {}", e))?;
Ok(match events.into_iter().next() {
Some(ev) if ev.created_at.as_secs() < our_last_publish() => load_local(),
Some(ev) => decrypt_event(&my_pk, &ev).await,
None => load_local(),
})
})
.await
}
pub async fn publish(client: &Client, list: &PinnedChats) -> Result<(), String> {
save_local(list)?;
publish_only(client, list).await
}
async fn publish_only(client: &Client, list: &PinnedChats) -> Result<(), String> {
let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
let signer = crate::signer::active_signer().map_err(|e| format!("Signer unavailable: {}", e))?;
let content = signer
.nip44_encrypt_async(&my_pk, &list.to_json())
.await
.map_err(|e| format!("nip44 encrypt pinned chats: {}", e))?;
let builder = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), content)
.tag(Tag::identifier(PINNED_D_TAG));
crate::sign_and_send(client, builder)
.await
.map_err(|e| format!("Failed to publish pinned chats (kind 30078): {}", e))?;
mark_published();
crate::log_info!("[PinnedChats] published {} pin(s)", list.chats.len());
Ok(())
}
pub async fn pin_chat(client: &Client, id: &str) -> Result<PinnedChats, String> {
let mut list = load_local();
list.pin(id, effective_max_pinned())?;
save_local(&list)?;
publish_in_background(client, &list);
Ok(list)
}
pub async fn unpin_chat(client: &Client, id: &str) -> Result<PinnedChats, String> {
let mut list = load_local();
list.unpin(id);
save_local(&list)?;
publish_in_background(client, &list);
Ok(list)
}
fn publish_in_background(client: &Client, list: &PinnedChats) {
let client = client.clone();
let list = list.clone();
crate::db::spawn_bound(async move {
if let Err(e) = publish_only(&client, &list).await {
crate::log_warn!("[PinnedChats] background publish failed: {e}");
}
});
}
pub async fn ingest_remote_event(my_pk: &PublicKey, event: &Event) -> Result<PinnedChats, String> {
crate::db::scoped(async move {
if event.created_at.as_secs() < our_last_publish() {
return Ok(load_local());
}
let incoming = decrypt_event(my_pk, event).await;
save_local(&incoming)?;
Ok(incoming)
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pinning_is_capped_but_reading_over_the_cap_is_not() {
let mut l = PinnedChats::default();
for i in 0..MAX_PINNED {
l.pin(&format!("id{i}"), MAX_PINNED).unwrap();
}
let err = l.pin("one-too-many", MAX_PINNED).unwrap_err();
assert!(err.contains(&MAX_PINNED.to_string()), "the refusal names the cap: {err}");
assert_eq!(l.chats.len(), MAX_PINNED);
let over = PinnedChats::from_json("{\"v\":1,\"chats\":[\"a\",\"b\",\"c\",\"d\",\"e\"]}");
assert_eq!(over.chats.len(), 5, "read tolerates what write refuses");
}
#[test]
fn the_badge_tiers_step_the_cap_up_to_unlimited() {
assert_eq!(PINNED_BY_TIER, [3, 6, 9, usize::MAX]);
assert_eq!(MAX_PINNED, 3, "the frontend mirror is the free-tier cap");
let mut premium = PinnedChats::default();
for i in 0..MAX_PINNED + 5 {
premium
.pin(&format!("id{i}"), PINNED_BY_TIER[3])
.unwrap_or_else(|e| panic!("full premium refused pin {i}: {e}"));
}
assert_eq!(premium.chats.len(), MAX_PINNED + 5, "no ceiling at the top tier");
let mut free = PinnedChats::from_json(&premium.to_json());
assert_eq!(free.chats.len(), MAX_PINNED + 5, "a premium list survives a free-tier read");
assert!(free.pin("one-more", MAX_PINNED).is_err(), "but adding past the free cap is refused");
assert_eq!(free.chats.len(), MAX_PINNED + 5, "and the refusal changed nothing");
}
#[test]
fn pinning_is_idempotent_and_unpinning_an_absent_id_is_success() {
let mut l = PinnedChats::default();
l.pin("a", MAX_PINNED).unwrap();
l.pin("a", MAX_PINNED).unwrap();
assert_eq!(l.chats, vec!["a".to_string()], "a second pin is not a second entry");
l.unpin("never-pinned");
l.unpin("a");
assert!(l.chats.is_empty());
}
#[test]
fn order_is_preserved_across_a_round_trip() {
let mut l = PinnedChats::default();
l.pin("first", MAX_PINNED).unwrap();
l.pin("second", MAX_PINNED).unwrap();
let back = PinnedChats::from_json(&l.to_json());
assert_eq!(back.chats, vec!["first".to_string(), "second".to_string()], "order IS the display order");
}
#[test]
fn an_unknown_id_survives_a_parse_and_republish() {
let json = "{\"v\":1,\"chats\":[\"stranger\"]}";
let mut l = PinnedChats::from_json(json);
l.pin("mine", usize::MAX).unwrap();
let out = PinnedChats::from_json(&l.to_json());
assert!(out.contains("stranger"), "an id we cannot resolve is never dropped");
assert!(out.contains("mine"));
}
#[test]
fn a_malformed_or_future_payload_never_errors() {
assert!(PinnedChats::from_json("not json").chats.is_empty());
assert!(PinnedChats::from_json("{}").chats.is_empty());
let future = PinnedChats::from_json("{\"v\":99,\"chats\":[\"a\"],\"unknown\":true}");
assert_eq!(future.chats, vec!["a".to_string()]);
}
}