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}