From e718da0981e18514a0136e80e3614826942bcc1f Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Thu, 16 Apr 2026 19:24:18 -0400 Subject: [PATCH 01/11] idk --- .gitignore | 63 -- spacetimedb/src/lib.rs | 27 +- spacetimedb/src/reducers.rs | 1043 ++++--------------------- spacetimedb/src/tables.rs | 77 +- spacetimedb/src/utils.rs | 455 +++-------- spacetimedb/src/views.rs | 302 ++----- src/chat/services/chat.svelte.ts | 45 +- src/chat/services/database.svelte.ts | 17 +- src/chat/services/messaging.svelte.ts | 56 +- 9 files changed, 517 insertions(+), 1568 deletions(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 13c9812..0000000 --- a/.gitignore +++ /dev/null @@ -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 diff --git a/spacetimedb/src/lib.rs b/spacetimedb/src/lib.rs index 9b61e8b..3770b82 100644 --- a/spacetimedb/src/lib.rs +++ b/spacetimedb/src/lib.rs @@ -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()); diff --git a/spacetimedb/src/reducers.rs b/spacetimedb/src/reducers.rs index 871977d..606ac70 100644 --- a/spacetimedb/src/reducers.rs +++ b/spacetimedb/src/reducers.rs @@ -4,70 +4,36 @@ use spacetimedb::{Identity, ReducerContext, Table}; #[spacetimedb::reducer] pub fn set_typing(ctx: &ReducerContext, channel_id: u64, typing: bool) { - if let Some(_) = ctx.db.typing_activity().identity().find(ctx.sender()) { - ctx.db.typing_activity().identity().update(TypingActivity { - identity: ctx.sender(), - channel_id, - typing: typing, - }); - } else { - ctx.db.typing_activity().insert(TypingActivity { - identity: ctx.sender(), - channel_id, - typing: typing, - }); - } + let existing = ctx.db.typing_activity().identity().find(ctx.sender()); + let activity = TypingActivity { identity: ctx.sender(), channel_id, typing }; + if existing.is_some() { ctx.db.typing_activity().identity().update(activity); } + else { ctx.db.typing_activity().insert(activity); } } #[spacetimedb::reducer] -pub fn upload_image( - ctx: &ReducerContext, - data: Vec, - mime_type: String, - name: Option, - client_id: Option, -) { +pub fn upload_image(ctx: &ReducerContext, data: Vec, mime_type: String, name: Option, client_id: Option) { if let Some(ref cid) = client_id { - if let Some(_) = ctx.db.upload_status().client_id().find(cid.clone()) { - ctx.db.upload_status().client_id().delete(cid.clone()); - } ctx.db.upload_status().insert(UploadStatus { - client_id: cid.clone(), - identity: ctx.sender(), - status: "pending".to_string(), - image_id: None, - error: None, + client_id: cid.clone(), identity: ctx.sender(), status: "pending".to_string(), image_id: None, error: None, }); } if data.len() > 4 * 1024 * 1024 { if let Some(ref cid) = client_id { ctx.db.upload_status().client_id().update(UploadStatus { - client_id: cid.clone(), - identity: ctx.sender(), - status: "error".to_string(), - image_id: None, - error: Some("Image exceeds 4MB limit".to_string()), + client_id: cid.clone(), identity: ctx.sender(), status: "error".to_string(), + image_id: None, error: Some("Image exceeds 4MB limit".to_string()), }); - return; } - panic!("Image exceeds 4MB limit"); + return; } - let img = ctx.db.image().insert(Image { - id: 0, - data, - mime_type, - name, - }); + let img = ctx.db.image().insert(Image { id: 0, mime_type, name }); + ctx.db.image_data().insert(ImageData { image_id: img.id, data }); if let Some(ref cid) = client_id { ctx.db.upload_status().client_id().update(UploadStatus { - client_id: cid.clone(), - identity: ctx.sender(), - image_id: Some(img.id), - status: "success".to_string(), - error: None, + client_id: cid.clone(), identity: ctx.sender(), image_id: Some(img.id), status: "success".to_string(), error: None, }); } } @@ -83,77 +49,44 @@ pub fn clear_upload_status(ctx: &ReducerContext, client_id: String) { #[spacetimedb::reducer] pub fn upload_custom_emoji(ctx: &ReducerContext, name: String, category: String, data: Vec) { - if data.len() > 256 * 1024 { - panic!("Emoji image exceeds 256KB limit"); - } - ctx.db.custom_emoji().insert(CustomEmoji { - id: 0, - name, - category, - data, - }); + if data.len() > 256 * 1024 { panic!("Emoji image exceeds 256KB limit"); } + ctx.db.custom_emoji().insert(CustomEmoji { id: 0, name, category, data }); } #[spacetimedb::reducer] pub fn upload_avatar(ctx: &ReducerContext, data: Vec, mime_type: String) { - if data.len() > 1024 * 1024 { - panic!("Avatar exceeds 1MB limit"); - } - let img = ctx.db.image().insert(Image { - id: 0, - data, - mime_type, - name: Some("avatar".to_string()), - }); + let img = ctx.db.image().insert(Image { id: 0, mime_type, name: Some("avatar".to_string()) }); + ctx.db.image_data().insert(ImageData { image_id: img.id, data }); if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) { user.avatar_id = Some(img.id); ctx.db.user().identity().update(user); - } else { - panic!("User not found"); + sync_server_member_info(&ctx.db, ctx.sender()); } } #[spacetimedb::reducer] pub fn upload_banner(ctx: &ReducerContext, data: Vec, mime_type: String) { - if data.len() > 2 * 1024 * 1024 { - panic!("Banner exceeds 2MB limit"); - } - let img = ctx.db.image().insert(Image { - id: 0, - data, - mime_type, - name: Some("banner".to_string()), - }); + let img = ctx.db.image().insert(Image { id: 0, mime_type, name: Some("banner".to_string()) }); + ctx.db.image_data().insert(ImageData { image_id: img.id, data }); if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) { user.banner_id = Some(img.id); ctx.db.user().identity().update(user); - } else { - panic!("User not found"); } } #[spacetimedb::reducer] -pub fn upload_server_avatar( - ctx: &ReducerContext, - server_id: u64, - data: Vec, - mime_type: String, -) { - if data.len() > 1024 * 1024 { - panic!("Avatar exceeds 1MB limit"); +pub fn set_banner(ctx: &ReducerContext, banner_id: Option) { + if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) { + user.banner_id = banner_id; + ctx.db.user().identity().update(user); } - let mut s = ctx - .db - .server() - .id() - .find(server_id) - .expect("Server not found"); - let img = ctx.db.image().insert(Image { - id: 0, - data, - mime_type, - name: Some("server_avatar".to_string()), - }); +} + +#[spacetimedb::reducer] +pub fn upload_server_avatar(ctx: &ReducerContext, server_id: u64, data: Vec, mime_type: String) { + let mut s = ctx.db.server().id().find(server_id).expect("Server not found"); + let img = ctx.db.image().insert(Image { id: 0, mime_type, name: Some("server_avatar".to_string()) }); + ctx.db.image_data().insert(ImageData { image_id: img.id, data }); s.avatar_id = Some(img.id); ctx.db.server().id().update(s); } @@ -161,225 +94,76 @@ pub fn upload_server_avatar( #[spacetimedb::reducer] pub fn update_server_name(ctx: &ReducerContext, server_id: u64, name: String) { validate_name(&name).expect("Invalid name"); - let mut s = ctx - .db - .server() - .id() - .find(server_id) - .expect("Server not found"); + let mut s = ctx.db.server().id().find(server_id).expect("Server not found"); s.name = name; ctx.db.server().id().update(s); } #[spacetimedb::reducer] pub fn delete_server(ctx: &ReducerContext, server_id: u64) { - let _ = ctx - .db - .server() - .id() - .find(server_id) - .expect("Server not found"); - - let channels: Vec<_> = ctx.db.channel().server_id().filter(server_id).collect(); - for c in channels { - let messages: Vec<_> = ctx - .db - .message() - .channel_id() - .filter(c.id) - .map(|m| m.id) - .collect(); - for id in messages { - ctx.db.message().id().delete(id); - } - let recent: Vec<_> = ctx - .db - .recent_message() - .channel_id() - .filter(c.id) - .map(|rm| rm.id) - .collect(); - for id in recent { - ctx.db.recent_message().id().delete(id); - } + for c in ctx.db.channel().server_id().filter(server_id) { + for msg in ctx.db.message().channel_id().filter(c.id) { ctx.db.message().id().delete(msg.id); } ctx.db.channel().id().delete(c.id); } - - let members: Vec<_> = ctx - .db - .server_member() - .server_id() - .filter(server_id) - .map(|m| m.id) - .collect(); - for id in members { - ctx.db.server_member().id().delete(id); - } - + for m in ctx.db.server_member().server_id().filter(server_id) { ctx.db.server_member().id().delete(m.id); } ctx.db.server().id().delete(server_id); } #[spacetimedb::reducer] pub fn edit_message(ctx: &ReducerContext, message_id: u64, new_text: String) { - if new_text.trim().is_empty() { - panic!("Message text cannot be empty"); - } validate_message_length(&ctx.db, &new_text).expect("Message too long"); - - let mut msg = ctx - .db - .message() - .id() - .find(message_id) - .expect("Message not found"); - - if msg.sender != ctx.sender() { - panic!("You can only edit your own messages"); - } - - msg.text = new_text.clone(); - msg.edited = true; - ctx.db.message().id().update(msg); - - if let Some(mut rm) = ctx.db.recent_message().id().find(message_id) { - rm.text = new_text; - rm.edited = true; - ctx.db.recent_message().id().update(rm); + if let Some(mut msg) = ctx.db.message().id().find(message_id) { + if msg.sender == ctx.sender() { + msg.text = new_text; + msg.edited = true; + ctx.db.message().id().update(msg); + } } } #[spacetimedb::reducer] pub fn delete_message(ctx: &ReducerContext, message_id: u64) { - let msg = ctx - .db - .message() - .id() - .find(message_id) - .expect("Message not found"); - - if msg.sender != ctx.sender() { - panic!("You can only delete your own messages"); + if let Some(msg) = ctx.db.message().id().find(message_id) { + if msg.sender == ctx.sender() { ctx.db.message().id().delete(message_id); } } - - ctx.db.message().id().delete(message_id); - ctx.db.recent_message().id().delete(message_id); } #[spacetimedb::reducer] pub fn set_server_public(ctx: &ReducerContext, server_id: u64, public: bool) { - let mut s = ctx - .db - .server() - .id() - .find(server_id) - .expect("Server not found"); - - if s.owner != Some(ctx.sender()) { - panic!("Only the server owner can change public visibility"); + let mut s = ctx.db.server().id().find(server_id).expect("Server not found"); + if s.owner == Some(ctx.sender()) { + s.public = public; + ctx.db.server().id().update(s); } - - s.public = public; - ctx.db.server().id().update(s); } #[spacetimedb::reducer] -pub fn toggle_reaction( - ctx: &ReducerContext, - message_id: u64, - emoji: Option, - custom_emoji_id: Option, -) { - if emoji.is_none() && custom_emoji_id.is_none() { - panic!("Emoji or CustomEmojiId required"); - } - - let user = ctx - .db - .user() - .identity() - .find(ctx.sender()) - .expect("User not found"); - if user.subject.is_none() && user.name.is_none() { - panic!("You must have a name or be logged in via OIDC to perform this action"); - } - - let mut msg = ctx - .db - .message() - .id() - .find(message_id) - .expect("Message not found"); - - let existing_idx = msg.reactions.iter().position(|r| { - if r.identity != ctx.sender() { - return false; - } - if emoji.is_some() && r.emoji == emoji { - return true; - } - if custom_emoji_id.is_some() && r.custom_emoji_id == custom_emoji_id { - return true; - } - false - }); - - if let Some(idx) = existing_idx { - msg.reactions.remove(idx); - } else { - msg.reactions.push(Reaction { - identity: ctx.sender(), - emoji, - custom_emoji_id, - }); - } - - let reactions = msg.reactions.clone(); - ctx.db.message().id().update(msg); - - // Also update RecentMessage if it exists - if let Some(mut rm) = ctx.db.recent_message().id().find(message_id) { - rm.reactions = reactions; - ctx.db.recent_message().id().update(rm); +pub fn toggle_reaction(ctx: &ReducerContext, message_id: u64, emoji: Option, custom_emoji_id: Option) { + if let Some(mut msg) = ctx.db.message().id().find(message_id) { + let existing_idx = msg.reactions.iter().position(|r| r.identity == ctx.sender() && r.emoji == emoji && r.custom_emoji_id == custom_emoji_id); + if let Some(idx) = existing_idx { msg.reactions.remove(idx); } + else { msg.reactions.push(Reaction { identity: ctx.sender(), emoji, custom_emoji_id }); } + ctx.db.message().id().update(msg); } } #[spacetimedb::reducer] pub fn set_name(ctx: &ReducerContext, name: String) { validate_name(&name).expect("Invalid name"); - let mut user = ctx - .db - .user() - .identity() - .find(ctx.sender()) - .expect("User not found"); - user.name = Some(name); - ctx.db.user().identity().update(user); - sync_server_member_info(&ctx.db, ctx.sender()); + if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) { + user.name = Some(name); + ctx.db.user().identity().update(user); + sync_server_member_info(&ctx.db, ctx.sender()); + } } #[spacetimedb::reducer] pub fn set_avatar(ctx: &ReducerContext, avatar_id: Option) { - let mut user = ctx - .db - .user() - .identity() - .find(ctx.sender()) - .expect("User not found"); - user.avatar_id = avatar_id; - ctx.db.user().identity().update(user); - sync_server_member_info(&ctx.db, ctx.sender()); -} - -#[spacetimedb::reducer] -pub fn set_banner(ctx: &ReducerContext, banner_id: Option) { - let mut user = ctx - .db - .user() - .identity() - .find(ctx.sender()) - .expect("User not found"); - user.banner_id = banner_id; - ctx.db.user().identity().update(user); + if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) { + user.avatar_id = avatar_id; + ctx.db.user().identity().update(user); + sync_server_member_info(&ctx.db, ctx.sender()); + } } #[spacetimedb::reducer] @@ -387,80 +171,57 @@ pub fn update_public_key(ctx: &ReducerContext, public_key: Option) { if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) { user.public_key = public_key; ctx.db.user().identity().update(user); - } else { - panic!("User not found"); } } #[spacetimedb::reducer] -pub fn set_biography( -ctx: &ReducerContext, biography: Option) { - let mut user = ctx - .db - .user() - .identity() - .find(ctx.sender()) - .expect("User not found"); - user.biography = biography; - ctx.db.user().identity().update(user); +pub fn set_biography(ctx: &ReducerContext, biography: Option) { + if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) { + user.biography = biography; + ctx.db.user().identity().update(user); + } } #[spacetimedb::reducer] pub fn set_status(ctx: &ReducerContext, status: Option) { - let mut user = ctx - .db - .user() - .identity() - .find(ctx.sender()) - .expect("User not found"); - user.status = status; - ctx.db.user().identity().update(user); -} - -#[spacetimedb::reducer] -pub fn subscribe_to_channel(ctx: &ReducerContext, channel_id: u64) { - let current_max = ctx - .db - .channel() - .id() - .find(channel_id) - .map(|c| c.last_seq_id) - .unwrap_or(0); - let limit = get_recent_message_limit(&ctx.db); - let earliest = if current_max >= limit { - current_max - (limit - 1) - } else { - 1 - }; - - if let Some(existing) = ctx.db.channel_subscription().identity().find(ctx.sender()) { - if existing.channel_id != channel_id { - ctx.db - .channel_subscription() - .identity() - .update(ChannelSubscription { - identity: ctx.sender(), - channel_id, - earliest_seq_id: earliest, - last_read_seq_id: current_max, - }); - } - } else { - ctx.db.channel_subscription().insert(ChannelSubscription { - identity: ctx.sender(), - channel_id, - earliest_seq_id: earliest, - last_read_seq_id: current_max, - }); + if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) { + user.status = status; + ctx.db.user().identity().update(user); } } +#[spacetimedb::reducer] +pub fn subscribe_to_channel(ctx: &ReducerContext, channel_id: u64) { + let current_max = ctx.db.channel_internal_state().channel_id().find(channel_id).map(|c| c.last_seq_id).unwrap_or(0); + let limit = get_recent_message_limit(&ctx.db); + let earliest = if current_max >= limit { current_max - (limit - 1) } else { 1 }; + + let mut sub = ctx.db.channel_subscription().identity().find(ctx.sender()) + .unwrap_or(ChannelSubscription { identity: ctx.sender(), channel_id, earliest_seq_id: earliest, last_read_seq_id: current_max }); + + sub.channel_id = channel_id; + sub.earliest_seq_id = earliest; + sub.last_read_seq_id = current_max; + + if ctx.db.channel_subscription().identity().find(ctx.sender()).is_some() { + ctx.db.channel_subscription().identity().update(sub); + } else { + ctx.db.channel_subscription().insert(sub); + } +} + +#[spacetimedb::reducer] +pub fn request_image_blob(ctx: &ReducerContext, image_id: u64) { + ctx.db.image_blob_request().identity().delete(ctx.sender()); + ctx.db.image_blob_request().insert(ImageBlobRequest { identity: ctx.sender(), image_id }); +} + #[spacetimedb::reducer] pub fn extend_subscription(ctx: &ReducerContext, channel_id: u64, earliest_seq_id: u64) { - if let Some(mut existing) = ctx.db.channel_subscription().identity().find(ctx.sender()) { - if existing.channel_id == channel_id { - existing.earliest_seq_id = earliest_seq_id; - ctx.db.channel_subscription().identity().update(existing); + if let Some(mut sub) = ctx.db.channel_subscription().identity().find(ctx.sender()) { + if sub.channel_id == channel_id { + sub.earliest_seq_id = earliest_seq_id; + ctx.db.channel_subscription().identity().update(sub); } } } @@ -468,200 +229,58 @@ pub fn extend_subscription(ctx: &ReducerContext, channel_id: u64, earliest_seq_i #[spacetimedb::reducer] pub fn create_server(ctx: &ReducerContext, name: String) { validate_name(&name).expect("Invalid name"); - let user = ctx - .db - .user() - .identity() - .find(ctx.sender()) - .expect("User not found"); - if user.subject.is_none() && user.name.is_none() { - panic!("You must have a name or be logged in via OIDC to perform this action"); - } - - let s = ctx.db.server().insert(Server { - id: 0, - name, - owner: Some(ctx.sender()), - avatar_id: None, - channels: Vec::new(), - public: false, - }); - ctx.db.server_member().insert(ServerMember { - id: 0, - identity: ctx.sender(), - server_id: s.id, - name: user.name.clone(), - avatar_id: user.avatar_id, - online: user.online, - }); - let c1 = ctx.db.channel().insert(Channel { - id: 0, - 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 user = ctx.db.user().identity().find(ctx.sender()).expect("User not found"); + let s = ctx.db.server().insert(Server { id: 0, name, owner: Some(ctx.sender()), avatar_id: None, channels: Vec::new(), public: false }); + ctx.db.server_member().insert(ServerMember { id: 0, identity: ctx.sender(), server_id: s.id, name: user.name.clone(), avatar_id: user.avatar_id, online: user.online }); + let c1 = ctx.db.channel().insert(Channel { id: 0, server_id: s.id, name: "general".to_string(), kind: ChannelKind::Text }); + let c2 = ctx.db.channel().insert(Channel { id: 0, server_id: s.id, name: "voice".to_string(), kind: ChannelKind::Voice }); + let mut s = ctx.db.server().id().find(s.id).unwrap(); - s.channels.push(ChannelMetadata { - 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); + s.channels.push(ChannelMetadata { id: c1.id, name: c1.name, kind: c1.kind }); + s.channels.push(ChannelMetadata { id: c2.id, name: c2.name, kind: c2.kind }); + ctx.db.server().id().update(s.clone()); + sync_server_access(&ctx.db, ctx.sender(), s.id); } #[spacetimedb::reducer] pub fn join_server(ctx: &ReducerContext, server_id: u64) { - let user = ctx - .db - .user() - .identity() - .find(ctx.sender()) - .expect("User not found"); - if user.subject.is_none() && user.name.is_none() { - panic!("You must have a name or be logged in via OIDC to perform this action"); - } + let user = ctx.db.user().identity().find(ctx.sender()).expect("User not found"); + let s = ctx.db.server().id().find(server_id).expect("Server not found"); + if !s.public && user.subject.is_none() && user.name.is_none() { panic!("Name required for private server"); } - let _ = ctx - .db - .server() - .id() - .find(server_id) - .expect("Server not found"); - - for m in ctx.db.server_member().identity().filter(ctx.sender()) { - if m.server_id == server_id { - return; - } - } - - ctx.db.server_member().insert(ServerMember { - id: 0, - identity: ctx.sender(), - server_id, - name: user.name.clone(), - avatar_id: user.avatar_id, - online: user.online, - }); + if ctx.db.server_member().identity().filter(ctx.sender()).any(|m| m.server_id == server_id) { return; } + ctx.db.server_member().insert(ServerMember { id: 0, identity: ctx.sender(), server_id, name: user.name.clone(), avatar_id: user.avatar_id, online: user.online }); + sync_server_access(&ctx.db, ctx.sender(), server_id); } #[spacetimedb::reducer] pub fn leave_server(ctx: &ReducerContext, server_id: u64) { - let members: Vec<_> = ctx - .db - .server_member() - .identity() - .filter(ctx.sender()) - .map(|m| m.id) - .collect(); - for id in members { - if let Some(m) = ctx.db.server_member().id().find(id) { - if m.server_id == server_id { - ctx.db.server_member().id().delete(id); - } - } - } + let members: Vec<_> = ctx.db.server_member().identity().filter(ctx.sender()).filter(|m| m.server_id == server_id).collect(); + for m in members { ctx.db.server_member().id().delete(m.id); } + revoke_server_access(&ctx.db, ctx.sender(), server_id); } #[spacetimedb::reducer] pub fn create_channel(ctx: &ReducerContext, name: String, server_id: u64, is_voice: bool) { validate_name(&name).expect("Invalid name"); - let user = ctx - .db - .user() - .identity() - .find(ctx.sender()) - .expect("User not found"); - if user.subject.is_none() && user.name.is_none() { - panic!("You must have a name or be logged in via OIDC to perform this action"); - } - - let mut s = ctx - .db - .server() - .id() - .find(server_id) - .expect("Server not found"); - - let kind = if is_voice { - ChannelKind::Voice - } else { - ChannelKind::Text - }; - - let chan = ctx.db.channel().insert(Channel { - id: 0, - server_id, - name: name.clone(), - kind: kind.clone(), - last_seq_id: 0, - }); - - s.channels.push(ChannelMetadata { - id: chan.id, - name, - kind, - last_seq_id: 0, - }); + let mut s = ctx.db.server().id().find(server_id).expect("Server not found"); + let kind = if is_voice { ChannelKind::Voice } else { ChannelKind::Text }; + let chan = ctx.db.channel().insert(Channel { id: 0, server_id, name: name.clone(), kind }); + s.channels.push(ChannelMetadata { id: chan.id, name, kind }); ctx.db.server().id().update(s); + for m in ctx.db.server_member().server_id().filter(server_id) { grant_user_channel_access(&ctx.db, m.identity, chan.id); } } #[spacetimedb::reducer] pub fn join_voice(ctx: &ReducerContext, channel_id: u64) { - let user = ctx - .db - .user() - .identity() - .find(ctx.sender()) - .expect("User not found"); - if user.subject.is_none() && user.name.is_none() { - panic!("You must have a name or be logged in via OIDC to perform this action"); - } - - let chan = ctx - .db - .channel() - .id() - .find(channel_id) - .expect("Invalid channel"); - if !matches!(chan.kind, ChannelKind::Voice) { - panic!("Invalid voice channel"); - } - if let Some(mut state) = ctx.db.user_state().identity().find(ctx.sender()) { if state.channel_id != channel_id { clear_signaling_for_user(&ctx.db, ctx.sender()); - state.channel_id = channel_id; - state.is_sharing_screen = false; - state.is_talking = false; - state.watching = None; + state.channel_id = channel_id; state.is_sharing_screen = false; state.is_talking = false; state.watching = None; ctx.db.user_state().identity().update(state); } } else { - ctx.db.user_state().insert(UserState { - identity: ctx.sender(), - channel_id, - is_sharing_screen: false, - is_muted: false, - is_deafened: false, - is_talking: false, - watching: None, - }); + ctx.db.user_state().insert(UserState { identity: ctx.sender(), channel_id, is_sharing_screen: false, is_muted: false, is_deafened: false, is_talking: false, watching: None }); } } @@ -670,28 +289,10 @@ pub fn set_sharing_screen(ctx: &ReducerContext, sharing: bool) { if let Some(mut state) = ctx.db.user_state().identity().find(ctx.sender()) { state.is_sharing_screen = sharing; ctx.db.user_state().identity().update(state); - if !sharing { - // Clean up watching state for people watching ME - let watchers: Vec<_> = ctx.db.user_state().iter() - .filter(|s| s.watching == Some(ctx.sender())) - .collect(); - for mut w in watchers { - w.watching = None; - ctx.db.user_state().identity().update(w); - } - - let signals: Vec<_> = ctx - .db - .webrtc_signal() - .sender() - .filter(ctx.sender()) - .filter(|s| s.media_type == MediaType::Screen) - .map(|s| s.id) - .collect(); - for id in signals { - ctx.db.webrtc_signal().id().delete(id); - } + let watchers: Vec<_> = ctx.db.user_state().iter().filter(|s| s.watching == Some(ctx.sender())).collect(); + for mut w in watchers { w.watching = None; ctx.db.user_state().identity().update(w); } + clear_signaling_for_user(&ctx.db, ctx.sender()); } } } @@ -722,13 +323,11 @@ pub fn set_talking(ctx: &ReducerContext, talking: bool) { #[spacetimedb::reducer] pub fn start_watching(ctx: &ReducerContext, watchee: Identity) { - if ctx.sender() == watchee { - return; - } - - if let Some(mut state) = ctx.db.user_state().identity().find(ctx.sender()) { - state.watching = Some(watchee); - ctx.db.user_state().identity().update(state); + if ctx.sender() != watchee { + if let Some(mut state) = ctx.db.user_state().identity().find(ctx.sender()) { + state.watching = Some(watchee); + ctx.db.user_state().identity().update(state); + } } } @@ -741,377 +340,81 @@ pub fn stop_watching(ctx: &ReducerContext) { } #[spacetimedb::reducer] -pub fn leave_voice(ctx: &ReducerContext) { - clear_user_presence(&ctx.db, ctx.sender()); +pub fn leave_voice(ctx: &ReducerContext) { clear_user_presence(&ctx.db, ctx.sender()); } + +#[spacetimedb::reducer] +pub fn send_webrtc_signal(ctx: &ReducerContext, receiver: Identity, signal_kind: SignalKind, media_type: MediaType, data: String, channel_id: u64) { + ctx.db.webrtc_signal().insert(WebRTCSignal { id: 0, sender: ctx.sender(), receiver, signal_kind, media_type, data, channel_id }); } #[spacetimedb::reducer] -pub fn send_webrtc_signal( - ctx: &ReducerContext, - receiver: Identity, - signal_kind: SignalKind, - media_type: MediaType, - data: String, - channel_id: u64, -) { - ctx.db.webrtc_signal().insert(WebRTCSignal { - id: 0, - sender: ctx.sender(), - receiver, - signal_kind, - media_type, - data, - channel_id, - }); +pub fn send_message(ctx: &ReducerContext, text: String, channel_id: u64, thread_id: Option, image_ids: Vec, is_encrypted: bool) { + internal_send_message(&ctx.db, ctx.sender(), channel_id, text, ctx.timestamp, thread_id, image_ids, is_encrypted); + if let Some(tid) = thread_id { + if let Some(thread) = ctx.db.thread().id().find(tid) { + if let Some(mut pm) = ctx.db.message().id().find(thread.parent_message_id) { + pm.thread_reply_count += 1; ctx.db.message().id().update(pm); + } + } + } } #[spacetimedb::reducer] pub fn set_configuration(ctx: &ReducerContext, key: String, value: String) { - if let Some(_) = ctx.db.system_configuration().key().find(key.clone()) { - ctx.db - .system_configuration() - .key() - .update(SystemConfiguration { key, value }); - } else { - ctx.db - .system_configuration() - .insert(SystemConfiguration { key, value }); - } + let config = SystemConfiguration { key: key.clone(), value }; + if ctx.db.system_configuration().key().find(key).is_some() { ctx.db.system_configuration().key().update(config); } + else { ctx.db.system_configuration().insert(config); } } #[spacetimedb::reducer] pub fn create_thread(ctx: &ReducerContext, name: String, channel_id: u64, parent_message_id: u64) { let mut thread_name = name; if thread_name.trim().is_empty() { - let parent_msg = ctx.db.message().id().find(parent_message_id); - thread_name = parent_msg - .map(|m| { - if m.text.trim().is_empty() { - "New Thread".to_string() - } else { - m.text.chars().take(32).collect() - } - }) + thread_name = ctx.db.message().id().find(parent_message_id) + .map(|m| if m.text.trim().is_empty() { "New Thread".to_string() } else { m.text.chars().take(32).collect() }) .unwrap_or("New Thread".to_string()); } - validate_name(&thread_name).expect("Invalid name"); - let user = ctx - .db - .user() - .identity() - .find(ctx.sender()) - .expect("User not found"); - if user.subject.is_none() && user.name.is_none() { - panic!("You must have a name or be logged in via OIDC to perform this action"); - } - - let _ = ctx - .db - .message() - .id() - .find(parent_message_id) - .expect("Parent message not found"); - - ctx.db.thread().insert(Thread { - id: 0, - channel_id, - parent_message_id, - name: thread_name.clone(), - }); - - // Update parent message metadata - if let Some(mut parent_msg) = ctx.db.message().id().find(parent_message_id) { - parent_msg.thread_name = Some(thread_name.clone()); - ctx.db.message().id().update(parent_msg); - } - if let Some(mut parent_rm) = ctx.db.recent_message().id().find(parent_message_id) { - parent_rm.thread_name = Some(thread_name); - ctx.db.recent_message().id().update(parent_rm); + ctx.db.thread().insert(Thread { id: 0, channel_id, parent_message_id, name: thread_name.clone() }); + if let Some(mut pm) = ctx.db.message().id().find(parent_message_id) { + pm.thread_name = Some(thread_name); ctx.db.message().id().update(pm); } } #[spacetimedb::reducer] -pub fn create_thread_with_message( - ctx: &ReducerContext, - name: String, - channel_id: u64, - parent_message_id: u64, - text: String, - image_ids: Vec, - is_encrypted: bool, -) { - - if text.trim().is_empty() && image_ids.is_empty() { - panic!("Messages must not be empty"); - } - - if !text.is_empty() && !is_encrypted { - validate_message_length(&ctx.db, &text).expect("Message too long"); - } - - if image_ids.len() > 5 { - panic!("Maximum 5 images allowed per message"); - } - - let user = ctx - .db - .user() - .identity() - .find(ctx.sender()) - .expect("User not found"); - if user.subject.is_none() && user.name.is_none() { - panic!("You must have a name or be logged in via OIDC to perform this action"); - } - - let _ = ctx - .db - .message() - .id() - .find(parent_message_id) - .expect("Parent message not found"); - - let t = ctx.db.thread().insert(Thread { - id: 0, - channel_id, - parent_message_id, - name: name.clone(), - }); - - let seq_id = get_next_seq_id(&ctx.db, channel_id); - - let msg = ctx.db.message().insert(Message { - id: 0, - sender: ctx.sender(), - text: text.clone(), - sent: ctx.timestamp, - channel_id, - thread_id: Some(t.id), - reactions: Vec::new(), - image_ids: image_ids.clone(), - thread_name: None, - thread_reply_count: 0, - edited: false, - is_encrypted, - seq_id, - }); - - let chan = ctx - .db - .channel() - .id() - .find(channel_id) - .expect("Channel not found"); - - ctx.db.recent_message().insert(RecentMessage { - id: msg.id, - server_id: chan.server_id, - channel_id, - sender: ctx.sender(), - text, - thread_id: Some(t.id), - sent: ctx.timestamp, - seq_id, - reactions: msg.reactions, - image_ids: msg.image_ids, - thread_name: None, - thread_reply_count: 0, - edited: false, - is_encrypted, - }); - - - // Update parent message metadata - if let Some(mut parent_msg) = ctx.db.message().id().find(parent_message_id) { - parent_msg.thread_name = Some(name.clone()); - parent_msg.thread_reply_count += 1; - ctx.db.message().id().update(parent_msg); - } - if let Some(mut parent_rm) = ctx.db.recent_message().id().find(parent_message_id) { - parent_rm.thread_name = Some(name); - parent_rm.thread_reply_count += 1; - ctx.db.recent_message().id().update(parent_rm); - } - - let limit = get_recent_message_limit(&ctx.db); - if seq_id > limit { - let old_seq_id = seq_id - limit; - let to_delete: Vec<_> = ctx - .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 { - ctx.db.recent_message().id().delete(id); - } - } -} - -#[spacetimedb::reducer] -pub fn send_message( - ctx: &ReducerContext, - text: String, - channel_id: u64, - thread_id: Option, - image_ids: Vec, - is_encrypted: bool, -) { - - if text.trim().is_empty() && image_ids.is_empty() { - panic!("Messages must not be empty"); - } - - if !text.is_empty() && !is_encrypted { - validate_message_length(&ctx.db, &text).expect("Message too long"); - } - - if image_ids.len() > 5 { - panic!("Maximum 5 images allowed per message"); - } - - let _user = ctx - .db - .user() - .identity() - .find(ctx.sender()) - .expect("You must be registered to send messages"); - - let seq_id = get_next_seq_id(&ctx.db, channel_id); - - let msg = ctx.db.message().insert(Message { - id: 0, - sender: ctx.sender(), - text: text.clone(), - sent: ctx.timestamp, - channel_id, - thread_id, - reactions: Vec::new(), - image_ids: image_ids.clone(), - thread_name: None, - thread_reply_count: 0, - edited: false, - is_encrypted, - seq_id, - }); - - let chan = ctx - .db - .channel() - .id() - .find(channel_id) - .expect("Channel not found"); - - ctx.db.recent_message().insert(RecentMessage { - id: msg.id, - server_id: chan.server_id, - channel_id, - sender: ctx.sender(), - text, - thread_id, - sent: ctx.timestamp, - seq_id, - reactions: msg.reactions, - image_ids: msg.image_ids, - thread_name: None, - thread_reply_count: 0, - edited: false, - is_encrypted, - }); - - - // If it's a thread message, update parent message metadata - if let Some(tid) = thread_id { - if let Some(thread) = ctx.db.thread().id().find(tid) { - if let Some(mut parent_msg) = ctx.db.message().id().find(thread.parent_message_id) { - parent_msg.thread_reply_count += 1; - ctx.db.message().id().update(parent_msg); - } - if let Some(mut parent_rm) = ctx.db.recent_message().id().find(thread.parent_message_id) - { - parent_rm.thread_reply_count += 1; - ctx.db.recent_message().id().update(parent_rm); - } - } - } - - let limit = get_recent_message_limit(&ctx.db); - if seq_id > limit { - let old_seq_id = seq_id - limit; - let to_delete: Vec<_> = ctx - .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 { - ctx.db.recent_message().id().delete(id); - } +pub fn create_thread_with_message(ctx: &ReducerContext, name: String, channel_id: u64, parent_message_id: u64, text: String, image_ids: Vec, is_encrypted: bool) { + let t = ctx.db.thread().insert(Thread { id: 0, channel_id, parent_message_id, name: name.clone() }); + internal_send_message(&ctx.db, ctx.sender(), channel_id, text, ctx.timestamp, Some(t.id), image_ids, is_encrypted); + if let Some(mut pm) = ctx.db.message().id().find(parent_message_id) { + pm.thread_name = Some(name); pm.thread_reply_count += 1; ctx.db.message().id().update(pm); } } #[spacetimedb::reducer] pub fn bootstrap_sequences(ctx: &ReducerContext) { - let rm_ids: Vec<_> = ctx.db.recent_message().iter().map(|r| r.id).collect(); - for id in rm_ids { - ctx.db.recent_message().id().delete(id); - } + let internal_states: Vec<_> = ctx.db.channel_internal_state().iter().collect(); + for s in internal_states { ctx.db.channel_internal_state().channel_id().delete(s.channel_id); } + let all_access: Vec<_> = ctx.db.user_channel_access().iter().collect(); + for a in all_access { ctx.db.user_channel_access().id().delete(a.id); } - let all_messages: Vec<_> = ctx.db.message().iter().collect(); - for mut msg in all_messages { + for mut msg in ctx.db.message().iter() { let seq_id = get_next_seq_id(&ctx.db, msg.channel_id); - msg.seq_id = seq_id; - ctx.db.message().id().update(msg.clone()); - - if let Some(channel) = ctx.db.channel().id().find(msg.channel_id) { - ctx.db.recent_message().insert(RecentMessage { - id: msg.id, - server_id: channel.server_id, - channel_id: msg.channel_id, - sender: msg.sender, - text: msg.text.clone(), - thread_id: msg.thread_id, - sent: msg.sent, - 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, - }); - } + msg.seq_id = seq_id; ctx.db.message().id().update(msg); } + for m in ctx.db.server_member().iter() { sync_server_access(&ctx.db, m.identity.clone(), m.server_id); } + for dm in ctx.db.direct_message().iter() { grant_user_channel_access(&ctx.db, dm.sender, dm.channel_id); grant_user_channel_access(&ctx.db, dm.recipient, dm.channel_id); } } #[spacetimedb::reducer] pub fn open_direct_message(ctx: &ReducerContext, recipient: Identity) { - if ctx.sender() == recipient { - panic!("You cannot open a direct message with yourself"); - } - - internal_open_direct_message(&ctx.db, ctx.sender(), recipient); + if ctx.sender() != recipient { internal_open_direct_message(&ctx.db, ctx.sender(), recipient); } } #[spacetimedb::reducer] pub fn close_direct_message(ctx: &ReducerContext, channel_id: u64) { - let dm = ctx - .db - .direct_message() - .channel_id() - .filter(channel_id) - .next() - .expect("Direct message not found"); - let mut dm = dm; - if dm.sender == ctx.sender() { - dm.is_open_sender = false; - ctx.db.direct_message().id().update(dm); - } else if dm.recipient == ctx.sender() { - dm.is_open_recipient = false; + if let Some(mut dm) = ctx.db.direct_message().channel_id().filter(channel_id).next() { + if dm.sender == ctx.sender() { dm.is_open_sender = false; } + else if dm.recipient == ctx.sender() { dm.is_open_recipient = false; } ctx.db.direct_message().id().update(dm); } } diff --git a/spacetimedb/src/tables.rs b/spacetimedb/src/tables.rs index e329a3f..87fe15c 100644 --- a/spacetimedb/src/tables.rs +++ b/spacetimedb/src/tables.rs @@ -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, pub reactions: Vec, pub image_ids: Vec, @@ -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, pub mime_type: String, pub name: Option, } +#[spacetimedb::table(accessor = image_data)] +#[derive(Clone)] +pub struct ImageData { + #[primary_key] + pub image_id: u64, + pub data: Vec, +} + +#[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, - pub sent: Timestamp, - pub seq_id: u64, - pub reactions: Vec, - pub image_ids: Vec, - pub thread_name: Option, - 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, diff --git a/spacetimedb/src/utils.rs b/spacetimedb/src/utils.rs index 936110b..0edc7f8 100644 --- a/spacetimedb/src/utils.rs +++ b/spacetimedb/src/utils.rs @@ -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::().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::().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 { - 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 = 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 { + 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 { - 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 = 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 { + db.user_channel_access().identity().filter(identity).map(|a| a.channel_id).collect() } pub fn get_visible_image_ids(db: &Local, identity: Identity) -> HashSet { - 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 { - 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::>() - { - db.webrtc_signal().delete(row); - } - for row in db - .webrtc_signal() - .receiver() - .filter(identity) - .collect::>() - { - 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, + image_ids: Vec, + 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); } +} diff --git a/spacetimedb/src/views.rs b/spacetimedb/src/views.rs index dbe1967..4a4b29a 100644 --- a/spacetimedb/src/views.rs +++ b/spacetimedb/src/views.rs @@ -5,7 +5,6 @@ use spacetimedb::{Identity, Query, Table, Timestamp, ViewContext}; #[derive(spacetimedb::SpacetimeType)] pub struct VisibleImageRow { pub id: u64, - pub data: Vec, pub mime_type: String, pub name: Option, } @@ -32,30 +31,8 @@ pub fn visible_typing_activity(ctx: &ViewContext) -> Vec { 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 { +pub fn visible_recent_activity(ctx: &ViewContext) -> Vec { 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 = 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); + + 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 }; - 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); - } - } - } - - // 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 { #[spacetimedb::view(accessor = visible_servers, public)] pub fn visible_servers(ctx: &ViewContext) -> Vec { let identity = ctx.sender(); - let mut results = Vec::new(); + let my_server_ids: std::collections::HashSet = 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 = 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 { 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 = 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,47 +120,28 @@ pub fn visible_server_members(ctx: &ViewContext) -> Vec { #[spacetimedb::view(accessor = visible_channels, public)] pub fn visible_channels(ctx: &ViewContext) -> Vec { - 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, - }); - } + results.push(VisibleChannelRow { + 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) { - 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, - }); - } - } - 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, - }); + // 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: 0, name: chan.name.clone(), kind: chan.kind, + }); + } } } @@ -242,8 +151,7 @@ pub fn visible_channels(ctx: &ViewContext) -> Vec { #[spacetimedb::view(accessor = visible_direct_messages, public)] pub fn visible_direct_messages(ctx: &ViewContext) -> impl Query { 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 { 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 { + 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 { 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 { 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 { - let mut results = Vec::new(); +pub fn visible_scrollback_messages(ctx: &ViewContext) -> impl Query { 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) - } else { - // DM channel: Check DM participants - ctx.db - .direct_message() - .channel_id() - .filter(sub.channel_id) - .any(|dm| dm.sender == identity || dm.recipient == identity) - } - } else { - false - }; + 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 { + ctx.from.message().r#where(|m| m.id.eq(0)) + } +} - if has_access { - for msg in ctx - .db - .message() - .channel_id() - .filter(sub.channel_id) - { - if msg.seq_id >= sub.earliest_seq_id { - 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, - }); - } +#[spacetimedb::view(accessor = visible_scrollback_thread_messages, public)] +pub fn visible_scrollback_thread_messages(ctx: &ViewContext) -> Vec { + 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.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 { 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![] diff --git a/src/chat/services/chat.svelte.ts b/src/chat/services/chat.svelte.ts index f9704b1..abce0d1 100644 --- a/src/chat/services/chat.svelte.ts +++ b/src/chat/services/chat.svelte.ts @@ -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)`); - this.#blobUrls.set(idStr, url); + 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() { diff --git a/src/chat/services/database.svelte.ts b/src/chat/services/database.svelte.ts index 1ffb550..6287ad6 100644 --- a/src/chat/services/database.svelte.ts +++ b/src/chat/services/database.svelte.ts @@ -13,6 +13,7 @@ export class DatabaseService { serverMembers = $state([]); allThreads = $state([]); images = $state([]); + imageBlobs = $state([]); customEmojis = $state([]); userStates = $state([]); typingActivity = $state([]); @@ -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)); diff --git a/src/chat/services/messaging.svelte.ts b/src/chat/services/messaging.svelte.ts index c43bddc..9dbab87 100644 --- a/src/chat/services/messaging.svelte.ts +++ b/src/chat/services/messaging.svelte.ts @@ -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([]); - // Optimized Per-Channel/Per-Message Buckets #channelBuckets = new SvelteMap, - sorted: (Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] })[] + map: Map, + sorted: Types.Message[] }>(); isLoadingMore = $state(false); #readyChannels = new SvelteSet(); isGlobalSyncDone = $state(false); encryptionOptIn = $state(new SvelteSet()); + #mySubscriptions = $state([]); 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(); @@ -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>(); + #updateBuckets(newMessages: readonly Types.Message[]) { + const tempBuckets = new Map>(); 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; - } + if (a.seqId < b.seqId) return -1; + if (a.seqId > b.seqId) return 1; 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) { From abdf79fc540b5e43f858ab2b823869d1873039e6 Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Thu, 16 Apr 2026 19:25:53 -0400 Subject: [PATCH 02/11] lint --- src/chat/services/messaging.svelte.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chat/services/messaging.svelte.ts b/src/chat/services/messaging.svelte.ts index 9dbab87..ddb990b 100644 --- a/src/chat/services/messaging.svelte.ts +++ b/src/chat/services/messaging.svelte.ts @@ -194,7 +194,7 @@ export class MessagingService { queries.push(`SELECT * FROM thread WHERE channel_id = ${channelId}`); queries.push(`SELECT * FROM visible_user_states`); 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}`); } From 49a325ebee495975b9a09bc4a90b293bdcbc4be8 Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Thu, 16 Apr 2026 22:04:14 -0400 Subject: [PATCH 03/11] responsive --- src/App.css | 1677 +---------------- src/auth/AuthGate.svelte | 135 ++ src/chat/ChatContainer.svelte | 437 +++-- src/chat/components/ChatInput.svelte | 28 +- src/chat/components/MemberList.svelte | 71 + src/chat/components/MessageItem.svelte | 53 +- src/chat/components/MessageList.svelte | 10 + src/chat/components/RichText.svelte | 70 +- src/chat/components/ServerList.svelte | 26 + .../channels/TextChannelGroup.svelte | 45 + .../channels/VoiceChannelGroup.svelte | 196 ++ 11 files changed, 1014 insertions(+), 1734 deletions(-) diff --git a/src/App.css b/src/App.css index 5294955..6ac0efb 100644 --- a/src/App.css +++ b/src/App.css @@ -13,7 +13,7 @@ } /* Premium Midnight Purple (Amethyst) */ -.theme-amethyst, :root { +.theme-amethyst { --background-primary: #1f1e22; --background-secondary: #19181c; --background-tertiary: #121114; @@ -108,7 +108,7 @@ } /* Generic Dark (Standard) */ -.theme-dark { +.theme-dark, :root { --background-primary: #2b2d31; --background-secondary: #232428; --background-tertiary: #1e1f22; @@ -145,7 +145,7 @@ --background-floating: #ffffff; } - +/* Reset & Foundation */ body { margin: 0; font-family: var(--font-primary); @@ -159,231 +159,18 @@ body { height: 100%; } -.chat-container { - display: flex; - height: 100vh; - width: 100vw; -} - -.left-sidebar-wrapper { - display: flex; - flex-direction: column; - width: calc(var(--server-sidebar-width) + var(--channel-sidebar-width)); - height: 100%; - flex-shrink: 0; - z-index: 100; - background-color: var(--background-secondary); -} - -.left-sidebar-top { - display: flex; - flex: 1; - min-height: 0; -} - -/* Server List */ -.server-list { - width: var(--server-sidebar-width); - background-color: var(--background-tertiary); - display: flex; - flex-direction: column; - align-items: center; - padding: 12px 0; - gap: 8px; - flex-shrink: 0; -} - -.server-icon { - width: 48px; - height: 48px; - background-color: var(--background-accent); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: - border-radius 0.2s, - background-color 0.2s; - font-weight: bold; - color: var(--text-normal); - position: relative; -} - -.server-icon:hover { - border-radius: 16px; - background-color: var(--brand); - color: white; -} - -.server-icon.active { - border-radius: 16px; - background-color: var(--brand); - color: white; -} - -.server-icon.active::before { - content: ""; - position: absolute; - left: -12px; +/* Shared UI Utility Classes */ +.status-dot { width: 8px; - height: 40px; - background-color: white; - border-radius: 0 4px 4px 0; -} - -/* Channel Sidebar */ -.sidebar-container { - display: flex; - flex-direction: column; - background-color: var(--background-secondary); - width: var(--channel-sidebar-width); - height: 100%; + height: 8px; + border-radius: 50%; flex-shrink: 0; - position: relative; - z-index: 100; } -.channel-sidebar { - flex: 1; - overflow-y: auto; - overflow-x: visible; - display: flex; - flex-direction: column; -} - -.server-header { - height: 48px; - padding: 0 16px; - display: flex; - align-items: center; - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2); - font-weight: bold; -} - -.server-header.clickable { - cursor: pointer; - transition: background-color 0.1s; -} - -.server-header.clickable:hover { - background-color: var(--background-modifier-hover); -} - -.server-dropdown { - background-color: var(--background-floating); - margin: 0 8px 8px 8px; - border-radius: 4px; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); - overflow: hidden; - z-index: 100; -} - -.server-dropdown-item { - padding: 8px 12px; - font-size: 0.85rem; - cursor: pointer; - transition: background-color 0.1s; -} - -.server-dropdown-item:hover { - background-color: var(--brand); - color: white; -} - -.server-dropdown-item.danger:hover { - background-color: var(--status-danger); -} - -.server-dropdown-item.muted { - color: var(--text-muted); -} - -.server-dropdown-item.muted:hover { - color: white; -} - -.channel-section { - padding: 16px 8px; -} - -.section-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0 8px; - font-size: 0.75rem; - font-weight: 700; - color: var(--text-muted); - margin-bottom: 4px; -} - -.add-btn { - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - font-size: 0.9rem; - line-height: 1; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.1s; -} - -.section-header:hover .add-btn { - opacity: 1; -} - -.add-btn:hover { - color: var(--interactive-hover); -} - -.channel-item { - padding: 6px 8px; - border-radius: 4px; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - color: var(--interactive-normal); -} - -.channel-item:hover { - background-color: var(--background-accent); - color: var(--interactive-hover); -} - -.channel-item.active { - background-color: var(--background-modifier-selected); - color: white; -} - -.channel-item-hash { - font-size: 1em; - color: var(--text-muted); -} - -/* User Info Bar */ -.user-info-bar { - background-color: var(--background-tertiary); - padding: 0 8px; - display: flex; - align-items: center; - justify-content: space-between; - height: 52px; - flex-shrink: 0; - border-top: 1px solid rgba(255, 255, 255, 0.05); -} - -.user-info-main { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} +.status-dot.green { background-color: var(--status-positive); } +.status-dot.yellow { background-color: var(--status-warning); } +.status-dot.red { background-color: var(--status-danger); } +.status-dot.grey { background-color: var(--text-muted); } .avatar { border-radius: 50%; @@ -397,169 +184,11 @@ body { color: white; } -.avatar.small { - width: 32px; - height: 32px; - font-size: 0.8rem; -} - -.avatar.tiny { - width: 24px; - height: 24px; - font-size: 0.65rem; -} - .avatar.talking { border: 2px solid var(--status-positive); box-shadow: 0 0 0 2px rgba(35, 165, 89, 0.15); } -/* Voice Member Item */ -.voice-member-list { - padding-left: 16px; - display: flex; - flex-direction: column; - gap: 2px; - margin-bottom: 8px; -} - -.voice-member-status-container { - margin-left: auto; - display: flex; - align-items: center; - height: 100%; - padding: 0 4px; - cursor: help; - gap: 6px; -} - -.voice-member-indicators { - display: flex; - align-items: center; - gap: 4px; -} - -.voice-indicator-icon { - font-size: 0.75rem; - color: var(--interactive-normal); - display: flex; - align-items: center; -} - -.deafen-indicator { - color: var(--interactive-normal) !important; -} - -.deafen-indicator-svg { - width: 1em; - height: 1em; - display: inline-block; - vertical-align: -0.125em; - color: var(--interactive-normal) !important; -} - -.icon-btn:hover .deafen-indicator, -.icon-btn:hover .deafen-indicator-svg { - color: var(--interactive-hover) !important; -} - -.voice-member-item { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 8px; - margin: 0 4px; - border-radius: 4px; - cursor: pointer; - transition: background-color 0.1s; - position: relative; - height: 32px; -} - -.voice-member-item:hover { - background-color: var(--background-modifier-hover); -} - -.voice-member-name { - font-size: 0.9rem; - color: var(--text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-weight: 500; - transition: color 0.1s; -} - -.voice-member-name.talking { - color: var(--header-primary); -} - -.watcher-eye { - color: var(--brand); - font-size: 0.75rem; - margin-left: 4px; - display: flex; - align-items: center; -} - -.avatar.talking { - border: 2px solid var(--status-positive); - box-shadow: 0 0 0 2px rgba(35, 165, 89, 0.15); -} - -.me-badge { - color: var(--text-muted); - font-size: 0.75rem; - margin-left: 4px; -} - -.member-item.offline { - opacity: 0.5; -} - -.member-name.talking { - color: var(--header-primary); -} - -.member-list-section-header { - padding: 16px 8px 8px 8px; - font-size: 0.75rem; - font-weight: 700; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.02em; -} - -.member-list-section-header:first-child { - padding-top: 0; -} - -.user-details { - min-width: 0; -} - -.user-display-name { - font-size: 0.85rem; - font-weight: 600; - color: var(--header-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.user-status { - font-size: 0.7rem; - color: var(--text-muted); - display: flex; - align-items: center; - gap: 4px; -} - -.user-actions { - display: flex; - gap: 4px; -} - .icon-btn { background: none; border: none; @@ -571,6 +200,7 @@ body { align-items: center; justify-content: center; font-size: 1.1rem; + transition: all 0.2s; } .icon-btn:hover { @@ -578,972 +208,34 @@ body { color: var(--interactive-hover); } -.icon-btn.active { - color: var(--brand); -} +.icon-btn.active { color: var(--brand); } +.icon-btn.danger.active { color: var(--status-danger); } -.icon-btn.active:hover { - background-color: var(--background-modifier-selected); -} - -.icon-btn.danger.active { - color: var(--status-danger); -} - -/* Main Content Area */ -.main-content { - flex: 1; - display: flex; - flex-direction: column; - background-color: var(--background-primary); - min-width: 0; - position: relative; - z-index: 1; -} - -.notification-toggle { - padding: 4px; - margin-left: 4px; - color: var(--interactive-normal); - opacity: 0.6; - font-size: 0.9rem !important; -} - -.notification-toggle:hover { - opacity: 1; -} - -.notification-toggle.subscribed { - color: var(--status-positive); - opacity: 1; -} - -.chat-header { - height: 48px; - padding: 0 16px; - display: flex; - align-items: center; - gap: 8px; - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2); - font-weight: bold; - z-index: 10; -} - -.encryption-indicator { - display: flex; - align-items: center; - gap: 4px; - background-color: rgba(88, 101, 242, 0.1); - border: 1px solid rgba(88, 101, 242, 0.3); - color: var(--brand); - padding: 2px 6px; - border-radius: 4px; - font-size: 0.65rem; - font-weight: 700; - text-transform: uppercase; - margin-left: 4px; -} - -.message-list { - flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; - padding-top: 24px; -} - -.message-item { - padding: 8px 16px; - display: flex; - gap: 16px; - position: relative; -} - -.message-item.grouped { - padding-top: 4px; - padding-bottom: 4px; -} - -.message-item.grouped .message-header { - display: none; -} - -.message-avatar-placeholder { - width: 40px; - height: 20px; /* Match approximate line-height */ - flex-shrink: 0; - position: relative; - display: flex; - justify-content: center; -} - -.thread-message-item .message-avatar-placeholder { +.close-btn { width: 32px; -} - -.message-hover-timestamp { - position: absolute; - top: 0; - left: 0; - width: 100%; - text-align: center; - font-size: 0.65rem; - color: var(--text-muted); - opacity: 0; - pointer-events: none; - line-height: 1.4; - padding-top: 2px; -} - -.message-item.grouped:hover .message-hover-timestamp { - opacity: 1; -} - -.message-item:hover { - background-color: rgba(0, 0, 0, 0.05); -} - -.message-item.active { - background-color: var(--background-modifier-hover); -} - -.message-item.active::before { - content: ""; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 2px; - background-color: var(--brand); -} - -.message-actions-toolbar { - position: absolute; - top: -16px; - right: 16px; - background-color: var(--background-primary); - border: 1px solid var(--background-accent); - border-radius: 4px; - display: flex; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - z-index: 10; - opacity: 0; - pointer-events: none; - transition: opacity 0.1s; -} - -.message-item:hover .message-actions-toolbar { - opacity: 1; - pointer-events: auto; -} - -.message-actions-toolbar .toolbar-btn { - background: none; - border: none; - color: var(--interactive-normal); - cursor: pointer; - padding: 4px 8px; - display: flex; - align-items: center; - justify-content: center; - font-size: 1rem; - transition: - color 0.1s, - background-color 0.1s; -} - -.message-actions-toolbar .toolbar-btn:hover { - color: var(--interactive-hover); - background-color: var(--background-modifier-hover); -} - -.message-actions-toolbar .toolbar-btn:first-child { - border-radius: 4px 0 0 4px; -} - -.message-actions-toolbar .toolbar-btn:last-child { - border-radius: 0 4px 4px 0; -} - -.message-avatar { - width: 40px; - height: 40px; - font-size: 1rem; -} - -.message-content { - display: flex; - flex-direction: column; - min-width: 0; -} - -.message-header { - display: flex; - align-items: baseline; - gap: 8px; -} - -.user-name { - font-weight: 600; - color: var(--header-primary); -} - -.message-time { - font-size: 0.75rem; - color: var(--text-muted); -} - -.message-text { - line-height: 1.4; - word-wrap: break-word; -} - -.url-link { - color: #00a8fc; - text-decoration: none; -} - -.url-link:hover { - text-decoration: underline; -} - -.message-image-container { - margin-top: 8px; - max-width: 400px; - max-height: 300px; - border-radius: 8px; - overflow: hidden; - border: 1px solid var(--background-accent); - background-color: var(--background-tertiary); -} - -.message-image { - max-width: 100%; - max-height: 300px; - display: block; - object-fit: contain; - cursor: pointer; -} - -.thread-link { - font-size: 0.75rem; - color: var(--brand); - text-decoration: none; - cursor: pointer; -} - -.thread-link:hover { - text-decoration: underline; -} - -/* Embeds */ -.embeds-container { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 4px; -} - -.embed-wrapper.shadow-box { - border: 1px solid var(--background-modifier-accent); - border-radius: 8px; - background-color: var(--background-secondary); - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - overflow: hidden; - width: fit-content; - max-width: 100%; -} - -.embed-header { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - padding: 8px 12px; - background-color: var(--background-tertiary); - border: none; - border-bottom: 1px solid var(--background-modifier-accent); - cursor: pointer; - font-family: inherit; - transition: background-color 0.2s; -} - -.embed-header:hover { - background-color: var(--background-modifier-hover); -} - -.embed-header.collapsed { - border-bottom: none; -} - -.embed-type-label { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.85rem; - font-weight: bold; - color: var(--text-normal); -} - -.embed-content-body { - padding: 8px; - display: flex; - justify-content: center; - background-color: var(--background-secondary); -} - -.media-embed-container { - border-radius: 4px; - overflow: hidden; - width: fit-content; - max-width: 100%; -} - -.media-iframe { - display: block; - border: none; - max-width: 100%; -} - -/* Reactions */ -.reaction-badge { - background-color: var(--background-accent); - border: 1px solid transparent; - border-radius: 8px; - padding: 2px 6px; - display: flex; - align-items: center; - gap: 4px; - cursor: pointer; - transition: all 0.1s; - color: var(--text-normal); -} - -.reaction-badge:hover { - background-color: var(--background-modifier-hover); - border-color: var(--interactive-normal); -} - -.reaction-badge.active { - background-color: rgba(88, 101, 242, 0.15); - border-color: var(--brand); -} - -.reaction-badge.active .count { - color: var(--brand); -} - -.reaction-badge .emoji { - font-size: 1rem; -} - -.reaction-badge .count { - font-size: 0.8rem; - font-weight: 600; - color: var(--interactive-normal); -} - -.add-reaction-btn { - background: none; - border: none; - color: var(--interactive-normal); - cursor: pointer; - padding: 4px; - border-radius: 4px; - transition: opacity 0.2s; -} - -.add-reaction-btn:hover { - color: var(--interactive-hover); - background-color: var(--background-modifier-hover); -} - -/* Chat Input */ -.chat-input-container { - padding: 0 16px 12px 16px; - position: relative; - display: flex; - flex-direction: column; -} - -.typing-indicator { - height: 20px; - margin-left: 16px; - margin-bottom: 2px; - font-size: 0.75rem; - color: var(--text-normal); - display: flex; - align-items: center; - gap: 8px; - pointer-events: none; - z-index: 20; -} - -.typing-indicator .dots { - display: flex; - gap: 2px; -} - -.typing-indicator .dot { - width: 4px; - height: 4px; - background-color: var(--text-muted); + height: 32px; border-radius: 50%; - animation: typing-dot 1.4s infinite ease-in-out; -} - -.typing-indicator .dot:nth-child(2) { - animation-delay: 0.2s; -} - -.typing-indicator .dot:nth-child(3) { - animation-delay: 0.4s; -} - -@keyframes typing-dot { - 0%, - 80%, - 100% { - transform: translateY(0); - } - 40% { - transform: translateY(-4px); - } -} - -.chat-input { - background-color: var(--background-tertiary); - border-radius: 8px; - padding: 0 16px; - display: flex; - align-items: center; -} - -.chat-input-wrapper { - background-color: var(--background-secondary); - border-radius: 8px; - padding: 0 12px; - display: flex; - align-items: center; -} - -/* Video Chat Layout */ -.video-grid { - flex: 1; - display: flex; - flex-direction: column; - padding: 16px; - background-color: var(--background-primary); - overflow: hidden; -} - -.video-grid-content { - display: grid; - gap: 16px; - width: 100%; - height: 100%; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - align-content: start; -} - -/* Layout when someone is sharing/focused */ -.video-grid.has-sharer .video-grid-content { - display: flex; - flex-direction: column; - gap: 16px; - height: 100%; - overflow: hidden; -} - -.video-tile-container.is-hero { - flex: 1; - min-height: 0; - width: 100%; -} - -/* We'll add a row-container to wrap only the row items in VideoGrid.svelte */ -.video-participants-row { - display: flex; - gap: 12px; - height: 160px; - overflow-x: auto; - overflow-y: hidden; - padding-bottom: 4px; /* Space for scrollbar */ - flex-shrink: 0; -} - -.video-tile-container.is-grid { - width: 100%; - aspect-ratio: 16 / 9; -} - -.video-tile-container.is-row { - width: 284px; /* 16:9 aspect for 160px height is 284px */ - flex-shrink: 0; - height: 100%; -} - -.video-tile { - position: relative; - background-color: var(--background-tertiary); - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - border: 2px solid transparent; - width: 100%; - height: 100%; -} - -.video-tile:not(.hero) { - aspect-ratio: 16 / 9; -} - -.video-tile.hero { - flex: 1; -} - -.video-tile.talking { - border-color: var(--status-positive); -} - -.video-tile video { - width: 100%; - height: 100%; - object-fit: contain; - background-color: black; -} - -.video-controls, -.tile-actions-right { - position: absolute; - display: flex; - gap: 8px; - opacity: 0; - transition: opacity 0.2s; - z-index: 10; -} - -.video-controls { - top: 8px; - right: 8px; -} - -.tile-actions-right { - bottom: 8px; - right: 8px; - display: flex; - align-items: center; - gap: 8px; -} - -.volume-control-container { - display: flex; - align-items: center; - gap: 8px; - background-color: rgba(30, 31, 34, 0.85); - padding: 4px 8px; - border-radius: 4px; - transition: background-color 0.2s; -} - -.volume-control-container:hover { - background-color: rgba(43, 45, 49, 0.95); -} - -.volume-slider { - cursor: pointer; - height: 4px; - -webkit-appearance: none; - background: rgba(255, 255, 255, 0.2); - border-radius: 2px; - outline: none; - margin: 0; - padding: 0; - accent-color: white; - transition: - width 0.2s, - opacity 0.2s; -} - -/* Default for VideoTile is hidden until hover */ -.volume-control-container .volume-slider { - width: 0; - opacity: 0; -} - -.volume-control-container:hover .volume-slider { - width: 80px; - opacity: 1; -} - -/* Context menu slider is always visible and full width */ -.context-menu-section .volume-slider { - width: 100%; - opacity: 1; - display: block; - margin: 4px 0; -} - -.volume-slider::-webkit-slider-runnable-track { - width: 100%; - height: 4px; - cursor: pointer; - background: rgba(255, 255, 255, 0.2); - border-radius: 2px; -} - -.volume-slider::-webkit-slider-thumb { - -webkit-appearance: none; - width: 14px; - height: 14px; - background: white; - border-radius: 50%; - cursor: pointer; - margin-top: -5px; /* Centers thumb on 4px track */ border: none; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); -} - -.volume-slider::-moz-range-track { - width: 100%; - height: 4px; - cursor: pointer; - background: rgba(255, 255, 255, 0.2); - border-radius: 2px; -} - -.volume-slider::-moz-range-thumb { - width: 14px; - height: 14px; - background: white; - border-radius: 50%; - cursor: pointer; - border: none; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); -} - -.volume-control-container .mute-tile-btn { background-color: transparent; - padding: 4px; - min-width: 24px; -} - -.volume-control-container .mute-tile-btn:hover { - background-color: rgba(255, 255, 255, 0.1); -} - -.video-tile:hover .video-controls, -.video-tile:hover .tile-actions-right { - opacity: 1; -} - -.watch-btn, -.mute-tile-btn { - background-color: var(--brand); - color: white; - border: none; - padding: 4px 12px; - border-radius: 4px; - cursor: pointer; - font-weight: 500; - display: flex; - align-items: center; - justify-content: center; -} - -.watch-btn.active, -.mute-tile-btn { - background-color: rgba(30, 31, 34, 0.7); -} - -.watch-btn.active:hover, -.mute-tile-btn:hover { - background-color: rgba(43, 45, 49, 0.9); -} - -.fullscreen-btn { - background-color: rgba(0, 0, 0, 0.5); - color: white; - border: none; - padding: 4px 8px; - border-radius: 4px; - cursor: pointer; -} - -.avatar-placeholder-container { - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; -} - -.avatar-placeholder { - width: 60px; - height: 60px; - border-radius: 50%; - background-color: var(--brand); - display: flex; - align-items: center; - justify-content: center; - font-size: 1.5rem; - font-weight: bold; -} - -.tile-info { - position: absolute; - bottom: 8px; - left: 8px; - background-color: rgba(0, 0, 0, 0.5); - padding: 2px 8px; - border-radius: 4px; - display: flex; - align-items: center; - gap: 6px; -} - -.tile-info .user-name { - font-size: 0.85rem; - color: white; -} - -.sharing-badge { - background-color: var(--status-danger); - color: white; - font-size: 0.6rem; - padding: 1px 4px; - border-radius: 2px; - font-weight: bold; -} - -/* Member List */ -.member-list { - padding: 16px 8px; - display: flex; - flex-direction: column; - gap: 2px; - overflow-y: auto; - height: 100%; -} - -.member-item { - display: flex; - align-items: center; - gap: 12px; - padding: 6px 8px; - border-radius: 4px; - cursor: pointer; color: var(--interactive-normal); -} - -.member-item:hover { - background-color: var(--background-modifier-hover); - color: var(--interactive-hover); -} - -.member-avatar { - border-radius: 50%; - flex-shrink: 0; -} - -.status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; - cursor: help; -} - -.status-dot.green { - background-color: var(--status-positive); -} - -.status-dot.yellow { - background-color: var(--status-warning); -} - -.status-dot.red { - background-color: var(--status-danger); -} -.status-dot.grey { - background-color: var(--text-muted); -} - -/* Connection Popover */ -.connection-popover { - position: fixed; - width: 216px; - background-color: var(--background-floating, #1e1f22); - border-radius: 8px; - padding: 12px; - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.24); - z-index: 3000; - color: var(--text-normal, #dbdee1); - font-family: var(--font-primary); - pointer-events: none; -} - -.connection-popover::before { - content: ""; - position: absolute; - top: 20px; - left: -6px; - width: 12px; - height: 12px; - background-color: var(--background-floating); - transform: rotate(45deg); -} - -.popover-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12px; - padding-bottom: 8px; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); -} - -.popover-name { - font-weight: bold; - font-size: 0.9rem; -} - -.popover-status { - font-size: 0.7rem; - text-transform: uppercase; - font-weight: 800; -} - -.popover-status.green { - color: var(--status-positive); -} -.popover-status.yellow { - color: var(--status-warning); -} -.popover-status.red { - color: var(--status-danger); -} - -.popover-info { - font-size: 0.8rem; - color: var(--text-muted); - font-style: italic; -} - -.stats-section { - margin-bottom: 12px; -} - -.stats-section:last-child { - margin-bottom: 0; -} - -.section-title { - font-size: 0.65rem; - font-weight: 800; - color: var(--text-muted); - margin-bottom: 4px; -} - -.stat-row { - display: flex; - justify-content: space-between; - font-size: 0.75rem; - line-height: 1.4; -} - -.stat-row span:last-child { - color: var(--header-primary); - font-family: var(--font-code); -} - -.member-name { - font-size: 0.9rem; - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Right Sidebar (Users/Thread) */ -.right-sidebar { - width: 240px; - background-color: var(--background-secondary); - display: flex; - flex-direction: column; - flex-shrink: 0; - border-left: 1px solid rgba(0, 0, 0, 0.2); - position: relative; - z-index: 100; -} - -.thread-view { - flex: 1; - display: flex; - flex-direction: column; -} - -.thread-header { - height: 48px; - padding: 0 16px; - display: flex; - align-items: center; - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2); - font-weight: bold; -} - -.thread-messages-list { - padding: 8px 0; -} - -.thread-message-item { - padding: 4px 16px; - gap: 12px; -} - -/* Modals */ -.modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.8); display: flex; align-items: center; justify-content: center; - z-index: 2000; + cursor: pointer; + transition: all 0.2s; } -.modal-content { - background-color: var(--background-primary); - padding: 24px; - border-radius: 8px; - width: 400px; - display: flex; - flex-direction: column; - gap: 16px; +.close-btn:hover { + color: var(--interactive-hover); + background-color: var(--background-modifier-hover); + transform: rotate(90deg); } -.modal-content h2 { - margin: 0; -} - -.modal-content input { - background-color: var(--background-tertiary); - border: none; - padding: 10px; - border-radius: 4px; - color: white; -} - -.modal-actions { - display: flex; - justify-content: flex-end; - gap: 12px; +.shadow-box { + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.24); } +/* Button Utilities */ .btn-primary { background-color: var(--brand); color: white; @@ -1570,24 +262,21 @@ body { } .btn-secondary { - background-color: transparent; - border: 1px solid var(--brand); - color: var(--brand); + background-color: var(--background-accent); + border: 1px solid var(--background-modifier-accent); + color: var(--text-normal); padding: 10px 20px; border-radius: 4px; cursor: pointer; font-weight: 600; - transition: - background-color 0.2s, - color 0.2s; + transition: background-color 0.2s; display: flex; align-items: center; justify-content: center; } .btn-secondary:hover { - background-color: var(--brand); - color: white; + background-color: var(--background-modifier-hover); } .btn-danger { @@ -1598,269 +287,79 @@ body { border-radius: 4px; cursor: pointer; font-weight: 600; - transition: background-color 0.2s; - display: flex; - align-items: center; - justify-content: center; + transition: opacity 0.2s; } .btn-danger:hover { - background-color: var(--status-danger); -} - -.btn-danger.small { - padding: 4px 12px; - font-size: 0.8rem; -} - -.btn-ghost { - background-color: transparent; - color: var(--text-muted); - border: none; - padding: 8px 16px; - border-radius: 4px; - cursor: pointer; - font-weight: 600; - transition: - background-color 0.2s, - color 0.2s; - display: flex; - align-items: center; - justify-content: center; -} - -.btn-ghost:hover { - background-color: var(--background-modifier-hover); - color: var(--text-normal); -} - -/* Login Screen */ -.login-screen { - display: flex; - align-items: center; - justify-content: center; - height: 100vh; - width: 100vw; - background: radial-gradient( - circle at center, - var(--background-secondary) 0%, - var(--background-tertiary) 100% - ); - background-color: var(--background-tertiary); -} - -.login-card { - background-color: var(--background-primary); - padding: 32px; - border-radius: 8px; - width: 480px; - box-shadow: 0 2px 10px 0 rgba(0, 0, 0, 0.2); - display: flex; - flex-direction: column; - align-items: center; - text-align: center; -} - -.login-card h1 { - color: var(--header-primary); - margin-bottom: 8px; - font-size: 24px; - font-weight: 600; -} - -.login-card p { - color: var(--text-normal); - margin-bottom: 24px; - font-size: 16px; -} - -.login-error { - color: var(--status-danger); - background-color: rgba(250, 119, 122, 0.1); - border: 1px solid rgba(250, 119, 122, 0.2); - padding: 8px; - border-radius: 4px; - margin-bottom: 16px; - width: 100%; - font-size: 0.9rem; -} - -/* Voice Connected Bar */ -.voice-status-bar { - background-color: var(--background-tertiary); - padding: 8px; - display: flex; - justify-content: space-between; - align-items: center; - border-top: 1px solid rgba(255, 255, 255, 0.05); -} - -.voice-info { - display: flex; - flex-direction: column; -} - -.voice-connected-text { - color: var(--status-positive); - font-size: 0.8rem; - font-weight: bold; -} - -.voice-channel-name { - color: var(--text-muted); - font-size: 0.75rem; -} - -.screen-share-btn { - background-color: var(--background-accent); - color: white; - border: none; - padding: 4px 12px; - border-radius: 4px; - cursor: pointer; - font-size: 0.8rem; - font-weight: 600; - transition: background-color 0.2s; -} - -.screen-share-btn:hover { - background-color: var(--background-modifier-hover); -} - -.screen-share-btn.active { - background-color: var(--status-danger); -} - -.screen-share-btn.active:hover { opacity: 0.9; } -.screen-share-controls { - display: flex; - align-items: center; - gap: 8px; +/* Embeds & Media */ +.embed-wrapper { + border-left: 4px solid var(--brand); background-color: var(--background-secondary); - padding: 2px 4px 2px 8px; - border-radius: 6px; - border: 1px solid var(--background-modifier-accent); -} - -.stream-settings-header { - display: flex; - gap: 4px; -} - -.setting-group-header select { - background: none; - border: none; - color: var(--text-muted); - font-size: 0.75rem; - font-weight: 600; - cursor: pointer; - padding: 2px 4px; - outline: none; border-radius: 4px; + margin-top: 8px; + max-width: 520px; + overflow: hidden; } -.setting-group-header select:hover { - color: var(--text-normal); - background-color: var(--background-modifier-hover); -} - -.setting-group-header select option { - background-color: var(--background-floating); - color: var(--text-normal); -} - -/* Context Menu */ -.context-menu { - position: fixed; - z-index: 3000; - background: var(--background-floating); - border-radius: 4px; - padding: 8px; - min-width: 180px; - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.24); - border: 1px solid rgba(255, 255, 255, 0.05); -} - -.context-menu-header { - padding: 8px; - font-size: 0.75rem; - font-weight: bold; - color: var(--text-muted); - text-transform: uppercase; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - margin-bottom: 4px; -} - -.context-menu-separator { - height: 1px; - background-color: rgba(255, 255, 255, 0.06); - margin: 4px 0; -} - -.context-menu-item { - padding: 8px; +.embed-header { + padding: 8px 12px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; - border-radius: 2px; - font-size: 0.9rem; - color: white; } -.context-menu-item:hover { - background: var(--brand); - color: white; -} - -.context-menu-section { - padding: 8px; - border-top: 1px solid rgba(255, 255, 255, 0.06); - margin-top: 4px; -} - -.context-menu-section label { - display: block; - font-size: 0.75rem; - font-weight: bold; - color: var(--text-muted); - text-transform: uppercase; - margin-bottom: 8px; -} - -.context-menu-section .volume-slider { - width: 100%; - opacity: 1; - display: block; - margin: 4px 0; -} - -.context-menu-section div:last-child { - font-size: 0.7rem; - text-align: right; - margin-top: 4px; -} - -/* Global Close Button */ -.close-btn { - width: 32px; - height: 32px; - border-radius: 50%; - border: none; - background-color: transparent; - color: var(--interactive-normal); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s; -} - -.close-btn:hover { - color: var(--interactive-hover); +.embed-header:hover { background-color: var(--background-modifier-hover); - transform: rotate(90deg); +} + +.embed-content { + padding: 0 12px 12px 12px; +} + +.embed-title { + font-weight: 600; + color: var(--header-primary); + margin-bottom: 4px; + display: block; + text-decoration: none; +} + +.embed-title:hover { + text-decoration: underline; +} + +.embed-description { + font-size: 0.9rem; + color: var(--text-normal); + line-height: 1.3; +} + +.media-embed-container { + margin-top: 8px; + border-radius: 4px; + overflow: hidden; + background-color: #000; + line-height: 0; +} + +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.3); } diff --git a/src/auth/AuthGate.svelte b/src/auth/AuthGate.svelte index d4e0b59..4dd11e2 100644 --- a/src/auth/AuthGate.svelte +++ b/src/auth/AuthGate.svelte @@ -181,11 +181,146 @@ + +
+

