Skip to main content

vector_core/
stored_event.rs

1//! Flat Event Storage Module
2//!
3//! This module provides the generic event storage layer aligned with Nostr's protocol model.
4//! All events (messages, reactions, attachments, etc.) are stored as flat rows in the database,
5//! with relationships computed at query/render time.
6//!
7//! ## Architecture
8//!
9//! ```text
10//! ┌─────────────────────────────────────────────────────────────────┐
11//! │ STORAGE LAYER (this module)                                    │
12//! │ - Stores raw events as-is, including unknown types             │
13//! │ - Schema: id, kind, content, tags, timestamp, pubkey, etc.     │
14//! │ - Can sync/store events Vector doesn't understand yet          │
15//! └─────────────────────────────────────────────────────────────────┘
16//!                               ↓
17//! ┌─────────────────────────────────────────────────────────────────┐
18//! │ PROCESSING LAYER (rumor.rs)                                    │
19//! │ - Transforms raw events → typed structs                        │
20//! │ - Unknown kind? → UnknownEvent (renders as placeholder)        │
21//! │ - New event type = add enum variant + processing logic         │
22//! └─────────────────────────────────────────────────────────────────┘
23//!                               ↓
24//! ┌─────────────────────────────────────────────────────────────────┐
25//! │ DISPLAY LAYER (message.rs - unchanged)                         │
26//! │ - Message, Reaction, Attachment, etc.                          │
27//! │ - Standardized interface for UI rendering                      │
28//! │ - Materialized views: compose events → Message with reactions  │
29//! └─────────────────────────────────────────────────────────────────┘
30//! ```
31//!
32//! ## Benefits
33//!
34//! - **Protocol alignment**: Matches Nostr's event-centric model
35//! - **Future-proof**: Unknown event types are stored, not dropped
36//! - **Easy extensibility**: New event types need no schema changes
37//! - **Uniform storage**: DMs, community channels, and public events all stored the same way
38
39use serde::{Deserialize, Serialize};
40
41/// Nostr event kinds used in Vector
42///
43/// These are the standard Nostr kinds plus Vector-specific extensions.
44/// Unknown kinds are stored but rendered as placeholders.
45pub mod event_kind {
46    /// Chat message text content (Kind 9). The internal storage kind for every
47    /// text message — DMs and community channels alike.
48    pub const CHAT_MESSAGE: u16 = 9;
49    /// NIP-14: Private Direct Message (text content)
50    pub const PRIVATE_DIRECT_MESSAGE: u16 = 14;
51    /// Vector-specific: File attachment with encryption metadata
52    pub const FILE_ATTACHMENT: u16 = 15;
53    /// Vector-specific: Message edit (references original message, contains new content)
54    pub const MESSAGE_EDIT: u16 = 16;
55    /// NIP-25: Emoji reaction
56    pub const REACTION: u16 = 7;
57    /// NIP-78: Application-specific data (typing indicators, peer ads, etc.)
58    pub const APPLICATION_SPECIFIC: u16 = 30078;
59
60    // Community protocol append-plane kinds (GROUP_PROTOCOL.md). Vector-claimed
61    // block 3300-3399 in the verified-empty 3000-3999 regular range. One kind per
62    // event type so relays can slice by type with a pure `kinds` filter. The inner
63    // signed event mirrors the outer kind (binding triad).
64    pub const COMMUNITY_MESSAGE: u16 = 3300;
65    pub const COMMUNITY_REACTION: u16 = 3301;
66    pub const COMMUNITY_EDIT: u16 = 3302;
67    pub const COMMUNITY_REKEY: u16 = 3303;
68    pub const COMMUNITY_INVITE_BUNDLE: u16 = 3304;
69    /// Cooperative delete: an inner control event referencing a target message's inner
70    /// id, honored only when its signer is the target's author. Lets a member tombstone their
71    /// own message in-app on peers WITHOUT the original per-message ephemeral key (multi-device
72    /// or pre-retention sends), where a relay-side NIP-09 nuke is impossible.
73    pub const COMMUNITY_DELETE: u16 = 3305;
74    /// Presence (join/leave) announcement: an inner event signed by the joining/leaving member's
75    /// identity, content "join" or "leave", posted to the primary channel. A client best-practice
76    /// (not enforced) so honest clients announce arrival/departure; feeds the observed member list
77    /// even before the member posts. A silent join is still possible by simply not sending this.
78    pub const COMMUNITY_PRESENCE: u16 = 3306;
79    // 3307 is RETIRED. Never reuse the number.
80    /// Cooperative kick: an inner directive signed by a `KICK`-permissioned member, naming a
81    /// target member (content = target hex) and carrying the actor's `vac` authority citation. NOT a
82    /// rekey and NOT folded — soft removal. On receipt the TARGET self-removes (drops the community keys
83    /// + wipes local chat data, like a leave); peers drop the target from their observed member list. A
84    /// target that ignores it (malicious) is escalated to a BAN (the cryptographic rekey).
85    pub const COMMUNITY_KICK: u16 = 3309;
86    /// Control-plane authority entity (keyless/real-npub model): a per-entity append
87    /// edition (RoleMetadata / Grant / RoleOrder / Banlist / ChannelMetadata / GroupRoot /
88    /// OwnerAttestation, distinguished by the `vsk` tag), signed INSIDE the encryption by the
89    /// actor's REAL npub and folded by its per-entity version chain.
90    pub const COMMUNITY_CONTROL: u16 = 3308;
91    /// WebXDC realtime peer signal: an inner event signed by the playing member's identity,
92    /// JSON content `{"op":"ad","topic":...,"addr":...}` (Iroh node advertisement) or
93    /// `{"op":"left","topic":...}` (stopped playing). The Community-transport twin of the
94    /// NIP-17 `peer-advertisement` / `peer-left` DM rumors: persisted on receipt (kind-30078
95    /// row keyed by topic) so a member who reopens Vector mid-session still discovers the
96    /// active players, exactly like the DM path.
97    pub const COMMUNITY_WEBXDC: u16 = 3310;
98    /// Typing indicator: an inner event signed by the typing member, content "typing", sealed under
99    /// the channel epoch key like presence. Ephemeral and never persisted/folded — the Community-transport
100    /// twin of the NIP-17 typing rumor. Receivers show the typer for a short window, then it expires.
101    pub const COMMUNITY_TYPING: u16 = 3311;
102}
103
104/// System event types for group member changes (stored as integers).
105#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
106#[repr(u8)]
107pub enum SystemEventType {
108    MemberLeft = 0,
109    MemberJoined = 1,
110    MemberRemoved = 2,
111    WallpaperChanged = 3,
112    WallpaperRemoved = 4,
113    PinsModified = 5,
114}
115
116impl SystemEventType {
117    pub fn display_message(&self, display_name: &str) -> String {
118        match self {
119            SystemEventType::MemberLeft => format!("{} has left", display_name),
120            SystemEventType::MemberJoined => format!("{} has joined", display_name),
121            SystemEventType::MemberRemoved => format!("{} was removed", display_name),
122            SystemEventType::WallpaperChanged => format!("{} changed the wallpaper", display_name),
123            SystemEventType::WallpaperRemoved => format!("{} removed the wallpaper", display_name),
124            SystemEventType::PinsModified => format!("{} modified the Pins", display_name),
125        }
126    }
127
128    pub fn as_u8(&self) -> u8 {
129        *self as u8
130    }
131}
132
133/// A stored event - the flat, protocol-aligned storage format
134///
135/// This struct represents any Nostr event after unwrapping/decryption.
136/// It's designed to store events generically, allowing Vector to:
137/// - Store unknown event types for future compatibility
138/// - Query events efficiently with parsed fields
139/// - Reconstruct typed display objects (Message, Reaction) at render time
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct StoredEvent {
142    /// Event ID (hex string, 64 chars)
143    pub id: String,
144
145    /// Nostr event kind (14=DM, 7=reaction, 15=file, etc.)
146    pub kind: u16,
147
148    /// Database chat ID (foreign key)
149    pub chat_id: i64,
150
151    /// Database user ID of sender (foreign key, optional)
152    pub user_id: Option<i64>,
153
154    /// Event content (encrypted for messages, emoji for reactions, URL for files)
155    pub content: String,
156
157    /// Nostr-style tags as JSON array
158    /// Example: [["e", "abc123", "", "reply"], ["p", "pubkey"]]
159    pub tags: Vec<Vec<String>>,
160
161    /// Parsed reference ID for quick lookups
162    /// - For reactions: the message ID being reacted to
163    /// - For attachments: the message ID they belong to (if separate)
164    /// - For messages: None
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub reference_id: Option<String>,
167
168    /// Event creation timestamp (Unix seconds)
169    pub created_at: u64,
170
171    /// When we received this event (Unix milliseconds)
172    pub received_at: u64,
173
174    /// Whether this event is from the current user
175    pub mine: bool,
176
177    /// Whether this event is pending confirmation (outgoing only)
178    #[serde(default)]
179    pub pending: bool,
180
181    /// Whether sending this event failed (outgoing only)
182    #[serde(default)]
183    pub failed: bool,
184
185    /// Outer giftwrap event ID for deduplication during sync
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub wrapper_event_id: Option<String>,
188
189    /// Sender's npub (for group chats where sender varies)
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub npub: Option<String>,
192
193    /// Cached link preview metadata (JSON serialized SiteMetadata)
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub preview_metadata: Option<String>,
196}
197
198impl StoredEvent {
199    /// Create a new StoredEvent with required fields
200    pub fn new(id: String, kind: u16, chat_id: i64, content: String, created_at: u64) -> Self {
201        Self {
202            id,
203            kind,
204            chat_id,
205            user_id: None,
206            content,
207            tags: Vec::new(),
208            reference_id: None,
209            created_at,
210            received_at: current_timestamp_ms(),
211            mine: false,
212            pending: false,
213            failed: false,
214            wrapper_event_id: None,
215            npub: None,
216            preview_metadata: None,
217        }
218    }
219
220    /// Check if this is a message event (text or file)
221    pub fn is_message(&self) -> bool {
222        self.kind == event_kind::PRIVATE_DIRECT_MESSAGE
223            || self.kind == event_kind::FILE_ATTACHMENT
224    }
225
226    /// Check if this is a reaction event
227    pub fn is_reaction(&self) -> bool {
228        self.kind == event_kind::REACTION
229    }
230
231    /// Check if this is a known event type
232    pub fn is_known_kind(&self) -> bool {
233        matches!(
234            self.kind,
235            event_kind::PRIVATE_DIRECT_MESSAGE
236                | event_kind::FILE_ATTACHMENT
237                | event_kind::REACTION
238                | event_kind::APPLICATION_SPECIFIC
239        )
240    }
241
242    /// Get a tag value by key (first match)
243    pub fn get_tag(&self, key: &str) -> Option<&str> {
244        self.tags
245            .iter()
246            .find(|tag| tag.first().map(|s| s.as_str()) == Some(key))
247            .and_then(|tag| tag.get(1))
248            .map(|s| s.as_str())
249    }
250
251    /// Get all tag values for a key
252    pub fn get_tags(&self, key: &str) -> Vec<&str> {
253        self.tags
254            .iter()
255            .filter(|tag| tag.first().map(|s| s.as_str()) == Some(key))
256            .filter_map(|tag| tag.get(1))
257            .map(|s| s.as_str())
258            .collect()
259    }
260
261    /// Get the reply reference (e tag with "reply" marker)
262    pub fn get_reply_reference(&self) -> Option<&str> {
263        self.tags
264            .iter()
265            .find(|tag| {
266                tag.first().map(|s| s.as_str()) == Some("e")
267                    && tag.get(3).map(|s| s.as_str()) == Some("reply")
268            })
269            .and_then(|tag| tag.get(1))
270            .map(|s| s.as_str())
271    }
272
273    /// Get millisecond-precision timestamp
274    /// Combines created_at (seconds) with "ms" tag if present
275    pub fn timestamp_ms(&self) -> u64 {
276        if let Some(ms_str) = self.get_tag("ms") {
277            if let Ok(ms) = ms_str.parse::<u64>() {
278                if ms <= 999 {
279                    return self.created_at * 1000 + ms;
280                }
281            }
282        }
283        self.created_at * 1000
284    }
285}
286
287/// Get current timestamp in milliseconds
288fn current_timestamp_ms() -> u64 {
289    std::time::SystemTime::now()
290        .duration_since(std::time::UNIX_EPOCH)
291        .map(|d| d.as_millis() as u64)
292        .unwrap_or(0)
293}
294
295/// Builder for creating StoredEvent from rumor processing
296#[derive(Debug, Default)]
297pub struct StoredEventBuilder {
298    id: String,
299    kind: u16,
300    chat_id: i64,
301    user_id: Option<i64>,
302    content: String,
303    tags: Vec<Vec<String>>,
304    reference_id: Option<String>,
305    created_at: u64,
306    mine: bool,
307    pending: bool,
308    failed: bool,
309    wrapper_event_id: Option<String>,
310    npub: Option<String>,
311}
312
313impl StoredEventBuilder {
314    pub fn new() -> Self {
315        Self::default()
316    }
317
318    pub fn id(mut self, id: impl Into<String>) -> Self {
319        self.id = id.into();
320        self
321    }
322
323    pub fn kind(mut self, kind: u16) -> Self {
324        self.kind = kind;
325        self
326    }
327
328    pub fn chat_id(mut self, chat_id: i64) -> Self {
329        self.chat_id = chat_id;
330        self
331    }
332
333    pub fn user_id(mut self, user_id: Option<i64>) -> Self {
334        self.user_id = user_id;
335        self
336    }
337
338    pub fn content(mut self, content: impl Into<String>) -> Self {
339        self.content = content.into();
340        self
341    }
342
343    pub fn tags(mut self, tags: Vec<Vec<String>>) -> Self {
344        self.tags = tags;
345        self
346    }
347
348    pub fn reference_id(mut self, reference_id: Option<String>) -> Self {
349        self.reference_id = reference_id;
350        self
351    }
352
353    pub fn created_at(mut self, created_at: u64) -> Self {
354        self.created_at = created_at;
355        self
356    }
357
358    pub fn mine(mut self, mine: bool) -> Self {
359        self.mine = mine;
360        self
361    }
362
363    pub fn pending(mut self, pending: bool) -> Self {
364        self.pending = pending;
365        self
366    }
367
368    pub fn failed(mut self, failed: bool) -> Self {
369        self.failed = failed;
370        self
371    }
372
373    pub fn wrapper_event_id(mut self, wrapper_event_id: Option<String>) -> Self {
374        self.wrapper_event_id = wrapper_event_id;
375        self
376    }
377
378    pub fn npub(mut self, npub: Option<String>) -> Self {
379        self.npub = npub;
380        self
381    }
382
383    pub fn build(self) -> StoredEvent {
384        StoredEvent {
385            id: self.id,
386            kind: self.kind,
387            chat_id: self.chat_id,
388            user_id: self.user_id,
389            content: self.content,
390            tags: self.tags,
391            reference_id: self.reference_id,
392            created_at: self.created_at,
393            received_at: current_timestamp_ms(),
394            mine: self.mine,
395            pending: self.pending,
396            failed: self.failed,
397            wrapper_event_id: self.wrapper_event_id,
398            npub: self.npub,
399            preview_metadata: None,
400        }
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn test_stored_event_new() {
410        let event = StoredEvent::new(
411            "abc123".to_string(),
412            event_kind::PRIVATE_DIRECT_MESSAGE,
413            1,
414            "Hello world".to_string(),
415            1234567890,
416        );
417
418        assert_eq!(event.id, "abc123");
419        assert_eq!(event.kind, 14);
420        assert!(event.is_message());
421        assert!(!event.is_reaction());
422        assert!(event.is_known_kind());
423    }
424
425    #[test]
426    fn test_get_tag() {
427        let mut event = StoredEvent::new(
428            "abc123".to_string(),
429            event_kind::PRIVATE_DIRECT_MESSAGE,
430            1,
431            "Hello".to_string(),
432            1234567890,
433        );
434        event.tags = vec![
435            vec!["e".to_string(), "ref123".to_string(), "".to_string(), "reply".to_string()],
436            vec!["ms".to_string(), "500".to_string()],
437        ];
438
439        assert_eq!(event.get_tag("ms"), Some("500"));
440        assert_eq!(event.get_reply_reference(), Some("ref123"));
441    }
442
443    #[test]
444    fn test_timestamp_ms() {
445        let mut event = StoredEvent::new(
446            "abc123".to_string(),
447            event_kind::PRIVATE_DIRECT_MESSAGE,
448            1,
449            "Hello".to_string(),
450            1234567890,
451        );
452
453        // Without ms tag
454        assert_eq!(event.timestamp_ms(), 1234567890000);
455
456        // With ms tag
457        event.tags = vec![vec!["ms".to_string(), "456".to_string()]];
458        assert_eq!(event.timestamp_ms(), 1234567890456);
459    }
460
461    #[test]
462    fn test_unknown_kind() {
463        let event = StoredEvent::new(
464            "abc123".to_string(),
465            65535, // Unknown kind (max u16 value)
466            1,
467            "Unknown content".to_string(),
468            1234567890,
469        );
470
471        assert!(!event.is_message());
472        assert!(!event.is_reaction());
473        assert!(!event.is_known_kind());
474    }
475
476    #[test]
477    fn test_builder() {
478        let event = StoredEventBuilder::new()
479            .id("abc123")
480            .kind(event_kind::REACTION)
481            .chat_id(1)
482            .content("👍")
483            .reference_id(Some("msg456".to_string()))
484            .mine(true)
485            .build();
486
487        assert_eq!(event.id, "abc123");
488        assert!(event.is_reaction());
489        assert_eq!(event.reference_id, Some("msg456".to_string()));
490        assert!(event.mine);
491    }
492}