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}