This commit is contained in:
2026-04-16 19:24:18 -04:00
parent bff7daecc3
commit e718da0981
9 changed files with 517 additions and 1568 deletions
-63
View File
@@ -1,63 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Dependency directory
node_modules/
jspm_packages/
# Build output
dist/**
out/
build/
# IDEs and editors
.idea
.vscode/
*.iml
*~
# Misc
.DS_Store
*.env.local
.env.development.local
.env.test.local
.env.production.local
# Vite
.vite/
# SpacetimeDB build output
spacetimedb/dist/
spacetimedb/target/
# SpacetimeDB generated module bindings
src/module_bindings/**
# Tauri & Rust
src-tauri/target/
src-tauri/gen/
# Rust's Cargo.lock is often committed in binary/app projects,
# but target/ is always ignored.
# Ignore this file
.gitignore
# wrangler files
.wrangler
.dev.vars*
!.dev.vars.example
!.env.example
+20 -7
View File
@@ -60,14 +60,12 @@ pub fn init(ctx: &ReducerContext) {
server_id: s.id,
name: "general".to_string(),
kind: ChannelKind::Text,
last_seq_id: 0,
});
let c2 = ctx.db.channel().insert(Channel {
id: 0,
server_id: s.id,
name: "Voice General".to_string(),
kind: ChannelKind::Voice,
last_seq_id: 0,
});
let mut s = ctx.db.server().id().find(s.id).unwrap();
@@ -75,15 +73,16 @@ pub fn init(ctx: &ReducerContext) {
id: c1.id,
name: c1.name,
kind: c1.kind,
last_seq_id: 0,
});
s.channels.push(ChannelMetadata {
id: c2.id,
name: c2.name,
kind: c2.kind,
last_seq_id: 0,
});
ctx.db.server().id().update(s);
ctx.db.server().id().update(s.clone());
// Grant access to system user
sync_server_access(&ctx.db, system_identity, s.id);
}
}
@@ -111,13 +110,27 @@ pub fn on_connect(ctx: &ReducerContext) {
});
// Minimal auto-join
auto_join_community_server(&ctx.db, ctx.sender());
join_server(ctx, 1);
// 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);
internal_send_message(
&ctx.db,
system_identity,
channel_id,
welcome_text.to_string(),
ctx.timestamp,
None,
vec![],
false,
);
}
// High Performance: Sync all channel access for this user
for member in ctx.db.server_member().identity().filter(ctx.sender()) {
sync_server_access(&ctx.db, ctx.sender(), member.server_id);
}
sync_server_member_info(&ctx.db, ctx.sender());
File diff suppressed because it is too large Load Diff
+51 -26
View File
@@ -7,6 +7,7 @@ pub enum ChannelKind {
}
#[spacetimedb::table(accessor = user, public)]
#[derive(Clone)]
pub struct User {
#[primary_key]
pub identity: Identity,
@@ -28,10 +29,10 @@ pub struct ChannelMetadata {
pub id: u64,
pub name: String,
pub kind: ChannelKind,
pub last_seq_id: u64,
}
#[spacetimedb::table(accessor = server)]
#[derive(Clone)]
pub struct Server {
#[primary_key]
#[auto_inc]
@@ -45,6 +46,7 @@ pub struct Server {
}
#[spacetimedb::table(accessor = server_member)]
#[derive(Clone)]
pub struct ServerMember {
#[primary_key]
#[auto_inc]
@@ -58,7 +60,28 @@ pub struct ServerMember {
pub online: bool,
}
#[spacetimedb::table(accessor = channel_internal_state)]
#[derive(Clone)]
pub struct ChannelInternalState {
#[primary_key]
pub channel_id: u64,
pub last_seq_id: u64,
}
#[spacetimedb::table(accessor = user_channel_access)]
#[derive(Clone)]
pub struct UserChannelAccess {
#[primary_key]
#[auto_inc]
pub id: u64,
#[index(btree)]
pub identity: Identity,
#[index(btree)]
pub channel_id: u64,
}
#[spacetimedb::table(accessor = channel)]
#[derive(Clone)]
pub struct Channel {
#[primary_key]
#[auto_inc]
@@ -67,7 +90,6 @@ pub struct Channel {
pub server_id: u64, // 0 if no server (DM)
pub name: String,
pub kind: ChannelKind,
pub last_seq_id: u64,
}
#[spacetimedb::table(accessor = direct_message)]
@@ -116,6 +138,7 @@ pub enum MediaType {
}
#[spacetimedb::table(accessor = webrtc_signal)]
#[derive(Clone)]
pub struct WebRTCSignal {
#[primary_key]
#[auto_inc]
@@ -132,6 +155,7 @@ pub struct WebRTCSignal {
}
#[spacetimedb::table(accessor = channel_subscription)]
#[derive(Clone)]
pub struct ChannelSubscription {
#[primary_key]
pub identity: Identity,
@@ -142,6 +166,7 @@ pub struct ChannelSubscription {
}
#[spacetimedb::table(accessor = thread, public)]
#[derive(Clone)]
pub struct Thread {
#[primary_key]
#[auto_inc]
@@ -173,6 +198,8 @@ pub struct Message {
#[index(btree)]
pub channel_id: u64,
#[index(btree)]
pub server_id: u64,
#[index(btree)]
pub thread_id: Option<u64>,
pub reactions: Vec<Reaction>,
pub image_ids: Vec<u64>,
@@ -180,10 +207,12 @@ pub struct Message {
pub thread_reply_count: u32,
pub edited: bool,
pub is_encrypted: bool,
#[index(btree)]
pub seq_id: u64,
}
#[spacetimedb::table(accessor = custom_emoji, public)]
#[derive(Clone)]
pub struct CustomEmoji {
#[primary_key]
#[auto_inc]
@@ -196,16 +225,32 @@ pub struct CustomEmoji {
}
#[spacetimedb::table(accessor = image)]
#[derive(Clone)]
pub struct Image {
#[primary_key]
#[auto_inc]
#[index(btree)]
pub id: u64,
pub data: Vec<u8>,
pub mime_type: String,
pub name: Option<String>,
}
#[spacetimedb::table(accessor = image_data)]
#[derive(Clone)]
pub struct ImageData {
#[primary_key]
pub image_id: u64,
pub data: Vec<u8>,
}
#[spacetimedb::table(accessor = image_blob_request)]
#[derive(Clone)]
pub struct ImageBlobRequest {
#[primary_key]
pub identity: Identity,
#[index(btree)]
pub image_id: u64,
}
#[spacetimedb::table(accessor = typing_activity, public)]
#[derive(Clone)]
pub struct TypingActivity {
@@ -217,29 +262,8 @@ pub struct TypingActivity {
pub typing: bool,
}
#[spacetimedb::table(accessor = recent_message)]
pub struct RecentMessage {
#[primary_key]
pub id: u64, // This is the message_id
#[index(btree)]
pub server_id: u64, // 0 if DM
#[index(btree)]
pub channel_id: u64,
pub sender: Identity,
pub text: String,
pub thread_id: Option<u64>,
pub sent: Timestamp,
pub seq_id: u64,
pub reactions: Vec<Reaction>,
pub image_ids: Vec<u64>,
pub thread_name: Option<String>,
pub thread_reply_count: u32,
pub edited: bool,
pub is_encrypted: bool,
}
#[spacetimedb::table(accessor = system_configuration, public)]
#[derive(Clone)]
pub struct SystemConfiguration {
#[primary_key]
pub key: String,
@@ -247,6 +271,7 @@ pub struct SystemConfiguration {
}
#[spacetimedb::table(accessor = upload_status, public)]
#[derive(Clone)]
pub struct UploadStatus {
#[primary_key]
pub client_id: String,
+120 -335
View File
@@ -3,27 +3,26 @@ use spacetimedb::{Identity, Local, LocalReadOnly, Table};
use std::collections::{HashMap, HashSet};
pub fn validate_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("Names must not be empty".to_string());
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("Name cannot be empty".to_string());
}
if trimmed.len() > 32 {
return Err("Name too long".to_string());
}
Ok(())
}
pub fn validate_message_length(db: &Local, text: &str) -> Result<(), String> {
let max_length_conf = db
let limit = db
.system_configuration()
.key()
.find("max_message_length".to_string());
let max_length = max_length_conf
.find("max_message_length".to_string())
.and_then(|c| c.value.parse::<usize>().ok())
.unwrap_or(262144);
.unwrap_or(2000);
if text.len() > max_length {
return Err(format!(
"Message exceeds maximum length of {} bytes ({}KB).",
max_length,
max_length / 1024
));
if text.len() > limit {
return Err(format!("Message exceeds {} characters", limit));
}
Ok(())
}
@@ -36,375 +35,120 @@ pub fn get_recent_message_limit(db: &Local) -> u64 {
.unwrap_or(50)
}
pub fn get_recent_message_limit_read_only(db: &LocalReadOnly) -> u64 {
db.system_configuration()
.key()
.find("recent_message_limit".to_string())
.and_then(|c| c.value.parse::<u64>().ok())
.unwrap_or(50)
}
pub fn get_next_seq_id(db: &Local, channel_id: u64) -> u64 {
let mut channel = db.channel().id().find(channel_id).expect("Channel not found");
let next_seq_id = channel.last_seq_id + 1;
channel.last_seq_id = next_seq_id;
db.channel().id().update(channel);
next_seq_id
if let Some(mut state) = db.channel_internal_state().channel_id().find(channel_id) {
state.last_seq_id += 1;
let new_id = state.last_seq_id;
db.channel_internal_state().channel_id().update(state);
new_id
} else {
db.channel_internal_state().insert(ChannelInternalState {
channel_id,
last_seq_id: 1,
});
1
}
}
pub fn get_visible_message_ids(db: &Local, identity: Identity) -> HashMap<u64, u64> {
let mut result = HashMap::new();
// 1. Scrollback Path
if let Some(sub) = db.channel_subscription().identity().find(identity) {
for msg in db
.message()
.channel_id()
.filter(sub.channel_id)
{
if msg.seq_id >= sub.earliest_seq_id {
result.insert(msg.id, msg.seq_id);
}
}
}
// 2. Fast Path: Recent Messages
let my_server_ids: Vec<u64> = db
.server_member()
.identity()
.filter(identity)
.map(|m| m.server_id)
.collect();
for server_id in my_server_ids {
for rm in db.recent_message().server_id().filter(server_id) {
result.entry(rm.id).or_insert(rm.seq_id);
}
}
// 3. DM Fast Path
let my_dms: Vec<_> = db
.direct_message()
.sender()
.filter(identity)
.filter(|dm| dm.is_open_sender)
.chain(
db.direct_message()
.recipient()
.filter(identity)
.filter(|dm| dm.is_open_recipient),
)
.map(|dm| dm.channel_id)
.collect();
for channel_id in my_dms {
for rm in db.recent_message().channel_id().filter(channel_id) {
result.entry(rm.id).or_insert(rm.seq_id);
}
}
result
/// Simplified: uses UserChannelAccess table directly
pub fn get_visible_message_ids(db: &Local, identity: Identity) -> HashSet<u64> {
db.user_channel_access().identity().filter(identity).map(|a| a.channel_id).collect()
}
pub fn get_visible_message_ids_read_only(
db: &LocalReadOnly,
identity: Identity,
) -> HashMap<u64, u64> {
let mut result = HashMap::new();
// 1. Scrollback Path (Selective)
if let Some(sub) = db.channel_subscription().identity().find(identity) {
for msg in db
.message()
.channel_id()
.filter(sub.channel_id)
{
if msg.seq_id >= sub.earliest_seq_id {
result.insert(msg.id, msg.seq_id);
}
}
}
// 2. Fast Path: Recent Messages from my Servers
let my_server_ids: Vec<u64> = db
.server_member()
.identity()
.filter(identity)
.map(|m| m.server_id)
.collect();
for server_id in my_server_ids {
for rm in db.recent_message().server_id().filter(server_id) {
// entry().or_insert is faster than double lookup
result.entry(rm.id).or_insert(rm.seq_id);
}
}
// 3. Fast Path: Recent Messages from my Open DMs
let my_dms: Vec<_> = db
.direct_message()
.sender()
.filter(identity)
.filter(|dm| dm.is_open_sender)
.chain(
db.direct_message()
.recipient()
.filter(identity)
.filter(|dm| dm.is_open_recipient),
)
.map(|dm| dm.channel_id)
.collect();
for channel_id in my_dms {
for rm in db.recent_message().channel_id().filter(channel_id) {
result.entry(rm.id).or_insert(rm.seq_id);
}
}
result
pub fn get_visible_message_ids_read_only(db: &LocalReadOnly, identity: Identity) -> HashSet<u64> {
db.user_channel_access().identity().filter(identity).map(|a| a.channel_id).collect()
}
pub fn get_visible_image_ids(db: &Local, identity: Identity) -> HashSet<u64> {
let mut ids = HashSet::new();
let mut results = HashSet::new();
let accessible_channels = get_visible_message_ids(db, identity);
// 1. My Servers and their Members (Avatars/Banners)
let memberships: Vec<_> = db.server_member().identity().filter(identity).collect();
for member in memberships {
if let Some(s) = db.server().id().find(member.server_id) {
if let Some(avatar_id) = s.avatar_id {
ids.insert(avatar_id);
}
}
for peer in db.server_member().server_id().filter(member.server_id) {
if let Some(u) = db.user().identity().find(peer.identity) {
if let Some(avatar_id) = u.avatar_id {
ids.insert(avatar_id);
}
if let Some(banner_id) = u.banner_id {
ids.insert(banner_id);
}
for channel_id in accessible_channels {
for msg in db.message().channel_id().filter(channel_id) {
for id in msg.image_ids {
results.insert(id);
}
}
}
// 2. Custom Emojis (Global)
for ce in db.custom_emoji().name().filter(""..) {
ids.insert(ce.id);
if let Some(user) = db.user().identity().find(identity) {
if let Some(id) = user.avatar_id { results.insert(id); }
if let Some(id) = user.banner_id { results.insert(id); }
}
// 3. Active Channel Images (Recent + Scrollback)
if let Some(sub) = db.channel_subscription().identity().find(identity) {
// From Recent Messages cache for this channel
for rm in db.recent_message().channel_id().filter(sub.channel_id) {
for id in &rm.image_ids {
ids.insert(*id);
}
}
// From Scrollback Messages for this channel
for msg in db
.message()
.channel_id()
.filter(sub.channel_id)
{
if msg.seq_id >= sub.earliest_seq_id {
for id in &msg.image_ids {
ids.insert(*id);
}
}
}
}
ids
results
}
pub fn get_visible_image_ids_read_only(db: &LocalReadOnly, identity: Identity) -> HashSet<u64> {
let mut ids = HashSet::new();
let mut results = HashSet::new();
let accessible_channels = get_visible_message_ids_read_only(db, identity);
// 1. My Servers and their Members (Avatars/Banners)
let memberships: Vec<_> = db.server_member().identity().filter(identity).collect();
for member in memberships {
if let Some(s) = db.server().id().find(member.server_id) {
if let Some(avatar_id) = s.avatar_id {
ids.insert(avatar_id);
}
}
for peer in db.server_member().server_id().filter(member.server_id) {
if let Some(u) = db.user().identity().find(peer.identity) {
if let Some(avatar_id) = u.avatar_id {
ids.insert(avatar_id);
}
if let Some(banner_id) = u.banner_id {
ids.insert(banner_id);
}
for channel_id in accessible_channels {
for msg in db.message().channel_id().filter(channel_id) {
for id in msg.image_ids {
results.insert(id);
}
}
}
// 2. Custom Emojis (Global)
for ce in db.custom_emoji().name().filter(""..) {
ids.insert(ce.id);
if let Some(user) = db.user().identity().find(identity) {
if let Some(id) = user.avatar_id { results.insert(id); }
if let Some(id) = user.banner_id { results.insert(id); }
}
// 3. Active Channel Images (Recent + Scrollback)
if let Some(sub) = db.channel_subscription().identity().find(identity) {
// From Recent Messages cache for this channel
for rm in db.recent_message().channel_id().filter(sub.channel_id) {
for id in &rm.image_ids {
ids.insert(*id);
}
}
// From Scrollback Messages for this channel
for msg in db
.message()
.channel_id()
.filter(sub.channel_id)
{
if msg.seq_id >= sub.earliest_seq_id {
for id in &msg.image_ids {
ids.insert(*id);
}
}
}
}
ids
}
pub fn clear_signaling_for_user(db: &Local, identity: Identity) {
for row in db
.webrtc_signal()
.sender()
.filter(identity)
.collect::<Vec<_>>()
{
db.webrtc_signal().delete(row);
}
for row in db
.webrtc_signal()
.receiver()
.filter(identity)
.collect::<Vec<_>>()
{
db.webrtc_signal().delete(row);
}
}
pub fn clear_user_presence(db: &Local, identity: Identity) {
if let Some(state) = db.user_state().identity().find(identity) {
db.user_state().delete(state);
}
clear_signaling_for_user(db, identity);
}
pub fn auto_join_community_server(db: &Local, identity: Identity) {
let community_server = db.server().name().filter(&"Zep".to_string()).next();
if let Some(s) = community_server {
let user = db.user().identity().find(identity);
db.server_member().insert(ServerMember {
id: 0,
identity,
server_id: s.id,
name: user.as_ref().and_then(|u| u.name.clone()),
avatar_id: user.as_ref().and_then(|u| u.avatar_id),
online: user.as_ref().map(|u| u.online).unwrap_or(false),
});
}
results
}
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)
});
let existing = db.direct_message().sender().filter(sender).find(|dm| dm.recipient == recipient)
.or_else(|| db.direct_message().sender().filter(recipient).find(|dm| dm.recipient == sender));
if let Some(mut dm) = existing {
if dm.sender == sender {
dm.is_open_sender = true;
} else {
dm.is_open_recipient = true;
}
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,
last_seq_id: 0,
});
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,
id: 0, channel_id: chan.id, sender, recipient, is_open_sender: true, is_open_recipient: true,
});
grant_user_channel_access(db, sender, chan.id);
grant_user_channel_access(db, recipient, chan.id);
chan.id
}
}
pub fn internal_send_message(db: &Local, sender: Identity, channel_id: u64, text: String, timestamp: spacetimedb::Timestamp) {
pub fn internal_send_message(
db: &Local,
sender: Identity,
channel_id: u64,
text: String,
timestamp: spacetimedb::Timestamp,
thread_id: Option<u64>,
image_ids: Vec<u64>,
is_encrypted: bool,
) {
let seq_id = get_next_seq_id(db, channel_id);
let server_id = db.channel().id().find(channel_id).map(|c| c.server_id).unwrap_or(0);
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,
is_encrypted: false,
seq_id,
db.message().insert(Message {
id: 0, sender, sent: timestamp, text, channel_id, server_id, thread_id,
reactions: Vec::new(), image_ids, thread_name: None, thread_reply_count: 0,
edited: false, is_encrypted, 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
is_encrypted: false,
});
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();
for mut member in members {
for mut member in db.server_member().identity().filter(identity) {
member.name = user.name.clone();
member.avatar_id = user.avatar_id;
member.online = user.online;
@@ -412,3 +156,44 @@ pub fn sync_server_member_info(db: &Local, identity: Identity) {
}
}
}
pub fn grant_user_channel_access(db: &Local, identity: Identity, channel_id: u64) {
let exists = db.user_channel_access().identity().filter(identity).any(|a| a.channel_id == channel_id);
if !exists {
db.user_channel_access().insert(UserChannelAccess { id: 0, identity, channel_id });
}
}
pub fn revoke_user_channel_access(db: &Local, identity: Identity, channel_id: u64) {
let to_delete: Vec<_> = db.user_channel_access().identity().filter(identity)
.filter(|a| a.channel_id == channel_id)
.map(|a| a.id)
.collect();
for id in to_delete {
db.user_channel_access().id().delete(id);
}
}
pub fn sync_server_access(db: &Local, identity: Identity, server_id: u64) {
for c in db.channel().server_id().filter(server_id) {
grant_user_channel_access(db, identity, c.id);
}
}
pub fn revoke_server_access(db: &Local, identity: Identity, server_id: u64) {
for c in db.channel().server_id().filter(server_id) {
revoke_user_channel_access(db, identity, c.id);
}
}
pub fn clear_user_presence(db: &Local, identity: Identity) {
if let Some(_) = db.user_state().identity().find(identity) {
db.user_state().identity().delete(identity);
}
clear_signaling_for_user(db, identity);
}
pub fn clear_signaling_for_user(db: &Local, identity: Identity) {
let signals: Vec<_> = db.webrtc_signal().sender().filter(identity).map(|s| s.id).collect();
for id in signals { db.webrtc_signal().id().delete(id); }
}
+76 -206
View File
@@ -5,7 +5,6 @@ use spacetimedb::{Identity, Query, Table, Timestamp, ViewContext};
#[derive(spacetimedb::SpacetimeType)]
pub struct VisibleImageRow {
pub id: u64,
pub data: Vec<u8>,
pub mime_type: String,
pub name: Option<String>,
}
@@ -32,30 +31,8 @@ pub fn visible_typing_activity(ctx: &ViewContext) -> Vec<TypingActivity> {
let identity = ctx.sender();
let mut results = std::collections::HashMap::new();
// 1. Server channels
for member in ctx.db.server_member().identity().filter(identity) {
if let Some(s) = ctx.db.server().id().find(member.server_id) {
for chan_meta in s.channels {
for activity in ctx.db.typing_activity().channel_id().filter(chan_meta.id) {
if activity.typing {
results.insert(activity.identity, activity.clone());
}
}
}
}
}
// 2. DM channels
let mut dm_channel_ids = Vec::new();
for dm in ctx.db.direct_message().sender().filter(identity) {
dm_channel_ids.push(dm.channel_id);
}
for dm in ctx.db.direct_message().recipient().filter(identity) {
dm_channel_ids.push(dm.channel_id);
}
for channel_id in dm_channel_ids {
for activity in ctx.db.typing_activity().channel_id().filter(channel_id) {
for access in ctx.db.user_channel_access().identity().filter(identity) {
for activity in ctx.db.typing_activity().channel_id().filter(access.channel_id) {
if activity.typing {
results.insert(activity.identity, activity.clone());
}
@@ -71,6 +48,7 @@ pub struct MyChannelSubscriptionRow {
pub channel_id: u64,
pub earliest_seq_id: u64,
pub last_read_seq_id: u64,
pub last_seq_id: u64,
}
#[derive(spacetimedb::SpacetimeType)]
@@ -79,7 +57,6 @@ pub struct VisibleChannelRow {
pub server_id: u64,
pub name: String,
pub kind: ChannelKind,
pub last_seq_id: u64,
}
#[derive(spacetimedb::SpacetimeType)]
@@ -93,49 +70,20 @@ pub struct VisibleDirectMessageRow {
}
#[spacetimedb::view(accessor = visible_recent_activity, public)]
pub fn visible_recent_activity(ctx: &ViewContext) -> Vec<RecentMessage> {
pub fn visible_recent_activity(ctx: &ViewContext) -> Vec<Message> {
let identity = ctx.sender();
let mut results = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
// 1. Servers I'm a member of
let my_server_ids: Vec<u64> = ctx
.db
.server_member()
.identity()
.filter(identity)
.map(|m| m.server_id)
.collect();
for access in ctx.db.user_channel_access().identity().filter(identity) {
let last_seq_id = ctx.db.channel_internal_state().channel_id().find(access.channel_id)
.map(|s| s.last_seq_id).unwrap_or(0);
for server_id in my_server_ids {
for rm in ctx.db.recent_message().server_id().filter(server_id) {
if seen_ids.insert(rm.id) {
results.push(rm);
}
}
}
let limit = get_recent_message_limit_read_only(&ctx.db);
let min_seq = if last_seq_id > limit { last_seq_id - (limit - 1) } else { 1 };
// 2. Open DMs
let my_dms: Vec<_> = ctx
.db
.direct_message()
.sender()
.filter(identity)
.filter(|dm| dm.is_open_sender)
.chain(
ctx.db
.direct_message()
.recipient()
.filter(identity)
.filter(|dm| dm.is_open_recipient),
)
.map(|dm| dm.channel_id)
.collect();
for channel_id in my_dms {
for rm in ctx.db.recent_message().channel_id().filter(channel_id) {
if seen_ids.insert(rm.id) {
results.push(rm);
for msg in ctx.db.message().channel_id().filter(access.channel_id) {
if msg.seq_id >= min_seq {
results.push(msg.clone());
}
}
}
@@ -146,23 +94,12 @@ pub fn visible_recent_activity(ctx: &ViewContext) -> Vec<RecentMessage> {
#[spacetimedb::view(accessor = visible_servers, public)]
pub fn visible_servers(ctx: &ViewContext) -> Vec<Server> {
let identity = ctx.sender();
let mut results = Vec::new();
let my_server_ids: std::collections::HashSet<u64> = ctx.db.server_member().identity().filter(identity).map(|m| m.server_id).collect();
// Servers I'm a member of
let my_server_ids: std::collections::HashSet<u64> = ctx
.db
.server_member()
.identity()
.filter(identity)
.map(|m| m.server_id)
.collect();
for server in ctx.db.server().name().filter(""..) {
if server.public || my_server_ids.contains(&server.id) {
results.push(server);
}
}
results
ctx.db.server().name().filter(""..)
.filter(|s: &Server| s.public || my_server_ids.contains(&s.id))
.map(|s: Server| s.clone())
.collect()
}
#[spacetimedb::view(accessor = visible_server_members, public)]
@@ -171,19 +108,10 @@ pub fn visible_server_members(ctx: &ViewContext) -> Vec<ServerMember> {
let mut results = Vec::new();
let mut seen = std::collections::HashSet::new();
// 1. Find all server IDs I am in
let my_server_ids: std::collections::HashSet<u64> = ctx
.db
.server_member()
.identity()
.filter(identity)
.map(|m| m.server_id)
.collect();
for server_id in my_server_ids {
for peer in ctx.db.server_member().server_id().filter(server_id) {
for member in ctx.db.server_member().identity().filter(identity) {
for peer in ctx.db.server_member().server_id().filter(member.server_id) {
if seen.insert(peer.id) {
results.push(peer);
results.push(peer.clone());
}
}
}
@@ -192,48 +120,29 @@ pub fn visible_server_members(ctx: &ViewContext) -> Vec<ServerMember> {
#[spacetimedb::view(accessor = visible_channels, public)]
pub fn visible_channels(ctx: &ViewContext) -> Vec<VisibleChannelRow> {
let mut results = Vec::new();
let identity = ctx.sender();
let mut results = Vec::new();
// Server channels (from nested data)
// 1. Server channels
for member in ctx.db.server_member().identity().filter(identity) {
if let Some(s) = ctx.db.server().id().find(member.server_id) {
for chan_meta in s.channels {
if let Some(chan) = ctx.db.channel().id().find(chan_meta.id) {
results.push(VisibleChannelRow {
id: chan.id,
server_id: s.id,
name: chan.name,
kind: chan.kind,
last_seq_id: chan.last_seq_id,
id: chan_meta.id, server_id: s.id, name: chan_meta.name.clone(), kind: chan_meta.kind,
});
}
}
}
}
// DM channels
for dm in ctx.db.direct_message().sender().filter(identity) {
if let Some(chan) = ctx.db.channel().id().find(dm.channel_id) {
// 2. DM channels
for access in ctx.db.user_channel_access().identity().filter(identity) {
if let Some(chan) = ctx.db.channel().id().find(access.channel_id) {
if chan.server_id == 0 {
results.push(VisibleChannelRow {
id: chan.id,
server_id: chan.server_id,
name: chan.name.clone(),
kind: chan.kind,
last_seq_id: chan.last_seq_id,
id: chan.id, server_id: 0, name: chan.name.clone(), kind: chan.kind,
});
}
}
for dm in ctx.db.direct_message().recipient().filter(identity) {
if let Some(chan) = ctx.db.channel().id().find(dm.channel_id) {
results.push(VisibleChannelRow {
id: chan.id,
server_id: chan.server_id,
name: chan.name.clone(),
kind: chan.kind,
last_seq_id: chan.last_seq_id,
});
}
}
results
@@ -242,8 +151,7 @@ pub fn visible_channels(ctx: &ViewContext) -> Vec<VisibleChannelRow> {
#[spacetimedb::view(accessor = visible_direct_messages, public)]
pub fn visible_direct_messages(ctx: &ViewContext) -> impl Query<DirectMessage> {
let identity = ctx.sender();
ctx.from
.direct_message()
ctx.from.direct_message()
.r#where(move |dm| dm.sender.eq(identity).or(dm.recipient.eq(identity)))
}
@@ -254,132 +162,94 @@ pub fn visible_images(ctx: &ViewContext) -> Vec<VisibleImageRow> {
for id in image_ids {
if let Some(img) = ctx.db.image().id().find(id) {
results.push(VisibleImageRow {
id: img.id,
data: img.data.clone(),
mime_type: img.mime_type,
name: img.name,
id: img.id, mime_type: img.mime_type.clone(), name: img.name.clone(),
});
}
}
results
}
#[spacetimedb::view(accessor = visible_image_blobs, public)]
pub fn visible_image_blobs(ctx: &ViewContext) -> Vec<ImageData> {
let identity = ctx.sender();
if let Some(req) = ctx.db.image_blob_request().identity().find(identity) {
if let Some(data) = ctx.db.image_data().image_id().find(req.image_id) {
return vec![data.clone()];
}
}
vec![]
}
#[spacetimedb::view(accessor = visible_user_states, public)]
pub fn visible_user_states(ctx: &ViewContext) -> Vec<UserState> {
let identity = ctx.sender();
let mut results = std::collections::HashMap::new();
// 1. My own state
for access in ctx.db.user_channel_access().identity().filter(identity) {
for state in ctx.db.user_state().channel_id().filter(access.channel_id) {
results.insert(state.identity, state.clone());
}
}
if let Some(my_state) = ctx.db.user_state().identity().find(identity) {
results.insert(my_state.identity, my_state.clone());
}
// 2. States in my servers
for member in ctx.db.server_member().identity().filter(identity) {
if let Some(s) = ctx.db.server().id().find(member.server_id) {
for chan_meta in s.channels {
for state in ctx.db.user_state().channel_id().filter(chan_meta.id) {
results.insert(state.identity, state.clone());
}
}
}
}
// 3. States in my DMs
for dm in ctx.db.direct_message().sender().filter(identity) {
for state in ctx.db.user_state().channel_id().filter(dm.channel_id) {
results.insert(state.identity, state.clone());
}
}
for dm in ctx.db.direct_message().recipient().filter(identity) {
for state in ctx.db.user_state().channel_id().filter(dm.channel_id) {
results.insert(state.identity, state.clone());
}
}
results.into_values().collect()
}
#[spacetimedb::view(accessor = visible_webrtc_signals, public)]
pub fn visible_webrtc_signals(ctx: &ViewContext) -> Vec<WebRTCSignal> {
let identity = ctx.sender();
let mut results = Vec::new();
for signal in ctx.db.webrtc_signal().sender().filter(identity) {
results.push(signal);
}
for signal in ctx.db.webrtc_signal().receiver().filter(identity) {
results.push(signal);
}
results
ctx.db.webrtc_signal().sender().filter(identity)
.chain(ctx.db.webrtc_signal().receiver().filter(identity))
.map(|s: WebRTCSignal| s.clone())
.collect()
}
#[spacetimedb::view(accessor = visible_scrollback_messages, public)]
pub fn visible_scrollback_messages(ctx: &ViewContext) -> Vec<VisibleMessageRow> {
let mut results = Vec::new();
pub fn visible_scrollback_messages(ctx: &ViewContext) -> impl Query<Message> {
let identity = ctx.sender();
// Only for the active channel subscription
if let Some(sub) = ctx.db.channel_subscription().identity().find(identity) {
// Security: Ensure I have access to this channel
let has_access = if let Some(chan) = ctx.db.channel().id().find(sub.channel_id) {
if chan.server_id != 0 {
// Server channel: Check membership
ctx.db
.server_member()
.identity()
.filter(identity)
.any(|m| m.server_id == chan.server_id)
let cid = sub.channel_id;
let min_seq = sub.earliest_seq_id;
ctx.from.message().r#where(move |m| m.channel_id.eq(cid).and(m.seq_id.gte(min_seq)))
} else {
// DM channel: Check DM participants
ctx.db
.direct_message()
.channel_id()
.filter(sub.channel_id)
.any(|dm| dm.sender == identity || dm.recipient == identity)
ctx.from.message().r#where(|m| m.id.eq(0))
}
} else {
false
};
}
if has_access {
for msg in ctx
.db
.message()
.channel_id()
.filter(sub.channel_id)
{
if msg.seq_id >= sub.earliest_seq_id {
#[spacetimedb::view(accessor = visible_scrollback_thread_messages, public)]
pub fn visible_scrollback_thread_messages(ctx: &ViewContext) -> Vec<VisibleMessageRow> {
let identity = ctx.sender();
let mut results = Vec::new();
if let Some(sub) = ctx.db.channel_subscription().identity().find(identity) {
for msg in ctx.db.message().channel_id().filter(sub.channel_id) {
if msg.thread_id.is_some() {
results.push(VisibleMessageRow {
id: msg.id,
sender: msg.sender,
sent: msg.sent,
text: msg.text,
channel_id: msg.channel_id,
thread_id: msg.thread_id,
seq_id: msg.seq_id,
reactions: msg.reactions.clone(),
image_ids: msg.image_ids.clone(),
thread_name: msg.thread_name.clone(),
thread_reply_count: msg.thread_reply_count,
edited: msg.edited,
is_encrypted: msg.is_encrypted,
id: msg.id, sender: msg.sender, sent: msg.sent, text: msg.text.clone(),
channel_id: msg.channel_id, thread_id: msg.thread_id, seq_id: msg.seq_id,
reactions: msg.reactions.clone(), image_ids: msg.image_ids.clone(),
thread_name: msg.thread_name.clone(), thread_reply_count: msg.thread_reply_count,
edited: msg.edited, is_encrypted: msg.is_encrypted,
});
}
}
}
}
results
}
#[spacetimedb::view(accessor = my_channel_subscriptions, public)]
pub fn my_channel_subscriptions(ctx: &ViewContext) -> Vec<MyChannelSubscriptionRow> {
if let Some(sub) = ctx.db.channel_subscription().identity().find(ctx.sender()) {
let last_seq_id = ctx.db.channel_internal_state().channel_id().find(sub.channel_id)
.map(|s| s.last_seq_id).unwrap_or(0);
vec![MyChannelSubscriptionRow {
identity: sub.identity,
channel_id: sub.channel_id,
earliest_seq_id: sub.earliest_seq_id,
last_read_seq_id: sub.last_read_seq_id,
identity: sub.identity, channel_id: sub.channel_id,
earliest_seq_id: sub.earliest_seq_id, last_read_seq_id: sub.last_read_seq_id,
last_seq_id,
}]
} else {
vec![]
+31 -12
View File
@@ -3,6 +3,7 @@ import { SvelteMap, SvelteSet } from "svelte/reactivity";
import * as Types from "../../module_bindings/types";
import { reducers } from "../../module_bindings";
import { getUsername, formatTime } from "../utils";
import { getConnection } from "../../config";
import { DatabaseService } from "./database.svelte";
import { NavigationService } from "./navigation.svelte";
import { ThemeService, themeService } from "./theme.svelte";
@@ -134,13 +135,15 @@ export class ChatService {
}
};
// Session-only image processing: creates Blob URLs directly from Database data.
// This ditched the persistent IndexedDB cache to prevent stale data between reloads.
// 1. Lazy Image Requesting: Track which images should be visible and request missing BLOBs
$effect(() => {
const currentImages = this.#db.images;
const conn = getConnection();
if (!conn || !this.identity) return;
const currentIds = new Set(currentImages.map(img => img.id.toString()));
// 1. Cleanup old Blob URLs no longer in visible_images
// Cleanup old Blob URLs no longer in visible_images
for (const [idStr, url] of this.#blobUrls.entries()) {
if (!currentIds.has(idStr)) {
console.log(`[ChatService] Revoking Blob URL for ${idStr}`);
@@ -153,20 +156,36 @@ export class ChatService {
}
}
// 2. Create URLs for new images
// Request blobs for any metadata we have but no data yet
for (const img of currentImages) {
const idStr = img.id.toString();
if (!this.#blobUrls.has(idStr)) {
// Use a copy of the data to ensure no buffer sharing issues
const dataCopy = img.data.slice();
const blob = new Blob([dataCopy], { type: img.mimeType });
const url = URL.createObjectURL(blob);
console.log(`[ChatService] Created Blob URL for ${idStr}: ${url} (size: ${dataCopy.length} bytes)`);
conn.reducers.requestImageBlob({ imageId: img.id });
}
}
});
// 2. Lazy Image Processing: Process BLOBs as they arrive in visible_image_blobs
$effect(() => {
const blobs = this.#db.imageBlobs;
const currentImages = this.#db.images;
for (const blob of blobs) {
const idStr = blob.imageId.toString();
if (!this.#blobUrls.has(idStr)) {
const metadata = currentImages.find(img => img.id === blob.imageId);
if (metadata) {
// Use a copy of the data
const dataCopy = blob.data.slice();
const browserBlob = new Blob([dataCopy], { type: metadata.mimeType });
const url = URL.createObjectURL(browserBlob);
console.log(`[ChatService] Lazy-loaded Blob URL for ${idStr}: ${url} (${dataCopy.length} bytes)`);
this.#blobUrls.set(idStr, url);
}
}
}
// 3. Update reactive maps for UI
// Update reactive maps for UI
// Avatars/Banners
for (const user of this.users) {
if (user.avatarId) {
@@ -194,7 +213,7 @@ export class ChatService {
}
}
}
// Message attachments and others from visible_images
// Message attachments and others
for (const img of currentImages) {
const idStr = img.id.toString();
const url = this.#blobUrls.get(idStr);
@@ -482,7 +501,7 @@ export class ChatService {
return this.#db.isUsersReady;
}
get isReady() {
return this.#db.isReady && this.#msg.isGlobalSyncDone;
return this.#db.isReady;
}
get isMessagesReady() {
+9 -8
View File
@@ -13,6 +13,7 @@ export class DatabaseService {
serverMembers = $state<readonly Types.ServerMember[]>([]);
allThreads = $state<readonly Types.Thread[]>([]);
images = $state<readonly Types.VisibleImageRow[]>([]);
imageBlobs = $state<readonly Types.ImageData[]>([]);
customEmojis = $state<readonly Types.CustomEmoji[]>([]);
userStates = $state<readonly Types.UserState[]>([]);
typingActivity = $state<readonly Types.TypingActivity[]>([]);
@@ -45,6 +46,7 @@ export class DatabaseService {
const [serverMembersStore, membersReadyStore] = useTable(tables.visible_server_members);
const [threadsStore] = useTable(tables.thread);
const [imagesStore, imagesReadyStore] = useTable(tables.visible_images);
const [imageBlobsStore] = useTable(tables.visible_image_blobs);
const [customEmojisStore] = useTable(tables.custom_emoji);
const [typingActivityStore] = useTable(tables.visible_typing_activity);
const [systemConfigStore] = useTable(tables.system_configuration);
@@ -61,17 +63,16 @@ export class DatabaseService {
serverMembersStore.subscribe((v) => (this.serverMembers = v));
membersReadyStore.subscribe((v) => (this.isMembersReady = v));
threadsStore.subscribe((v) => (this.allThreads = v));
imagesStore.subscribe((v) => {
imagesStore.subscribe((v) => (this.images = v));
imagesReadyStore.subscribe((v) => (this.isImagesReady = v));
imageBlobsStore.subscribe((v) => {
// CRITICAL: We MUST copy the Uint8Array data immediately.
// SpacetimeDB JS SDK uses a shared WASM buffer for byteArrays,
// so if we don't copy it here, all image rows will eventually
// point to the data of the last image fetched.
this.images = v.map(img => ({
...img,
data: new Uint8Array(img.data)
// SpacetimeDB JS SDK uses a shared WASM buffer for byteArrays.
this.imageBlobs = v.map(blob => ({
...blob,
data: new Uint8Array(blob.data)
}));
});
imagesReadyStore.subscribe((v) => (this.isImagesReady = v));
customEmojisStore.subscribe((v) => (this.customEmojis = v));
typingActivityStore.subscribe((v) => (this.typingActivity = v));
systemConfigStore.subscribe((v) => (this.systemConfiguration = v));
+24 -28
View File
@@ -37,18 +37,17 @@ export class MessagingService {
onMessageReceived?: (params: { channelId: bigint, senderIdentity: Identity, id: bigint, text: string, isEncrypted: boolean }) => void;
// Internal reactive state from SpacetimeDB
#mySubscriptions = $state<readonly Types.MyChannelSubscriptionRow[]>([]);
// Optimized Per-Channel/Per-Message Buckets
#channelBuckets = new SvelteMap<bigint, {
map: Map<bigint, Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] }>,
sorted: (Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] })[]
map: Map<bigint, Types.Message>,
sorted: Types.Message[]
}>();
isLoadingMore = $state(false);
#readyChannels = new SvelteSet<bigint>();
isGlobalSyncDone = $state(false);
encryptionOptIn = $state(new SvelteSet<string>());
#mySubscriptions = $state<readonly Types.MyChannelSubscriptionRow[]>([]);
get isMessagesReady() {
const cid = this.#nav.activeChannelId;
@@ -92,10 +91,10 @@ export class MessagingService {
const [visibleScrollbackStore] = useTable(tables.visible_scrollback_messages);
const [mySubscriptionsStore] = useTable(tables.my_channel_subscriptions);
type CombinedMessageRow = Types.RecentMessage | Types.VisibleMessageRow;
mySubscriptionsStore.subscribe((v) => (this.#mySubscriptions = v));
let recentMessages: readonly Types.RecentMessage[] = [];
let scrollbackMessages: readonly Types.VisibleMessageRow[] = [];
let recentMessages: readonly Types.Message[] = [];
let scrollbackMessages: readonly Types.Message[] = [];
// Incremental update logic for visible messages
const seenMessageIds = new Set<bigint>();
@@ -157,8 +156,6 @@ export class MessagingService {
this.#updateBuckets([...recentMessages, ...scrollbackMessages]);
});
mySubscriptionsStore.subscribe((v) => (this.#mySubscriptions = v));
$effect(() => {
const channelId = this.#nav.activeChannelId;
const identity = this.#identity();
@@ -172,6 +169,7 @@ export class MessagingService {
// 1. Global/Session-long queries
queries.push("SELECT * FROM upload_status");
queries.push("SELECT * FROM visible_images");
queries.push("SELECT * FROM visible_image_blobs");
if (identity) {
const idHex = identity.toHexString();
@@ -185,10 +183,7 @@ export class MessagingService {
queries.push(`SELECT * FROM visible_direct_messages`);
queries.push(`SELECT * FROM my_channel_subscriptions`);
// Recent messages for all joined channels/DMs
queries.push(`SELECT * FROM visible_recent_activity`);
// WebRTC Signaling
// WebRTC Signaling (Needs to stay global for incoming calls)
queries.push(`SELECT * FROM visible_webrtc_signals`);
}
@@ -198,7 +193,10 @@ export class MessagingService {
queries.push(`SELECT * FROM visible_scrollback_messages`);
queries.push(`SELECT * FROM thread WHERE channel_id = ${channelId}`);
queries.push(`SELECT * FROM visible_user_states`);
queries.push(`SELECT * FROM visible_typing_activity WHERE channel_id = ${channelId}`);
queries.push(`SELECT * FROM visible_typing_activity`);
// Fast-path recent activity for the ACTIVE channel only
queries.push(`SELECT * FROM visible_recent_activity WHERE channel_id = ${channelId}`);
}
console.log(`[MessagingService] Updating subscriptions: ${queries.length} queries`);
@@ -213,8 +211,8 @@ export class MessagingService {
});
}
#updateBuckets(newMessages: readonly (Types.RecentMessage | Types.VisibleMessageRow)[]) {
const tempBuckets = new Map<bigint, Map<bigint, Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] }>>();
#updateBuckets(newMessages: readonly Types.Message[]) {
const tempBuckets = new Map<bigint, Map<bigint, Types.Message>>();
for (const m of newMessages) {
let bucketMap = tempBuckets.get(m.channelId);
@@ -222,23 +220,15 @@ export class MessagingService {
bucketMap = new Map();
tempBuckets.set(m.channelId, bucketMap);
}
bucketMap.set(m.id, {
...(m as unknown as Types.Message),
seqId: m.seqId,
reactions: m.reactions,
imageIds: m.imageIds
});
bucketMap.set(m.id, m);
}
this.#channelBuckets.clear();
for (const [chanId, messagesMap] of tempBuckets.entries()) {
const sorted = Array.from(messagesMap.values()).sort((a, b) => {
if (a.seqId !== undefined && b.seqId !== undefined) {
if (a.seqId < b.seqId) return -1;
if (a.seqId > b.seqId) return 1;
return 0;
}
return a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1;
});
@@ -247,7 +237,7 @@ export class MessagingService {
}
get synchronizedMessages() {
const all: (Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] })[] = [];
const all: Types.Message[] = [];
for (const bucket of this.#channelBuckets.values()) {
all.push(...bucket.sorted);
}
@@ -308,12 +298,18 @@ export class MessagingService {
const channelId = this.#nav.activeChannelId;
if (!channelId) return false;
const sub = this.#mySubscriptions.find(s => s.channelId === channelId);
const sub = this.#mySubscriptions.find((s) => s.channelId === channelId);
if (!sub) return false;
// Check if the earliest message we have is > 1
// OR if we don't have all messages up to the latest head
const bucket = this.#channelBuckets.get(channelId);
if (!bucket || bucket.sorted.length === 0) return sub.lastSeqId > 0n;
return sub.earliestSeqId > 1n;
}
handleStartThread = (msg: Types.Message) => {
const existing = this.#db.allThreads.find((t) => t.parentMessageId === msg.id);
if (existing) {
@@ -373,7 +369,7 @@ export class MessagingService {
const msgs = this.channelMessages;
if (msgs.length === 0) return false;
const oldestMsg = msgs[0] as any;
const oldestMsg = msgs[0];
const oldestSeq = oldestMsg.seqId;
if (oldestSeq === undefined || oldestSeq <= 1n) {