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..9dd4313 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); } } @@ -91,18 +90,33 @@ 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; + let mut is_anon = true; + if let Some(jwt) = ctx.sender_auth().jwt() { + let sub = jwt.subject(); + let issuer = jwt.issuer(); + // 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() }); + is_anon = issuer.contains("localhost"); + } + 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; + } + user.anonymous = is_anon; 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, - anonymous: true, + anonymous: is_anon, avatar_id: None, banner_id: None, biography: None, @@ -111,13 +125,27 @@ pub fn on_connect(ctx: &ReducerContext) { }); // Minimal auto-join - auto_join_community_server(&ctx.db, ctx.sender()); + join_server(ctx, 1); // System Welcome DM let system_identity = Identity::from_hex(SYSTEM_IDENTITY).unwrap(); let channel_id = internal_open_direct_message(&ctx.db, system_identity, ctx.sender()); let welcome_text = "Welcome to Zep! We're glad to have you here.\n\nZep is a decentralized, private, and fast chat service built on SpacetimeDB. You can join servers, create channels, and message friends directly—all with the security and performance of a modern relational backend."; - internal_send_message(&ctx.db, system_identity, channel_id, welcome_text.to_string(), ctx.timestamp); + internal_send_message( + &ctx.db, + system_identity, + channel_id, + welcome_text.to_string(), + ctx.timestamp, + None, + vec![], + false, + ); + } + + // High Performance: Sync all channel access for this user + for member in ctx.db.server_member().identity().filter(ctx.sender()) { + sync_server_access(&ctx.db, ctx.sender(), member.server_id); } sync_server_member_info(&ctx.db, ctx.sender()); @@ -147,12 +175,22 @@ 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() { - user.issuer = Some(jwt.issuer().to_string()); - user.subject = Some(jwt.subject().to_string()); - user.anonymous = false; + let sub = jwt.subject(); + let issuer = jwt.issuer(); + user.issuer = Some(issuer.to_string()); + user.subject = Some(sub.to_string()); + + // Flag as anonymous if issuer is localhost + user.anonymous = issuer.contains("localhost"); + + // 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() }); + } + + log::info!("update_auth_info: updated user with OIDC info (anon={})", user.anonymous); 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/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..f45ffc0 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,184 @@ 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); + // User's own avatar/banner + 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); - } - } + // Server avatars for servers I am a member of or are public + let my_server_ids: HashSet = db.server_member().identity().filter(identity).map(|m| m.server_id).collect(); + for s in db.server().name().filter(""..) { + if s.public || my_server_ids.contains(&s.id) { + if let Some(id) = s.avatar_id { results.insert(id); } } } - ids + // Avatars for members of servers I am in (and redundant check for server avatars stored in membership) + for server_id in my_server_ids { + for member in db.server_member().server_id().filter(server_id) { + if let Some(id) = member.avatar_id { results.insert(id); } + } + // Also check if any server I'm in has an avatar id that might not have been caught in the name filter + if let Some(s) = db.server().id().find(server_id) { + if let Some(id) = s.avatar_id { results.insert(id); } + } + } + + // Avatars for DM participants + for dm in db.direct_message().sender().filter(identity) { + if let Some(u) = db.user().identity().find(dm.recipient) { + if let Some(id) = u.avatar_id { results.insert(id); } + } + } + for dm in db.direct_message().recipient().filter(identity) { + if let Some(u) = db.user().identity().find(dm.sender) { + if let Some(id) = u.avatar_id { results.insert(id); } + } + } + + 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); + // User's own avatar/banner + 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); - } - } + // Server avatars for servers I am a member of or are public + let my_server_ids: HashSet = db.server_member().identity().filter(identity).map(|m| m.server_id).collect(); + for s in db.server().name().filter(""..) { + if s.public || my_server_ids.contains(&s.id) { + if let Some(id) = s.avatar_id { results.insert(id); } } } - ids -} + // Avatars for members of servers I am in (and redundant check for server avatars stored in membership) + for server_id in my_server_ids { + for member in db.server_member().server_id().filter(server_id) { + if let Some(id) = member.avatar_id { results.insert(id); } + } + // Also check if any server I'm in has an avatar id that might not have been caught in the name filter + if let Some(s) = db.server().id().find(server_id) { + if let Some(id) = s.avatar_id { results.insert(id); } + } + } + + // Avatars for DM participants + for dm in db.direct_message().sender().filter(identity) { + if let Some(u) = db.user().identity().find(dm.recipient) { + if let Some(id) = u.avatar_id { results.insert(id); } + } + } + for dm in db.direct_message().recipient().filter(identity) { + if let Some(u) = db.user().identity().find(dm.sender) { + if let Some(id) = u.avatar_id { results.insert(id); } + } + } -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 +220,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/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/InnerSpacetimeDBProvider.svelte b/src/InnerSpacetimeDBProvider.svelte new file mode 100644 index 0000000..e8cb9d9 --- /dev/null +++ b/src/InnerSpacetimeDBProvider.svelte @@ -0,0 +1,201 @@ + + +{#if $db.identity} + {@render children()} +{:else} + +{/if} + + diff --git a/src/InnerSpacetimeProvider.svelte b/src/InnerSpacetimeProvider.svelte deleted file mode 100644 index f836aa4..0000000 --- a/src/InnerSpacetimeProvider.svelte +++ /dev/null @@ -1,57 +0,0 @@ - - -{#if $db.identity} - {@render children()} -{:else} - -{/if} diff --git a/src/SpacetimeProvider.svelte b/src/SpacetimeProvider.svelte index 372fce3..19d77ef 100644 --- a/src/SpacetimeProvider.svelte +++ b/src/SpacetimeProvider.svelte @@ -1,23 +1,138 @@ -{#key reconnectKey} - - {@render children()} - -{/key} +{#if !builder || (auth.isLoading && !auth.user)} + +{:else} + {#key providerKey} + + {@render children()} + + {/key} +{/if} + + diff --git a/src/auth/AuthGate.svelte b/src/auth/AuthGate.svelte index d4e0b59..6b1411d 100644 --- a/src/auth/AuthGate.svelte +++ b/src/auth/AuthGate.svelte @@ -17,7 +17,6 @@ let stdbHost = $state(""); let stdbDbName = $state(""); - let storedConnections = $state([]); let combinedConnection = $state(""); let userWantsToConnect = $state(false); @@ -26,25 +25,41 @@ stdbHost = getStdbHost(); stdbDbName = getStdbDbName(); - storedConnections = TokenStore.listStoredConnections(); combinedConnection = `${stdbHost.replace(/^(https?|wss?):\/\//, "")}:${stdbDbName}`; const isChanging = localStorage.getItem("zep_changing_server") === "true"; + if (isChanging) { userWantsToConnect = false; } else if (TokenStore.get(stdbHost, stdbDbName)) { - // Auto-connect if we have a token and NOT changing server + // Auto-connect if we have a stored SpacetimeDB token. userWantsToConnect = true; } }); - // Split combined connection if it changes + // Handle auto-connect for OIDC users once the session is loaded/settled $effect(() => { - if (combinedConnection.includes(":")) { + if (auth.isAuthenticated && !auth.isLoading) { + const isChanging = localStorage.getItem("zep_changing_server") === "true"; + if (!isChanging && !userWantsToConnect) { + untrack(() => { + console.log("AuthGate: Auto-connecting active OIDC session."); + userWantsToConnect = true; + }); + } + } + }); + + // 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); - if (host && db) { + + // Update internal state only if it actually changed to prevent loops + if (host && db && (host !== stdbHost || db !== stdbDbName)) { untrack(() => { stdbHost = host; stdbDbName = db; @@ -53,20 +68,26 @@ } }); - // Update combined connection if individual fields change (e.g. on mount) + // 2. State persistence $effect(() => { - const hostPart = stdbHost.replace(/^(https?|wss?):\/\//, ""); - combinedConnection = `${hostPart}:${stdbDbName}`; - }); - - const hasStoredToken = $derived(!!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); + } + }); + + let hasStoredToken = $state(false); + + $effect(() => { + // Check for token when connection params change + if (stdbHost && stdbDbName) { + hasStoredToken = !!TokenStore.get(stdbHost, stdbDbName); + } }); const isBypassEnabled = @@ -176,16 +197,150 @@ id="stdb-connection" bind:value={combinedConnection} placeholder="connect.zep.chat:zep" - options={storedConnections} /> + +
+

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/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 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}