welcome dm

This commit is contained in:
2026-04-09 02:56:50 -04:00
parent c577b33f91
commit 86623d2f6d
7 changed files with 173 additions and 50 deletions
+27 -1
View File
@@ -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());
+1 -40
View File
@@ -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]
+1
View File
@@ -68,6 +68,7 @@ pub struct Channel {
}
#[spacetimedb::table(accessor = direct_message)]
#[derive(Clone)]
pub struct DirectMessage {
#[primary_key]
#[auto_inc]
+98
View File
@@ -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();
+1 -1
View File
@@ -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.
+25 -7
View File
@@ -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;
}
});
}
};
+20 -1
View File
@@ -7,6 +7,7 @@ export class NavigationService {
activeChannelId = $state<bigint | null>(null);
activeThreadId = $state<bigint | null>(null);
pendingThreadParentMessageId = $state<bigint | null>(null);
lastActiveDmId = $state<bigint | null>(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;
}
});
}
}