Technical Specifications

+
+
+ + Svelte 5 +
+
+ + SpacetimeDB +
+
+ + Rust / WASM +
+
+ + WebRTC Mesh +
+
+ + GPG E2EE +
+
+ + Tauri 2.0 +
+
+
{/if} diff --git a/src/chat/components/ChatInput.svelte b/src/chat/components/ChatInput.svelte index 3351742..dd81ed1 100644 --- a/src/chat/components/ChatInput.svelte +++ b/src/chat/components/ChatInput.svelte @@ -724,6 +724,16 @@ .chat-input, .chat-input-wrapper { transition: border-radius 0.2s; + display: flex; + align-items: flex-end; + padding: 0 12px; + background-color: var(--background-tertiary); + border-radius: 8px; + gap: 4px; + } + + .chat-input-wrapper { + background-color: var(--background-secondary); } .has-staged { @@ -731,21 +741,7 @@ border-top-right-radius: 0 !important; } - .thread-input-inner .chat-input-wrapper textarea { - font-size: 1rem; - background: none; - border: none; - outline: none; - color: var(--text-normal); - width: 100%; - resize: none; - padding: 11px 0; - font-family: inherit; - line-height: 1.375rem; - box-sizing: border-box; - } - - .chat-input textarea { + .chat-input textarea, .chat-input-wrapper textarea { background: none; border: none; outline: none; @@ -761,7 +757,7 @@ width: 100%; } - .chat-input { + .chat-input, .chat-input-wrapper { height: auto !important; min-height: 44px; } diff --git a/src/chat/components/MemberList.svelte b/src/chat/components/MemberList.svelte index 9b78ea5..2a42556 100644 --- a/src/chat/components/MemberList.svelte +++ b/src/chat/components/MemberList.svelte @@ -131,4 +131,75 @@ text-overflow: ellipsis; line-height: 1; } + + .right-sidebar { + width: 240px; + background-color: var(--background-secondary); + display: flex; + flex-direction: column; + flex-shrink: 0; + border-left: 1px solid rgba(0, 0, 0, 0.2); + position: relative; + z-index: 100; + } + + .member-list { + padding: 16px 8px; + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + height: 100%; + } + + .member-item { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 8px; + border-radius: 4px; + cursor: pointer; + color: var(--interactive-normal); + transition: all 0.1s; + } + + .member-item:hover { + background-color: var(--background-modifier-hover); + color: var(--interactive-hover); + } + + .member-item.offline { + opacity: 0.45; + } + + .member-name { + font-size: 0.9rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .member-name.talking { + color: var(--status-positive); + } + + .member-list-section-header { + padding: 16px 8px 8px 8px; + font-size: 0.75rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.02em; + } + + .sharing-badge { + background-color: var(--status-danger); + color: white; + font-size: 0.6rem; + padding: 1px 4px; + border-radius: 2px; + font-weight: bold; + margin-right: 4px; + } diff --git a/src/chat/components/MessageItem.svelte b/src/chat/components/MessageItem.svelte index 967d9ac..2e52e51 100644 --- a/src/chat/components/MessageItem.svelte +++ b/src/chat/components/MessageItem.svelte @@ -454,7 +454,7 @@ aria-expanded={!collapsedImages} >
- + {msg.imageIds.length} Image{msg.imageIds.length > 1 ? 's' : ''}
@@ -764,6 +764,57 @@ border-top-color: var(--background-floating); } + .message-image { + cursor: pointer; + display: block; + max-width: 100%; + border-radius: 4px; + object-fit: contain; + } + + .message-image-container { + max-width: 100%; + overflow: hidden; + cursor: pointer; + } + + .embed-wrapper { + border-left: 4px solid var(--brand); + background-color: var(--background-secondary); + border-radius: 4px; + max-width: 520px; + overflow: hidden; + } + + .embed-header { + width: 100%; + padding: 8px 12px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + background: none; + border: none; + font-family: inherit; + color: var(--text-normal); + } + + .embed-header:hover { + background-color: var(--background-modifier-hover); + } + + .embed-type-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + font-weight: bold; + } + + .embed-content-body { + padding: 8px 12px 12px 12px; + } + .reaction-badge { background-color: var(--background-accent); border: 1px solid transparent; diff --git a/src/chat/components/MessageList.svelte b/src/chat/components/MessageList.svelte index 70b7ec3..18dddda 100644 --- a/src/chat/components/MessageList.svelte +++ b/src/chat/components/MessageList.svelte @@ -414,4 +414,14 @@ max-width: 400px; line-height: 1.4; } + + .message-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + padding-top: 24px; + min-height: 0; + } diff --git a/src/chat/components/RichText.svelte b/src/chat/components/RichText.svelte index 0bdc122..ec09696 100644 --- a/src/chat/components/RichText.svelte +++ b/src/chat/components/RichText.svelte @@ -191,9 +191,9 @@ {:else if embed.type === 'facebook'} Facebook {:else if embed.type === 'image'} - Image + Image {:else} - Link + Link {/if} @@ -322,5 +322,71 @@ .message-image { cursor: pointer; + max-width: 100%; + max-height: 400px; + display: block; + border-radius: 4px; + object-fit: contain; + } + + .embeds-container { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 4px; + } + + .embed-wrapper { + border-left: 4px solid var(--brand); + background-color: var(--background-secondary); + border-radius: 4px; + max-width: 520px; + overflow: hidden; + } + + .embed-header { + width: 100%; + padding: 8px 12px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + background: none; + border: none; + font-family: inherit; + color: var(--text-normal); + } + + .embed-header:hover { + background-color: var(--background-modifier-hover); + } + + .embed-type-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + font-weight: bold; + } + + .embed-content-body { + padding: 8px 12px 12px 12px; + } + + .media-embed-container { + border-radius: 4px; + overflow: hidden; + background-color: #000; + line-height: 0; + } + + .media-iframe { + max-width: 100%; + border: none; + } + + .message-image-container { + max-width: 100%; + overflow: hidden; } diff --git a/src/chat/components/ServerList.svelte b/src/chat/components/ServerList.svelte index 66ad0a3..d2aea2d 100644 --- a/src/chat/components/ServerList.svelte +++ b/src/chat/components/ServerList.svelte @@ -261,4 +261,30 @@ background-color: var(--brand); color: white; } + + .server-list { + width: var(--server-sidebar-width); + background-color: var(--background-tertiary); + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 0; + gap: 8px; + flex-shrink: 0; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + } + + .server-icon.active::before { + content: ""; + position: absolute; + left: -4px; + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 40px; + background-color: white; + border-radius: 0 4px 4px 0; + } diff --git a/src/chat/components/channels/TextChannelGroup.svelte b/src/chat/components/channels/TextChannelGroup.svelte index 16e8674..b39d2e7 100644 --- a/src/chat/components/channels/TextChannelGroup.svelte +++ b/src/chat/components/channels/TextChannelGroup.svelte @@ -76,4 +76,49 @@ background-color: rgba(255, 255, 255, 0.1); color: white; } + + .channel-section { + padding: 16px 0 8px 0; + } + + .section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 16px; + font-size: 0.75rem; + font-weight: 700; + color: var(--text-muted); + margin-bottom: 4px; + } + + .add-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 0.9rem; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.1s, color 0.1s; + } + + .section-header:hover .add-btn { + opacity: 1; + } + + .add-btn:hover { + color: var(--interactive-hover); + } + + .channel-item-hash { + font-size: 1.2rem; + color: var(--text-muted); + margin-right: 4px; + width: 20px; + text-align: center; + } diff --git a/src/chat/components/channels/VoiceChannelGroup.svelte b/src/chat/components/channels/VoiceChannelGroup.svelte index ec7710d..bce898d 100644 --- a/src/chat/components/channels/VoiceChannelGroup.svelte +++ b/src/chat/components/channels/VoiceChannelGroup.svelte @@ -257,4 +257,200 @@ background-color: rgba(255, 255, 255, 0.1); color: white; } + + .channel-section { + padding: 16px 0 8px 0; + } + + .section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 16px; + font-size: 0.75rem; + font-weight: 700; + color: var(--text-muted); + margin-bottom: 4px; + } + + .add-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 0.9rem; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.1s, color 0.1s; + } + + .section-header:hover .add-btn { + opacity: 1; + } + + .add-btn:hover { + color: var(--interactive-hover); + } + + .channel-item-hash { + font-size: 1.2rem; + color: var(--text-muted); + margin-right: 4px; + width: 20px; + text-align: center; + } + + /* Voice Member List Styles */ + .voice-member-list { + padding-left: 36px; + padding-right: 8px; + display: flex; + flex-direction: column; + gap: 1px; + margin-bottom: 8px; + } + + .voice-member-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.1s; + height: 32px; + } + + .voice-member-item:hover { + background-color: var(--background-modifier-hover); + } + + .voice-member-name { + font-size: 0.85rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + } + + .voice-member-name.talking { + color: var(--header-primary); + } + + .voice-member-status-container { + margin-left: auto; + display: flex; + align-items: center; + gap: 4px; + } + + .voice-member-indicators { + display: flex; + align-items: center; + gap: 4px; + } + + .voice-indicator-icon { + font-size: 0.75rem; + color: var(--interactive-normal); + display: flex; + align-items: center; + } + + .deafen-indicator-svg { + width: 0.9rem; + height: 0.9rem; + } + + .watcher-eye { + color: var(--brand); + font-size: 0.75rem; + margin-left: 4px; + } + + .sharing-badge { + background-color: var(--status-danger); + color: white; + font-size: 0.6rem; + padding: 1px 4px; + border-radius: 2px; + font-weight: bold; + } + + /* Connection Popover */ + .connection-popover { + position: fixed; + width: 216px; + background-color: var(--background-floating); + border-radius: 8px; + padding: 12px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); + z-index: 3000; + color: var(--text-normal); + pointer-events: none; + border: 1px solid var(--background-modifier-accent); + } + + .popover-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + .popover-name { + font-weight: bold; + font-size: 0.9rem; + } + + .popover-status { + font-size: 0.7rem; + text-transform: uppercase; + font-weight: 800; + } + + .popover-status.green { color: var(--status-positive); } + .popover-status.yellow { color: var(--status-warning); } + .popover-status.red { color: var(--status-danger); } + + .popover-info { + font-size: 0.8rem; + color: var(--text-muted); + font-style: italic; + } + + .stats-section { + margin-bottom: 12px; + } + + .section-title { + font-size: 0.65rem; + font-weight: 800; + color: var(--text-muted); + margin-bottom: 4px; + } + + .stat-row { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + line-height: 1.4; + } + + .stat-row span:last-child { + color: var(--header-primary); + font-family: var(--font-code); + } + + .volume-slider { + width: 60px; + height: 4px; + accent-color: var(--brand); + } From 7b4260bc90d3014436effd414c7df24f5da499b4 Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Fri, 17 Apr 2026 03:10:55 -0400 Subject: [PATCH 04/11] oidc working --- src/InnerProvider.svelte | 71 ++++++++++++++++++++++ src/InnerSpacetimeProvider.svelte | 53 +++++------------ src/auth/AuthGate.svelte | 27 ++++++++- src/auth/auth.svelte.ts | 29 +++++++-- src/config.ts | 99 +++++++++++++++---------------- 5 files changed, 184 insertions(+), 95 deletions(-) create mode 100644 src/InnerProvider.svelte diff --git a/src/InnerProvider.svelte b/src/InnerProvider.svelte new file mode 100644 index 0000000..1a5403e --- /dev/null +++ b/src/InnerProvider.svelte @@ -0,0 +1,71 @@ + + +{#if $db.identity} + {@render children()} +{:else} + +{/if} diff --git a/src/InnerSpacetimeProvider.svelte b/src/InnerSpacetimeProvider.svelte index f836aa4..a5049d4 100644 --- a/src/InnerSpacetimeProvider.svelte +++ b/src/InnerSpacetimeProvider.svelte @@ -1,57 +1,36 @@ -{#if $db.identity} - {@render children()} +{#if builder} + + {@render children()} + {:else} {/if} diff --git a/src/auth/AuthGate.svelte b/src/auth/AuthGate.svelte index 4dd11e2..6a4617b 100644 --- a/src/auth/AuthGate.svelte +++ b/src/auth/AuthGate.svelte @@ -30,14 +30,29 @@ combinedConnection = `${stdbHost.replace(/^(https?|wss?):\/\//, "")}:${stdbDbName}`; const isChanging = localStorage.getItem("zep_changing_server") === "true"; + const isInCallback = window.location.search.includes("code=") && window.location.search.includes("state="); + if (isChanging) { userWantsToConnect = false; - } else if (TokenStore.get(stdbHost, stdbDbName)) { - // Auto-connect if we have a token and NOT changing server + } else if (TokenStore.get(stdbHost, stdbDbName) || isInCallback) { + // Auto-connect ONLY if we have a SpacetimeDB token OR are in a redirect callback. + // We don't auto-connect just because of auth.isAuthenticated to prevent logout loops. userWantsToConnect = true; } }); + // Ensure we transition to connecting if we just finished an OIDC redirect + $effect(() => { + if (auth.isAuthenticated && !auth.isLoading) { + const isInCallback = window.location.search.includes("code=") && window.location.search.includes("state="); + if (isInCallback) { + untrack(() => { + userWantsToConnect = true; + }); + } + } + }); + // Split combined connection if it changes $effect(() => { if (combinedConnection.includes(":")) { @@ -59,7 +74,13 @@ combinedConnection = `${hostPart}:${stdbDbName}`; }); - const hasStoredToken = $derived(!!TokenStore.get(stdbHost, stdbDbName)); + let hasStoredToken = $state(false); + + $effect(() => { + // Check for token on mount and when connection params change + const _ = combinedConnection; + hasStoredToken = !!TokenStore.get(stdbHost, stdbDbName); + }); $effect(() => { if (stdbHost) localStorage.setItem(HOST_KEY, stdbHost); diff --git a/src/auth/auth.svelte.ts b/src/auth/auth.svelte.ts index b90a31d..3953da1 100644 --- a/src/auth/auth.svelte.ts +++ b/src/auth/auth.svelte.ts @@ -7,8 +7,8 @@ import { getEnv } from "../env"; // OIDC Configuration - User should replace these with their own provider values export const oidcConfig: UserManagerSettings = { - authority: getEnv("VITE_OIDC_AUTHORITY", "https://accounts.google.com"), - client_id: getEnv("VITE_OIDC_CLIENT_ID", "REPLACE_ME"), + authority: getEnv("VITE_OIDC_AUTHORITY", "https://sso.adamlamers.com/application/o/truenas-zep/"), + client_id: getEnv("VITE_OIDC_CLIENT_ID", "Omb5jIeYY2rJqkxK52qZgLRGejOBCK5Km2BcA5RO"), redirect_uri: window.location.origin, scope: "openid profile email", response_type: "code", @@ -18,6 +18,7 @@ class AuthStore { #userManager: UserManager; #user = $state(null); #isLoading = $state(true); + #isProcessingCallback = false; constructor(settings: UserManagerSettings) { this.#userManager = new UserManager(settings); @@ -27,8 +28,19 @@ class AuthStore { window.location.search.includes("code=") && window.location.search.includes("state=") ) { - this.signinCallback(); + if (!this.#isProcessingCallback) { + this.signinCallback(); + } } else { + // Not in a callback, safe to clear any stale/redundant OIDC state keys + // that might have accumulated from partial attempts + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith("oidc.")) { + localStorage.removeItem(key); + } + } + this.#userManager .getUser() .then((user) => { @@ -71,10 +83,19 @@ class AuthStore { } async signinCallback() { + if (this.#isProcessingCallback) return; + this.#isProcessingCallback = true; this.#isLoading = true; try { const user = await this.#userManager.signinCallback(); this.#user = user; + + // Stage the id_token so SpacetimeDB can find it even if OIDC state is cleared + if (user.id_token) { + console.log("AuthStore: Staging OIDC id_token for SpacetimeDB handshake"); + localStorage.setItem("zep_oidc_staging_token", user.id_token); + } + window.history.replaceState({}, document.title, window.location.pathname); } catch (error) { console.error("Signin callback error:", error); @@ -101,7 +122,7 @@ class AuthStore { const keysToRemove: string[] = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); - if (key && key.includes("auth_token")) { + if (key && (key.includes("stdb_token:") || key.includes("auth_token") || key === "zep_oidc_staging_token")) { keysToRemove.push(key); } } diff --git a/src/config.ts b/src/config.ts index 16e2c04..3441d9c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -45,35 +45,20 @@ export const TokenStore = { const key = localStorage.key(i); if (!key) continue; - let connStr: string | null = null; - if (key.startsWith("stdb_token:")) { - // New format: stdb_token:protocol://host[:port]:database const parts = key.split(":"); if (parts.length >= 4) { const dbName = parts[parts.length - 1]; const hostParts = parts.slice(2, parts.length - 1); const host = hostParts.join(":").replace(/^\/\//, ""); - connStr = `${host}:${dbName}`; - } - } else if (key.endsWith("/auth_token")) { - // Legacy format: protocol://host[:port]/database/auth_token - // Example: http://localhost:3000/zep/auth_token - const parts = key.split("/"); - // parts: ["http:", "", "localhost:3000", "zep", "auth_token"] - if (parts.length >= 5) { - const dbName = parts[parts.length - 2]; - const host = parts[2]; - if (dbName && host) { - connStr = `${host}:${dbName}`; + const connStr = `${host}:${dbName}`; + + if (!seen.has(connStr)) { + connections.push(connStr); + seen.add(connStr); } } } - - if (connStr && !seen.has(connStr)) { - connections.push(connStr); - seen.add(connStr); - } } return connections; } @@ -102,6 +87,39 @@ export const stopActiveConnection = () => { } }; +export const handleConnect = (conn: DbConnection, identity: any, token: string) => { + const host = getStdbHost(); + const dbName = getStdbDbName(); + + console.log(`handleConnect: Connection established! Token received (len: ${token?.length || 0})`); + + _connection = conn; + if (token) { + console.log("handleConnect: Persisting token to TokenStore..."); + TokenStore.set(host, dbName, token); + + // Clear staging token now that we have a permanent one + localStorage.removeItem("zep_oidc_staging_token"); + } else { + console.warn("handleConnect: No token received from SpacetimeDB!"); + } + + connectionState.status = "connected"; + connectionState.hasConnectedOnce = true; + connectionState.error = null; + + // Call the auth update reducer to ensure OIDC info is synced + setTimeout(() => { + if (_connection) { + console.log("handleConnect: Requesting auth info update..."); + // Use the typed reducer call if available + if ((_connection.reducers as any).updateAuthInfo) { + (_connection.reducers as any).updateAuthInfo({}); + } + } + }, 100); +}; + class ConnectionManager { #retryCount = 0; #reconnectTimeout: any = null; @@ -197,50 +215,29 @@ export const connectionBuilder = (oidcToken?: string) => { const host = normalizeHost(rawHost); const dbName = getStdbDbName(); - console.log(`connectionBuilder: Using host: ${host} (raw: ${rawHost}), database: ${dbName}`); - - connectionState.status = "connecting"; - - if (activeManager) { - console.log("connectionBuilder: Stopping previous activeManager"); - activeManager.stop(); - } - - const manager = new ConnectionManager(host, dbName); - activeManager = manager; + console.log(`connectionBuilder: Creating builder for host: ${host}, database: ${dbName}`); const builder = DbConnection.builder() .withUri(host) .withDatabaseName(dbName); const storedToken = TokenStore.get(host, dbName); - console.log("connectionBuilder: oidcToken:", oidcToken ? "present" : "absent"); - console.log("connectionBuilder: storedToken:", storedToken ? "present" : "absent"); + const stagingToken = localStorage.getItem("zep_oidc_staging_token"); + + console.log("connectionBuilder: tokens -> oidc:", oidcToken ? "yes" : "no", "staging:", stagingToken ? "yes" : "no", "stored:", storedToken ? "yes" : "no"); if (oidcToken) { - console.log("connectionBuilder: Calling withToken with oidcToken"); builder.withToken(oidcToken); + } else if (stagingToken) { + console.log("connectionBuilder: Using staged OIDC token"); + builder.withToken(stagingToken); } else if (storedToken) { - console.log("connectionBuilder: Calling withToken with storedToken"); builder.withToken(storedToken); - } else { - console.log("connectionBuilder: No token, will connect anonymously"); } - // Register our manager's listeners directly on the connection object. - // This ensures they coexist with the Svelte provider's listeners. - builder.onConnect((c: any, id: any, token: string) => { - console.log("connectionBuilder: CONNECTION onConnect TRIGGERED!"); - manager.onConnect(c as any, id, token); - }); - builder.onDisconnect((_ctx: any, err: any) => { - console.log("connectionBuilder: CONNECTION onDisconnect TRIGGERED!", err); - manager.onDisconnect(); - }); - builder.onConnectError((ctx: any, err: any) => { - console.log("connectionBuilder: CONNECTION onConnectError TRIGGERED!", err); - manager.onConnectError(ctx, err); - }); + builder.onConnect((conn: any, identity: any, token: string) => { + handleConnect(conn, identity, token); + }); return builder; }; From fbdc19e8abd8d26a1f213e00681d059fd0517e66 Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Fri, 17 Apr 2026 03:36:39 -0400 Subject: [PATCH 05/11] small fixes --- spacetimedb/src/lib.rs | 24 +++++++++++-- src/InnerProvider.svelte | 36 +++++++++---------- src/InnerSpacetimeProvider.svelte | 13 +++++-- src/auth/AuthGate.svelte | 26 +++++++++----- src/chat/ChatContainer.svelte | 6 +++- src/chat/components/UserContextMenu.svelte | 11 ++++-- .../channels/TextChannelGroup.svelte | 7 ++-- .../channels/VoiceChannelGroup.svelte | 7 ++-- 8 files changed, 89 insertions(+), 41 deletions(-) diff --git a/spacetimedb/src/lib.rs b/spacetimedb/src/lib.rs index 3770b82..5362873 100644 --- a/spacetimedb/src/lib.rs +++ b/spacetimedb/src/lib.rs @@ -90,14 +90,25 @@ pub fn init(ctx: &ReducerContext) { pub fn on_connect(ctx: &ReducerContext) { log::info!("on_connect START: identity={}", ctx.sender().to_hex()); - // We'll keep this extremely minimal to ensure connection stability + // Extract potential name from OIDC if available + let mut initial_name = None; + if let Some(jwt) = ctx.sender_auth().jwt() { + let sub = jwt.subject(); + // Use first 8 chars of sub if it's a long string/UUID + initial_name = Some(if sub.len() > 12 { sub[..8].to_string() } else { sub.to_string() }); + } + if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) { user.online = true; + // Update name from OIDC if current user has no name + if user.name.is_none() && initial_name.is_some() { + user.name = initial_name; + } ctx.db.user().identity().update(user); } else { ctx.db.user().insert(User { identity: ctx.sender(), - name: None, + name: initial_name, online: true, issuer: None, subject: None, @@ -160,9 +171,16 @@ pub fn update_auth_info(ctx: &ReducerContext) { log::info!("update_auth_info: identity={}", ctx.sender().to_hex()); if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) { if let Some(jwt) = ctx.sender_auth().jwt() { + let sub = jwt.subject(); user.issuer = Some(jwt.issuer().to_string()); - user.subject = Some(jwt.subject().to_string()); + user.subject = Some(sub.to_string()); user.anonymous = false; + + // Also update name if they don't have a custom one yet + if user.name.is_none() { + user.name = Some(if sub.len() > 12 { sub[..8].to_string() } else { sub.to_string() }); + } + ctx.db.user().identity().update(user); sync_server_member_info(&ctx.db, ctx.sender()); log::info!("update_auth_info: updated user with OIDC info"); diff --git a/src/InnerProvider.svelte b/src/InnerProvider.svelte index 1a5403e..69b93d0 100644 --- a/src/InnerProvider.svelte +++ b/src/InnerProvider.svelte @@ -1,6 +1,7 @@ diff --git a/src/auth/AuthGate.svelte b/src/auth/AuthGate.svelte index 6a4617b..9dde66b 100644 --- a/src/auth/AuthGate.svelte +++ b/src/auth/AuthGate.svelte @@ -53,13 +53,13 @@ } }); - // Split combined connection if it changes + // Split combined connection if it changes (Only when on login screen) $effect(() => { - if (combinedConnection.includes(":")) { + if (!userWantsToConnect && combinedConnection.includes(":")) { const lastColon = combinedConnection.lastIndexOf(":"); const host = combinedConnection.substring(0, lastColon); const db = combinedConnection.substring(lastColon + 1); - if (host && db) { + if (host && db && (host !== stdbHost || db !== stdbDbName)) { untrack(() => { stdbHost = host; stdbDbName = db; @@ -71,23 +71,31 @@ // Update combined connection if individual fields change (e.g. on mount) $effect(() => { const hostPart = stdbHost.replace(/^(https?|wss?):\/\//, ""); - combinedConnection = `${hostPart}:${stdbDbName}`; + const expected = `${hostPart}:${stdbDbName}`; + if (combinedConnection !== expected) { + combinedConnection = expected; + } }); let hasStoredToken = $state(false); $effect(() => { - // Check for token on mount and when connection params change - const _ = combinedConnection; - hasStoredToken = !!TokenStore.get(stdbHost, stdbDbName); + // Check for token when connection params change + if (stdbHost && stdbDbName) { + hasStoredToken = !!TokenStore.get(stdbHost, stdbDbName); + } }); $effect(() => { - if (stdbHost) localStorage.setItem(HOST_KEY, stdbHost); + if (stdbHost && localStorage.getItem(HOST_KEY) !== stdbHost) { + localStorage.setItem(HOST_KEY, stdbHost); + } }); $effect(() => { - if (stdbDbName) localStorage.setItem(DB_NAME_KEY, stdbDbName); + if (stdbDbName && localStorage.getItem(DB_NAME_KEY) !== stdbDbName) { + localStorage.setItem(DB_NAME_KEY, stdbDbName); + } }); const isBypassEnabled = diff --git a/src/chat/ChatContainer.svelte b/src/chat/ChatContainer.svelte index 365c626..c5e032a 100644 --- a/src/chat/ChatContainer.svelte +++ b/src/chat/ChatContainer.svelte @@ -301,7 +301,11 @@ {/if} {#if chat.userContextMenu} - (chat.userContextMenu = null)} /> + (chat.userContextMenu = null)} + onAction={closeSidebars} + /> {/if} {#if chat.confirmModal} diff --git a/src/chat/components/UserContextMenu.svelte b/src/chat/components/UserContextMenu.svelte index f6a3e96..f41fd4a 100644 --- a/src/chat/components/UserContextMenu.svelte +++ b/src/chat/components/UserContextMenu.svelte @@ -5,11 +5,12 @@ import type * as Types from "../../module_bindings/types"; import { portal } from "../../portal"; - let { x, y, user, onClose }: { + let { x, y, user, onClose, onAction }: { x: number, y: number, user: Types.User, - onClose: () => void + onClose: () => void, + onAction?: () => void } = $props(); const chat = getContext("chat"); @@ -108,6 +109,7 @@ {#if !isMe} diff --git a/src/chat/components/channels/TextChannelGroup.svelte b/src/chat/components/channels/TextChannelGroup.svelte index b39d2e7..32cdae6 100644 --- a/src/chat/components/channels/TextChannelGroup.svelte +++ b/src/chat/components/channels/TextChannelGroup.svelte @@ -115,10 +115,13 @@ } .channel-item-hash { - font-size: 1.2rem; + font-size: 1rem; color: var(--text-muted); margin-right: 4px; width: 20px; - text-align: center; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; } diff --git a/src/chat/components/channels/VoiceChannelGroup.svelte b/src/chat/components/channels/VoiceChannelGroup.svelte index bce898d..272642a 100644 --- a/src/chat/components/channels/VoiceChannelGroup.svelte +++ b/src/chat/components/channels/VoiceChannelGroup.svelte @@ -296,11 +296,14 @@ } .channel-item-hash { - font-size: 1.2rem; + font-size: 1rem; color: var(--text-muted); margin-right: 4px; width: 20px; - text-align: center; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; } /* Voice Member List Styles */ From ccea3cc9a5177af938d714525e82741a2377fca5 Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Fri, 17 Apr 2026 03:42:51 -0400 Subject: [PATCH 06/11] fix imageviewer --- src/chat/components/ImageViewer.svelte | 120 +++++++++++-------------- src/chat/services/chat.svelte.ts | 2 + 2 files changed, 56 insertions(+), 66 deletions(-) diff --git a/src/chat/components/ImageViewer.svelte b/src/chat/components/ImageViewer.svelte index a6e717e..1c8b448 100644 --- a/src/chat/components/ImageViewer.svelte +++ b/src/chat/components/ImageViewer.svelte @@ -1,14 +1,19 @@ - -{#if $db.identity} - {@render children()} -{:else} - -{/if} diff --git a/src/InnerSpacetimeDBProvider.svelte b/src/InnerSpacetimeDBProvider.svelte new file mode 100644 index 0000000..e1aa5c1 --- /dev/null +++ b/src/InnerSpacetimeDBProvider.svelte @@ -0,0 +1,166 @@ + + +{#if $db.identity} + {@render children()} +{:else} + +{/if} + + diff --git a/src/InnerSpacetimeProvider.svelte b/src/InnerSpacetimeProvider.svelte deleted file mode 100644 index baf9e31..0000000 --- a/src/InnerSpacetimeProvider.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - -{#if builder} - - {@render children()} - -{:else} - -{/if} diff --git a/src/SpacetimeProvider.svelte b/src/SpacetimeProvider.svelte index 372fce3..f8fec2d 100644 --- a/src/SpacetimeProvider.svelte +++ b/src/SpacetimeProvider.svelte @@ -1,23 +1,29 @@ -{#key reconnectKey} - - {@render children()} - -{/key} + + {@render children()} + diff --git a/src/auth/AuthGate.svelte b/src/auth/AuthGate.svelte index 9dde66b..665b7e9 100644 --- a/src/auth/AuthGate.svelte +++ b/src/auth/AuthGate.svelte @@ -53,12 +53,15 @@ } }); - // Split combined connection if it changes (Only when on login screen) + // 1. One-way Sync: Parse combined connection into fields $effect(() => { + // Only parse if we are NOT currently connecting and there is something to parse if (!userWantsToConnect && combinedConnection.includes(":")) { const lastColon = combinedConnection.lastIndexOf(":"); const host = combinedConnection.substring(0, lastColon); const db = combinedConnection.substring(lastColon + 1); + + // Update internal state only if it actually changed to prevent loops if (host && db && (host !== stdbHost || db !== stdbDbName)) { untrack(() => { stdbHost = host; @@ -68,24 +71,7 @@ } }); - // Update combined connection if individual fields change (e.g. on mount) - $effect(() => { - const hostPart = stdbHost.replace(/^(https?|wss?):\/\//, ""); - const expected = `${hostPart}:${stdbDbName}`; - if (combinedConnection !== expected) { - combinedConnection = expected; - } - }); - - let hasStoredToken = $state(false); - - $effect(() => { - // Check for token when connection params change - if (stdbHost && stdbDbName) { - hasStoredToken = !!TokenStore.get(stdbHost, stdbDbName); - } - }); - + // 2. State persistence $effect(() => { if (stdbHost && localStorage.getItem(HOST_KEY) !== stdbHost) { localStorage.setItem(HOST_KEY, stdbHost); @@ -98,6 +84,15 @@ } }); + let hasStoredToken = $state(false); + + $effect(() => { + // Check for token when connection params change + if (stdbHost && stdbDbName) { + hasStoredToken = !!TokenStore.get(stdbHost, stdbDbName); + } + }); + const isBypassEnabled = import.meta.env.VITE_BYPASS_AUTH === "true" || new URLSearchParams(window.location.search).has("bypass_auth"); diff --git a/src/auth/auth.svelte.ts b/src/auth/auth.svelte.ts index 3953da1..d8eff79 100644 --- a/src/auth/auth.svelte.ts +++ b/src/auth/auth.svelte.ts @@ -10,8 +10,10 @@ export const oidcConfig: UserManagerSettings = { authority: getEnv("VITE_OIDC_AUTHORITY", "https://sso.adamlamers.com/application/o/truenas-zep/"), client_id: getEnv("VITE_OIDC_CLIENT_ID", "Omb5jIeYY2rJqkxK52qZgLRGejOBCK5Km2BcA5RO"), redirect_uri: window.location.origin, - scope: "openid profile email", + scope: "openid profile email offline_access", response_type: "code", + automaticSilentRenew: true, + loadUserInfo: true, }; class AuthStore { @@ -89,13 +91,6 @@ class AuthStore { try { const user = await this.#userManager.signinCallback(); this.#user = user; - - // Stage the id_token so SpacetimeDB can find it even if OIDC state is cleared - if (user.id_token) { - console.log("AuthStore: Staging OIDC id_token for SpacetimeDB handshake"); - localStorage.setItem("zep_oidc_staging_token", user.id_token); - } - window.history.replaceState({}, document.title, window.location.pathname); } catch (error) { console.error("Signin callback error:", error); @@ -118,15 +113,33 @@ class AuthStore { async logout() { this.#isLoading = true; try { - // Clear all potential SpacetimeDB tokens from local storage - const keysToRemove: string[] = []; + console.log("AuthStore: Initiating full session purge..."); + + // 1. Purge LocalStorage + const localKeysToRemove: string[] = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); - if (key && (key.includes("stdb_token:") || key.includes("auth_token") || key === "zep_oidc_staging_token")) { - keysToRemove.push(key); + if (key && (key.includes("stdb_token:") || key.includes("auth_token") || key.startsWith("oidc."))) { + localKeysToRemove.push(key); } } - keysToRemove.forEach((key) => localStorage.removeItem(key)); + localKeysToRemove.forEach((key) => { + console.log(`AuthStore: Removing LocalStorage key: ${key}`); + localStorage.removeItem(key); + }); + + // 2. Purge SessionStorage (where oidc-client-ts often hides state) + const sessionKeysToRemove: string[] = []; + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if (key && (key.startsWith("oidc.") || key.includes("auth_token"))) { + sessionKeysToRemove.push(key); + } + } + sessionKeysToRemove.forEach((key) => { + console.log(`AuthStore: Removing SessionStorage key: ${key}`); + sessionStorage.removeItem(key); + }); if (this.#user) { await this.#userManager.signoutRedirect(); diff --git a/src/chat/components/VideoGrid.svelte b/src/chat/components/VideoGrid.svelte index 7293e01..1dce1c2 100644 --- a/src/chat/components/VideoGrid.svelte +++ b/src/chat/components/VideoGrid.svelte @@ -8,138 +8,222 @@ const chat = getContext("chat"); const webrtc = getContext("webrtc"); - let focusedIdentity = $state(null); + // Higher reliability derivation + const participants = $derived.by(() => { + const channelId = chat.activeChannelId; + if (!channelId) return []; - const participants = $derived( - chat.userStates.filter((s) => s.channelId === webrtc.connectedChannelId), - ); + const results = chat.userStates.filter(s => s.channelId === channelId); + console.log(`[VideoGrid] Rendering ${results.length} participants for channel ${channelId}`); + return results; + }); - const localSharing = $derived(!!webrtc.localScreenStream); - const remoteSharerVs = $derived( - participants.find((s) => { - if (s.identity.isEqual(webrtc.identity!)) return false; - return s.isSharingScreen; - }), - ); + const sharer = $derived(participants.find(s => s.isSharingScreen)); + const localSharing = $derived(webrtc.isSharingScreen); - const defaultSharerIdentity = $derived( - localSharing ? webrtc.identity : remoteSharerVs?.identity, - ); + // Explicit check for local user existence in participants + const isMeInChannel = $derived(participants.some(p => p.identity.isEqual(chat.identity!))); - const primarySharerIdentity = $derived(focusedIdentity || defaultSharerIdentity); + const effectiveSharer = $derived(localSharing ? { identity: chat.identity } : sharer); - function isWatchingPeer(peerIdHex: string) { - const s = participants.find(p => p.identity.toHexString() === peerIdHex); - // I am watching the peer if my state's watching field points to them - return chat.currentVoiceState?.watching?.toHexString() === peerIdHex; - } - - function toggleWatch(peerIdentity: Identity) { - if (isWatchingPeer(peerIdentity.toHexString())) { + function toggleWatch(identity: Identity) { + if (chat.currentVoiceState?.watching?.isEqual(identity)) { webrtc.stopWatching(); } else { - webrtc.startWatching(peerIdentity); + webrtc.startWatching(identity); } } - - const heroVs = $derived( - participants.find((s) => - s.identity.isEqual(primarySharerIdentity || Identity.zero()), - ), - ); - const rowParticipants = $derived( - participants.filter( - (s) => !s.identity.isEqual(primarySharerIdentity || Identity.zero()), - ), - ); -
-
- {#if primarySharerIdentity} - {#if heroVs} -
(focusedIdentity = heroVs.identity)} - role="button" - tabindex="0" - onkeydown={(e) => e.key === "Enter" && (focusedIdentity = heroVs.identity)} - style="cursor: pointer;" - > +
+ {#if participants.length === 0} +
+ +

Synchronizing channel participants...

+
+ {:else} +
+ {#if effectiveSharer} +
toggleWatch(heroVs.identity)} + identity={effectiveSharer.identity!} + isLocal={effectiveSharer.identity!.isEqual(chat.identity!)} + stream={effectiveSharer.identity!.isEqual(chat.identity!) + ? webrtc.localMedia.screenStream + : webrtc.getRemoteStream(effectiveSharer.identity!.toHexString(), 'screen')} + isSharing={true} + isWatching={true} + onToggleWatch={() => toggleWatch(effectiveSharer.identity!)} isHero={true} users={chat.users} + isTalking={participants.find(p => p.identity.isEqual(effectiveSharer.identity!))?.isTalking} />
- {/if} - {#if rowParticipants.length > 0} -
- {#each rowParticipants as s (s.identity.toHexString())} -
(focusedIdentity = s.identity)} - role="button" - tabindex="0" - onkeydown={(e) => e.key === "Enter" && (focusedIdentity = s.identity)} - style="cursor: pointer;" - > + +
+ {#each participants.filter(s => !effectiveSharer.identity!.isEqual(s.identity)) as s (s.identity.toHexString())} +
toggleWatch(s.identity)} isHero={false} users={chat.users} + isTalking={s.isTalking} />
{/each} + + {#if !localSharing && !effectiveSharer.identity!.isEqual(chat.identity!)} +
+ {}} + isHero={false} + users={chat.users} + isTalking={webrtc.localMedia.isTalking} + /> +
+ {/if} +
+ {:else} +
+ {#each participants as s (s.identity.toHexString())} +
+ toggleWatch(s.identity)} + isHero={false} + users={chat.users} + isTalking={s.isTalking} + /> +
+ {/each} + + {#if !isMeInChannel} +
+ {}} + isHero={false} + users={chat.users} + isTalking={webrtc.localMedia.isTalking} + /> +
+ {/if}
{/if} - {:else} - {#each participants as s (s.identity.toHexString())} -
(focusedIdentity = s.identity)} - role="button" - tabindex="0" - onkeydown={(e) => e.key === "Enter" && (focusedIdentity = s.identity)} - style="cursor: pointer;" - > - toggleWatch(s.identity)} - isHero={false} - users={chat.users} - /> -
- {/each} - {/if} -
+
+ {/if}
+ + diff --git a/src/chat/components/VideoTile.svelte b/src/chat/components/VideoTile.svelte index 6b5edd6..11f434b 100644 --- a/src/chat/components/VideoTile.svelte +++ b/src/chat/components/VideoTile.svelte @@ -93,7 +93,8 @@ } } - const showStream = $derived((isLocal || isWatching) && !!stream); + const showStream = $derived(isLocal ? !!stream : (isWatching && isSharing && !!stream)); + const showWatchButton = $derived(!isLocal && isSharing && !isWatching);
u.identity.isEqual(identity))} size="large" /> - {#if !isLocal && isSharing} + {#if showWatchButton}
diff --git a/src/auth/auth.svelte.ts b/src/auth/auth.svelte.ts index d8eff79..6599435 100644 --- a/src/auth/auth.svelte.ts +++ b/src/auth/auth.svelte.ts @@ -20,6 +20,7 @@ class AuthStore { #userManager: UserManager; #user = $state(null); #isLoading = $state(true); + #isRefreshing = $state(false); #isProcessingCallback = false; constructor(settings: UserManagerSettings) { @@ -45,8 +46,33 @@ class AuthStore { this.#userManager .getUser() - .then((user) => { - this.#user = user; + .then(async (user) => { + if (user && !user.expired) { + console.log("[AuthStore] Found valid session in storage."); + this.#user = user; + } else if (user && user.expired) { + console.log("[AuthStore] Found expired session, attempting silent renew..."); + this.#isRefreshing = true; + try { + const renewedUser = await this.#userManager.signinSilent(); + this.#user = renewedUser; + console.log("[AuthStore] Silent renew successful on load."); + } catch (err) { + console.warn("[AuthStore] Silent renew failed on load, clearing expired session:", err); + this.#user = null; + // CRITICAL: If silent renew fails, remove the invalid user from storage to prevent re-auth loops on page reload. + await this.#userManager.removeUser(); + } finally { + this.#isRefreshing = false; + } + } else { + console.log("[AuthStore] No session found in storage."); + this.#user = null; + } + }) + .catch((err) => { + console.error("[AuthStore] Error retrieving user from storage:", err); + this.#user = null; }) .finally(() => { this.#isLoading = false; @@ -54,12 +80,34 @@ class AuthStore { } this.#userManager.events.addUserLoaded((user) => { + console.log(`[AuthStore] User loaded: ${user.profile.preferred_username || user.profile.sub} (ID Token present: ${!!user.id_token})`); this.#user = user; }); this.#userManager.events.addUserUnloaded(() => { + console.log("[AuthStore] User unloaded"); this.#user = null; }); + + this.#userManager.events.addAccessTokenExpiring(() => { + console.log("[AuthStore] Access token is expiring soon... triggering silent renew."); + }); + + this.#userManager.events.addAccessTokenExpired(() => { + console.warn("[AuthStore] Access token has expired!"); + }); + + this.#userManager.events.addUserSessionChanged(() => { + console.log("[AuthStore] User session changed at the provider."); + }); + + this.#userManager.events.addUserSignedOut(() => { + console.warn("[AuthStore] User signed out at the provider."); + }); + + this.#userManager.events.addSilentRenewError((err) => { + console.error("[AuthStore] Silent renew error:", err); + }); } get user() { @@ -70,6 +118,10 @@ class AuthStore { return this.#isLoading; } + get isRefreshing() { + return this.#isRefreshing; + } + get isAuthenticated() { return !!this.#user; } @@ -77,7 +129,11 @@ class AuthStore { async signinRedirect() { this.#isLoading = true; try { - await this.#userManager.signinRedirect(); + await this.#userManager.signinRedirect({ + extraQueryParams: { + prompt: "consent" + } + }); } catch (error) { console.error("Signin redirect error:", error); this.#isLoading = false; @@ -91,11 +147,15 @@ class AuthStore { try { const user = await this.#userManager.signinCallback(); this.#user = user; - window.history.replaceState({}, document.title, window.location.pathname); } catch (error) { console.error("Signin callback error:", error); } finally { + // Always clear the URL parameters to prevent infinite loops if the callback fails + if (window.location.search.includes("code=") && window.location.search.includes("state=")) { + window.history.replaceState({}, document.title, window.location.pathname); + } this.#isLoading = false; + this.#isProcessingCallback = false; } } @@ -110,6 +170,31 @@ class AuthStore { } } + /** + * Proactively forces a silent renewal of the OIDC session. + * Useful for recovering from 401 errors or preemptively refreshing tokens. + */ + async forceTokenRefresh() { + if (this.#isRefreshing) { + console.log("[AuthStore] Refresh already in progress, skipping redundant request."); + return true; + } + + console.log("[AuthStore] Forcing silent token refresh..."); + this.#isRefreshing = true; + try { + const user = await this.#userManager.signinSilent(); + this.#user = user; + console.log("[AuthStore] Silent refresh successful."); + return true; + } catch (error) { + console.error("[AuthStore] Silent refresh failed:", error); + return false; + } finally { + this.#isRefreshing = false; + } + } + async logout() { this.#isLoading = true; try { @@ -119,7 +204,12 @@ class AuthStore { const localKeysToRemove: string[] = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); - if (key && (key.includes("stdb_token:") || key.includes("auth_token") || key.startsWith("oidc."))) { + if (key && ( + key.includes("stdb_token:") || + key.includes("auth_token") || + key.startsWith("oidc.") || + key === "stdb_connection_data" + )) { localKeysToRemove.push(key); } } @@ -141,6 +231,10 @@ class AuthStore { sessionStorage.removeItem(key); }); + // 3. Clear OIDC internal state + await this.#userManager.removeUser(); + await this.#userManager.clearStaleState(); + if (this.#user) { await this.#userManager.signoutRedirect(); } else { diff --git a/src/config.ts b/src/config.ts index a088a98..ef90d46 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,7 +6,7 @@ import { connectionState } from "./connection.svelte"; export { HOST_KEY, DB_NAME_KEY, getEnv }; /** - * Normalizes the host URL for SpacetimeDB. + * Normalizes the host URL for SpacetimeDB URI construction. */ export const normalizeHost = (host: string) => { let normalized = host.trim().replace(/\/+$/, ""); @@ -18,44 +18,49 @@ export const normalizeHost = (host: string) => { export const TokenStore = { get: (host: string, dbName: string) => { - const key = `stdb_token:${normalizeHost(host)}:${dbName}`; - return localStorage.getItem(key); + try { + const dataStr = localStorage.getItem("stdb_connection_data"); + if (!dataStr) return null; + const data = JSON.parse(dataStr); + + const normalizedStoredHost = normalizeHost(data.host); + const normalizedCurrentHost = normalizeHost(host); + + if (normalizedStoredHost === normalizedCurrentHost && data.dbName === dbName) { + // Simple expiration check: if the token is older than 30 days, ignore it + const thirtyDaysInMs = 30 * 24 * 60 * 60 * 1000; + if (data.timestamp && Date.now() - data.timestamp > thirtyDaysInMs) { + console.warn("[TokenStore] Stored token is older than 30 days, treating as expired."); + return null; + } + console.log(`[TokenStore] Retrieved valid token for ${data.host}:${data.dbName} (age: ${data.timestamp ? Math.round((Date.now() - data.timestamp) / 1000 / 60) : 'unknown'} mins)`); + return data.token; + } + } catch (e) { + console.error("[TokenStore] Error parsing connection data:", e); + } + return null; }, set: (host: string, dbName: string, token: string) => { - const key = `stdb_token:${normalizeHost(host)}:${dbName}`; - console.log("TokenStore: Setting token for key:", key); - localStorage.setItem(key, token); + const normalizedHostStr = normalizeHost(host); + console.log(`[TokenStore] Persisting new token for ${normalizedHostStr}:${dbName}`); + localStorage.setItem("stdb_connection_data", JSON.stringify({ + host: normalizedHostStr, + dbName, + token, + timestamp: Date.now() + })); }, - clear: (host: string, dbName: string) => { - const key = `stdb_token:${normalizeHost(host)}:${dbName}`; - localStorage.removeItem(key); - }, - listStoredConnections: () => { - const connections: string[] = []; - const seen = new Set(); - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key?.startsWith("stdb_token:")) { - const parts = key.split(":"); - if (parts.length >= 4) { - const dbName = parts[parts.length - 1]; - const hostParts = parts.slice(2, parts.length - 1); - const host = hostParts.join(":").replace(/^\/\//, ""); - const connStr = `${host}:${dbName}`; - if (!seen.has(connStr)) { - connections.push(connStr); - seen.add(connStr); - } - } - } - } - return connections; + clear: () => { + console.log("[TokenStore] Clearing connection data."); + localStorage.removeItem("stdb_connection_data"); } }; export const getStdbHost = () => localStorage.getItem(HOST_KEY) || getEnv("VITE_SPACETIMEDB_HOST", "connect.zep.chat"); + export const getStdbDbName = () => localStorage.getItem(DB_NAME_KEY) || getEnv("VITE_SPACETIMEDB_DB_NAME", "zep"); @@ -72,18 +77,24 @@ export const stopActiveConnection = () => { let lastSyncedIdentity: string | null = null; -export const handleConnect = (conn: DbConnection, identity: any, token: string) => { +export const handleConnect = (conn: DbConnection, identity: any, token: string, isOIDC: boolean = false) => { const host = getStdbHost(); const dbName = getStdbDbName(); const identityHex = identity?.toHexString(); - console.log(`[Handshake] Connection established! Identity: ${identityHex}, Token received: ${!!token}`); + console.log(`[Handshake] Connection established! Identity: ${identityHex}, Token received: ${!!token}, OIDC: ${isOIDC}`); _connection = conn; - if (token) { - console.log(`[Handshake] Persisting SpacetimeDB token (len: ${token.length}) to local storage.`); + if (token && !isOIDC) { + console.log("[Handshake] Persisting Guest session token."); TokenStore.set(host, dbName, token); + } else if (isOIDC) { + // For OIDC users, we DON'T store the SpacetimeDB session token in localStorage. + // We rely entirely on the OIDC provider's tokens in sessionStorage. + // We clear any legacy tokens to ensure OIDC is the single source of truth. + console.log("[Handshake] OIDC session active, ensuring localStorage is clean of stale tokens."); + TokenStore.clear(); } connectionState.status = "connected"; @@ -101,20 +112,53 @@ export const handleConnect = (conn: DbConnection, identity: any, token: string) } }; -export const handleConnectError = (err: Error) => { +let lastRefreshAttempt = 0; + +export const handleConnectError = async (err: Error) => { const host = getStdbHost(); const dbName = getStdbDbName(); console.log("[Handshake] Error connecting to SpacetimeDB:", err); const errStr = err.message || ""; - if (errStr.includes("401") || errStr.toLowerCase().includes("unauthorized")) { - console.warn(`[Handshake] Unauthorized (401) for ${host}:${dbName}! Purging host session...`); - TokenStore.clear(host, dbName); + const isAuthError = + errStr.includes("401") || + errStr.toLowerCase().includes("unauthorized") || + errStr.toLowerCase().includes("timeout") || + errStr.toLowerCase().includes("identity") || + errStr.toLowerCase().includes("token"); - // Trigger full application logout (which now purges sessionStorage too) - import("./auth/auth.svelte").then(({ auth }) => { - auth.logout(); - }); + if (isAuthError) { + console.warn(`[Handshake] Auth or Timeout error for ${host}:${dbName}: ${errStr}`); + + const { auth } = await import("./auth/auth.svelte"); + + // Only clear TokenStore if we are NOT an OIDC user (Guest mode) + // or if we explicitly failed a refresh. + if (!auth.isAuthenticated) { + TokenStore.clear(); + } + + // If we are already refreshing or loading, don't trigger another one + if (auth.isLoading || auth.isRefreshing) { + console.log("[Handshake] Auth is already loading/refreshing, skipping redundant recovery."); + return; + } + + const now = Date.now(); + // Only attempt recovery if we haven't tried in the last 30 seconds to prevent loops + if (auth.isAuthenticated && (now - lastRefreshAttempt > 30000)) { + console.log("[Handshake] User is authenticated with OIDC, attempting silent refresh..."); + lastRefreshAttempt = now; + const success = await auth.forceTokenRefresh(); + if (success) { + console.log("[Handshake] Silent refresh triggered successfully. Connection should auto-rebuild."); + return; + } + } + + console.warn("[Handshake] Recovery failed, not applicable, or looping. Purging session..."); + // auth.logout() will reload the page and clear oidc state + auth.logout(); return; } connectionState.error = err.message; @@ -130,18 +174,20 @@ export const connectionBuilder = (oidcToken?: string) => { .withUri(host) .withDatabaseName(dbName); - const storedToken = TokenStore.get(host, dbName); - - // CRITICAL: OIDC Token MUST take precedence over stored guest tokens + // CRITICAL: If we have an OIDC token, use it exclusively. + // Stored tokens in TokenStore are ONLY for guest mode. if (oidcToken) { - console.log("[Builder] Using OIDC token for handshake (overriding stored token)"); + console.log("[Builder] Using OIDC token for handshake."); builder.withToken(oidcToken); - } else if (storedToken) { - console.log("[Builder] Using existing stored token for handshake"); - builder.withToken(storedToken); + } else { + const storedToken = TokenStore.get(host, dbName); + if (storedToken) { + console.log("[Builder] Using existing stored Guest token for handshake."); + builder.withToken(storedToken); + } } - builder.onConnect((conn, identity, token) => handleConnect(conn as any, identity, token)); + builder.onConnect((conn, identity, token) => handleConnect(conn as any, identity, token, !!oidcToken)); builder.onConnectError((_ctx, err) => handleConnectError(err)); return builder; From 705baceee5d814753e7f5e5d13c0ede3ca58761b Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Mon, 20 Apr 2026 21:08:08 -0400 Subject: [PATCH 09/11] fix embed container --- src/chat/components/MessageItem.svelte | 8 +++++-- src/chat/components/RichText.svelte | 33 ++++++++++++++------------ 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/chat/components/MessageItem.svelte b/src/chat/components/MessageItem.svelte index 2e52e51..97d1038 100644 --- a/src/chat/components/MessageItem.svelte +++ b/src/chat/components/MessageItem.svelte @@ -480,7 +480,7 @@ src={imageUrl} alt="Uploaded" class="message-image" - style="cursor: pointer; max-width: {isThread ? '200px' : '300px'}; max-height: {isThread ? '200px' : '300px'}; border-radius: 4px;" + style="cursor: pointer; max-width: {isThread ? '300px' : '400px'}; max-height: 400px; width: auto; height: auto; border-radius: 4px; object-fit: contain;" onload={handleImageLoad} />
@@ -782,7 +782,9 @@ border-left: 4px solid var(--brand); background-color: var(--background-secondary); border-radius: 4px; - max-width: 520px; + width: fit-content; + max-width: 100%; + min-width: 150px; overflow: hidden; } @@ -813,6 +815,8 @@ .embed-content-body { padding: 8px 12px 12px 12px; + width: 100%; + box-sizing: border-box; } .reaction-badge { diff --git a/src/chat/components/RichText.svelte b/src/chat/components/RichText.svelte index ec09696..b7fe174 100644 --- a/src/chat/components/RichText.svelte +++ b/src/chat/components/RichText.svelte @@ -216,88 +216,81 @@ {:else if embed.type === "youtube"}
{:else if embed.type === "youtube-shorts"}
{:else if embed.type === "twitch"}
{:else if embed.type === "kick"}
{:else if embed.type === "tiktok"}
{:else if embed.type === "instagram"}
{:else if embed.type === "facebook"}
{:else if embed.type === "link"} @@ -323,7 +316,9 @@ .message-image { cursor: pointer; max-width: 100%; - max-height: 400px; + max-height: 500px; + width: auto; + height: auto; display: block; border-radius: 4px; object-fit: contain; @@ -340,7 +335,9 @@ border-left: 4px solid var(--brand); background-color: var(--background-secondary); border-radius: 4px; + width: fit-content; max-width: 520px; + min-width: 150px; overflow: hidden; } @@ -371,6 +368,8 @@ .embed-content-body { padding: 8px 12px 12px 12px; + width: 100%; + box-sizing: border-box; } .media-embed-container { @@ -378,11 +377,15 @@ overflow: hidden; background-color: #000; line-height: 0; + width: fit-content; + max-width: 100%; + position: relative; } .media-iframe { max-width: 100%; border: none; + display: block; } .message-image-container { From 0b2b31d0bd58cfb2b0be855cfc33bdbea55ee9af Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Mon, 20 Apr 2026 21:13:52 -0400 Subject: [PATCH 10/11] fix dms --- src/chat/ChatContainer.svelte | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/chat/ChatContainer.svelte b/src/chat/ChatContainer.svelte index c5e032a..00c8603 100644 --- a/src/chat/ChatContainer.svelte +++ b/src/chat/ChatContainer.svelte @@ -78,8 +78,10 @@ }); function closeSidebars() { - showSidebar = false; - showMemberList = false; + if (isMobile.value) { + showSidebar = false; + showMemberList = false; + } } @@ -265,7 +267,7 @@ -