idk
This commit is contained in:
-63
@@ -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
@@ -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());
|
||||
|
||||
+147
-844
File diff suppressed because it is too large
Load Diff
+51
-26
@@ -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,
|
||||
|
||||
+119
-334
@@ -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
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
@@ -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![]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user