diff --git a/spacetimedb/src/lib.rs b/spacetimedb/src/lib.rs index 345ce43..f92d16e 100644 --- a/spacetimedb/src/lib.rs +++ b/spacetimedb/src/lib.rs @@ -1,4 +1,4 @@ -use spacetimedb::{ReducerContext, Table}; +use spacetimedb::{Identity, ReducerContext, Table}; mod reducers; mod tables; @@ -10,8 +10,28 @@ pub use tables::*; pub use utils::*; pub use views::*; +pub const SYSTEM_IDENTITY: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + #[spacetimedb::reducer(init)] pub fn init(ctx: &ReducerContext) { + let system_identity = Identity::from_hex(SYSTEM_IDENTITY).unwrap(); + + // Create system user if not exists + if ctx.db.user().identity().find(system_identity).is_none() { + ctx.db.user().insert(User { + identity: system_identity, + name: Some("Zep".to_string()), + online: true, + issuer: None, + subject: None, + anonymous: false, + avatar_id: None, + banner_id: None, + biography: Some("I am the Zep system assistant.".to_string()), + status: Some("Online".to_string()), + }); + } + if ctx .db .system_configuration() @@ -86,6 +106,12 @@ pub fn on_connect(ctx: &ReducerContext) { // Minimal auto-join auto_join_community_server(&ctx.db, ctx.sender()); + + // System Welcome DM + let system_identity = Identity::from_hex(SYSTEM_IDENTITY).unwrap(); + let channel_id = internal_open_direct_message(&ctx.db, system_identity, ctx.sender()); + let welcome_text = "Welcome to Zep! We're glad to have you here.\n\nZep is a decentralized, private, and fast chat service built on SpacetimeDB. You can join servers, create channels, and message friends directly—all with the security and performance of a modern relational backend."; + internal_send_message(&ctx.db, system_identity, channel_id, welcome_text.to_string(), ctx.timestamp); } sync_server_member_info(&ctx.db, ctx.sender()); diff --git a/spacetimedb/src/reducers.rs b/spacetimedb/src/reducers.rs index e035f8b..329db0f 100644 --- a/spacetimedb/src/reducers.rs +++ b/spacetimedb/src/reducers.rs @@ -1132,46 +1132,7 @@ pub fn open_direct_message(ctx: &ReducerContext, recipient: Identity) { panic!("You cannot open a direct message with yourself"); } - // Check if a DM already exists using indexes - let existing = ctx - .db - .direct_message() - .sender() - .filter(ctx.sender()) - .find(|dm| dm.recipient == recipient) - .or_else(|| { - ctx.db - .direct_message() - .recipient() - .filter(ctx.sender()) - .find(|dm| dm.sender == recipient) - }); - - if let Some(mut dm) = existing { - if dm.sender == ctx.sender() { - dm.is_open_sender = true; - } else { - dm.is_open_recipient = true; - } - ctx.db.direct_message().id().update(dm); - } else { - // Create a new DM channel - let chan = ctx.db.channel().insert(Channel { - id: 0, - server_id: 0, - name: "dm".to_string(), - kind: ChannelKind::Text, - }); - - ctx.db.direct_message().insert(DirectMessage { - id: 0, - channel_id: chan.id, - sender: ctx.sender(), - recipient, - is_open_sender: true, - is_open_recipient: true, - }); - } + internal_open_direct_message(&ctx.db, ctx.sender(), recipient); } #[spacetimedb::reducer] diff --git a/spacetimedb/src/tables.rs b/spacetimedb/src/tables.rs index 7393421..a4c3844 100644 --- a/spacetimedb/src/tables.rs +++ b/spacetimedb/src/tables.rs @@ -68,6 +68,7 @@ pub struct Channel { } #[spacetimedb::table(accessor = direct_message)] +#[derive(Clone)] pub struct DirectMessage { #[primary_key] #[auto_inc] diff --git a/spacetimedb/src/utils.rs b/spacetimedb/src/utils.rs index e504bb9..eeac4a6 100644 --- a/spacetimedb/src/utils.rs +++ b/spacetimedb/src/utils.rs @@ -328,6 +328,104 @@ pub fn auto_join_community_server(db: &Local, identity: Identity) { } } +pub fn internal_open_direct_message(db: &Local, sender: Identity, recipient: Identity) -> u64 { + // Check if a DM already exists + let existing = db + .direct_message() + .sender() + .filter(sender) + .find(|dm| dm.recipient == recipient) + .or_else(|| { + db.direct_message() + .recipient() + .filter(sender) + .find(|dm| dm.sender == recipient) + }); + + if let Some(mut dm) = existing { + if dm.sender == sender { + dm.is_open_sender = true; + } else { + dm.is_open_recipient = true; + } + db.direct_message().id().update(dm.clone()); + dm.channel_id + } else { + // Create a new DM channel + let chan = db.channel().insert(Channel { + id: 0, + server_id: 0, + name: "dm".to_string(), + kind: ChannelKind::Text, + }); + + db.direct_message().insert(DirectMessage { + id: 0, + channel_id: chan.id, + sender, + recipient, + is_open_sender: true, + is_open_recipient: true, + }); + chan.id + } +} + +pub fn internal_send_message(db: &Local, sender: Identity, channel_id: u64, text: String, timestamp: spacetimedb::Timestamp) { + let msg = db.message().insert(Message { + id: 0, + sender, + sent: timestamp, + text, + channel_id, + thread_id: None, + reactions: Vec::new(), + image_ids: Vec::new(), + thread_name: None, + thread_reply_count: 0, + edited: false, + }); + + let seq_id = get_next_seq_id(db, channel_id); + db.channel_message_sequence() + .insert(ChannelMessageSequence { + message_id: msg.id, + channel_id, + seq_id, + }); + + db.recent_message().insert(RecentMessage { + id: msg.id, + sender: msg.sender, + sent: msg.sent, + text: msg.text, + channel_id: msg.channel_id, + thread_id: msg.thread_id, + seq_id, + reactions: msg.reactions, + image_ids: msg.image_ids, + thread_name: msg.thread_name, + thread_reply_count: msg.thread_reply_count, + edited: msg.edited, + server_id: 0, // DMs have server_id 0 + }); + + let limit = get_recent_message_limit(db); + if seq_id > limit { + let old_seq_id = seq_id - limit; + let to_delete: Vec<_> = db + .recent_message() + .channel_id() + .filter(channel_id) + .filter(|m| m.seq_id <= old_seq_id) + .map(|m| m.id) + .collect(); + for id in to_delete { + db.recent_message().id().delete(id); + } + } +} + pub fn sync_server_member_info(db: &Local, identity: Identity) { if let Some(user) = db.user().identity().find(identity) { let members: Vec<_> = db.server_member().identity().filter(identity).collect(); diff --git a/src/chat/services/chat.svelte.ts b/src/chat/services/chat.svelte.ts index 6a2d09a..8f31a7f 100644 --- a/src/chat/services/chat.svelte.ts +++ b/src/chat/services/chat.svelte.ts @@ -44,7 +44,7 @@ export class ChatService { this.#voice = new VoiceService(this.#db, this.#nav, () => this.identity); this.#account = new AccountService(); this.#server = new ServerManagementService(); - this.#dm = new DirectMessagingService(this.#db, () => this.identity); + this.#dm = new DirectMessagingService(this.#db, this.#nav, () => this.identity); // Session-only image processing: creates Blob URLs directly from Database data. // This ditched the persistent IndexedDB cache to prevent stale data between reloads. diff --git a/src/chat/services/direct-messaging.svelte.ts b/src/chat/services/direct-messaging.svelte.ts index 68912c8..ecf4cc0 100644 --- a/src/chat/services/direct-messaging.svelte.ts +++ b/src/chat/services/direct-messaging.svelte.ts @@ -2,15 +2,18 @@ import { useReducer } from "spacetimedb/svelte"; import { reducers } from "../../module_bindings"; import type { Identity } from "spacetimedb"; import type { DatabaseService } from "./database.svelte"; +import type { NavigationService } from "./navigation.svelte"; export class DirectMessagingService { #openDirectMessageReducer = useReducer(reducers.openDirectMessage); #closeDirectMessageReducer = useReducer(reducers.closeDirectMessage); #db: DatabaseService; + #nav: NavigationService; #identity: () => Identity | null; - constructor(db: DatabaseService, identity: () => Identity | null) { + constructor(db: DatabaseService, nav: NavigationService, identity: () => Identity | null) { this.#db = db; + this.#nav = nav; this.#identity = identity; } @@ -25,15 +28,30 @@ export class DirectMessagingService { ); if (existing) { - // Session exists, open it if closed - if (existing.sender.isEqual(identity) && !existing.isOpenSender) { - this.#openDirectMessageReducer({ recipient }); - } else if (existing.recipient.isEqual(identity) && !existing.isOpenRecipient) { + // Session exists, focus it + this.#nav.activeServerId = null; // Go home + this.#nav.activeChannelId = existing.channelId; + + // Ensure it's open in DB if it was closed + const isMeSender = existing.sender.isEqual(identity); + const isClosed = isMeSender ? !existing.isOpenSender : !existing.isOpenRecipient; + + if (isClosed) { this.#openDirectMessageReducer({ recipient }); } } else { - // No session, create it - this.#openDirectMessageReducer({ recipient }); + // No session, create it and wait for application to focus + this.#openDirectMessageReducer({ recipient }).onApplied(() => { + // Find the newly created DM + const newDm = this.#db.directMessages.find(dm => + (dm.sender.isEqual(identity) && dm.recipient.isEqual(recipient)) || + (dm.sender.isEqual(recipient) && dm.identity.isEqual(identity)) // fallback + ); + if (newDm) { + this.#nav.activeServerId = null; + this.#nav.activeChannelId = newDm.channelId; + } + }); } }; diff --git a/src/chat/services/navigation.svelte.ts b/src/chat/services/navigation.svelte.ts index 156c62f..09e97a8 100644 --- a/src/chat/services/navigation.svelte.ts +++ b/src/chat/services/navigation.svelte.ts @@ -7,6 +7,7 @@ export class NavigationService { activeChannelId = $state(null); activeThreadId = $state(null); pendingThreadParentMessageId = $state(null); + lastActiveDmId = $state(null); #db: DatabaseService; #identity: () => Identity | null; @@ -27,6 +28,16 @@ export class NavigationService { prevChannelId = currentChannelId; }); + $effect(() => { + const currentServerId = this.activeServerId; + const currentChannelId = this.activeChannelId; + if (currentServerId === null && currentChannelId !== null) { + untrack(() => { + this.lastActiveDmId = currentChannelId; + }); + } + }); + $effect(() => { if (this.pendingThreadParentMessageId) { const newThread = this.#db.allThreads.find( @@ -68,7 +79,15 @@ export class NavigationService { if (!isCurrentChannelValid) { untrack(() => { - this.activeChannelId = null; + // Try to restore last active DM if valid + if (this.lastActiveDmId && activeDms.some(dm => dm.channelId === this.lastActiveDmId)) { + this.activeChannelId = this.lastActiveDmId; + } else if (activeDms.length > 0) { + // Otherwise pick the first one + this.activeChannelId = activeDms[0].channelId; + } else { + this.activeChannelId = null; + } }); } }