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}
114
115impl SystemEventType {
116    pub fn display_message(&self, display_name: &str) -> String {
117        match self {
118            SystemEventType::MemberLeft => format!("{} has left", display_name),
119            SystemEventType::MemberJoined => format!("{} has joined", display_name),
120            SystemEventType::MemberRemoved => format!("{} was removed", display_name),
121            SystemEventType::WallpaperChanged => format!("{} changed the wallpaper", display_name),
122            SystemEventType::WallpaperRemoved => format!("{} removed the wallpaper", display_name),
123        }
124    }
125
126    pub fn as_u8(&self) -> u8 {
127        *self as u8
128    }
129}
130
131/// A stored event - the flat, protocol-aligned storage format
132///
133/// This struct represents any Nostr event after unwrapping/decryption.
134/// It's designed to store events generically, allowing Vector to:
135/// - Store unknown event types for future compatibility
136/// - Query events efficiently with parsed fields
137/// - Reconstruct typed display objects (Message, Reaction) at render time
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct StoredEvent {
140    /// Event ID (hex string, 64 chars)
141    pub id: String,
142
143    /// Nostr event kind (14=DM, 7=reaction, 15=file, etc.)
144    pub kind: u16,
145
146    /// Database chat ID (foreign key)
147    pub chat_id: i64,
148
149    /// Database user ID of sender (foreign key, optional)
150    pub user_id: Option<i64>,
151
152    /// Event content (encrypted for messages, emoji for reactions, URL for files)
153    pub content: String,
154
155    /// Nostr-style tags as JSON array
156    /// Example: [["e", "abc123", "", "reply"], ["p", "pubkey"]]
157    pub tags: Vec<Vec<String>>,
158
159    /// Parsed reference ID for quick lookups
160    /// - For reactions: the message ID being reacted to
161    /// - For attachments: the message ID they belong to (if separate)
162    /// - For messages: None
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub reference_id: Option<String>,
165
166    /// Event creation timestamp (Unix seconds)
167    pub created_at: u64,
168
169    /// When we received this event (Unix milliseconds)
170    pub received_at: u64,
171
172    /// Whether this event is from the current user
173    pub mine: bool,
174
175    /// Whether this event is pending confirmation (outgoing only)
176    #[serde(default)]
177    pub pending: bool,
178
179    /// Whether sending this event failed (outgoing only)
180    #[serde(default)]
181    pub failed: bool,
182
183    /// Outer giftwrap event ID for deduplication during sync
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub wrapper_event_id: Option<String>,
186
187    /// Sender's npub (for group chats where sender varies)
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub npub: Option<String>,
190
191    /// Cached link preview metadata (JSON serialized SiteMetadata)
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub preview_metadata: Option<String>,
194}
195
196impl StoredEvent {
197    /// Create a new StoredEvent with required fields
198    pub fn new(id: String, kind: u16, chat_id: i64, content: String, created_at: u64) -> Self {
199        Self {
200            id,
201            kind,
202            chat_id,
203            user_id: None,
204            content,
205            tags: Vec::new(),
206            reference_id: None,
207            created_at,
208            received_at: current_timestamp_ms(),
209            mine: false,
210            pending: false,
211            failed: false,
212            wrapper_event_id: None,
213            npub: None,
214            preview_metadata: None,
215        }
216    }
217
218    /// Check if this is a message event (text or file)
219    pub fn is_message(&self) -> bool {
220        self.kind == event_kind::PRIVATE_DIRECT_MESSAGE
221            || self.kind == event_kind::FILE_ATTACHMENT
222    }
223
224    /// Check if this is a reaction event
225    pub fn is_reaction(&self) -> bool {
226        self.kind == event_kind::REACTION
227    }
228
229    /// Check if this is a known event type
230    pub fn is_known_kind(&self) -> bool {
231        matches!(
232            self.kind,
233            event_kind::PRIVATE_DIRECT_MESSAGE
234                | event_kind::FILE_ATTACHMENT
235                | event_kind::REACTION
236                | event_kind::APPLICATION_SPECIFIC
237        )
238    }
239
240    /// Get a tag value by key (first match)
241    pub fn get_tag(&self, key: &str) -> Option<&str> {
242        self.tags
243            .iter()
244            .find(|tag| tag.first().map(|s| s.as_str()) == Some(key))
245            .and_then(|tag| tag.get(1))
246            .map(|s| s.as_str())
247    }
248
249    /// Get all tag values for a key
250    pub fn get_tags(&self, key: &str) -> Vec<&str> {
251        self.tags
252            .iter()
253            .filter(|tag| tag.first().map(|s| s.as_str()) == Some(key))
254            .filter_map(|tag| tag.get(1))
255            .map(|s| s.as_str())
256            .collect()
257    }
258
259    /// Get the reply reference (e tag with "reply" marker)
260    pub fn get_reply_reference(&self) -> Option<&str> {
261        self.tags
262            .iter()
263            .find(|tag| {
264                tag.first().map(|s| s.as_str()) == Some("e")
265                    && tag.get(3).map(|s| s.as_str()) == Some("reply")
266            })
267            .and_then(|tag| tag.get(1))
268            .map(|s| s.as_str())
269    }
270
271    /// Get millisecond-precision timestamp
272    /// Combines created_at (seconds) with "ms" tag if present
273    pub fn timestamp_ms(&self) -> u64 {
274        if let Some(ms_str) = self.get_tag("ms") {
275            if let Ok(ms) = ms_str.parse::<u64>() {
276                if ms <= 999 {
277                    return self.created_at * 1000 + ms;
278                }
279            }
280        }
281        self.created_at * 1000
282    }
283}
284
285/// Get current timestamp in milliseconds
286fn current_timestamp_ms() -> u64 {
287    std::time::SystemTime::now()
288        .duration_since(std::time::UNIX_EPOCH)
289        .map(|d| d.as_millis() as u64)
290        .unwrap_or(0)
291}
292
293/// Builder for creating StoredEvent from rumor processing
294#[derive(Debug, Default)]
295pub struct StoredEventBuilder {
296    id: String,
297    kind: u16,
298    chat_id: i64,
299    user_id: Option<i64>,
300    content: String,
301    tags: Vec<Vec<String>>,
302    reference_id: Option<String>,
303    created_at: u64,
304    mine: bool,
305    pending: bool,
306    failed: bool,
307    wrapper_event_id: Option<String>,
308    npub: Option<String>,
309}
310
311impl StoredEventBuilder {
312    pub fn new() -> Self {
313        Self::default()
314    }
315
316    pub fn id(mut self, id: impl Into<String>) -> Self {
317        self.id = id.into();
318        self
319    }
320
321    pub fn kind(mut self, kind: u16) -> Self {
322        self.kind = kind;
323        self
324    }
325
326    pub fn chat_id(mut self, chat_id: i64) -> Self {
327        self.chat_id = chat_id;
328        self
329    }
330
331    pub fn user_id(mut self, user_id: Option<i64>) -> Self {
332        self.user_id = user_id;
333        self
334    }
335
336    pub fn content(mut self, content: impl Into<String>) -> Self {
337        self.content = content.into();
338        self
339    }
340
341    pub fn tags(mut self, tags: Vec<Vec<String>>) -> Self {
342        self.tags = tags;
343        self
344    }
345
346    pub fn reference_id(mut self, reference_id: Option<String>) -> Self {
347        self.reference_id = reference_id;
348        self
349    }
350
351    pub fn created_at(mut self, created_at: u64) -> Self {
352        self.created_at = created_at;
353        self
354    }
355
356    pub fn mine(mut self, mine: bool) -> Self {
357        self.mine = mine;
358        self
359    }
360
361    pub fn pending(mut self, pending: bool) -> Self {
362        self.pending = pending;
363        self
364    }
365
366    pub fn failed(mut self, failed: bool) -> Self {
367        self.failed = failed;
368        self
369    }
370
371    pub fn wrapper_event_id(mut self, wrapper_event_id: Option<String>) -> Self {
372        self.wrapper_event_id = wrapper_event_id;
373        self
374    }
375
376    pub fn npub(mut self, npub: Option<String>) -> Self {
377        self.npub = npub;
378        self
379    }
380
381    pub fn build(self) -> StoredEvent {
382        StoredEvent {
383            id: self.id,
384            kind: self.kind,
385            chat_id: self.chat_id,
386            user_id: self.user_id,
387            content: self.content,
388            tags: self.tags,
389            reference_id: self.reference_id,
390            created_at: self.created_at,
391            received_at: current_timestamp_ms(),
392            mine: self.mine,
393            pending: self.pending,
394            failed: self.failed,
395            wrapper_event_id: self.wrapper_event_id,
396            npub: self.npub,
397            preview_metadata: None,
398        }
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_stored_event_new() {
408        let event = StoredEvent::new(
409            "abc123".to_string(),
410            event_kind::PRIVATE_DIRECT_MESSAGE,
411            1,
412            "Hello world".to_string(),
413            1234567890,
414        );
415
416        assert_eq!(event.id, "abc123");
417        assert_eq!(event.kind, 14);
418        assert!(event.is_message());
419        assert!(!event.is_reaction());
420        assert!(event.is_known_kind());
421    }
422
423    #[test]
424    fn test_get_tag() {
425        let mut event = StoredEvent::new(
426            "abc123".to_string(),
427            event_kind::PRIVATE_DIRECT_MESSAGE,
428            1,
429            "Hello".to_string(),
430            1234567890,
431        );
432        event.tags = vec![
433            vec!["e".to_string(), "ref123".to_string(), "".to_string(), "reply".to_string()],
434            vec!["ms".to_string(), "500".to_string()],
435        ];
436
437        assert_eq!(event.get_tag("ms"), Some("500"));
438        assert_eq!(event.get_reply_reference(), Some("ref123"));
439    }
440
441    #[test]
442    fn test_timestamp_ms() {
443        let mut event = StoredEvent::new(
444            "abc123".to_string(),
445            event_kind::PRIVATE_DIRECT_MESSAGE,
446            1,
447            "Hello".to_string(),
448            1234567890,
449        );
450
451        // Without ms tag
452        assert_eq!(event.timestamp_ms(), 1234567890000);
453
454        // With ms tag
455        event.tags = vec![vec!["ms".to_string(), "456".to_string()]];
456        assert_eq!(event.timestamp_ms(), 1234567890456);
457    }
458
459    #[test]
460    fn test_unknown_kind() {
461        let event = StoredEvent::new(
462            "abc123".to_string(),
463            65535, // Unknown kind (max u16 value)
464            1,
465            "Unknown content".to_string(),
466            1234567890,
467        );
468
469        assert!(!event.is_message());
470        assert!(!event.is_reaction());
471        assert!(!event.is_known_kind());
472    }
473
474    #[test]
475    fn test_builder() {
476        let event = StoredEventBuilder::new()
477            .id("abc123")
478            .kind(event_kind::REACTION)
479            .chat_id(1)
480            .content("👍")
481            .reference_id(Some("msg456".to_string()))
482            .mine(true)
483            .build();
484
485        assert_eq!(event.id, "abc123");
486        assert!(event.is_reaction());
487        assert_eq!(event.reference_id, Some("msg456".to_string()));
488        assert!(event.mine);
489    }
490}