Merge pull request 'a bunch of changes' (#1) from feat/optimizations into master
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
-63
@@ -1,63 +0,0 @@
|
|||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Dependency directory
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Build output
|
|
||||||
dist/**
|
|
||||||
out/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# IDEs and editors
|
|
||||||
.idea
|
|
||||||
.vscode/
|
|
||||||
*.iml
|
|
||||||
*~
|
|
||||||
|
|
||||||
# Misc
|
|
||||||
.DS_Store
|
|
||||||
*.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# Vite
|
|
||||||
.vite/
|
|
||||||
|
|
||||||
# SpacetimeDB build output
|
|
||||||
spacetimedb/dist/
|
|
||||||
spacetimedb/target/
|
|
||||||
|
|
||||||
# SpacetimeDB generated module bindings
|
|
||||||
src/module_bindings/**
|
|
||||||
|
|
||||||
# Tauri & Rust
|
|
||||||
src-tauri/target/
|
|
||||||
src-tauri/gen/
|
|
||||||
# Rust's Cargo.lock is often committed in binary/app projects,
|
|
||||||
# but target/ is always ignored.
|
|
||||||
|
|
||||||
# Ignore this file
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
|
|
||||||
# wrangler files
|
|
||||||
.wrangler
|
|
||||||
.dev.vars*
|
|
||||||
!.dev.vars.example
|
|
||||||
!.env.example
|
|
||||||
+52
-14
@@ -60,14 +60,12 @@ pub fn init(ctx: &ReducerContext) {
|
|||||||
server_id: s.id,
|
server_id: s.id,
|
||||||
name: "general".to_string(),
|
name: "general".to_string(),
|
||||||
kind: ChannelKind::Text,
|
kind: ChannelKind::Text,
|
||||||
last_seq_id: 0,
|
|
||||||
});
|
});
|
||||||
let c2 = ctx.db.channel().insert(Channel {
|
let c2 = ctx.db.channel().insert(Channel {
|
||||||
id: 0,
|
id: 0,
|
||||||
server_id: s.id,
|
server_id: s.id,
|
||||||
name: "Voice General".to_string(),
|
name: "Voice General".to_string(),
|
||||||
kind: ChannelKind::Voice,
|
kind: ChannelKind::Voice,
|
||||||
last_seq_id: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut s = ctx.db.server().id().find(s.id).unwrap();
|
let mut s = ctx.db.server().id().find(s.id).unwrap();
|
||||||
@@ -75,15 +73,16 @@ pub fn init(ctx: &ReducerContext) {
|
|||||||
id: c1.id,
|
id: c1.id,
|
||||||
name: c1.name,
|
name: c1.name,
|
||||||
kind: c1.kind,
|
kind: c1.kind,
|
||||||
last_seq_id: 0,
|
|
||||||
});
|
});
|
||||||
s.channels.push(ChannelMetadata {
|
s.channels.push(ChannelMetadata {
|
||||||
id: c2.id,
|
id: c2.id,
|
||||||
name: c2.name,
|
name: c2.name,
|
||||||
kind: c2.kind,
|
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) {
|
pub fn on_connect(ctx: &ReducerContext) {
|
||||||
log::info!("on_connect START: identity={}", ctx.sender().to_hex());
|
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()) {
|
if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) {
|
||||||
user.online = true;
|
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);
|
ctx.db.user().identity().update(user);
|
||||||
} else {
|
} else {
|
||||||
ctx.db.user().insert(User {
|
ctx.db.user().insert(User {
|
||||||
identity: ctx.sender(),
|
identity: ctx.sender(),
|
||||||
name: None,
|
name: initial_name,
|
||||||
online: true,
|
online: true,
|
||||||
issuer: None,
|
issuer: None,
|
||||||
subject: None,
|
subject: None,
|
||||||
anonymous: true,
|
anonymous: is_anon,
|
||||||
avatar_id: None,
|
avatar_id: None,
|
||||||
banner_id: None,
|
banner_id: None,
|
||||||
biography: None,
|
biography: None,
|
||||||
@@ -111,13 +125,27 @@ pub fn on_connect(ctx: &ReducerContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Minimal auto-join
|
// Minimal auto-join
|
||||||
auto_join_community_server(&ctx.db, ctx.sender());
|
join_server(ctx, 1);
|
||||||
|
|
||||||
// System Welcome DM
|
// System Welcome DM
|
||||||
let system_identity = Identity::from_hex(SYSTEM_IDENTITY).unwrap();
|
let system_identity = Identity::from_hex(SYSTEM_IDENTITY).unwrap();
|
||||||
let channel_id = internal_open_direct_message(&ctx.db, system_identity, ctx.sender());
|
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.";
|
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());
|
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());
|
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(mut user) = ctx.db.user().identity().find(ctx.sender()) {
|
||||||
if let Some(jwt) = ctx.sender_auth().jwt() {
|
if let Some(jwt) = ctx.sender_auth().jwt() {
|
||||||
user.issuer = Some(jwt.issuer().to_string());
|
let sub = jwt.subject();
|
||||||
user.subject = Some(jwt.subject().to_string());
|
let issuer = jwt.issuer();
|
||||||
user.anonymous = false;
|
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);
|
ctx.db.user().identity().update(user);
|
||||||
sync_server_member_info(&ctx.db, ctx.sender());
|
sync_server_member_info(&ctx.db, ctx.sender());
|
||||||
log::info!("update_auth_info: updated user with OIDC info");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+173
-870
File diff suppressed because it is too large
Load Diff
+51
-26
@@ -7,6 +7,7 @@ pub enum ChannelKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = user, public)]
|
#[spacetimedb::table(accessor = user, public)]
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
#[primary_key]
|
#[primary_key]
|
||||||
pub identity: Identity,
|
pub identity: Identity,
|
||||||
@@ -28,10 +29,10 @@ pub struct ChannelMetadata {
|
|||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub kind: ChannelKind,
|
pub kind: ChannelKind,
|
||||||
pub last_seq_id: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = server)]
|
#[spacetimedb::table(accessor = server)]
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
#[primary_key]
|
#[primary_key]
|
||||||
#[auto_inc]
|
#[auto_inc]
|
||||||
@@ -45,6 +46,7 @@ pub struct Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = server_member)]
|
#[spacetimedb::table(accessor = server_member)]
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct ServerMember {
|
pub struct ServerMember {
|
||||||
#[primary_key]
|
#[primary_key]
|
||||||
#[auto_inc]
|
#[auto_inc]
|
||||||
@@ -58,7 +60,28 @@ pub struct ServerMember {
|
|||||||
pub online: bool,
|
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)]
|
#[spacetimedb::table(accessor = channel)]
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Channel {
|
pub struct Channel {
|
||||||
#[primary_key]
|
#[primary_key]
|
||||||
#[auto_inc]
|
#[auto_inc]
|
||||||
@@ -67,7 +90,6 @@ pub struct Channel {
|
|||||||
pub server_id: u64, // 0 if no server (DM)
|
pub server_id: u64, // 0 if no server (DM)
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub kind: ChannelKind,
|
pub kind: ChannelKind,
|
||||||
pub last_seq_id: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = direct_message)]
|
#[spacetimedb::table(accessor = direct_message)]
|
||||||
@@ -116,6 +138,7 @@ pub enum MediaType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = webrtc_signal)]
|
#[spacetimedb::table(accessor = webrtc_signal)]
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct WebRTCSignal {
|
pub struct WebRTCSignal {
|
||||||
#[primary_key]
|
#[primary_key]
|
||||||
#[auto_inc]
|
#[auto_inc]
|
||||||
@@ -132,6 +155,7 @@ pub struct WebRTCSignal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = channel_subscription)]
|
#[spacetimedb::table(accessor = channel_subscription)]
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct ChannelSubscription {
|
pub struct ChannelSubscription {
|
||||||
#[primary_key]
|
#[primary_key]
|
||||||
pub identity: Identity,
|
pub identity: Identity,
|
||||||
@@ -142,6 +166,7 @@ pub struct ChannelSubscription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = thread, public)]
|
#[spacetimedb::table(accessor = thread, public)]
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Thread {
|
pub struct Thread {
|
||||||
#[primary_key]
|
#[primary_key]
|
||||||
#[auto_inc]
|
#[auto_inc]
|
||||||
@@ -173,6 +198,8 @@ pub struct Message {
|
|||||||
#[index(btree)]
|
#[index(btree)]
|
||||||
pub channel_id: u64,
|
pub channel_id: u64,
|
||||||
#[index(btree)]
|
#[index(btree)]
|
||||||
|
pub server_id: u64,
|
||||||
|
#[index(btree)]
|
||||||
pub thread_id: Option<u64>,
|
pub thread_id: Option<u64>,
|
||||||
pub reactions: Vec<Reaction>,
|
pub reactions: Vec<Reaction>,
|
||||||
pub image_ids: Vec<u64>,
|
pub image_ids: Vec<u64>,
|
||||||
@@ -180,10 +207,12 @@ pub struct Message {
|
|||||||
pub thread_reply_count: u32,
|
pub thread_reply_count: u32,
|
||||||
pub edited: bool,
|
pub edited: bool,
|
||||||
pub is_encrypted: bool,
|
pub is_encrypted: bool,
|
||||||
|
#[index(btree)]
|
||||||
pub seq_id: u64,
|
pub seq_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = custom_emoji, public)]
|
#[spacetimedb::table(accessor = custom_emoji, public)]
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct CustomEmoji {
|
pub struct CustomEmoji {
|
||||||
#[primary_key]
|
#[primary_key]
|
||||||
#[auto_inc]
|
#[auto_inc]
|
||||||
@@ -196,16 +225,32 @@ pub struct CustomEmoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = image)]
|
#[spacetimedb::table(accessor = image)]
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Image {
|
pub struct Image {
|
||||||
#[primary_key]
|
#[primary_key]
|
||||||
#[auto_inc]
|
#[auto_inc]
|
||||||
#[index(btree)]
|
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub data: Vec<u8>,
|
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[spacetimedb::table(accessor = image_data)]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ImageData {
|
||||||
|
#[primary_key]
|
||||||
|
pub image_id: u64,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[spacetimedb::table(accessor = image_blob_request)]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ImageBlobRequest {
|
||||||
|
#[primary_key]
|
||||||
|
pub identity: Identity,
|
||||||
|
#[index(btree)]
|
||||||
|
pub image_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = typing_activity, public)]
|
#[spacetimedb::table(accessor = typing_activity, public)]
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct TypingActivity {
|
pub struct TypingActivity {
|
||||||
@@ -217,29 +262,8 @@ pub struct TypingActivity {
|
|||||||
pub typing: bool,
|
pub typing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = recent_message)]
|
|
||||||
pub struct RecentMessage {
|
|
||||||
#[primary_key]
|
|
||||||
pub id: u64, // This is the message_id
|
|
||||||
#[index(btree)]
|
|
||||||
pub server_id: u64, // 0 if DM
|
|
||||||
#[index(btree)]
|
|
||||||
pub channel_id: u64,
|
|
||||||
pub sender: Identity,
|
|
||||||
pub text: String,
|
|
||||||
pub thread_id: Option<u64>,
|
|
||||||
pub sent: Timestamp,
|
|
||||||
pub seq_id: u64,
|
|
||||||
pub reactions: Vec<Reaction>,
|
|
||||||
pub image_ids: Vec<u64>,
|
|
||||||
pub thread_name: Option<String>,
|
|
||||||
pub thread_reply_count: u32,
|
|
||||||
pub edited: bool,
|
|
||||||
pub is_encrypted: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = system_configuration, public)]
|
#[spacetimedb::table(accessor = system_configuration, public)]
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct SystemConfiguration {
|
pub struct SystemConfiguration {
|
||||||
#[primary_key]
|
#[primary_key]
|
||||||
pub key: String,
|
pub key: String,
|
||||||
@@ -247,6 +271,7 @@ pub struct SystemConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = upload_status, public)]
|
#[spacetimedb::table(accessor = upload_status, public)]
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct UploadStatus {
|
pub struct UploadStatus {
|
||||||
#[primary_key]
|
#[primary_key]
|
||||||
pub client_id: String,
|
pub client_id: String,
|
||||||
|
|||||||
+177
-328
@@ -3,27 +3,26 @@ use spacetimedb::{Identity, Local, LocalReadOnly, Table};
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
pub fn validate_name(name: &str) -> Result<(), String> {
|
pub fn validate_name(name: &str) -> Result<(), String> {
|
||||||
if name.trim().is_empty() {
|
let trimmed = name.trim();
|
||||||
return Err("Names must not be empty".to_string());
|
if trimmed.is_empty() {
|
||||||
|
return Err("Name cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
if trimmed.len() > 32 {
|
||||||
|
return Err("Name too long".to_string());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_message_length(db: &Local, text: &str) -> Result<(), String> {
|
pub fn validate_message_length(db: &Local, text: &str) -> Result<(), String> {
|
||||||
let max_length_conf = db
|
let limit = db
|
||||||
.system_configuration()
|
.system_configuration()
|
||||||
.key()
|
.key()
|
||||||
.find("max_message_length".to_string());
|
.find("max_message_length".to_string())
|
||||||
let max_length = max_length_conf
|
|
||||||
.and_then(|c| c.value.parse::<usize>().ok())
|
.and_then(|c| c.value.parse::<usize>().ok())
|
||||||
.unwrap_or(262144);
|
.unwrap_or(2000);
|
||||||
|
|
||||||
if text.len() > max_length {
|
if text.len() > limit {
|
||||||
return Err(format!(
|
return Err(format!("Message exceeds {} characters", limit));
|
||||||
"Message exceeds maximum length of {} bytes ({}KB).",
|
|
||||||
max_length,
|
|
||||||
max_length / 1024
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -36,375 +35,184 @@ pub fn get_recent_message_limit(db: &Local) -> u64 {
|
|||||||
.unwrap_or(50)
|
.unwrap_or(50)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_recent_message_limit_read_only(db: &LocalReadOnly) -> u64 {
|
||||||
|
db.system_configuration()
|
||||||
|
.key()
|
||||||
|
.find("recent_message_limit".to_string())
|
||||||
|
.and_then(|c| c.value.parse::<u64>().ok())
|
||||||
|
.unwrap_or(50)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_next_seq_id(db: &Local, channel_id: u64) -> u64 {
|
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");
|
if let Some(mut state) = db.channel_internal_state().channel_id().find(channel_id) {
|
||||||
let next_seq_id = channel.last_seq_id + 1;
|
state.last_seq_id += 1;
|
||||||
channel.last_seq_id = next_seq_id;
|
let new_id = state.last_seq_id;
|
||||||
db.channel().id().update(channel);
|
db.channel_internal_state().channel_id().update(state);
|
||||||
next_seq_id
|
new_id
|
||||||
|
} else {
|
||||||
|
db.channel_internal_state().insert(ChannelInternalState {
|
||||||
|
channel_id,
|
||||||
|
last_seq_id: 1,
|
||||||
|
});
|
||||||
|
1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Simplified: uses UserChannelAccess table directly
|
||||||
pub fn get_visible_message_ids(db: &Local, identity: Identity) -> HashMap<u64, u64> {
|
pub fn get_visible_message_ids(db: &Local, identity: Identity) -> HashSet<u64> {
|
||||||
let mut result = HashMap::new();
|
db.user_channel_access().identity().filter(identity).map(|a| a.channel_id).collect()
|
||||||
|
|
||||||
// 1. Scrollback Path
|
|
||||||
if let Some(sub) = db.channel_subscription().identity().find(identity) {
|
|
||||||
for msg in db
|
|
||||||
.message()
|
|
||||||
.channel_id()
|
|
||||||
.filter(sub.channel_id)
|
|
||||||
{
|
|
||||||
if msg.seq_id >= sub.earliest_seq_id {
|
|
||||||
result.insert(msg.id, msg.seq_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fast Path: Recent Messages
|
|
||||||
let my_server_ids: Vec<u64> = db
|
|
||||||
.server_member()
|
|
||||||
.identity()
|
|
||||||
.filter(identity)
|
|
||||||
.map(|m| m.server_id)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for server_id in my_server_ids {
|
|
||||||
for rm in db.recent_message().server_id().filter(server_id) {
|
|
||||||
result.entry(rm.id).or_insert(rm.seq_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. DM Fast Path
|
|
||||||
let my_dms: Vec<_> = db
|
|
||||||
.direct_message()
|
|
||||||
.sender()
|
|
||||||
.filter(identity)
|
|
||||||
.filter(|dm| dm.is_open_sender)
|
|
||||||
.chain(
|
|
||||||
db.direct_message()
|
|
||||||
.recipient()
|
|
||||||
.filter(identity)
|
|
||||||
.filter(|dm| dm.is_open_recipient),
|
|
||||||
)
|
|
||||||
.map(|dm| dm.channel_id)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for channel_id in my_dms {
|
|
||||||
for rm in db.recent_message().channel_id().filter(channel_id) {
|
|
||||||
result.entry(rm.id).or_insert(rm.seq_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_visible_message_ids_read_only(
|
pub fn get_visible_message_ids_read_only(db: &LocalReadOnly, identity: Identity) -> HashSet<u64> {
|
||||||
db: &LocalReadOnly,
|
db.user_channel_access().identity().filter(identity).map(|a| a.channel_id).collect()
|
||||||
identity: Identity,
|
|
||||||
) -> HashMap<u64, u64> {
|
|
||||||
let mut result = HashMap::new();
|
|
||||||
|
|
||||||
// 1. Scrollback Path (Selective)
|
|
||||||
if let Some(sub) = db.channel_subscription().identity().find(identity) {
|
|
||||||
for msg in db
|
|
||||||
.message()
|
|
||||||
.channel_id()
|
|
||||||
.filter(sub.channel_id)
|
|
||||||
{
|
|
||||||
if msg.seq_id >= sub.earliest_seq_id {
|
|
||||||
result.insert(msg.id, msg.seq_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fast Path: Recent Messages from my Servers
|
|
||||||
let my_server_ids: Vec<u64> = db
|
|
||||||
.server_member()
|
|
||||||
.identity()
|
|
||||||
.filter(identity)
|
|
||||||
.map(|m| m.server_id)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for server_id in my_server_ids {
|
|
||||||
for rm in db.recent_message().server_id().filter(server_id) {
|
|
||||||
// entry().or_insert is faster than double lookup
|
|
||||||
result.entry(rm.id).or_insert(rm.seq_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fast Path: Recent Messages from my Open DMs
|
|
||||||
let my_dms: Vec<_> = db
|
|
||||||
.direct_message()
|
|
||||||
.sender()
|
|
||||||
.filter(identity)
|
|
||||||
.filter(|dm| dm.is_open_sender)
|
|
||||||
.chain(
|
|
||||||
db.direct_message()
|
|
||||||
.recipient()
|
|
||||||
.filter(identity)
|
|
||||||
.filter(|dm| dm.is_open_recipient),
|
|
||||||
)
|
|
||||||
.map(|dm| dm.channel_id)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for channel_id in my_dms {
|
|
||||||
for rm in db.recent_message().channel_id().filter(channel_id) {
|
|
||||||
result.entry(rm.id).or_insert(rm.seq_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_visible_image_ids(db: &Local, identity: Identity) -> HashSet<u64> {
|
pub fn get_visible_image_ids(db: &Local, identity: Identity) -> HashSet<u64> {
|
||||||
let mut ids = HashSet::new();
|
let mut results = HashSet::new();
|
||||||
|
let accessible_channels = get_visible_message_ids(db, identity);
|
||||||
|
|
||||||
// 1. My Servers and their Members (Avatars/Banners)
|
for channel_id in accessible_channels {
|
||||||
let memberships: Vec<_> = db.server_member().identity().filter(identity).collect();
|
for msg in db.message().channel_id().filter(channel_id) {
|
||||||
for member in memberships {
|
for id in msg.image_ids {
|
||||||
if let Some(s) = db.server().id().find(member.server_id) {
|
results.insert(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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Custom Emojis (Global)
|
// User's own avatar/banner
|
||||||
for ce in db.custom_emoji().name().filter(""..) {
|
if let Some(user) = db.user().identity().find(identity) {
|
||||||
ids.insert(ce.id);
|
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)
|
// Server avatars for servers I am a member of or are public
|
||||||
if let Some(sub) = db.channel_subscription().identity().find(identity) {
|
let my_server_ids: HashSet<u64> = db.server_member().identity().filter(identity).map(|m| m.server_id).collect();
|
||||||
// From Recent Messages cache for this channel
|
for s in db.server().name().filter(""..) {
|
||||||
for rm in db.recent_message().channel_id().filter(sub.channel_id) {
|
if s.public || my_server_ids.contains(&s.id) {
|
||||||
for id in &rm.image_ids {
|
if let Some(id) = s.avatar_id { results.insert(id); }
|
||||||
ids.insert(*id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// From Scrollback Messages for this channel
|
|
||||||
for msg in db
|
|
||||||
.message()
|
|
||||||
.channel_id()
|
|
||||||
.filter(sub.channel_id)
|
|
||||||
{
|
|
||||||
if msg.seq_id >= sub.earliest_seq_id {
|
|
||||||
for id in &msg.image_ids {
|
|
||||||
ids.insert(*id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ids
|
// 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<u64> {
|
pub fn get_visible_image_ids_read_only(db: &LocalReadOnly, identity: Identity) -> HashSet<u64> {
|
||||||
let mut ids = HashSet::new();
|
let mut results = HashSet::new();
|
||||||
|
let accessible_channels = get_visible_message_ids_read_only(db, identity);
|
||||||
|
|
||||||
// 1. My Servers and their Members (Avatars/Banners)
|
for channel_id in accessible_channels {
|
||||||
let memberships: Vec<_> = db.server_member().identity().filter(identity).collect();
|
for msg in db.message().channel_id().filter(channel_id) {
|
||||||
for member in memberships {
|
for id in msg.image_ids {
|
||||||
if let Some(s) = db.server().id().find(member.server_id) {
|
results.insert(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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Custom Emojis (Global)
|
// User's own avatar/banner
|
||||||
for ce in db.custom_emoji().name().filter(""..) {
|
if let Some(user) = db.user().identity().find(identity) {
|
||||||
ids.insert(ce.id);
|
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)
|
// Server avatars for servers I am a member of or are public
|
||||||
if let Some(sub) = db.channel_subscription().identity().find(identity) {
|
let my_server_ids: HashSet<u64> = db.server_member().identity().filter(identity).map(|m| m.server_id).collect();
|
||||||
// From Recent Messages cache for this channel
|
for s in db.server().name().filter(""..) {
|
||||||
for rm in db.recent_message().channel_id().filter(sub.channel_id) {
|
if s.public || my_server_ids.contains(&s.id) {
|
||||||
for id in &rm.image_ids {
|
if let Some(id) = s.avatar_id { results.insert(id); }
|
||||||
ids.insert(*id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// From Scrollback Messages for this channel
|
|
||||||
for msg in db
|
|
||||||
.message()
|
|
||||||
.channel_id()
|
|
||||||
.filter(sub.channel_id)
|
|
||||||
{
|
|
||||||
if msg.seq_id >= sub.earliest_seq_id {
|
|
||||||
for id in &msg.image_ids {
|
|
||||||
ids.insert(*id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ids
|
// 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) {
|
results
|
||||||
for row in db
|
|
||||||
.webrtc_signal()
|
|
||||||
.sender()
|
|
||||||
.filter(identity)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
{
|
|
||||||
db.webrtc_signal().delete(row);
|
|
||||||
}
|
|
||||||
for row in db
|
|
||||||
.webrtc_signal()
|
|
||||||
.receiver()
|
|
||||||
.filter(identity)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
{
|
|
||||||
db.webrtc_signal().delete(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_user_presence(db: &Local, identity: Identity) {
|
|
||||||
if let Some(state) = db.user_state().identity().find(identity) {
|
|
||||||
db.user_state().delete(state);
|
|
||||||
}
|
|
||||||
clear_signaling_for_user(db, identity);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn auto_join_community_server(db: &Local, identity: Identity) {
|
|
||||||
let community_server = db.server().name().filter(&"Zep".to_string()).next();
|
|
||||||
if let Some(s) = community_server {
|
|
||||||
let user = db.user().identity().find(identity);
|
|
||||||
db.server_member().insert(ServerMember {
|
|
||||||
id: 0,
|
|
||||||
identity,
|
|
||||||
server_id: s.id,
|
|
||||||
name: user.as_ref().and_then(|u| u.name.clone()),
|
|
||||||
avatar_id: user.as_ref().and_then(|u| u.avatar_id),
|
|
||||||
online: user.as_ref().map(|u| u.online).unwrap_or(false),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn internal_open_direct_message(db: &Local, sender: Identity, recipient: Identity) -> u64 {
|
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)
|
||||||
let existing = db
|
.or_else(|| db.direct_message().sender().filter(recipient).find(|dm| dm.recipient == sender));
|
||||||
.direct_message()
|
|
||||||
.sender()
|
|
||||||
.filter(sender)
|
|
||||||
.find(|dm| dm.recipient == recipient)
|
|
||||||
.or_else(|| {
|
|
||||||
db.direct_message()
|
|
||||||
.recipient()
|
|
||||||
.filter(sender)
|
|
||||||
.find(|dm| dm.sender == recipient)
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(mut dm) = existing {
|
if let Some(mut dm) = existing {
|
||||||
if dm.sender == sender {
|
if dm.sender == sender { dm.is_open_sender = true; } else { dm.is_open_recipient = true; }
|
||||||
dm.is_open_sender = true;
|
|
||||||
} else {
|
|
||||||
dm.is_open_recipient = true;
|
|
||||||
}
|
|
||||||
db.direct_message().id().update(dm.clone());
|
db.direct_message().id().update(dm.clone());
|
||||||
dm.channel_id
|
dm.channel_id
|
||||||
} else {
|
} else {
|
||||||
// Create a new DM channel
|
let chan = db.channel().insert(Channel { id: 0, server_id: 0, name: "dm".to_string(), kind: ChannelKind::Text });
|
||||||
let chan = db.channel().insert(Channel {
|
|
||||||
id: 0,
|
|
||||||
server_id: 0,
|
|
||||||
name: "dm".to_string(),
|
|
||||||
kind: ChannelKind::Text,
|
|
||||||
last_seq_id: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
db.direct_message().insert(DirectMessage {
|
db.direct_message().insert(DirectMessage {
|
||||||
id: 0,
|
id: 0, channel_id: chan.id, sender, recipient, is_open_sender: true, is_open_recipient: true,
|
||||||
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
|
chan.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn internal_send_message(db: &Local, sender: Identity, channel_id: u64, text: String, timestamp: spacetimedb::Timestamp) {
|
pub fn internal_send_message(
|
||||||
|
db: &Local,
|
||||||
|
sender: Identity,
|
||||||
|
channel_id: u64,
|
||||||
|
text: String,
|
||||||
|
timestamp: spacetimedb::Timestamp,
|
||||||
|
thread_id: Option<u64>,
|
||||||
|
image_ids: Vec<u64>,
|
||||||
|
is_encrypted: bool,
|
||||||
|
) {
|
||||||
let seq_id = get_next_seq_id(db, channel_id);
|
let 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 {
|
db.message().insert(Message {
|
||||||
id: 0,
|
id: 0, sender, sent: timestamp, text, channel_id, server_id, thread_id,
|
||||||
sender,
|
reactions: Vec::new(), image_ids, thread_name: None, thread_reply_count: 0,
|
||||||
sent: timestamp,
|
edited: false, is_encrypted, seq_id,
|
||||||
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.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) {
|
pub fn sync_server_member_info(db: &Local, identity: Identity) {
|
||||||
if let Some(user) = db.user().identity().find(identity) {
|
if let Some(user) = db.user().identity().find(identity) {
|
||||||
let members: Vec<_> = db.server_member().identity().filter(identity).collect();
|
for mut member in db.server_member().identity().filter(identity) {
|
||||||
for mut member in members {
|
|
||||||
member.name = user.name.clone();
|
member.name = user.name.clone();
|
||||||
member.avatar_id = user.avatar_id;
|
member.avatar_id = user.avatar_id;
|
||||||
member.online = user.online;
|
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); }
|
||||||
|
}
|
||||||
|
|||||||
+86
-216
@@ -5,7 +5,6 @@ use spacetimedb::{Identity, Query, Table, Timestamp, ViewContext};
|
|||||||
#[derive(spacetimedb::SpacetimeType)]
|
#[derive(spacetimedb::SpacetimeType)]
|
||||||
pub struct VisibleImageRow {
|
pub struct VisibleImageRow {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub data: Vec<u8>,
|
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -32,30 +31,8 @@ pub fn visible_typing_activity(ctx: &ViewContext) -> Vec<TypingActivity> {
|
|||||||
let identity = ctx.sender();
|
let identity = ctx.sender();
|
||||||
let mut results = std::collections::HashMap::new();
|
let mut results = std::collections::HashMap::new();
|
||||||
|
|
||||||
// 1. Server channels
|
for access in ctx.db.user_channel_access().identity().filter(identity) {
|
||||||
for member in ctx.db.server_member().identity().filter(identity) {
|
for activity in ctx.db.typing_activity().channel_id().filter(access.channel_id) {
|
||||||
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) {
|
|
||||||
if activity.typing {
|
if activity.typing {
|
||||||
results.insert(activity.identity, activity.clone());
|
results.insert(activity.identity, activity.clone());
|
||||||
}
|
}
|
||||||
@@ -71,6 +48,7 @@ pub struct MyChannelSubscriptionRow {
|
|||||||
pub channel_id: u64,
|
pub channel_id: u64,
|
||||||
pub earliest_seq_id: u64,
|
pub earliest_seq_id: u64,
|
||||||
pub last_read_seq_id: u64,
|
pub last_read_seq_id: u64,
|
||||||
|
pub last_seq_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(spacetimedb::SpacetimeType)]
|
#[derive(spacetimedb::SpacetimeType)]
|
||||||
@@ -79,7 +57,6 @@ pub struct VisibleChannelRow {
|
|||||||
pub server_id: u64,
|
pub server_id: u64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub kind: ChannelKind,
|
pub kind: ChannelKind,
|
||||||
pub last_seq_id: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(spacetimedb::SpacetimeType)]
|
#[derive(spacetimedb::SpacetimeType)]
|
||||||
@@ -93,49 +70,20 @@ pub struct VisibleDirectMessageRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::view(accessor = visible_recent_activity, public)]
|
#[spacetimedb::view(accessor = visible_recent_activity, public)]
|
||||||
pub fn visible_recent_activity(ctx: &ViewContext) -> Vec<RecentMessage> {
|
pub fn visible_recent_activity(ctx: &ViewContext) -> Vec<Message> {
|
||||||
let identity = ctx.sender();
|
let identity = ctx.sender();
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
let mut seen_ids = std::collections::HashSet::new();
|
|
||||||
|
|
||||||
// 1. Servers I'm a member of
|
for access in ctx.db.user_channel_access().identity().filter(identity) {
|
||||||
let my_server_ids: Vec<u64> = ctx
|
let last_seq_id = ctx.db.channel_internal_state().channel_id().find(access.channel_id)
|
||||||
.db
|
.map(|s| s.last_seq_id).unwrap_or(0);
|
||||||
.server_member()
|
|
||||||
.identity()
|
let limit = get_recent_message_limit_read_only(&ctx.db);
|
||||||
.filter(identity)
|
let min_seq = if last_seq_id > limit { last_seq_id - (limit - 1) } else { 1 };
|
||||||
.map(|m| m.server_id)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for server_id in my_server_ids {
|
for msg in ctx.db.message().channel_id().filter(access.channel_id) {
|
||||||
for rm in ctx.db.recent_message().server_id().filter(server_id) {
|
if msg.seq_id >= min_seq {
|
||||||
if seen_ids.insert(rm.id) {
|
results.push(msg.clone());
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,23 +94,12 @@ pub fn visible_recent_activity(ctx: &ViewContext) -> Vec<RecentMessage> {
|
|||||||
#[spacetimedb::view(accessor = visible_servers, public)]
|
#[spacetimedb::view(accessor = visible_servers, public)]
|
||||||
pub fn visible_servers(ctx: &ViewContext) -> Vec<Server> {
|
pub fn visible_servers(ctx: &ViewContext) -> Vec<Server> {
|
||||||
let identity = ctx.sender();
|
let identity = ctx.sender();
|
||||||
let mut results = Vec::new();
|
let my_server_ids: std::collections::HashSet<u64> = ctx.db.server_member().identity().filter(identity).map(|m| m.server_id).collect();
|
||||||
|
|
||||||
// Servers I'm a member of
|
ctx.db.server().name().filter(""..)
|
||||||
let my_server_ids: std::collections::HashSet<u64> = ctx
|
.filter(|s: &Server| s.public || my_server_ids.contains(&s.id))
|
||||||
.db
|
.map(|s: Server| s.clone())
|
||||||
.server_member()
|
.collect()
|
||||||
.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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::view(accessor = visible_server_members, public)]
|
#[spacetimedb::view(accessor = visible_server_members, public)]
|
||||||
@@ -171,19 +108,10 @@ pub fn visible_server_members(ctx: &ViewContext) -> Vec<ServerMember> {
|
|||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
|
||||||
// 1. Find all server IDs I am in
|
for member in ctx.db.server_member().identity().filter(identity) {
|
||||||
let my_server_ids: std::collections::HashSet<u64> = ctx
|
for peer in ctx.db.server_member().server_id().filter(member.server_id) {
|
||||||
.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) {
|
|
||||||
if seen.insert(peer.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<ServerMember> {
|
|||||||
|
|
||||||
#[spacetimedb::view(accessor = visible_channels, public)]
|
#[spacetimedb::view(accessor = visible_channels, public)]
|
||||||
pub fn visible_channels(ctx: &ViewContext) -> Vec<VisibleChannelRow> {
|
pub fn visible_channels(ctx: &ViewContext) -> Vec<VisibleChannelRow> {
|
||||||
let mut results = Vec::new();
|
|
||||||
let identity = ctx.sender();
|
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) {
|
for member in ctx.db.server_member().identity().filter(identity) {
|
||||||
if let Some(s) = ctx.db.server().id().find(member.server_id) {
|
if let Some(s) = ctx.db.server().id().find(member.server_id) {
|
||||||
for chan_meta in s.channels {
|
for chan_meta in s.channels {
|
||||||
if let Some(chan) = ctx.db.channel().id().find(chan_meta.id) {
|
results.push(VisibleChannelRow {
|
||||||
results.push(VisibleChannelRow {
|
id: chan_meta.id, server_id: s.id, name: chan_meta.name.clone(), kind: chan_meta.kind,
|
||||||
id: chan.id,
|
});
|
||||||
server_id: s.id,
|
|
||||||
name: chan.name,
|
|
||||||
kind: chan.kind,
|
|
||||||
last_seq_id: chan.last_seq_id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DM channels
|
// 2. DM channels
|
||||||
for dm in ctx.db.direct_message().sender().filter(identity) {
|
for access in ctx.db.user_channel_access().identity().filter(identity) {
|
||||||
if let Some(chan) = ctx.db.channel().id().find(dm.channel_id) {
|
if let Some(chan) = ctx.db.channel().id().find(access.channel_id) {
|
||||||
results.push(VisibleChannelRow {
|
if chan.server_id == 0 {
|
||||||
id: chan.id,
|
results.push(VisibleChannelRow {
|
||||||
server_id: chan.server_id,
|
id: chan.id, server_id: 0, name: chan.name.clone(), kind: chan.kind,
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,8 +151,7 @@ pub fn visible_channels(ctx: &ViewContext) -> Vec<VisibleChannelRow> {
|
|||||||
#[spacetimedb::view(accessor = visible_direct_messages, public)]
|
#[spacetimedb::view(accessor = visible_direct_messages, public)]
|
||||||
pub fn visible_direct_messages(ctx: &ViewContext) -> impl Query<DirectMessage> {
|
pub fn visible_direct_messages(ctx: &ViewContext) -> impl Query<DirectMessage> {
|
||||||
let identity = ctx.sender();
|
let identity = ctx.sender();
|
||||||
ctx.from
|
ctx.from.direct_message()
|
||||||
.direct_message()
|
|
||||||
.r#where(move |dm| dm.sender.eq(identity).or(dm.recipient.eq(identity)))
|
.r#where(move |dm| dm.sender.eq(identity).or(dm.recipient.eq(identity)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,132 +162,94 @@ pub fn visible_images(ctx: &ViewContext) -> Vec<VisibleImageRow> {
|
|||||||
for id in image_ids {
|
for id in image_ids {
|
||||||
if let Some(img) = ctx.db.image().id().find(id) {
|
if let Some(img) = ctx.db.image().id().find(id) {
|
||||||
results.push(VisibleImageRow {
|
results.push(VisibleImageRow {
|
||||||
id: img.id,
|
id: img.id, mime_type: img.mime_type.clone(), name: img.name.clone(),
|
||||||
data: img.data.clone(),
|
|
||||||
mime_type: img.mime_type,
|
|
||||||
name: img.name,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[spacetimedb::view(accessor = visible_image_blobs, public)]
|
||||||
|
pub fn visible_image_blobs(ctx: &ViewContext) -> Vec<ImageData> {
|
||||||
|
let identity = ctx.sender();
|
||||||
|
if let Some(req) = ctx.db.image_blob_request().identity().find(identity) {
|
||||||
|
if let Some(data) = ctx.db.image_data().image_id().find(req.image_id) {
|
||||||
|
return vec![data.clone()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
#[spacetimedb::view(accessor = visible_user_states, public)]
|
#[spacetimedb::view(accessor = visible_user_states, public)]
|
||||||
pub fn visible_user_states(ctx: &ViewContext) -> Vec<UserState> {
|
pub fn visible_user_states(ctx: &ViewContext) -> Vec<UserState> {
|
||||||
let identity = ctx.sender();
|
let identity = ctx.sender();
|
||||||
let mut results = std::collections::HashMap::new();
|
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) {
|
if let Some(my_state) = ctx.db.user_state().identity().find(identity) {
|
||||||
results.insert(my_state.identity, my_state.clone());
|
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()
|
results.into_values().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::view(accessor = visible_webrtc_signals, public)]
|
#[spacetimedb::view(accessor = visible_webrtc_signals, public)]
|
||||||
pub fn visible_webrtc_signals(ctx: &ViewContext) -> Vec<WebRTCSignal> {
|
pub fn visible_webrtc_signals(ctx: &ViewContext) -> Vec<WebRTCSignal> {
|
||||||
let identity = ctx.sender();
|
let identity = ctx.sender();
|
||||||
let mut results = Vec::new();
|
ctx.db.webrtc_signal().sender().filter(identity)
|
||||||
for signal in ctx.db.webrtc_signal().sender().filter(identity) {
|
.chain(ctx.db.webrtc_signal().receiver().filter(identity))
|
||||||
results.push(signal);
|
.map(|s: WebRTCSignal| s.clone())
|
||||||
}
|
.collect()
|
||||||
for signal in ctx.db.webrtc_signal().receiver().filter(identity) {
|
|
||||||
results.push(signal);
|
|
||||||
}
|
|
||||||
results
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::view(accessor = visible_scrollback_messages, public)]
|
#[spacetimedb::view(accessor = visible_scrollback_messages, public)]
|
||||||
pub fn visible_scrollback_messages(ctx: &ViewContext) -> Vec<VisibleMessageRow> {
|
pub fn visible_scrollback_messages(ctx: &ViewContext) -> impl Query<Message> {
|
||||||
let mut results = Vec::new();
|
|
||||||
let identity = ctx.sender();
|
let identity = ctx.sender();
|
||||||
|
|
||||||
// Only for the active channel subscription
|
|
||||||
if let Some(sub) = ctx.db.channel_subscription().identity().find(identity) {
|
if let Some(sub) = ctx.db.channel_subscription().identity().find(identity) {
|
||||||
// Security: Ensure I have access to this channel
|
let cid = sub.channel_id;
|
||||||
let has_access = if let Some(chan) = ctx.db.channel().id().find(sub.channel_id) {
|
let min_seq = sub.earliest_seq_id;
|
||||||
if chan.server_id != 0 {
|
ctx.from.message().r#where(move |m| m.channel_id.eq(cid).and(m.seq_id.gte(min_seq)))
|
||||||
// Server channel: Check membership
|
} else {
|
||||||
ctx.db
|
ctx.from.message().r#where(|m| m.id.eq(0))
|
||||||
.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
|
|
||||||
};
|
|
||||||
|
|
||||||
if has_access {
|
#[spacetimedb::view(accessor = visible_scrollback_thread_messages, public)]
|
||||||
for msg in ctx
|
pub fn visible_scrollback_thread_messages(ctx: &ViewContext) -> Vec<VisibleMessageRow> {
|
||||||
.db
|
let identity = ctx.sender();
|
||||||
.message()
|
let mut results = Vec::new();
|
||||||
.channel_id()
|
|
||||||
.filter(sub.channel_id)
|
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.seq_id >= sub.earliest_seq_id {
|
if msg.thread_id.is_some() {
|
||||||
results.push(VisibleMessageRow {
|
results.push(VisibleMessageRow {
|
||||||
id: msg.id,
|
id: msg.id, sender: msg.sender, sent: msg.sent, text: msg.text.clone(),
|
||||||
sender: msg.sender,
|
channel_id: msg.channel_id, thread_id: msg.thread_id, seq_id: msg.seq_id,
|
||||||
sent: msg.sent,
|
reactions: msg.reactions.clone(), image_ids: msg.image_ids.clone(),
|
||||||
text: msg.text,
|
thread_name: msg.thread_name.clone(), thread_reply_count: msg.thread_reply_count,
|
||||||
channel_id: msg.channel_id,
|
edited: msg.edited, is_encrypted: msg.is_encrypted,
|
||||||
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
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::view(accessor = my_channel_subscriptions, public)]
|
#[spacetimedb::view(accessor = my_channel_subscriptions, public)]
|
||||||
pub fn my_channel_subscriptions(ctx: &ViewContext) -> Vec<MyChannelSubscriptionRow> {
|
pub fn my_channel_subscriptions(ctx: &ViewContext) -> Vec<MyChannelSubscriptionRow> {
|
||||||
if let Some(sub) = ctx.db.channel_subscription().identity().find(ctx.sender()) {
|
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 {
|
vec![MyChannelSubscriptionRow {
|
||||||
identity: sub.identity,
|
identity: sub.identity, channel_id: sub.channel_id,
|
||||||
channel_id: sub.channel_id,
|
earliest_seq_id: sub.earliest_seq_id, last_read_seq_id: sub.last_read_seq_id,
|
||||||
earliest_seq_id: sub.earliest_seq_id,
|
last_seq_id,
|
||||||
last_read_seq_id: sub.last_read_seq_id,
|
|
||||||
}]
|
}]
|
||||||
} else {
|
} else {
|
||||||
vec![]
|
vec![]
|
||||||
|
|||||||
+88
-1589
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createSpacetimeDBProvider } from "spacetimedb/svelte";
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { auth } from "./auth/auth.svelte";
|
||||||
|
import { connectionState } from "./connection.svelte";
|
||||||
|
import { handleConnect, handleConnectError } from "./config";
|
||||||
|
import type { ConnectionBuilder } from "spacetimedb";
|
||||||
|
|
||||||
|
let { builder, children, onCancel, host, dbName, oidcToken }: {
|
||||||
|
builder: ConnectionBuilder<any>,
|
||||||
|
children: any,
|
||||||
|
onCancel?: () => void,
|
||||||
|
host: string,
|
||||||
|
dbName: string,
|
||||||
|
oidcToken?: string
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// 1. Initialize the provider instance for this specific builder
|
||||||
|
const db = createSpacetimeDBProvider(builder);
|
||||||
|
|
||||||
|
// 1.1 Connection Timeout
|
||||||
|
// If we stay in "connecting" state for too long without an identity or error,
|
||||||
|
// we'll force a timeout error to trigger recovery/logout.
|
||||||
|
let connectionTimeout = $state<any>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!$db.identity && !$db.error) {
|
||||||
|
if (!connectionTimeout) {
|
||||||
|
connectionTimeout = setTimeout(() => {
|
||||||
|
console.warn("[Handshake] Connection attempt timed out after 10s.");
|
||||||
|
handleConnectError(new Error("Connection timeout: Server did not respond to handshake. This may be due to an expired token or network issues."));
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (connectionTimeout) {
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
connectionTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (connectionTimeout) {
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
connectionTimeout = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Handshake Synchronization
|
||||||
|
// Ensures session persistence happens when a valid identity and token are available
|
||||||
|
let lastPersistedToken = $state<string | undefined>(undefined);
|
||||||
|
let lastUsedOidcTokenForUpgrade = $state<string | undefined>(oidcToken);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const conn = $db.connection;
|
||||||
|
const isActive = $db.isActive;
|
||||||
|
const identity = $db.identity;
|
||||||
|
const token = $db.token;
|
||||||
|
|
||||||
|
if (isActive && identity && conn && token) {
|
||||||
|
// If the token from the server is different than the one we last saved,
|
||||||
|
// trigger persistence. This covers both initial connection and upgrades.
|
||||||
|
if (token !== lastPersistedToken) {
|
||||||
|
untrack(() => {
|
||||||
|
console.log("[Handshake] Identity and Token established, syncing persistence...");
|
||||||
|
handleConnect(conn as any, identity, token, !!oidcToken);
|
||||||
|
lastPersistedToken = token;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!isActive) {
|
||||||
|
lastPersistedToken = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Background Token Upgrades (In-place)
|
||||||
|
// If we receive a new OIDC token while already connected, upgrade the session in-place.
|
||||||
|
// This is crucial for zero-flicker background updates.
|
||||||
|
$effect(() => {
|
||||||
|
if (oidcToken && oidcToken !== lastUsedOidcTokenForUpgrade && $db.isActive && $db.connection) {
|
||||||
|
console.log("[Handshake] Background upgrade with new OIDC token (In-place)...");
|
||||||
|
untrack(() => {
|
||||||
|
lastUsedOidcTokenForUpgrade = oidcToken;
|
||||||
|
// Upgrade the existing connection with the new credentials.
|
||||||
|
// SpacetimeDB will emit a new token, which the effect above will catch.
|
||||||
|
($db.connection as any).withToken(oidcToken);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Error Monitoring
|
||||||
|
$effect(() => {
|
||||||
|
if ($db.error) {
|
||||||
|
untrack(() => handleConnectError(new Error($db.error!)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update global connection status for UI visibility
|
||||||
|
$effect(() => {
|
||||||
|
if ($db.isActive) {
|
||||||
|
connectionState.status = "connected";
|
||||||
|
} else {
|
||||||
|
connectionState.status = "connecting";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $db.identity}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<div class="login-screen">
|
||||||
|
<div class="login-card" style="text-align: center;">
|
||||||
|
{#if $db.error}
|
||||||
|
<i class="fas fa-exclamation-triangle" style="font-size: 3rem; color: var(--status-danger); margin-bottom: 20px;"></i>
|
||||||
|
<h1>Connection Failed</h1>
|
||||||
|
<p style="color: var(--status-danger); margin-top: 8px; margin-bottom: 24px;">{$db.error}</p>
|
||||||
|
{:else if auth.isLoading}
|
||||||
|
<i class="fas fa-id-card fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
|
||||||
|
<h1>Authenticating...</h1>
|
||||||
|
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Verifying your identity with the provider.</p>
|
||||||
|
{:else}
|
||||||
|
<i class="fas fa-circle-notch fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
|
||||||
|
<h1>Connecting to SpacetimeDB...</h1>
|
||||||
|
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Establishing a secure connection to the chat server.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div style="background-color: var(--background-tertiary); padding: 16px; border-radius: 8px; margin-bottom: 24px; text-align: left; font-size: 0.8rem; border: 1px solid var(--background-modifier-accent); display: flex; flex-direction: column; gap: 12px; width: 100%; box-sizing: border-box;">
|
||||||
|
<div class="connection-detail">
|
||||||
|
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Host</div>
|
||||||
|
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{host}</div>
|
||||||
|
</div>
|
||||||
|
<div class="connection-detail">
|
||||||
|
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Database</div>
|
||||||
|
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{dbName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
|
||||||
|
{#if $db.error}
|
||||||
|
<button
|
||||||
|
onclick={() => window.location.reload()}
|
||||||
|
class="btn-primary"
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
Retry Connection
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if onCancel}
|
||||||
|
<button
|
||||||
|
onclick={onCancel}
|
||||||
|
class="btn-secondary"
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
{ $db.error ? "Back to Login" : "Cancel" }
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createSpacetimeDBProvider } from "spacetimedb/svelte";
|
|
||||||
import { connectionBuilder, getStdbHost, getStdbDbName } from "./config";
|
|
||||||
import { auth } from "./auth/auth.svelte";
|
|
||||||
|
|
||||||
let { children, onCancel } = $props<{
|
|
||||||
children: any,
|
|
||||||
onCancel?: () => void
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const host = getStdbHost();
|
|
||||||
const dbName = getStdbDbName();
|
|
||||||
|
|
||||||
// Initialize SpacetimeDB provider
|
|
||||||
const db = createSpacetimeDBProvider(connectionBuilder(
|
|
||||||
auth.user?.id_token
|
|
||||||
));
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
console.log("InnerSpacetimeProvider: $db.isActive:", $db.isActive);
|
|
||||||
console.log("InnerSpacetimeProvider: $db.identity:", $db.identity?.toHexString());
|
|
||||||
console.log("InnerSpacetimeProvider: $db.token:", $db.token ? "present" : "absent");
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if $db.identity}
|
|
||||||
{@render children()}
|
|
||||||
{:else}
|
|
||||||
<div class="login-screen">
|
|
||||||
<div class="login-card" style="text-align: center;">
|
|
||||||
<i class="fas fa-circle-notch fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
|
|
||||||
<h1>Connecting to SpacetimeDB...</h1>
|
|
||||||
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Establishing a secure connection to the chat server.</p>
|
|
||||||
|
|
||||||
<div style="background-color: var(--background-tertiary); padding: 16px; border-radius: 8px; margin-bottom: 24px; text-align: left; font-size: 0.8rem; border: 1px solid var(--background-modifier-accent); display: flex; flex-direction: column; gap: 12px; width: 100%; box-sizing: border-box;">
|
|
||||||
<div class="connection-detail">
|
|
||||||
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Host</div>
|
|
||||||
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{host}</div>
|
|
||||||
</div>
|
|
||||||
<div class="connection-detail">
|
|
||||||
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Database</div>
|
|
||||||
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{dbName}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if onCancel}
|
|
||||||
<button
|
|
||||||
onclick={onCancel}
|
|
||||||
class="btn-secondary"
|
|
||||||
style="width: 100%;"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
+127
-12
@@ -1,23 +1,138 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { stopActiveConnection } from "./config";
|
import { auth } from "./auth/auth.svelte";
|
||||||
import InnerSpacetimeProvider from "./InnerSpacetimeProvider.svelte";
|
import { connectionState } from "./connection.svelte";
|
||||||
|
import { connectionBuilder, getStdbHost, getStdbDbName } from "./config";
|
||||||
|
import InnerSpacetimeDBProvider from "./InnerSpacetimeDBProvider.svelte";
|
||||||
|
|
||||||
let { children, onCancel } = $props<{
|
let { children, onCancel } = $props<{
|
||||||
children: any,
|
children: any,
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let reconnectKey = $state(0);
|
// 1. Connection Builder Lifecycle
|
||||||
|
// We MUST wait for OIDC to finish its loading/silent-renew phase
|
||||||
|
// before we construct the initial SpacetimeDB connection.
|
||||||
|
let builder = $state<any>(null);
|
||||||
|
let lastUsedOidcToken = $state<string | undefined>(undefined);
|
||||||
|
let providerKey = $state(0);
|
||||||
|
|
||||||
onDestroy(() => {
|
$effect(() => {
|
||||||
console.log("SpacetimeProvider: Destroying, stopping connection...");
|
// Hold off until OIDC is settled for the first time
|
||||||
stopActiveConnection();
|
if (auth.isLoading) return;
|
||||||
|
|
||||||
|
const currentToken = auth.user?.id_token;
|
||||||
|
|
||||||
|
// 1. Initial creation
|
||||||
|
if (!builder) {
|
||||||
|
console.log(`[SpacetimeProvider] Initializing connection builder. OIDC present: ${!!currentToken}`);
|
||||||
|
untrack(() => {
|
||||||
|
builder = connectionBuilder(currentToken);
|
||||||
|
lastUsedOidcToken = currentToken;
|
||||||
|
providerKey += 1;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Identity transition (Logged out -> Logged in)
|
||||||
|
// If we were a guest (or null) and now have a token, we SHOULD remount
|
||||||
|
// to ensure the OIDC credentials take over completely.
|
||||||
|
if (currentToken && !lastUsedOidcToken) {
|
||||||
|
console.log("[SpacetimeProvider] Transitioning from Guest/None to OIDC session. Remounting...");
|
||||||
|
untrack(() => {
|
||||||
|
builder = connectionBuilder(currentToken);
|
||||||
|
lastUsedOidcToken = currentToken;
|
||||||
|
providerKey += 1;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Background Refresh (Token -> New Token)
|
||||||
|
// If it's just a refresh, we DON'T remount. We let InnerSpacetimeDBProvider
|
||||||
|
// handle the in-place upgrade via withToken.
|
||||||
|
if (currentToken && currentToken !== lastUsedOidcToken) {
|
||||||
|
console.log("[SpacetimeProvider] Background token refresh detected. Upgrading in-place.");
|
||||||
|
untrack(() => {
|
||||||
|
lastUsedOidcToken = currentToken;
|
||||||
|
// Notice we DON'T increment providerKey here
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Logout (Token -> Null)
|
||||||
|
if (!currentToken && lastUsedOidcToken) {
|
||||||
|
console.log("[SpacetimeProvider] User logged out. Remounting for Guest mode.");
|
||||||
|
untrack(() => {
|
||||||
|
builder = connectionBuilder(undefined);
|
||||||
|
lastUsedOidcToken = undefined;
|
||||||
|
providerKey += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reactive labels for the loading screen
|
||||||
|
const host = getStdbHost();
|
||||||
|
const dbName = getStdbDbName();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#key reconnectKey}
|
{#if !builder || (auth.isLoading && !auth.user)}
|
||||||
<InnerSpacetimeProvider {onCancel}>
|
<div class="login-screen">
|
||||||
{@render children()}
|
<div class="login-card" style="text-align: center;">
|
||||||
</InnerSpacetimeProvider>
|
<i class="fas fa-id-card fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
|
||||||
{/key}
|
<h1>Authenticating...</h1>
|
||||||
|
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Synchronizing your session credentials.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#key providerKey}
|
||||||
|
<InnerSpacetimeDBProvider
|
||||||
|
{builder}
|
||||||
|
{onCancel}
|
||||||
|
{host}
|
||||||
|
{dbName}
|
||||||
|
oidcToken={auth.user?.id_token}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</InnerSpacetimeDBProvider>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+172
-17
@@ -17,7 +17,6 @@
|
|||||||
|
|
||||||
let stdbHost = $state("");
|
let stdbHost = $state("");
|
||||||
let stdbDbName = $state("");
|
let stdbDbName = $state("");
|
||||||
let storedConnections = $state<string[]>([]);
|
|
||||||
let combinedConnection = $state("");
|
let combinedConnection = $state("");
|
||||||
|
|
||||||
let userWantsToConnect = $state(false);
|
let userWantsToConnect = $state(false);
|
||||||
@@ -26,25 +25,41 @@
|
|||||||
stdbHost = getStdbHost();
|
stdbHost = getStdbHost();
|
||||||
stdbDbName = getStdbDbName();
|
stdbDbName = getStdbDbName();
|
||||||
|
|
||||||
storedConnections = TokenStore.listStoredConnections();
|
|
||||||
combinedConnection = `${stdbHost.replace(/^(https?|wss?):\/\//, "")}:${stdbDbName}`;
|
combinedConnection = `${stdbHost.replace(/^(https?|wss?):\/\//, "")}:${stdbDbName}`;
|
||||||
|
|
||||||
const isChanging = localStorage.getItem("zep_changing_server") === "true";
|
const isChanging = localStorage.getItem("zep_changing_server") === "true";
|
||||||
|
|
||||||
if (isChanging) {
|
if (isChanging) {
|
||||||
userWantsToConnect = false;
|
userWantsToConnect = false;
|
||||||
} else if (TokenStore.get(stdbHost, stdbDbName)) {
|
} 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;
|
userWantsToConnect = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Split combined connection if it changes
|
// Handle auto-connect for OIDC users once the session is loaded/settled
|
||||||
$effect(() => {
|
$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 lastColon = combinedConnection.lastIndexOf(":");
|
||||||
const host = combinedConnection.substring(0, lastColon);
|
const host = combinedConnection.substring(0, lastColon);
|
||||||
const db = combinedConnection.substring(lastColon + 1);
|
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(() => {
|
untrack(() => {
|
||||||
stdbHost = host;
|
stdbHost = host;
|
||||||
stdbDbName = db;
|
stdbDbName = db;
|
||||||
@@ -53,20 +68,26 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update combined connection if individual fields change (e.g. on mount)
|
// 2. State persistence
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const hostPart = stdbHost.replace(/^(https?|wss?):\/\//, "");
|
if (stdbHost && localStorage.getItem(HOST_KEY) !== stdbHost) {
|
||||||
combinedConnection = `${hostPart}:${stdbDbName}`;
|
localStorage.setItem(HOST_KEY, stdbHost);
|
||||||
});
|
}
|
||||||
|
|
||||||
const hasStoredToken = $derived(!!TokenStore.get(stdbHost, stdbDbName));
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (stdbHost) localStorage.setItem(HOST_KEY, stdbHost);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$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 =
|
const isBypassEnabled =
|
||||||
@@ -176,16 +197,150 @@
|
|||||||
id="stdb-connection"
|
id="stdb-connection"
|
||||||
bind:value={combinedConnection}
|
bind:value={combinedConnection}
|
||||||
placeholder="connect.zep.chat:zep"
|
placeholder="connect.zep.chat:zep"
|
||||||
options={storedConnections}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="specs-section">
|
||||||
|
<h3>Technical Specifications</h3>
|
||||||
|
<div class="specs-grid">
|
||||||
|
<div class="spec-tag" title="Reactive state management with Svelte 5 Runes">
|
||||||
|
<i class="fab fa-js-square"></i>
|
||||||
|
<span>Svelte 5</span>
|
||||||
|
</div>
|
||||||
|
<div class="spec-tag" title="High-performance relational database backend">
|
||||||
|
<i class="fas fa-database"></i>
|
||||||
|
<span>SpacetimeDB</span>
|
||||||
|
</div>
|
||||||
|
<div class="spec-tag" title="Secure, efficient logic compiled to WebAssembly">
|
||||||
|
<i class="fab fa-rust"></i>
|
||||||
|
<span>Rust / WASM</span>
|
||||||
|
</div>
|
||||||
|
<div class="spec-tag" title="Real-time peer-to-peer voice and screen sharing">
|
||||||
|
<i class="fas fa-network-wired"></i>
|
||||||
|
<span>WebRTC Mesh</span>
|
||||||
|
</div>
|
||||||
|
<div class="spec-tag" title="End-to-end encryption via OpenPGP">
|
||||||
|
<i class="fas fa-lock"></i>
|
||||||
|
<span>GPG E2EE</span>
|
||||||
|
</div>
|
||||||
|
<div class="spec-tag" title="Cross-platform desktop app foundation">
|
||||||
|
<i class="fas fa-desktop"></i>
|
||||||
|
<span>Tauri 2.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs-section {
|
||||||
|
margin-top: 32px;
|
||||||
|
width: 100%;
|
||||||
|
border-top: 1px solid var(--background-modifier-accent);
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs-section h3 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-tag {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--background-modifier-accent);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: 0.95rem; /* Bigger text */
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-tag i {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--brand);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-tag:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
border-color: var(--brand);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-tag:hover i {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.ios-switch {
|
.ios-switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
+141
-13
@@ -7,17 +7,21 @@ import { getEnv } from "../env";
|
|||||||
|
|
||||||
// OIDC Configuration - User should replace these with their own provider values
|
// OIDC Configuration - User should replace these with their own provider values
|
||||||
export const oidcConfig: UserManagerSettings = {
|
export const oidcConfig: UserManagerSettings = {
|
||||||
authority: getEnv("VITE_OIDC_AUTHORITY", "https://accounts.google.com"),
|
authority: getEnv("VITE_OIDC_AUTHORITY", "https://sso.adamlamers.com/application/o/truenas-zep/"),
|
||||||
client_id: getEnv("VITE_OIDC_CLIENT_ID", "REPLACE_ME"),
|
client_id: getEnv("VITE_OIDC_CLIENT_ID", "Omb5jIeYY2rJqkxK52qZgLRGejOBCK5Km2BcA5RO"),
|
||||||
redirect_uri: window.location.origin,
|
redirect_uri: window.location.origin,
|
||||||
scope: "openid profile email",
|
scope: "openid profile email offline_access",
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
|
automaticSilentRenew: true,
|
||||||
|
loadUserInfo: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
class AuthStore {
|
class AuthStore {
|
||||||
#userManager: UserManager;
|
#userManager: UserManager;
|
||||||
#user = $state<User | null | undefined>(null);
|
#user = $state<User | null | undefined>(null);
|
||||||
#isLoading = $state(true);
|
#isLoading = $state(true);
|
||||||
|
#isRefreshing = $state(false);
|
||||||
|
#isProcessingCallback = false;
|
||||||
|
|
||||||
constructor(settings: UserManagerSettings) {
|
constructor(settings: UserManagerSettings) {
|
||||||
this.#userManager = new UserManager(settings);
|
this.#userManager = new UserManager(settings);
|
||||||
@@ -27,12 +31,48 @@ class AuthStore {
|
|||||||
window.location.search.includes("code=") &&
|
window.location.search.includes("code=") &&
|
||||||
window.location.search.includes("state=")
|
window.location.search.includes("state=")
|
||||||
) {
|
) {
|
||||||
this.signinCallback();
|
if (!this.#isProcessingCallback) {
|
||||||
|
this.signinCallback();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Not in a callback, safe to clear any stale/redundant OIDC state keys
|
||||||
|
// that might have accumulated from partial attempts
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith("oidc.")) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.#userManager
|
this.#userManager
|
||||||
.getUser()
|
.getUser()
|
||||||
.then((user) => {
|
.then(async (user) => {
|
||||||
this.#user = user;
|
if (user && !user.expired) {
|
||||||
|
console.log("[AuthStore] Found valid session in storage.");
|
||||||
|
this.#user = user;
|
||||||
|
} else if (user && user.expired) {
|
||||||
|
console.log("[AuthStore] Found expired session, attempting silent renew...");
|
||||||
|
this.#isRefreshing = true;
|
||||||
|
try {
|
||||||
|
const renewedUser = await this.#userManager.signinSilent();
|
||||||
|
this.#user = renewedUser;
|
||||||
|
console.log("[AuthStore] Silent renew successful on load.");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[AuthStore] Silent renew failed on load, clearing expired session:", err);
|
||||||
|
this.#user = null;
|
||||||
|
// CRITICAL: If silent renew fails, remove the invalid user from storage to prevent re-auth loops on page reload.
|
||||||
|
await this.#userManager.removeUser();
|
||||||
|
} finally {
|
||||||
|
this.#isRefreshing = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("[AuthStore] No session found in storage.");
|
||||||
|
this.#user = null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[AuthStore] Error retrieving user from storage:", err);
|
||||||
|
this.#user = null;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.#isLoading = false;
|
this.#isLoading = false;
|
||||||
@@ -40,12 +80,34 @@ class AuthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.#userManager.events.addUserLoaded((user) => {
|
this.#userManager.events.addUserLoaded((user) => {
|
||||||
|
console.log(`[AuthStore] User loaded: ${user.profile.preferred_username || user.profile.sub} (ID Token present: ${!!user.id_token})`);
|
||||||
this.#user = user;
|
this.#user = user;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.#userManager.events.addUserUnloaded(() => {
|
this.#userManager.events.addUserUnloaded(() => {
|
||||||
|
console.log("[AuthStore] User unloaded");
|
||||||
this.#user = null;
|
this.#user = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.#userManager.events.addAccessTokenExpiring(() => {
|
||||||
|
console.log("[AuthStore] Access token is expiring soon... triggering silent renew.");
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#userManager.events.addAccessTokenExpired(() => {
|
||||||
|
console.warn("[AuthStore] Access token has expired!");
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#userManager.events.addUserSessionChanged(() => {
|
||||||
|
console.log("[AuthStore] User session changed at the provider.");
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#userManager.events.addUserSignedOut(() => {
|
||||||
|
console.warn("[AuthStore] User signed out at the provider.");
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#userManager.events.addSilentRenewError((err) => {
|
||||||
|
console.error("[AuthStore] Silent renew error:", err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get user() {
|
get user() {
|
||||||
@@ -56,6 +118,10 @@ class AuthStore {
|
|||||||
return this.#isLoading;
|
return this.#isLoading;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isRefreshing() {
|
||||||
|
return this.#isRefreshing;
|
||||||
|
}
|
||||||
|
|
||||||
get isAuthenticated() {
|
get isAuthenticated() {
|
||||||
return !!this.#user;
|
return !!this.#user;
|
||||||
}
|
}
|
||||||
@@ -63,7 +129,11 @@ class AuthStore {
|
|||||||
async signinRedirect() {
|
async signinRedirect() {
|
||||||
this.#isLoading = true;
|
this.#isLoading = true;
|
||||||
try {
|
try {
|
||||||
await this.#userManager.signinRedirect();
|
await this.#userManager.signinRedirect({
|
||||||
|
extraQueryParams: {
|
||||||
|
prompt: "consent"
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Signin redirect error:", error);
|
console.error("Signin redirect error:", error);
|
||||||
this.#isLoading = false;
|
this.#isLoading = false;
|
||||||
@@ -71,15 +141,21 @@ class AuthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async signinCallback() {
|
async signinCallback() {
|
||||||
|
if (this.#isProcessingCallback) return;
|
||||||
|
this.#isProcessingCallback = true;
|
||||||
this.#isLoading = true;
|
this.#isLoading = true;
|
||||||
try {
|
try {
|
||||||
const user = await this.#userManager.signinCallback();
|
const user = await this.#userManager.signinCallback();
|
||||||
this.#user = user;
|
this.#user = user;
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Signin callback error:", error);
|
console.error("Signin callback error:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
// Always clear the URL parameters to prevent infinite loops if the callback fails
|
||||||
|
if (window.location.search.includes("code=") && window.location.search.includes("state=")) {
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
}
|
||||||
this.#isLoading = false;
|
this.#isLoading = false;
|
||||||
|
this.#isProcessingCallback = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,18 +170,70 @@ class AuthStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proactively forces a silent renewal of the OIDC session.
|
||||||
|
* Useful for recovering from 401 errors or preemptively refreshing tokens.
|
||||||
|
*/
|
||||||
|
async forceTokenRefresh() {
|
||||||
|
if (this.#isRefreshing) {
|
||||||
|
console.log("[AuthStore] Refresh already in progress, skipping redundant request.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[AuthStore] Forcing silent token refresh...");
|
||||||
|
this.#isRefreshing = true;
|
||||||
|
try {
|
||||||
|
const user = await this.#userManager.signinSilent();
|
||||||
|
this.#user = user;
|
||||||
|
console.log("[AuthStore] Silent refresh successful.");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AuthStore] Silent refresh failed:", error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.#isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
this.#isLoading = true;
|
this.#isLoading = true;
|
||||||
try {
|
try {
|
||||||
// Clear all potential SpacetimeDB tokens from local storage
|
console.log("AuthStore: Initiating full session purge...");
|
||||||
const keysToRemove: string[] = [];
|
|
||||||
|
// 1. Purge LocalStorage
|
||||||
|
const localKeysToRemove: string[] = [];
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
const key = localStorage.key(i);
|
const key = localStorage.key(i);
|
||||||
if (key && key.includes("auth_token")) {
|
if (key && (
|
||||||
keysToRemove.push(key);
|
key.includes("stdb_token:") ||
|
||||||
|
key.includes("auth_token") ||
|
||||||
|
key.startsWith("oidc.") ||
|
||||||
|
key === "stdb_connection_data"
|
||||||
|
)) {
|
||||||
|
localKeysToRemove.push(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
localKeysToRemove.forEach((key) => {
|
||||||
|
console.log(`AuthStore: Removing LocalStorage key: ${key}`);
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Purge SessionStorage (where oidc-client-ts often hides state)
|
||||||
|
const sessionKeysToRemove: string[] = [];
|
||||||
|
for (let i = 0; i < sessionStorage.length; i++) {
|
||||||
|
const key = sessionStorage.key(i);
|
||||||
|
if (key && (key.startsWith("oidc.") || key.includes("auth_token"))) {
|
||||||
|
sessionKeysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessionKeysToRemove.forEach((key) => {
|
||||||
|
console.log(`AuthStore: Removing SessionStorage key: ${key}`);
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Clear OIDC internal state
|
||||||
|
await this.#userManager.removeUser();
|
||||||
|
await this.#userManager.clearStaleState();
|
||||||
|
|
||||||
if (this.#user) {
|
if (this.#user) {
|
||||||
await this.#userManager.signoutRedirect();
|
await this.#userManager.signoutRedirect();
|
||||||
|
|||||||
+314
-123
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useSpacetimeDB } from "spacetimedb/svelte";
|
import { useSpacetimeDB } from "spacetimedb/svelte";
|
||||||
import { setContext } from "svelte";
|
import { setContext, onMount, untrack } from "svelte";
|
||||||
import { ChatService } from "./services/chat.svelte";
|
import { ChatService } from "./services/chat.svelte";
|
||||||
import { WebRTCService } from "./services/webrtc/webrtc.svelte";
|
import { WebRTCService } from "./services/webrtc/webrtc.svelte";
|
||||||
import ServerList from "./components/ServerList.svelte";
|
import ServerList from "./components/ServerList.svelte";
|
||||||
@@ -24,7 +24,6 @@
|
|||||||
const spacetime = useSpacetimeDB();
|
const spacetime = useSpacetimeDB();
|
||||||
|
|
||||||
// identity is guaranteed to be non-null here because of the guard in SpacetimeProvider
|
// identity is guaranteed to be non-null here because of the guard in SpacetimeProvider
|
||||||
// but we should still handle it robustly
|
|
||||||
const chat = new ChatService($spacetime.identity!);
|
const chat = new ChatService($spacetime.identity!);
|
||||||
const webrtc = new WebRTCService($spacetime.identity, undefined);
|
const webrtc = new WebRTCService($spacetime.identity, undefined);
|
||||||
|
|
||||||
@@ -47,10 +46,48 @@
|
|||||||
|
|
||||||
let showSettings = $state(false);
|
let showSettings = $state(false);
|
||||||
let showMemberList = $state(true);
|
let showMemberList = $state(true);
|
||||||
|
let showSidebar = $state(true); // Toggle for mobile
|
||||||
|
|
||||||
|
const isMobile = $state({ value: false });
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth < 1024;
|
||||||
|
if (isMobile.value) {
|
||||||
|
showSidebar = false;
|
||||||
|
showMemberList = false;
|
||||||
|
} else {
|
||||||
|
showSidebar = true;
|
||||||
|
showMemberList = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
return () => window.removeEventListener('resize', checkMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-hide sidebar when switching channels on mobile
|
||||||
|
$effect(() => {
|
||||||
|
const _ = chat.activeChannelId;
|
||||||
|
if (isMobile.value) {
|
||||||
|
untrack(() => {
|
||||||
|
showSidebar = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeSidebars() {
|
||||||
|
if (isMobile.value) {
|
||||||
|
showSidebar = false;
|
||||||
|
showMemberList = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="chat-container">
|
<div class="chat-container">
|
||||||
<div class="left-sidebar-wrapper">
|
<!-- 1. Left Sidebar (Servers + Channels) -->
|
||||||
|
<aside class="left-sidebar-wrapper" class:visible={showSidebar}>
|
||||||
<div class="left-sidebar-top">
|
<div class="left-sidebar-top">
|
||||||
<ServerList onShowServerSettings={_onShowServerSettings} />
|
<ServerList onShowServerSettings={_onShowServerSettings} />
|
||||||
<ChannelList />
|
<ChannelList />
|
||||||
@@ -66,7 +103,7 @@
|
|||||||
{chat.connectedVoiceChannel.name} / {chat.connectedVoiceServer?.name || "Server"}
|
{chat.connectedVoiceChannel.name} / {chat.connectedVoiceServer?.name || "Server"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="voice-status-actions" style="display: flex; gap: 4px;">
|
<div class="voice-status-actions">
|
||||||
<button
|
<button
|
||||||
class="icon-btn {webrtc.isSharingScreen ? 'active danger' : ''}"
|
class="icon-btn {webrtc.isSharingScreen ? 'active danger' : ''}"
|
||||||
onclick={() => webrtc.isSharingScreen ? webrtc.stopScreenShare() : webrtc.startScreenShare()}
|
onclick={() => webrtc.isSharingScreen ? webrtc.stopScreenShare() : webrtc.startScreenShare()}
|
||||||
@@ -86,17 +123,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="user-info-bar">
|
<div class="user-info-bar">
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
<div
|
||||||
class="user-info-main"
|
class="user-info-main"
|
||||||
oncontextmenu={(e) => {
|
onclick={(e) => {
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (chat.currentUser) {
|
if (chat.currentUser) {
|
||||||
chat.userContextMenu = { x: e.clientX, y: e.clientY, user: chat.currentUser };
|
chat.userContextMenu = { x: e.clientX, y: e.clientY, user: chat.currentUser };
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style="cursor: pointer;"
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && (chat.userContextMenu = null)}
|
||||||
>
|
>
|
||||||
<Avatar user={chat.currentUser} isTalking={webrtc.localMedia.isTalking} />
|
<Avatar user={chat.currentUser} isTalking={webrtc.localMedia.isTalking} />
|
||||||
<div class="user-details">
|
<div class="user-details">
|
||||||
@@ -138,21 +174,33 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
|
|
||||||
|
<!-- 2. Main Content (Backdrop + Header + Chat/Video) -->
|
||||||
|
<main class="main-content">
|
||||||
|
{#if isMobile.value && (showSidebar || showMemberList)}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="mobile-backdrop" onclick={closeSidebars}></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<div style="flex: 1; display: flex; align-items: center; gap: 8px;">
|
<button
|
||||||
|
class="icon-btn mobile-toggle"
|
||||||
|
onclick={() => (showSidebar = !showSidebar)}
|
||||||
|
title="Toggle Navigation"
|
||||||
|
>
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="header-info">
|
||||||
{#if chat.activeServer}
|
{#if chat.activeServer}
|
||||||
<span style="color: var(--text-muted); font-size: 1.2rem;">
|
<span class="header-icon"><i class="fas {chat.isActiveChannelVoice ? 'fa-volume-up' : 'fa-hashtag'}"></i></span>
|
||||||
<i class="fas {chat.isActiveChannelVoice ? 'fa-volume-up' : 'fa-hashtag'}"></i>
|
<h2 class="header-title">{chat.activeChannel?.name || "Select a channel"}</h2>
|
||||||
</span>
|
|
||||||
<h2 style="margin: 0; font-size: 1rem;">{chat.activeChannel?.name || "Select a channel"}</h2>
|
|
||||||
{#if chat.activeChannelId !== undefined}
|
{#if chat.activeChannelId !== undefined}
|
||||||
<button
|
<button
|
||||||
class="icon-btn notification-toggle {chat.isChannelNotificationsEnabled(chat.activeChannelId) ? 'subscribed' : ''}"
|
class="icon-btn notification-toggle {chat.isChannelNotificationsEnabled(chat.activeChannelId) ? 'subscribed' : ''}"
|
||||||
onclick={() => chat.toggleChannelNotifications(chat.activeChannelId!)}
|
onclick={() => chat.toggleChannelNotifications(chat.activeChannelId!)}
|
||||||
title={chat.isChannelNotificationsEnabled(chat.activeChannelId) ? "Unsubscribe from Notifications" : "Subscribe to Notifications"}
|
|
||||||
>
|
>
|
||||||
<i class="fas {chat.isChannelNotificationsEnabled(chat.activeChannelId) ? 'fa-bell' : 'fa-bell-slash'}"></i>
|
<i class="fas {chat.isChannelNotificationsEnabled(chat.activeChannelId) ? 'fa-bell' : 'fa-bell-slash'}"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -165,32 +213,26 @@
|
|||||||
{@const recipient = chat.users.find(u => u.identity.toHexString() === otherIdentity.toHexString())}
|
{@const recipient = chat.users.find(u => u.identity.toHexString() === otherIdentity.toHexString())}
|
||||||
{#if recipient}
|
{#if recipient}
|
||||||
<Avatar user={recipient} size="tiny" />
|
<Avatar user={recipient} size="tiny" />
|
||||||
<h2 style="margin: 0; font-size: 1rem;">{recipient.name || "Unknown User"}</h2>
|
<h2 class="header-title">{recipient.name || "Unknown User"}</h2>
|
||||||
<button
|
<button
|
||||||
class="icon-btn notification-toggle {chat.isChannelNotificationsEnabled(chat.activeChannelId!) ? 'subscribed' : ''}"
|
class="icon-btn notification-toggle {chat.isChannelNotificationsEnabled(chat.activeChannelId!) ? 'subscribed' : ''}"
|
||||||
onclick={() => chat.toggleChannelNotifications(chat.activeChannelId!)}
|
onclick={() => chat.toggleChannelNotifications(chat.activeChannelId!)}
|
||||||
title={chat.isChannelNotificationsEnabled(chat.activeChannelId!) ? "Unsubscribe from Notifications" : "Subscribe to Notifications"}
|
|
||||||
>
|
>
|
||||||
<i class="fas {chat.isChannelNotificationsEnabled(chat.activeChannelId!) ? 'fa-bell' : 'fa-bell-slash'}"></i>
|
<i class="fas {chat.isChannelNotificationsEnabled(chat.activeChannelId!) ? 'fa-bell' : 'fa-bell-slash'}"></i>
|
||||||
</button>
|
</button>
|
||||||
{#if chat.getRecipientPublicKey(otherIdentity)}
|
|
||||||
<div class="encryption-indicator" title="End-to-end encrypted">
|
|
||||||
<i class="fas fa-lock"></i>
|
|
||||||
<span>Encryption Available</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<h2 style="margin: 0; font-size: 1rem;">Select a conversation</h2>
|
<h2 class="header-title">Select a conversation</h2>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-header-actions" style="display: flex; align-items: center; gap: 16px;">
|
|
||||||
|
<div class="header-actions">
|
||||||
{#if chat.activeServer}
|
{#if chat.activeServer}
|
||||||
<button
|
<button
|
||||||
class="icon-btn {showMemberList ? 'active' : ''}"
|
class="icon-btn {showMemberList ? 'active' : ''}"
|
||||||
onclick={() => (showMemberList = !showMemberList)}
|
onclick={() => (showMemberList = !showMemberList)}
|
||||||
title="Member List"
|
title="Toggle Member List"
|
||||||
>
|
>
|
||||||
<i class="fas fa-users"></i>
|
<i class="fas fa-users"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -198,118 +240,267 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if chat.isActiveChannelVoice}
|
<div class="chat-view-container">
|
||||||
<VideoGrid />
|
{#if chat.isActiveChannelVoice}
|
||||||
{:else}
|
<VideoGrid />
|
||||||
<MessageList />
|
{:else}
|
||||||
<div class="chat-input-container">
|
<MessageList />
|
||||||
<div class="typing-indicator">
|
<div class="chat-input-container">
|
||||||
{#if chat.typingUsers.length > 0}
|
<div class="typing-indicator">
|
||||||
<div class="dots">
|
{#if chat.typingUsers.length > 0}
|
||||||
<div class="dot"></div>
|
<div class="dots"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
|
||||||
<div class="dot"></div>
|
<span class="typing-text">
|
||||||
<div class="dot"></div>
|
<strong>{chat.typingUsers[0].name || "Someone"}</strong>
|
||||||
</div>
|
{chat.typingUsers.length > 1 ? ` and ${chat.typingUsers.length - 1} more` : ""}
|
||||||
<span style="font-weight: bold;">
|
{chat.typingUsers.length === 1 ? "is" : "are"} typing...
|
||||||
{#if chat.typingUsers.length === 1}
|
</span>
|
||||||
{chat.typingUsers[0].name || "Someone"}
|
{/if}
|
||||||
{:else if chat.typingUsers.length === 2}
|
</div>
|
||||||
{chat.typingUsers[0].name || "Someone"} and {chat.typingUsers[1].name || "Someone"}
|
<ChatInput
|
||||||
{:else if chat.typingUsers.length === 3}
|
activeChannelId={chat.activeChannelId}
|
||||||
{chat.typingUsers[0].name || "Someone"}, {chat.typingUsers[1].name || "Someone"} and {chat.typingUsers[2].name || "Someone"}
|
activeThreadId={null}
|
||||||
{:else}
|
isFullyAuthenticated={chat.isFullyAuthenticated}
|
||||||
Several people
|
/>
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span>{chat.typingUsers.length === 1 ? "is" : "are"} typing...</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<ChatInput
|
{/if}
|
||||||
activeChannelId={chat.activeChannelId}
|
</div>
|
||||||
activeThreadId={null}
|
</main>
|
||||||
isFullyAuthenticated={chat.isFullyAuthenticated}
|
|
||||||
/>
|
<!-- 3. Right Sidebar (Members or Threads) -->
|
||||||
</div>
|
<aside class="right-sidebar-wrapper" class:visible={(showMemberList && chat.activeServer) || chat.activeThreadId || chat.pendingThreadParentMessageId}>
|
||||||
|
{#if chat.activeThreadId || chat.pendingThreadParentMessageId}
|
||||||
|
<ThreadView
|
||||||
|
activeThreadId={chat.activeThreadId}
|
||||||
|
setActiveThreadId={(id) => (chat.activeThreadId = id)}
|
||||||
|
pendingThreadParentMessageId={chat.pendingThreadParentMessageId}
|
||||||
|
setPendingThreadParentMessageId={(id) => (chat.pendingThreadParentMessageId = id)}
|
||||||
|
activeChannelId={chat.activeChannelId}
|
||||||
|
activeServer={chat.activeServer}
|
||||||
|
isFullyAuthenticated={chat.isFullyAuthenticated}
|
||||||
|
users={chat.users}
|
||||||
|
identity={chat.identity}
|
||||||
|
allThreads={chat.allThreads}
|
||||||
|
allMessages={chat.allMessages}
|
||||||
|
allImages={chat.images}
|
||||||
|
/>
|
||||||
|
{:else if showMemberList && chat.activeServer}
|
||||||
|
<MemberList />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</aside>
|
||||||
|
|
||||||
{#if chat.activeThreadId || chat.pendingThreadParentMessageId}
|
<!-- Overlays & Modals -->
|
||||||
<ThreadView
|
{#if chat.showDiscoveryModal} <ServerDiscovery /> {/if}
|
||||||
activeThreadId={chat.activeThreadId}
|
{#if showSettings} <SettingsPanel currentUser={chat.currentUser} onClose={() => (showSettings = false)} /> {/if}
|
||||||
setActiveThreadId={(id) => (chat.activeThreadId = id)}
|
{#if chat.showServerSettings} <ServerSettingsPanel onClose={() => (chat.showServerSettings = false)} /> {/if}
|
||||||
pendingThreadParentMessageId={chat.pendingThreadParentMessageId}
|
|
||||||
setPendingThreadParentMessageId={(id) => (chat.pendingThreadParentMessageId = id)}
|
|
||||||
activeChannelId={chat.activeChannelId}
|
|
||||||
activeServer={chat.activeServer}
|
|
||||||
isFullyAuthenticated={chat.isFullyAuthenticated}
|
|
||||||
users={chat.users}
|
|
||||||
identity={chat.identity}
|
|
||||||
allThreads={chat.allThreads}
|
|
||||||
allMessages={chat.allMessages}
|
|
||||||
allImages={chat.images}
|
|
||||||
/>
|
|
||||||
{:else if showMemberList && chat.activeServer}
|
|
||||||
<MemberList />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if chat.showDiscoveryModal}
|
|
||||||
<ServerDiscovery />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showSettings}
|
|
||||||
<SettingsPanel
|
|
||||||
currentUser={chat.currentUser}
|
|
||||||
onClose={() => (showSettings = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if chat.showServerSettings}
|
|
||||||
<ServerSettingsPanel
|
|
||||||
onClose={() => (chat.showServerSettings = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if chat.viewingImageId}
|
{#if chat.viewingImageId}
|
||||||
{@const image = chat.images.find(img => img.id === chat.viewingImageId)}
|
{@const image = chat.images.find(img => img.id === chat.viewingImageId)}
|
||||||
{#if image}
|
{#if image} <ImageViewer {image} onClose={() => (chat.viewingImageId = null)} /> {/if}
|
||||||
<ImageViewer
|
|
||||||
{image}
|
|
||||||
onClose={() => (chat.viewingImageId = null)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if chat.viewingProfileUser}
|
{#if chat.viewingProfileUser}
|
||||||
<ProfileModal
|
<ProfileModal user={chat.viewingProfileUser} onClose={() => (chat.viewingProfileUser = null)} />
|
||||||
user={chat.viewingProfileUser}
|
|
||||||
onClose={() => (chat.viewingProfileUser = null)}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if chat.userContextMenu}
|
{#if chat.userContextMenu}
|
||||||
<UserContextMenu
|
<UserContextMenu
|
||||||
x={chat.userContextMenu.x}
|
{...chat.userContextMenu}
|
||||||
y={chat.userContextMenu.y}
|
|
||||||
user={chat.userContextMenu.user}
|
|
||||||
onClose={() => (chat.userContextMenu = null)}
|
onClose={() => (chat.userContextMenu = null)}
|
||||||
|
onAction={closeSidebars}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if chat.confirmModal}
|
{#if chat.confirmModal}
|
||||||
<ConfirmModal
|
<ConfirmModal {...chat.confirmModal} onConfirm={() => { chat.confirmModal?.onConfirm(); chat.confirmModal = null; }} onCancel={() => { chat.confirmModal?.onCancel?.(); chat.confirmModal = null; }} />
|
||||||
title={chat.confirmModal.title}
|
|
||||||
message={chat.confirmModal.message}
|
|
||||||
confirmText={chat.confirmModal.confirmText}
|
|
||||||
cancelText={chat.confirmModal.cancelText}
|
|
||||||
isDanger={chat.confirmModal.isDanger}
|
|
||||||
onConfirm={() => {
|
|
||||||
chat.confirmModal?.onConfirm();
|
|
||||||
chat.confirmModal = null;
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
chat.confirmModal?.onCancel?.();
|
|
||||||
chat.confirmModal = null;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
background-color: var(--background-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SIDEBARS */
|
||||||
|
.left-sidebar-wrapper, .right-sidebar-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-sidebar-wrapper {
|
||||||
|
width: calc(var(--server-sidebar-width) + var(--channel-sidebar-width));
|
||||||
|
border-right: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-sidebar-wrapper {
|
||||||
|
width: 240px;
|
||||||
|
border-left: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden states for desktop */
|
||||||
|
@media (min-width: 1025px) {
|
||||||
|
.left-sidebar-wrapper:not(.visible) { display: none; }
|
||||||
|
.right-sidebar-wrapper:not(.visible) { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-sidebar-top {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MAIN CONTENT */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 10;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--header-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-view-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0; /* CRITICAL for scrolling children */
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-container {
|
||||||
|
padding: 0 16px 24px 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RESPONSIVE MOBILE LOGIC */
|
||||||
|
.mobile-toggle { display: none; }
|
||||||
|
.mobile-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 90;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.mobile-toggle { display: flex; margin-right: 8px; }
|
||||||
|
|
||||||
|
.left-sidebar-wrapper, .right-sidebar-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
box-shadow: 0 0 20px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-sidebar-wrapper {
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-sidebar-wrapper.visible {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-sidebar-wrapper {
|
||||||
|
right: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-sidebar-wrapper.visible {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SHARED PARTS */
|
||||||
|
.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; }
|
||||||
|
.voice-status-actions { display: flex; gap: 4px; }
|
||||||
|
|
||||||
|
.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; cursor: pointer; }
|
||||||
|
.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: 2px; }
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
height: 24px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-normal);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots { display: flex; gap: 2px; }
|
||||||
|
.dot { width: 4px; height: 4px; background-color: var(--text-muted); border-radius: 50%; animation: typing-dot 1.4s infinite ease-in-out; }
|
||||||
|
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes typing-dot {
|
||||||
|
0%, 80%, 100% { transform: translateY(0); }
|
||||||
|
40% { transform: translateY(-4px); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -724,6 +724,16 @@
|
|||||||
|
|
||||||
.chat-input, .chat-input-wrapper {
|
.chat-input, .chat-input-wrapper {
|
||||||
transition: border-radius 0.2s;
|
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 {
|
.has-staged {
|
||||||
@@ -731,21 +741,7 @@
|
|||||||
border-top-right-radius: 0 !important;
|
border-top-right-radius: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-input-inner .chat-input-wrapper textarea {
|
.chat-input textarea, .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 {
|
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -761,7 +757,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input {
|
.chat-input, .chat-input-wrapper {
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { ChatService } from "../services/chat.svelte";
|
||||||
import type * as Types from "../../module_bindings/types";
|
import type * as Types from "../../module_bindings/types";
|
||||||
|
|
||||||
let { image, onClose }: { image: Types.Image, onClose: () => void } = $props();
|
let { image, onClose }: { image: Types.Image, onClose: () => void } = $props();
|
||||||
|
|
||||||
|
const chat = getContext<ChatService>("chat");
|
||||||
|
const imageUrl = $derived(chat.getImageUrl(image.id));
|
||||||
|
|
||||||
let imgRef = $state<HTMLImageElement | null>(null);
|
let imgRef = $state<HTMLImageElement | null>(null);
|
||||||
let baseScale = $state(1.0); // Scale required to fit image in shadowbox (90% viewport)
|
let baseScale = $state(1.0);
|
||||||
let zoomLevel = $state(1.0); // Absolute scale (e.g. 0.1 to 3.0)
|
let zoomLevel = $state(1.0);
|
||||||
|
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
let isChangingZoom = $state(false); // New flag to enable animation during programmatic jumps
|
let isChangingZoom = $state(false);
|
||||||
let startX = 0;
|
let startX = 0;
|
||||||
let startY = 0;
|
let startY = 0;
|
||||||
let translateX = $state(0);
|
let translateX = $state(0);
|
||||||
@@ -21,9 +26,7 @@
|
|||||||
const isZoomed = $derived(zoomLevel > baseScale + 0.001);
|
const isZoomed = $derived(zoomLevel > baseScale + 0.001);
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") onClose();
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onImageLoad(e: Event) {
|
function onImageLoad(e: Event) {
|
||||||
@@ -33,9 +36,7 @@
|
|||||||
const viewportW = window.innerWidth * 0.9;
|
const viewportW = window.innerWidth * 0.9;
|
||||||
const viewportH = window.innerHeight * 0.9;
|
const viewportH = window.innerHeight * 0.9;
|
||||||
|
|
||||||
// How much we scale the natural image to fit the 90% shadowbox
|
|
||||||
baseScale = Math.min(1.0, viewportW / naturalW, viewportH / naturalH);
|
baseScale = Math.min(1.0, viewportW / naturalW, viewportH / naturalH);
|
||||||
// Initial zoom is "fitted"
|
|
||||||
zoomLevel = baseScale;
|
zoomLevel = baseScale;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +45,6 @@
|
|||||||
if (isZoomed) {
|
if (isZoomed) {
|
||||||
resetZoom();
|
resetZoom();
|
||||||
} else {
|
} else {
|
||||||
// Zoom to 2x fit size
|
|
||||||
zoomTo(Math.max(1.0, baseScale * 2.0));
|
zoomTo(Math.max(1.0, baseScale * 2.0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,10 +52,8 @@
|
|||||||
function resetZoom() {
|
function resetZoom() {
|
||||||
isChangingZoom = true;
|
isChangingZoom = true;
|
||||||
zoomLevel = baseScale;
|
zoomLevel = baseScale;
|
||||||
translateX = 0;
|
translateX = 0; translateY = 0;
|
||||||
translateY = 0;
|
lastTranslateX = 0; lastTranslateY = 0;
|
||||||
lastTranslateX = 0;
|
|
||||||
lastTranslateY = 0;
|
|
||||||
setTimeout(() => { isChangingZoom = false; }, 150);
|
setTimeout(() => { isChangingZoom = false; }, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +65,6 @@
|
|||||||
|
|
||||||
function handleImageClick(e: MouseEvent) {
|
function handleImageClick(e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const clickDuration = Date.now() - mousedownTime;
|
const clickDuration = Date.now() - mousedownTime;
|
||||||
if (hasMoved || clickDuration > 300) return;
|
if (hasMoved || clickDuration > 300) return;
|
||||||
|
|
||||||
@@ -76,10 +73,8 @@
|
|||||||
} else {
|
} else {
|
||||||
const target = e.currentTarget as HTMLImageElement;
|
const target = e.currentTarget as HTMLImageElement;
|
||||||
const rect = target.getBoundingClientRect();
|
const rect = target.getBoundingClientRect();
|
||||||
|
|
||||||
const clickX = e.clientX - rect.left;
|
const clickX = e.clientX - rect.left;
|
||||||
const clickY = e.clientY - rect.top;
|
const clickY = e.clientY - rect.top;
|
||||||
|
|
||||||
const w = target.clientWidth;
|
const w = target.clientWidth;
|
||||||
const h = target.clientHeight;
|
const h = target.clientHeight;
|
||||||
|
|
||||||
@@ -88,10 +83,8 @@
|
|||||||
|
|
||||||
isChangingZoom = true;
|
isChangingZoom = true;
|
||||||
zoomLevel = targetScale;
|
zoomLevel = targetScale;
|
||||||
|
|
||||||
translateX = (w / 2 - clickX) * (S - 1);
|
translateX = (w / 2 - clickX) * (S - 1);
|
||||||
translateY = (h / 2 - clickY) * (S - 1);
|
translateY = (h / 2 - clickY) * (S - 1);
|
||||||
|
|
||||||
lastTranslateX = translateX;
|
lastTranslateX = translateX;
|
||||||
lastTranslateY = translateY;
|
lastTranslateY = translateY;
|
||||||
setTimeout(() => { isChangingZoom = false; }, 150);
|
setTimeout(() => { isChangingZoom = false; }, 150);
|
||||||
@@ -112,14 +105,9 @@
|
|||||||
|
|
||||||
function handleMouseMove(e: MouseEvent) {
|
function handleMouseMove(e: MouseEvent) {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
|
|
||||||
const deltaX = e.clientX - startX;
|
const deltaX = e.clientX - startX;
|
||||||
const deltaY = e.clientY - startY;
|
const deltaY = e.clientY - startY;
|
||||||
|
if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) hasMoved = true;
|
||||||
if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
|
|
||||||
hasMoved = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
translateX = lastTranslateX + deltaX;
|
translateX = lastTranslateX + deltaX;
|
||||||
translateY = lastTranslateY + deltaY;
|
translateY = lastTranslateY + deltaY;
|
||||||
}
|
}
|
||||||
@@ -136,18 +124,17 @@
|
|||||||
|
|
||||||
function handleDownload(e: MouseEvent) {
|
function handleDownload(e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const blob = new Blob([image.data], { type: image.mimeType });
|
if (!imageUrl) return;
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = imageUrl;
|
||||||
a.download = image.name || "download.png";
|
a.download = image.name || "download.png";
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatSize = (bytes: number) => {
|
const formatSize = (bytes: number | undefined) => {
|
||||||
|
if (!bytes) return "Unknown size";
|
||||||
if (bytes < 1024) return bytes + " B";
|
if (bytes < 1024) return bytes + " B";
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||||
@@ -167,8 +154,6 @@
|
|||||||
onclick={handleOverlayClick}
|
onclick={handleOverlayClick}
|
||||||
onmousedown={handleMouseDown}
|
onmousedown={handleMouseDown}
|
||||||
>
|
>
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
<div
|
||||||
class="image-viewer-info"
|
class="image-viewer-info"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
@@ -176,32 +161,20 @@
|
|||||||
>
|
>
|
||||||
<div class="info-filename">{image.name || "Untitled Image"}</div>
|
<div class="info-filename">{image.name || "Untitled Image"}</div>
|
||||||
<div class="info-details">
|
<div class="info-details">
|
||||||
{image.mimeType} • {formatSize(image.data.length)} • {Math.round(zoomLevel * 100)}%
|
{image.mimeType} • {formatSize(chat.imageSizes.get(image.id.toString()))} • {Math.round(zoomLevel * 100)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
<div
|
||||||
class="image-viewer-actions"
|
class="image-viewer-actions"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onmousedown={(e) => e.stopPropagation()}
|
onmousedown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<button class="action-btn" onclick={handleDownload} title="Save Image">
|
||||||
class="action-btn"
|
|
||||||
onclick={handleDownload}
|
|
||||||
title="Save Image"
|
|
||||||
aria-label="Save image"
|
|
||||||
>
|
|
||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="zoom-control-container">
|
<div class="zoom-control-container">
|
||||||
<button
|
<button class="action-btn" onclick={toggleZoom} title={isZoomed ? "Reset Zoom" : "Zoom In"}>
|
||||||
class="action-btn"
|
|
||||||
onclick={toggleZoom}
|
|
||||||
title={isZoomed ? "Reset Zoom" : "Zoom In"}
|
|
||||||
aria-label={isZoomed ? "Reset zoom" : "Zoom in"}
|
|
||||||
>
|
|
||||||
<i class="fas {isZoomed ? 'fa-search-minus' : 'fa-search-plus'}"></i>
|
<i class="fas {isZoomed ? 'fa-search-minus' : 'fa-search-plus'}"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="zoom-slider-drawer">
|
<div class="zoom-slider-drawer">
|
||||||
@@ -217,35 +190,37 @@
|
|||||||
onmouseup={() => { isChangingZoom = false; }}
|
onmouseup={() => { isChangingZoom = false; }}
|
||||||
oninput={() => {
|
oninput={() => {
|
||||||
if (zoomLevel <= baseScale + 0.001) {
|
if (zoomLevel <= baseScale + 0.001) {
|
||||||
translateX = 0;
|
translateX = 0; translateY = 0;
|
||||||
translateY = 0;
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button class="action-btn close" onclick={(e) => { e.stopPropagation(); onClose(); }}>
|
||||||
class="action-btn close"
|
|
||||||
onclick={(e) => { e.stopPropagation(); onClose(); }}
|
|
||||||
aria-label="Close image viewer"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="image-viewer-content">
|
<div class="image-viewer-content">
|
||||||
<img
|
{#if imageUrl}
|
||||||
bind:this={imgRef}
|
<img
|
||||||
src={URL.createObjectURL(new Blob([image.data], { type: image.mimeType }))}
|
bind:this={imgRef}
|
||||||
alt="Full resolution"
|
src={imageUrl}
|
||||||
class="full-image"
|
alt="Full resolution"
|
||||||
class:animate={isChangingZoom}
|
class="full-image"
|
||||||
style="transform: translate({translateX}px, {translateY}px) scale({zoomLevel / baseScale}); cursor: {isZoomed ? (isDragging ? 'grabbing' : 'zoom-out') : 'zoom-in'}"
|
class:animate={isChangingZoom}
|
||||||
onclick={handleImageClick}
|
style="transform: translate({translateX}px, {translateY}px) scale({zoomLevel / baseScale}); cursor: {isZoomed ? (isDragging ? 'grabbing' : 'zoom-out') : 'zoom-in'}"
|
||||||
onload={onImageLoad}
|
onclick={handleImageClick}
|
||||||
draggable="false"
|
onload={onImageLoad}
|
||||||
/>
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="loading-state">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
|
<span>Downloading image data...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -405,11 +380,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
pointer-events: none; /* Let clicks pass through to overlay */
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-viewer-content img {
|
.image-viewer-content img {
|
||||||
pointer-events: auto; /* Re-enable for the image itself */
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-image {
|
.full-image {
|
||||||
@@ -430,4 +405,17 @@
|
|||||||
.full-image.animate {
|
.full-image.animate {
|
||||||
transition: transform 0.05s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: transform 0.05s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state i {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -131,4 +131,75 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.right-sidebar {
|
||||||
|
width: 240px;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-left: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-list {
|
||||||
|
padding: 16px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--interactive-normal);
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item.offline {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name.talking {
|
||||||
|
color: var(--status-positive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-list-section-header {
|
||||||
|
padding: 16px 8px 8px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sharing-badge {
|
||||||
|
background-color: var(--status-danger);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -454,7 +454,7 @@
|
|||||||
aria-expanded={!collapsedImages}
|
aria-expanded={!collapsedImages}
|
||||||
>
|
>
|
||||||
<div class="embed-type-label">
|
<div class="embed-type-label">
|
||||||
<i class="fas fa-image" style="color: var(--brand);"></i>
|
<i class="fas fa-image"></i>
|
||||||
{msg.imageIds.length} Image{msg.imageIds.length > 1 ? 's' : ''}
|
{msg.imageIds.length} Image{msg.imageIds.length > 1 ? 's' : ''}
|
||||||
</div>
|
</div>
|
||||||
<i class="fas fa-chevron-{collapsedImages ? 'down' : 'up'}" style="color: var(--text-muted); font-size: 0.8rem;"></i>
|
<i class="fas fa-chevron-{collapsedImages ? 'down' : 'up'}" style="color: var(--text-muted); font-size: 0.8rem;"></i>
|
||||||
@@ -480,7 +480,7 @@
|
|||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt="Uploaded"
|
alt="Uploaded"
|
||||||
class="message-image"
|
class="message-image"
|
||||||
style="cursor: pointer; max-width: {isThread ? '200px' : '300px'}; max-height: {isThread ? '200px' : '300px'}; border-radius: 4px;"
|
style="cursor: pointer; max-width: {isThread ? '300px' : '400px'}; max-height: 400px; width: auto; height: auto; border-radius: 4px; object-fit: contain;"
|
||||||
onload={handleImageLoad}
|
onload={handleImageLoad}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -764,6 +764,61 @@
|
|||||||
border-top-color: var(--background-floating);
|
border-top-color: var(--background-floating);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-image {
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-image-container {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-wrapper {
|
||||||
|
border-left: 4px solid var(--brand);
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-header {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-header:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-type-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-content-body {
|
||||||
|
padding: 8px 12px 12px 12px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.reaction-badge {
|
.reaction-badge {
|
||||||
background-color: var(--background-accent);
|
background-color: var(--background-accent);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
|||||||
@@ -414,4 +414,14 @@
|
|||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: 24px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -191,9 +191,9 @@
|
|||||||
{:else if embed.type === 'facebook'}
|
{:else if embed.type === 'facebook'}
|
||||||
<i class="fab fa-facebook" style="color: #1877F2;"></i> Facebook
|
<i class="fab fa-facebook" style="color: #1877F2;"></i> Facebook
|
||||||
{:else if embed.type === 'image'}
|
{:else if embed.type === 'image'}
|
||||||
<i class="fas fa-image" style="color: var(--brand);"></i> Image
|
<i class="fas fa-image"></i> Image
|
||||||
{:else}
|
{:else}
|
||||||
<i class="fas fa-link" style="color: var(--text-normal);"></i> Link
|
<i class="fas fa-link"></i> Link
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<i class="fas fa-chevron-{isCollapsed(i, embed.type) ? 'down' : 'up'}" style="color: var(--text-muted); font-size: 0.8rem;"></i>
|
<i class="fas fa-chevron-{isCollapsed(i, embed.type) ? 'down' : 'up'}" style="color: var(--text-muted); font-size: 0.8rem;"></i>
|
||||||
@@ -216,88 +216,81 @@
|
|||||||
{:else if embed.type === "youtube"}
|
{:else if embed.type === "youtube"}
|
||||||
<div class="media-embed-container">
|
<div class="media-embed-container">
|
||||||
<iframe
|
<iframe
|
||||||
width="640"
|
|
||||||
height="360"
|
|
||||||
src="https://www.youtube.com/embed/{embed.videoId}"
|
src="https://www.youtube.com/embed/{embed.videoId}"
|
||||||
title="YouTube video player"
|
title="YouTube video player"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
class="media-iframe"
|
class="media-iframe"
|
||||||
|
style="aspect-ratio: 16/9; width: 640px;"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
{:else if embed.type === "youtube-shorts"}
|
{:else if embed.type === "youtube-shorts"}
|
||||||
<div class="media-embed-container">
|
<div class="media-embed-container">
|
||||||
<iframe
|
<iframe
|
||||||
width="315"
|
|
||||||
height="560"
|
|
||||||
src="https://www.youtube.com/embed/{embed.videoId}"
|
src="https://www.youtube.com/embed/{embed.videoId}"
|
||||||
title="YouTube Shorts player"
|
title="YouTube Shorts player"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
class="media-iframe"
|
class="media-iframe"
|
||||||
|
style="aspect-ratio: 9/16; width: 315px; max-height: 80vh;"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
{:else if embed.type === "twitch"}
|
{:else if embed.type === "twitch"}
|
||||||
<div class="media-embed-container">
|
<div class="media-embed-container">
|
||||||
<iframe
|
<iframe
|
||||||
width="640"
|
|
||||||
height="360"
|
|
||||||
src="https://player.twitch.tv/?{embed.videoId ? `video=${embed.videoId}` : `channel=${embed.channelName}`}&parent={hostname}"
|
src="https://player.twitch.tv/?{embed.videoId ? `video=${embed.videoId}` : `channel=${embed.channelName}`}&parent={hostname}"
|
||||||
title="Twitch player"
|
title="Twitch player"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
class="media-iframe"
|
class="media-iframe"
|
||||||
|
style="aspect-ratio: 16/9; width: 640px;"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
{:else if embed.type === "kick"}
|
{:else if embed.type === "kick"}
|
||||||
<div class="media-embed-container">
|
<div class="media-embed-container">
|
||||||
<iframe
|
<iframe
|
||||||
width="640"
|
|
||||||
height="360"
|
|
||||||
src="https://player.kick.com/{embed.videoId ? `video/${embed.videoId}` : embed.channelName}"
|
src="https://player.kick.com/{embed.videoId ? `video/${embed.videoId}` : embed.channelName}"
|
||||||
title="Kick player"
|
title="Kick player"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
class="media-iframe"
|
class="media-iframe"
|
||||||
|
style="aspect-ratio: 16/9; width: 640px;"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
{:else if embed.type === "tiktok"}
|
{:else if embed.type === "tiktok"}
|
||||||
<div class="media-embed-container">
|
<div class="media-embed-container">
|
||||||
<iframe
|
<iframe
|
||||||
width="325"
|
|
||||||
height="580"
|
|
||||||
src="https://www.tiktok.com/embed/v2/{embed.videoId}"
|
src="https://www.tiktok.com/embed/v2/{embed.videoId}"
|
||||||
title="TikTok video player"
|
title="TikTok video player"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
class="media-iframe"
|
class="media-iframe"
|
||||||
|
style="aspect-ratio: 9/16; width: 325px; max-height: 80vh;"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
{:else if embed.type === "instagram"}
|
{:else if embed.type === "instagram"}
|
||||||
<div class="media-embed-container" style="background: white;">
|
<div class="media-embed-container" style="background: white;">
|
||||||
<iframe
|
<iframe
|
||||||
width="400"
|
|
||||||
height="480"
|
|
||||||
src="https://www.instagram.com/reel/{embed.videoId}/embed"
|
src="https://www.instagram.com/reel/{embed.videoId}/embed"
|
||||||
title="Instagram Reel player"
|
title="Instagram Reel player"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
class="media-iframe"
|
class="media-iframe"
|
||||||
|
style="aspect-ratio: 400/480; width: 400px; max-height: 80vh;"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
{:else if embed.type === "facebook"}
|
{:else if embed.type === "facebook"}
|
||||||
<div class="media-embed-container" style="background: white;">
|
<div class="media-embed-container" style="background: white;">
|
||||||
<iframe
|
<iframe
|
||||||
width="400"
|
|
||||||
height="480"
|
|
||||||
src="https://www.facebook.com/plugins/video.php?href=https://www.facebook.com/reel/{embed.videoId}"
|
src="https://www.facebook.com/plugins/video.php?href=https://www.facebook.com/reel/{embed.videoId}"
|
||||||
title="Facebook Reel player"
|
title="Facebook Reel player"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
class="media-iframe"
|
class="media-iframe"
|
||||||
|
style="aspect-ratio: 400/480; width: 400px; max-height: 80vh;"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
{:else if embed.type === "link"}
|
{:else if embed.type === "link"}
|
||||||
@@ -322,5 +315,81 @@
|
|||||||
|
|
||||||
.message-image {
|
.message-image {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 500px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embeds-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-wrapper {
|
||||||
|
border-left: 4px solid var(--brand);
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 520px;
|
||||||
|
min-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-header {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-header:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-type-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-content-body {
|
||||||
|
padding: 8px 12px 12px 12px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-embed-container {
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #000;
|
||||||
|
line-height: 0;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-iframe {
|
||||||
|
max-width: 100%;
|
||||||
|
border: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-image-container {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -261,4 +261,30 @@
|
|||||||
background-color: var(--brand);
|
background-color: var(--brand);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-list {
|
||||||
|
width: var(--server-sidebar-width);
|
||||||
|
background-color: var(--background-tertiary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon.active::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -4px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 8px;
|
||||||
|
height: 40px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,11 +5,12 @@
|
|||||||
import type * as Types from "../../module_bindings/types";
|
import type * as Types from "../../module_bindings/types";
|
||||||
import { portal } from "../../portal";
|
import { portal } from "../../portal";
|
||||||
|
|
||||||
let { x, y, user, onClose }: {
|
let { x, y, user, onClose, onAction }: {
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
user: Types.User,
|
user: Types.User,
|
||||||
onClose: () => void
|
onClose: () => void,
|
||||||
|
onAction?: () => void
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const chat = getContext<ChatService>("chat");
|
const chat = getContext<ChatService>("chat");
|
||||||
@@ -108,6 +109,7 @@
|
|||||||
{#if !isMe}
|
{#if !isMe}
|
||||||
<button class="menu-item" onclick={() => {
|
<button class="menu-item" onclick={() => {
|
||||||
chat.handleOpenDirectMessage(user.identity);
|
chat.handleOpenDirectMessage(user.identity);
|
||||||
|
onAction?.();
|
||||||
onClose();
|
onClose();
|
||||||
}}>
|
}}>
|
||||||
<span>Message</span>
|
<span>Message</span>
|
||||||
@@ -116,7 +118,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !isMe}
|
{#if !isMe}
|
||||||
<button class="menu-item" onclick={mentionUser}>
|
<button class="menu-item" onclick={() => {
|
||||||
|
mentionUser();
|
||||||
|
onAction?.();
|
||||||
|
}}>
|
||||||
<span>Mention</span>
|
<span>Mention</span>
|
||||||
<i class="fas fa-at"></i>
|
<i class="fas fa-at"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -8,138 +8,222 @@
|
|||||||
const chat = getContext<ChatService>("chat");
|
const chat = getContext<ChatService>("chat");
|
||||||
const webrtc = getContext<WebRTCService>("webrtc");
|
const webrtc = getContext<WebRTCService>("webrtc");
|
||||||
|
|
||||||
let focusedIdentity = $state<Identity | null>(null);
|
// Higher reliability derivation
|
||||||
|
const participants = $derived.by(() => {
|
||||||
|
const channelId = chat.activeChannelId;
|
||||||
|
if (!channelId) return [];
|
||||||
|
|
||||||
const participants = $derived(
|
const results = chat.userStates.filter(s => s.channelId === channelId);
|
||||||
chat.userStates.filter((s) => s.channelId === webrtc.connectedChannelId),
|
console.log(`[VideoGrid] Rendering ${results.length} participants for channel ${channelId}`);
|
||||||
);
|
return results;
|
||||||
|
});
|
||||||
|
|
||||||
const localSharing = $derived(!!webrtc.localScreenStream);
|
const sharer = $derived(participants.find(s => s.isSharingScreen));
|
||||||
const remoteSharerVs = $derived(
|
const localSharing = $derived(webrtc.isSharingScreen);
|
||||||
participants.find((s) => {
|
|
||||||
if (s.identity.isEqual(webrtc.identity!)) return false;
|
|
||||||
return s.isSharingScreen;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultSharerIdentity = $derived(
|
// Explicit check for local user existence in participants
|
||||||
localSharing ? webrtc.identity : remoteSharerVs?.identity,
|
const isMeInChannel = $derived(participants.some(p => p.identity.isEqual(chat.identity!)));
|
||||||
);
|
|
||||||
|
|
||||||
const primarySharerIdentity = $derived(focusedIdentity || defaultSharerIdentity);
|
const effectiveSharer = $derived(localSharing ? { identity: chat.identity } : sharer);
|
||||||
|
|
||||||
function isWatchingPeer(peerIdHex: string) {
|
function toggleWatch(identity: Identity) {
|
||||||
const s = participants.find(p => p.identity.toHexString() === peerIdHex);
|
if (chat.currentVoiceState?.watching?.isEqual(identity)) {
|
||||||
// I am watching the peer if my state's watching field points to them
|
|
||||||
return chat.currentVoiceState?.watching?.toHexString() === peerIdHex;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleWatch(peerIdentity: Identity) {
|
|
||||||
if (isWatchingPeer(peerIdentity.toHexString())) {
|
|
||||||
webrtc.stopWatching();
|
webrtc.stopWatching();
|
||||||
} else {
|
} else {
|
||||||
webrtc.startWatching(peerIdentity);
|
webrtc.startWatching(identity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const heroVs = $derived(
|
|
||||||
participants.find((s) =>
|
|
||||||
s.identity.isEqual(primarySharerIdentity || Identity.zero()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const rowParticipants = $derived(
|
|
||||||
participants.filter(
|
|
||||||
(s) => !s.identity.isEqual(primarySharerIdentity || Identity.zero()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="video-grid {primarySharerIdentity ? 'has-sharer' : ''}">
|
<div class="video-grid" class:has-sharer={!!effectiveSharer}>
|
||||||
<div class="video-grid-content">
|
{#if participants.length === 0}
|
||||||
{#if primarySharerIdentity}
|
<div class="empty-channel-state">
|
||||||
{#if heroVs}
|
<i class="fas fa-microphone-alt-slash"></i>
|
||||||
<div
|
<p>Synchronizing channel participants...</p>
|
||||||
class="video-tile-container is-hero"
|
</div>
|
||||||
onclick={() => (focusedIdentity = heroVs.identity)}
|
{:else}
|
||||||
role="button"
|
<div class="video-grid-content">
|
||||||
tabindex="0"
|
{#if effectiveSharer}
|
||||||
onkeydown={(e) => e.key === "Enter" && (focusedIdentity = heroVs.identity)}
|
<div class="hero-container">
|
||||||
style="cursor: pointer;"
|
|
||||||
>
|
|
||||||
<VideoTile
|
<VideoTile
|
||||||
identity={heroVs.identity}
|
identity={effectiveSharer.identity!}
|
||||||
stream={heroVs.identity.isEqual(webrtc.identity!)
|
isLocal={effectiveSharer.identity!.isEqual(chat.identity!)}
|
||||||
? webrtc.localScreenStream || undefined
|
stream={effectiveSharer.identity!.isEqual(chat.identity!)
|
||||||
: webrtc.peers.get(heroVs.identity.toHexString())?.videoStream}
|
? webrtc.localMedia.screenStream
|
||||||
isLocal={heroVs.identity.isEqual(webrtc.identity!)}
|
: webrtc.getRemoteStream(effectiveSharer.identity!.toHexString(), 'screen')}
|
||||||
isTalking={heroVs.isTalking}
|
isSharing={true}
|
||||||
isWatching={isWatchingPeer(heroVs.identity.toHexString())}
|
isWatching={true}
|
||||||
isSharing={heroVs.identity.isEqual(webrtc.identity!)
|
onToggleWatch={() => toggleWatch(effectiveSharer.identity!)}
|
||||||
? localSharing
|
|
||||||
: heroVs.isSharingScreen}
|
|
||||||
onToggleWatch={() => toggleWatch(heroVs.identity)}
|
|
||||||
isHero={true}
|
isHero={true}
|
||||||
users={chat.users}
|
users={chat.users}
|
||||||
|
isTalking={participants.find(p => p.identity.isEqual(effectiveSharer.identity!))?.isTalking}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
{#if rowParticipants.length > 0}
|
<div class="participants-row">
|
||||||
<div class="video-participants-row">
|
{#each participants.filter(s => !effectiveSharer.identity!.isEqual(s.identity)) as s (s.identity.toHexString())}
|
||||||
{#each rowParticipants as s (s.identity.toHexString())}
|
<div class="row-tile-wrapper">
|
||||||
<div
|
|
||||||
class="video-tile-container is-row"
|
|
||||||
onclick={() => (focusedIdentity = s.identity)}
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
onkeydown={(e) => e.key === "Enter" && (focusedIdentity = s.identity)}
|
|
||||||
style="cursor: pointer;"
|
|
||||||
>
|
|
||||||
<VideoTile
|
<VideoTile
|
||||||
identity={s.identity}
|
identity={s.identity}
|
||||||
stream={s.identity.isEqual(webrtc.identity!)
|
isLocal={s.identity.isEqual(chat.identity!)}
|
||||||
? webrtc.localScreenStream || undefined
|
stream={s.identity.isEqual(chat.identity!)
|
||||||
: webrtc.peers.get(s.identity.toHexString())?.videoStream}
|
? webrtc.localMedia.stream
|
||||||
isLocal={s.identity.isEqual(webrtc.identity!)}
|
: webrtc.getRemoteStream(s.identity.toHexString(), 'voice')}
|
||||||
isTalking={s.isTalking}
|
isSharing={s.identity.isEqual(chat.identity!) ? localSharing : s.isSharingScreen}
|
||||||
isWatching={isWatchingPeer(s.identity.toHexString())}
|
isWatching={s.identity.isEqual(chat.identity!) ? false : (s.isSharingScreen ? (chat.currentVoiceState?.watching?.isEqual(s.identity) || false) : true)}
|
||||||
isSharing={s.identity.isEqual(webrtc.identity!)
|
|
||||||
? localSharing
|
|
||||||
: s.isSharingScreen}
|
|
||||||
onToggleWatch={() => toggleWatch(s.identity)}
|
onToggleWatch={() => toggleWatch(s.identity)}
|
||||||
isHero={false}
|
isHero={false}
|
||||||
users={chat.users}
|
users={chat.users}
|
||||||
|
isTalking={s.isTalking}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if !localSharing && !effectiveSharer.identity!.isEqual(chat.identity!)}
|
||||||
|
<div class="row-tile-wrapper">
|
||||||
|
<VideoTile
|
||||||
|
identity={chat.identity!}
|
||||||
|
isLocal={true}
|
||||||
|
stream={webrtc.localMedia.stream}
|
||||||
|
isSharingScreen={false}
|
||||||
|
isWatching={true}
|
||||||
|
onToggleWatch={() => {}}
|
||||||
|
isHero={false}
|
||||||
|
users={chat.users}
|
||||||
|
isTalking={webrtc.localMedia.isTalking}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="standard-grid">
|
||||||
|
{#each participants as s (s.identity.toHexString())}
|
||||||
|
<div class="grid-tile-wrapper">
|
||||||
|
<VideoTile
|
||||||
|
identity={s.identity}
|
||||||
|
isLocal={s.identity.isEqual(chat.identity!)}
|
||||||
|
stream={s.identity.isEqual(chat.identity!)
|
||||||
|
? webrtc.localMedia.stream
|
||||||
|
: webrtc.getRemoteStream(s.identity.toHexString(), 'voice')}
|
||||||
|
isSharing={s.identity.isEqual(chat.identity!) ? localSharing : s.isSharingScreen}
|
||||||
|
isWatching={s.identity.isEqual(chat.identity!) ? false : (s.isSharingScreen ? (chat.currentVoiceState?.watching?.isEqual(s.identity) || false) : true)}
|
||||||
|
|
||||||
|
onToggleWatch={() => toggleWatch(s.identity)}
|
||||||
|
isHero={false}
|
||||||
|
users={chat.users}
|
||||||
|
isTalking={s.isTalking}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if !isMeInChannel}
|
||||||
|
<div class="grid-tile-wrapper">
|
||||||
|
<VideoTile
|
||||||
|
identity={chat.identity!}
|
||||||
|
isLocal={true}
|
||||||
|
stream={webrtc.localMedia.stream}
|
||||||
|
isSharingScreen={localSharing}
|
||||||
|
isWatching={true}
|
||||||
|
onToggleWatch={() => {}}
|
||||||
|
isHero={false}
|
||||||
|
users={chat.users}
|
||||||
|
isTalking={webrtc.localMedia.isTalking}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
</div>
|
||||||
{#each participants as s (s.identity.toHexString())}
|
{/if}
|
||||||
<div
|
|
||||||
class="video-tile-container is-grid"
|
|
||||||
onclick={() => (focusedIdentity = s.identity)}
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
onkeydown={(e) => e.key === "Enter" && (focusedIdentity = s.identity)}
|
|
||||||
style="cursor: pointer;"
|
|
||||||
>
|
|
||||||
<VideoTile
|
|
||||||
identity={s.identity}
|
|
||||||
stream={s.identity.isEqual(webrtc.identity!)
|
|
||||||
? webrtc.localScreenStream || undefined
|
|
||||||
: webrtc.peers.get(s.identity.toHexString())?.videoStream}
|
|
||||||
isLocal={s.identity.isEqual(webrtc.identity!)}
|
|
||||||
isTalking={s.isTalking}
|
|
||||||
isWatching={isWatchingPeer(s.identity.toHexString())}
|
|
||||||
isSharing={s.identity.isEqual(webrtc.identity!)
|
|
||||||
? localSharing
|
|
||||||
: s.isSharingScreen}
|
|
||||||
onToggleWatch={() => toggleWatch(s.identity)}
|
|
||||||
isHero={false}
|
|
||||||
users={chat.users}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.video-grid {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-grid-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-channel-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-channel-state i {
|
||||||
|
font-size: 4rem;
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid Layout (No one sharing) */
|
||||||
|
.standard-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
align-content: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-tile-wrapper {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero Layout (Someone sharing screen) */
|
||||||
|
.hero-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participants-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
height: 140px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-tile-wrapper {
|
||||||
|
width: 248px;
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.standard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participants-row {
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-tile-wrapper {
|
||||||
|
width: 177px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -93,7 +93,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showStream = $derived((isLocal || isWatching) && !!stream);
|
const showStream = $derived(isLocal ? !!stream : (isWatching && isSharing && !!stream));
|
||||||
|
const showWatchButton = $derived(!isLocal && isSharing && !isWatching);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -128,7 +129,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="avatar-placeholder-container">
|
<div class="avatar-placeholder-container">
|
||||||
<Avatar user={users.find(u => u.identity.isEqual(identity))} size="large" />
|
<Avatar user={users.find(u => u.identity.isEqual(identity))} size="large" />
|
||||||
{#if !isLocal && isSharing}
|
{#if showWatchButton}
|
||||||
<button
|
<button
|
||||||
class="watch-btn"
|
class="watch-btn"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
@@ -199,7 +200,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.video-tile.talking {
|
.video-tile.talking {
|
||||||
border-color: #23a559;
|
border-color: var(--status-positive);
|
||||||
}
|
}
|
||||||
|
|
||||||
video {
|
video {
|
||||||
|
|||||||
@@ -76,4 +76,52 @@
|
|||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-section {
|
||||||
|
padding: 16px 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header:hover .add-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:hover {
|
||||||
|
color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item-hash {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-right: 4px;
|
||||||
|
width: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -257,4 +257,203 @@
|
|||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-section {
|
||||||
|
padding: 16px 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header:hover .add-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:hover {
|
||||||
|
color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item-hash {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-right: 4px;
|
||||||
|
width: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voice Member List Styles */
|
||||||
|
.voice-member-list {
|
||||||
|
padding-left: 36px;
|
||||||
|
padding-right: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-member-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-member-item:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-member-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-member-name.talking {
|
||||||
|
color: var(--header-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-member-status-container {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-member-indicators {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-indicator-icon {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--interactive-normal);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deafen-indicator-svg {
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watcher-eye {
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sharing-badge {
|
||||||
|
background-color: var(--status-danger);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection Popover */
|
||||||
|
.connection-popover {
|
||||||
|
position: fixed;
|
||||||
|
width: 216px;
|
||||||
|
background-color: var(--background-floating);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 3000;
|
||||||
|
color: var(--text-normal);
|
||||||
|
pointer-events: none;
|
||||||
|
border: 1px solid var(--background-modifier-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-status {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-status.green { color: var(--status-positive); }
|
||||||
|
.popover-status.yellow { color: var(--status-warning); }
|
||||||
|
.popover-status.red { color: var(--status-danger); }
|
||||||
|
|
||||||
|
.popover-info {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row span:last-child {
|
||||||
|
color: var(--header-primary);
|
||||||
|
font-family: var(--font-code);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
width: 60px;
|
||||||
|
height: 4px;
|
||||||
|
accent-color: var(--brand);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Identity } from "spacetimedb";
|
import { Identity } from "spacetimedb";
|
||||||
import { SvelteMap, SvelteSet } from "svelte/reactivity";
|
import { SvelteMap, SvelteSet } from "svelte/reactivity";
|
||||||
import * as Types from "../../module_bindings/types";
|
import * as Types from "../../module_bindings/types";
|
||||||
import { reducers } from "../../module_bindings";
|
|
||||||
import { getUsername, formatTime } from "../utils";
|
import { getUsername, formatTime } from "../utils";
|
||||||
|
import { getConnection } from "../../config";
|
||||||
import { DatabaseService } from "./database.svelte";
|
import { DatabaseService } from "./database.svelte";
|
||||||
import { NavigationService } from "./navigation.svelte";
|
import { NavigationService } from "./navigation.svelte";
|
||||||
import { ThemeService, themeService } from "./theme.svelte";
|
import { ThemeService, themeService } from "./theme.svelte";
|
||||||
@@ -38,6 +38,7 @@ export class ChatService {
|
|||||||
#bannerUrls = new SvelteMap<string, string>();
|
#bannerUrls = new SvelteMap<string, string>();
|
||||||
#serverAvatarUrls = new SvelteMap<string, string>();
|
#serverAvatarUrls = new SvelteMap<string, string>();
|
||||||
#messageImageUrls = new SvelteMap<string, string>();
|
#messageImageUrls = new SvelteMap<string, string>();
|
||||||
|
imageSizes = new SvelteMap<string, number>();
|
||||||
|
|
||||||
constructor(initialIdentity: Identity | null) {
|
constructor(initialIdentity: Identity | null) {
|
||||||
console.log("ChatService: Initializing with identity:", initialIdentity?.toHexString());
|
console.log("ChatService: Initializing with identity:", initialIdentity?.toHexString());
|
||||||
@@ -134,13 +135,15 @@ export class ChatService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Session-only image processing: creates Blob URLs directly from Database data.
|
// 1. Lazy Image Requesting: Track which images should be visible and request missing BLOBs
|
||||||
// This ditched the persistent IndexedDB cache to prevent stale data between reloads.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const currentImages = this.#db.images;
|
const currentImages = this.#db.images;
|
||||||
|
const conn = getConnection();
|
||||||
|
if (!conn || !this.identity) return;
|
||||||
|
|
||||||
const currentIds = new Set(currentImages.map(img => img.id.toString()));
|
const currentIds = new Set(currentImages.map(img => img.id.toString()));
|
||||||
|
|
||||||
// 1. Cleanup old Blob URLs no longer in visible_images
|
// Cleanup old Blob URLs no longer in visible_images
|
||||||
for (const [idStr, url] of this.#blobUrls.entries()) {
|
for (const [idStr, url] of this.#blobUrls.entries()) {
|
||||||
if (!currentIds.has(idStr)) {
|
if (!currentIds.has(idStr)) {
|
||||||
console.log(`[ChatService] Revoking Blob URL for ${idStr}`);
|
console.log(`[ChatService] Revoking Blob URL for ${idStr}`);
|
||||||
@@ -153,20 +156,39 @@ export class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Create URLs for new images
|
// Request blobs for any metadata we have but no data yet
|
||||||
for (const img of currentImages) {
|
for (const img of currentImages) {
|
||||||
const idStr = img.id.toString();
|
const idStr = img.id.toString();
|
||||||
if (!this.#blobUrls.has(idStr)) {
|
if (!this.#blobUrls.has(idStr)) {
|
||||||
// Use a copy of the data to ensure no buffer sharing issues
|
conn.reducers.requestImageBlob({ imageId: img.id });
|
||||||
const dataCopy = img.data.slice();
|
}
|
||||||
const blob = new Blob([dataCopy], { type: img.mimeType });
|
}
|
||||||
const url = URL.createObjectURL(blob);
|
});
|
||||||
console.log(`[ChatService] Created Blob URL for ${idStr}: ${url} (size: ${dataCopy.length} bytes)`);
|
|
||||||
this.#blobUrls.set(idStr, url);
|
// 2. Lazy Image Processing: Process BLOBs as they arrive in visible_image_blobs
|
||||||
|
$effect(() => {
|
||||||
|
const blobs = this.#db.imageBlobs;
|
||||||
|
const currentImages = this.#db.images;
|
||||||
|
const conn = getConnection();
|
||||||
|
if (!conn || !this.identity) return;
|
||||||
|
|
||||||
|
for (const blob of blobs) {
|
||||||
|
const idStr = blob.imageId.toString();
|
||||||
|
if (!this.#blobUrls.has(idStr)) {
|
||||||
|
const metadata = currentImages.find(img => img.id === blob.imageId);
|
||||||
|
if (metadata) {
|
||||||
|
// Use a copy of the data
|
||||||
|
const dataCopy = blob.data.slice();
|
||||||
|
this.imageSizes.set(idStr, dataCopy.length);
|
||||||
|
const browserBlob = new Blob([dataCopy], { type: metadata.mimeType });
|
||||||
|
const url = URL.createObjectURL(browserBlob);
|
||||||
|
console.log(`[ChatService] Lazy-loaded Blob URL for ${idStr}: ${url} (${dataCopy.length} bytes)`);
|
||||||
|
this.#blobUrls.set(idStr, url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Update reactive maps for UI
|
// Update reactive maps for UI
|
||||||
// Avatars/Banners
|
// Avatars/Banners
|
||||||
for (const user of this.users) {
|
for (const user of this.users) {
|
||||||
if (user.avatarId) {
|
if (user.avatarId) {
|
||||||
@@ -190,11 +212,18 @@ export class ChatService {
|
|||||||
const idStr = server.avatarId.toString();
|
const idStr = server.avatarId.toString();
|
||||||
const url = this.#blobUrls.get(idStr);
|
const url = this.#blobUrls.get(idStr);
|
||||||
if (url && this.#serverAvatarUrls.get(idStr) !== url) {
|
if (url && this.#serverAvatarUrls.get(idStr) !== url) {
|
||||||
|
console.log(`[ChatService] Mapping server avatar URL: ${idStr} -> ${url} for server ${server.name}`);
|
||||||
this.#serverAvatarUrls.set(idStr, url);
|
this.#serverAvatarUrls.set(idStr, url);
|
||||||
|
} else if (!url) {
|
||||||
|
// Check if we already requested this
|
||||||
|
if (!this.#blobUrls.has(idStr)) {
|
||||||
|
console.log(`[ChatService] Requesting missing server avatar blob for ${server.name}: ${idStr}`);
|
||||||
|
conn.reducers.requestImageBlob({ imageId: server.avatarId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Message attachments and others from visible_images
|
// Message attachments and others
|
||||||
for (const img of currentImages) {
|
for (const img of currentImages) {
|
||||||
const idStr = img.id.toString();
|
const idStr = img.id.toString();
|
||||||
const url = this.#blobUrls.get(idStr);
|
const url = this.#blobUrls.get(idStr);
|
||||||
@@ -482,7 +511,7 @@ export class ChatService {
|
|||||||
return this.#db.isUsersReady;
|
return this.#db.isUsersReady;
|
||||||
}
|
}
|
||||||
get isReady() {
|
get isReady() {
|
||||||
return this.#db.isReady && this.#msg.isGlobalSyncDone;
|
return this.#db.isReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isMessagesReady() {
|
get isMessagesReady() {
|
||||||
@@ -562,10 +591,45 @@ export class ChatService {
|
|||||||
|
|
||||||
// Derived Helpers
|
// Derived Helpers
|
||||||
get isActiveChannelVoice() {
|
get isActiveChannelVoice() {
|
||||||
return this.activeChannel?.kind.tag === "Voice";
|
const channelId = this.activeChannelId;
|
||||||
|
if (!channelId) return false;
|
||||||
|
|
||||||
|
// Helper to extract tag from various possible object structures
|
||||||
|
const getTag = (kind: any) => {
|
||||||
|
if (!kind) return null;
|
||||||
|
if (typeof kind.tag === 'string') return kind.tag.toLowerCase();
|
||||||
|
// Fallback for raw enum objects if the tag property is missing
|
||||||
|
return Object.keys(kind)[0]?.toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbChannel = this.#db.channels.find(c => c.id === channelId);
|
||||||
|
if (dbChannel && getTag(dbChannel.kind) === "voice") return true;
|
||||||
|
|
||||||
|
const server = this.activeServer;
|
||||||
|
const meta = server?.channels.find(c => c.id === channelId);
|
||||||
|
if (meta && getTag(meta.kind) === "voice") return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isActiveChannelText() {
|
get isActiveChannelText() {
|
||||||
return this.activeChannel?.kind.tag === "Text";
|
const channelId = this.activeChannelId;
|
||||||
|
if (!channelId) return false;
|
||||||
|
|
||||||
|
const getTag = (kind: any) => {
|
||||||
|
if (!kind) return null;
|
||||||
|
if (typeof kind.tag === 'string') return kind.tag.toLowerCase();
|
||||||
|
return Object.keys(kind)[0]?.toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbChannel = this.#db.channels.find(c => c.id === channelId);
|
||||||
|
if (dbChannel && getTag(dbChannel.kind) === "text") return true;
|
||||||
|
|
||||||
|
const server = this.activeServer;
|
||||||
|
const meta = server?.channels.find(c => c.id === channelId);
|
||||||
|
if (meta && getTag(meta.kind) === "text") return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
get textChannels() {
|
get textChannels() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export class DatabaseService {
|
|||||||
serverMembers = $state<readonly Types.ServerMember[]>([]);
|
serverMembers = $state<readonly Types.ServerMember[]>([]);
|
||||||
allThreads = $state<readonly Types.Thread[]>([]);
|
allThreads = $state<readonly Types.Thread[]>([]);
|
||||||
images = $state<readonly Types.VisibleImageRow[]>([]);
|
images = $state<readonly Types.VisibleImageRow[]>([]);
|
||||||
|
imageBlobs = $state<readonly Types.ImageData[]>([]);
|
||||||
customEmojis = $state<readonly Types.CustomEmoji[]>([]);
|
customEmojis = $state<readonly Types.CustomEmoji[]>([]);
|
||||||
userStates = $state<readonly Types.UserState[]>([]);
|
userStates = $state<readonly Types.UserState[]>([]);
|
||||||
typingActivity = $state<readonly Types.TypingActivity[]>([]);
|
typingActivity = $state<readonly Types.TypingActivity[]>([]);
|
||||||
@@ -45,6 +46,7 @@ export class DatabaseService {
|
|||||||
const [serverMembersStore, membersReadyStore] = useTable(tables.visible_server_members);
|
const [serverMembersStore, membersReadyStore] = useTable(tables.visible_server_members);
|
||||||
const [threadsStore] = useTable(tables.thread);
|
const [threadsStore] = useTable(tables.thread);
|
||||||
const [imagesStore, imagesReadyStore] = useTable(tables.visible_images);
|
const [imagesStore, imagesReadyStore] = useTable(tables.visible_images);
|
||||||
|
const [imageBlobsStore] = useTable(tables.visible_image_blobs);
|
||||||
const [customEmojisStore] = useTable(tables.custom_emoji);
|
const [customEmojisStore] = useTable(tables.custom_emoji);
|
||||||
const [typingActivityStore] = useTable(tables.visible_typing_activity);
|
const [typingActivityStore] = useTable(tables.visible_typing_activity);
|
||||||
const [systemConfigStore] = useTable(tables.system_configuration);
|
const [systemConfigStore] = useTable(tables.system_configuration);
|
||||||
@@ -61,17 +63,16 @@ export class DatabaseService {
|
|||||||
serverMembersStore.subscribe((v) => (this.serverMembers = v));
|
serverMembersStore.subscribe((v) => (this.serverMembers = v));
|
||||||
membersReadyStore.subscribe((v) => (this.isMembersReady = v));
|
membersReadyStore.subscribe((v) => (this.isMembersReady = v));
|
||||||
threadsStore.subscribe((v) => (this.allThreads = v));
|
threadsStore.subscribe((v) => (this.allThreads = v));
|
||||||
imagesStore.subscribe((v) => {
|
imagesStore.subscribe((v) => (this.images = v));
|
||||||
|
imagesReadyStore.subscribe((v) => (this.isImagesReady = v));
|
||||||
|
imageBlobsStore.subscribe((v) => {
|
||||||
// CRITICAL: We MUST copy the Uint8Array data immediately.
|
// CRITICAL: We MUST copy the Uint8Array data immediately.
|
||||||
// SpacetimeDB JS SDK uses a shared WASM buffer for byteArrays,
|
// SpacetimeDB JS SDK uses a shared WASM buffer for byteArrays.
|
||||||
// so if we don't copy it here, all image rows will eventually
|
this.imageBlobs = v.map(blob => ({
|
||||||
// point to the data of the last image fetched.
|
...blob,
|
||||||
this.images = v.map(img => ({
|
data: new Uint8Array(blob.data)
|
||||||
...img,
|
|
||||||
data: new Uint8Array(img.data)
|
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
imagesReadyStore.subscribe((v) => (this.isImagesReady = v));
|
|
||||||
customEmojisStore.subscribe((v) => (this.customEmojis = v));
|
customEmojisStore.subscribe((v) => (this.customEmojis = v));
|
||||||
typingActivityStore.subscribe((v) => (this.typingActivity = v));
|
typingActivityStore.subscribe((v) => (this.typingActivity = v));
|
||||||
systemConfigStore.subscribe((v) => (this.systemConfiguration = v));
|
systemConfigStore.subscribe((v) => (this.systemConfiguration = v));
|
||||||
|
|||||||
@@ -37,18 +37,17 @@ export class MessagingService {
|
|||||||
onMessageReceived?: (params: { channelId: bigint, senderIdentity: Identity, id: bigint, text: string, isEncrypted: boolean }) => void;
|
onMessageReceived?: (params: { channelId: bigint, senderIdentity: Identity, id: bigint, text: string, isEncrypted: boolean }) => void;
|
||||||
|
|
||||||
// Internal reactive state from SpacetimeDB
|
// Internal reactive state from SpacetimeDB
|
||||||
#mySubscriptions = $state<readonly Types.MyChannelSubscriptionRow[]>([]);
|
|
||||||
|
|
||||||
// Optimized Per-Channel/Per-Message Buckets
|
// Optimized Per-Channel/Per-Message Buckets
|
||||||
#channelBuckets = new SvelteMap<bigint, {
|
#channelBuckets = new SvelteMap<bigint, {
|
||||||
map: Map<bigint, Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] }>,
|
map: Map<bigint, Types.Message>,
|
||||||
sorted: (Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] })[]
|
sorted: Types.Message[]
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
isLoadingMore = $state(false);
|
isLoadingMore = $state(false);
|
||||||
#readyChannels = new SvelteSet<bigint>();
|
#readyChannels = new SvelteSet<bigint>();
|
||||||
isGlobalSyncDone = $state(false);
|
isGlobalSyncDone = $state(false);
|
||||||
encryptionOptIn = $state(new SvelteSet<string>());
|
encryptionOptIn = $state(new SvelteSet<string>());
|
||||||
|
#mySubscriptions = $state<readonly Types.MyChannelSubscriptionRow[]>([]);
|
||||||
|
|
||||||
get isMessagesReady() {
|
get isMessagesReady() {
|
||||||
const cid = this.#nav.activeChannelId;
|
const cid = this.#nav.activeChannelId;
|
||||||
@@ -92,10 +91,10 @@ export class MessagingService {
|
|||||||
const [visibleScrollbackStore] = useTable(tables.visible_scrollback_messages);
|
const [visibleScrollbackStore] = useTable(tables.visible_scrollback_messages);
|
||||||
const [mySubscriptionsStore] = useTable(tables.my_channel_subscriptions);
|
const [mySubscriptionsStore] = useTable(tables.my_channel_subscriptions);
|
||||||
|
|
||||||
type CombinedMessageRow = Types.RecentMessage | Types.VisibleMessageRow;
|
mySubscriptionsStore.subscribe((v) => (this.#mySubscriptions = v));
|
||||||
|
|
||||||
let recentMessages: readonly Types.RecentMessage[] = [];
|
let recentMessages: readonly Types.Message[] = [];
|
||||||
let scrollbackMessages: readonly Types.VisibleMessageRow[] = [];
|
let scrollbackMessages: readonly Types.Message[] = [];
|
||||||
|
|
||||||
// Incremental update logic for visible messages
|
// Incremental update logic for visible messages
|
||||||
const seenMessageIds = new Set<bigint>();
|
const seenMessageIds = new Set<bigint>();
|
||||||
@@ -157,8 +156,6 @@ export class MessagingService {
|
|||||||
this.#updateBuckets([...recentMessages, ...scrollbackMessages]);
|
this.#updateBuckets([...recentMessages, ...scrollbackMessages]);
|
||||||
});
|
});
|
||||||
|
|
||||||
mySubscriptionsStore.subscribe((v) => (this.#mySubscriptions = v));
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const channelId = this.#nav.activeChannelId;
|
const channelId = this.#nav.activeChannelId;
|
||||||
const identity = this.#identity();
|
const identity = this.#identity();
|
||||||
@@ -172,6 +169,7 @@ export class MessagingService {
|
|||||||
// 1. Global/Session-long queries
|
// 1. Global/Session-long queries
|
||||||
queries.push("SELECT * FROM upload_status");
|
queries.push("SELECT * FROM upload_status");
|
||||||
queries.push("SELECT * FROM visible_images");
|
queries.push("SELECT * FROM visible_images");
|
||||||
|
queries.push("SELECT * FROM visible_image_blobs");
|
||||||
|
|
||||||
if (identity) {
|
if (identity) {
|
||||||
const idHex = identity.toHexString();
|
const idHex = identity.toHexString();
|
||||||
@@ -185,10 +183,7 @@ export class MessagingService {
|
|||||||
queries.push(`SELECT * FROM visible_direct_messages`);
|
queries.push(`SELECT * FROM visible_direct_messages`);
|
||||||
queries.push(`SELECT * FROM my_channel_subscriptions`);
|
queries.push(`SELECT * FROM my_channel_subscriptions`);
|
||||||
|
|
||||||
// Recent messages for all joined channels/DMs
|
// WebRTC Signaling (Needs to stay global for incoming calls)
|
||||||
queries.push(`SELECT * FROM visible_recent_activity`);
|
|
||||||
|
|
||||||
// WebRTC Signaling
|
|
||||||
queries.push(`SELECT * FROM visible_webrtc_signals`);
|
queries.push(`SELECT * FROM visible_webrtc_signals`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +193,10 @@ export class MessagingService {
|
|||||||
queries.push(`SELECT * FROM visible_scrollback_messages`);
|
queries.push(`SELECT * FROM visible_scrollback_messages`);
|
||||||
queries.push(`SELECT * FROM thread WHERE channel_id = ${channelId}`);
|
queries.push(`SELECT * FROM thread WHERE channel_id = ${channelId}`);
|
||||||
queries.push(`SELECT * FROM visible_user_states`);
|
queries.push(`SELECT * FROM visible_user_states`);
|
||||||
queries.push(`SELECT * FROM visible_typing_activity WHERE channel_id = ${channelId}`);
|
queries.push(`SELECT * FROM visible_typing_activity`);
|
||||||
|
|
||||||
|
// Fast-path recent activity for the ACTIVE channel only
|
||||||
|
queries.push(`SELECT * FROM visible_recent_activity WHERE channel_id = ${channelId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[MessagingService] Updating subscriptions: ${queries.length} queries`);
|
console.log(`[MessagingService] Updating subscriptions: ${queries.length} queries`);
|
||||||
@@ -213,8 +211,8 @@ export class MessagingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateBuckets(newMessages: readonly (Types.RecentMessage | Types.VisibleMessageRow)[]) {
|
#updateBuckets(newMessages: readonly Types.Message[]) {
|
||||||
const tempBuckets = new Map<bigint, Map<bigint, Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] }>>();
|
const tempBuckets = new Map<bigint, Map<bigint, Types.Message>>();
|
||||||
|
|
||||||
for (const m of newMessages) {
|
for (const m of newMessages) {
|
||||||
let bucketMap = tempBuckets.get(m.channelId);
|
let bucketMap = tempBuckets.get(m.channelId);
|
||||||
@@ -222,23 +220,15 @@ export class MessagingService {
|
|||||||
bucketMap = new Map();
|
bucketMap = new Map();
|
||||||
tempBuckets.set(m.channelId, bucketMap);
|
tempBuckets.set(m.channelId, bucketMap);
|
||||||
}
|
}
|
||||||
bucketMap.set(m.id, {
|
bucketMap.set(m.id, m);
|
||||||
...(m as unknown as Types.Message),
|
|
||||||
seqId: m.seqId,
|
|
||||||
reactions: m.reactions,
|
|
||||||
imageIds: m.imageIds
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#channelBuckets.clear();
|
this.#channelBuckets.clear();
|
||||||
|
|
||||||
for (const [chanId, messagesMap] of tempBuckets.entries()) {
|
for (const [chanId, messagesMap] of tempBuckets.entries()) {
|
||||||
const sorted = Array.from(messagesMap.values()).sort((a, b) => {
|
const sorted = Array.from(messagesMap.values()).sort((a, b) => {
|
||||||
if (a.seqId !== undefined && b.seqId !== undefined) {
|
if (a.seqId < b.seqId) return -1;
|
||||||
if (a.seqId < b.seqId) return -1;
|
if (a.seqId > b.seqId) return 1;
|
||||||
if (a.seqId > b.seqId) return 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1;
|
return a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -247,7 +237,7 @@ export class MessagingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get synchronizedMessages() {
|
get synchronizedMessages() {
|
||||||
const all: (Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] })[] = [];
|
const all: Types.Message[] = [];
|
||||||
for (const bucket of this.#channelBuckets.values()) {
|
for (const bucket of this.#channelBuckets.values()) {
|
||||||
all.push(...bucket.sorted);
|
all.push(...bucket.sorted);
|
||||||
}
|
}
|
||||||
@@ -308,12 +298,18 @@ export class MessagingService {
|
|||||||
const channelId = this.#nav.activeChannelId;
|
const channelId = this.#nav.activeChannelId;
|
||||||
if (!channelId) return false;
|
if (!channelId) return false;
|
||||||
|
|
||||||
const sub = this.#mySubscriptions.find(s => s.channelId === channelId);
|
const sub = this.#mySubscriptions.find((s) => s.channelId === channelId);
|
||||||
if (!sub) return false;
|
if (!sub) return false;
|
||||||
|
|
||||||
|
// Check if the earliest message we have is > 1
|
||||||
|
// OR if we don't have all messages up to the latest head
|
||||||
|
const bucket = this.#channelBuckets.get(channelId);
|
||||||
|
if (!bucket || bucket.sorted.length === 0) return sub.lastSeqId > 0n;
|
||||||
|
|
||||||
return sub.earliestSeqId > 1n;
|
return sub.earliestSeqId > 1n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
handleStartThread = (msg: Types.Message) => {
|
handleStartThread = (msg: Types.Message) => {
|
||||||
const existing = this.#db.allThreads.find((t) => t.parentMessageId === msg.id);
|
const existing = this.#db.allThreads.find((t) => t.parentMessageId === msg.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -373,7 +369,7 @@ export class MessagingService {
|
|||||||
const msgs = this.channelMessages;
|
const msgs = this.channelMessages;
|
||||||
if (msgs.length === 0) return false;
|
if (msgs.length === 0) return false;
|
||||||
|
|
||||||
const oldestMsg = msgs[0] as any;
|
const oldestMsg = msgs[0];
|
||||||
const oldestSeq = oldestMsg.seqId;
|
const oldestSeq = oldestMsg.seqId;
|
||||||
|
|
||||||
if (oldestSeq === undefined || oldestSeq <= 1n) {
|
if (oldestSeq === undefined || oldestSeq <= 1n) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DatabaseService } from "./database.svelte";
|
import { DatabaseService } from "./database.svelte";
|
||||||
import { Identity } from "spacetimedb";
|
import { Identity } from "spacetimedb";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
|
import * as Types from "../../module_bindings/types";
|
||||||
|
|
||||||
export class NavigationService {
|
export class NavigationService {
|
||||||
activeServerId = $state<bigint | null>(null);
|
activeServerId = $state<bigint | null>(null);
|
||||||
@@ -134,16 +135,24 @@ export class NavigationService {
|
|||||||
const channelId = this.activeChannelId;
|
const channelId = this.activeChannelId;
|
||||||
if (!channelId) return undefined;
|
if (!channelId) return undefined;
|
||||||
|
|
||||||
const channel = this.#db.channels.find(c => c.id === channelId);
|
// 1. Try to find in the synchronized channel rows
|
||||||
if (!channel) return undefined;
|
const dbChannel = this.#db.channels.find(c => c.id === channelId);
|
||||||
|
if (dbChannel) return dbChannel;
|
||||||
|
|
||||||
if (channel.serverId !== 0n) {
|
// 2. Fallback to active server metadata if available
|
||||||
|
const server = this.activeServer;
|
||||||
|
if (server) {
|
||||||
|
const meta = server.channels.find(c => c.id === channelId);
|
||||||
|
if (meta) {
|
||||||
return {
|
return {
|
||||||
...channel,
|
id: meta.id,
|
||||||
serverId: channel.serverId
|
serverId: server.id,
|
||||||
};
|
name: meta.name,
|
||||||
|
kind: meta.kind
|
||||||
|
} as Types.VisibleChannelRow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return channel;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ export class VoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleJoinVoice = (channelId: bigint) => {
|
handleJoinVoice = (channelId: bigint) => {
|
||||||
|
// Only join if not already in this channel
|
||||||
|
if (this.currentVoiceState?.channelId === channelId) {
|
||||||
|
console.log("VoiceService: Already in this channel, skipping join.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
sounds.playConnect();
|
sounds.playConnect();
|
||||||
this.#joinVoiceReducer({ channelId });
|
this.#joinVoiceReducer({ channelId });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -268,4 +268,19 @@ export class WebRTCService {
|
|||||||
toggleDeafen = () => this.localMedia.toggleDeafen();
|
toggleDeafen = () => this.localMedia.toggleDeafen();
|
||||||
setPeerAudioPreference = (peerIdHex: string, pref: any) =>
|
setPeerAudioPreference = (peerIdHex: string, pref: any) =>
|
||||||
this.voice.peerManager.setPeerAudioPreference(peerIdHex, pref);
|
this.voice.peerManager.setPeerAudioPreference(peerIdHex, pref);
|
||||||
|
|
||||||
|
getRemoteStream = (peerIdHex: string, type: 'voice' | 'screen'): MediaStream | undefined => {
|
||||||
|
if (type === 'voice') {
|
||||||
|
const peer = this.voice.peerManager.peers.get(peerIdHex);
|
||||||
|
if (peer?.gainNode) {
|
||||||
|
// Voice streams are managed via Web Audio API, but we return a MediaStream if needed
|
||||||
|
// For standard voice participants, the audio is already connected to ctx.destination
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} else {
|
||||||
|
const peer = this.screen.peerManager.peers.get(peerIdHex);
|
||||||
|
return peer?.videoStream;
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-174
@@ -6,82 +6,61 @@ import { connectionState } from "./connection.svelte";
|
|||||||
export { HOST_KEY, DB_NAME_KEY, getEnv };
|
export { HOST_KEY, DB_NAME_KEY, getEnv };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes the host URL for SpacetimeDB.
|
* Normalizes the host URL for SpacetimeDB URI construction.
|
||||||
* This field now takes a hostname without a protocol and assumes https://.
|
|
||||||
* The SDK automatically handles the upgrade from https:// to wss://.
|
|
||||||
*/
|
*/
|
||||||
export const normalizeHost = (host: string) => {
|
export const normalizeHost = (host: string) => {
|
||||||
let normalized = host.trim().replace(/\/+$/, "");
|
let normalized = host.trim().replace(/\/+$/, "");
|
||||||
|
|
||||||
// Remove any existing protocol (http, https, ws, wss)
|
|
||||||
normalized = normalized.replace(/^(https?|wss?):\/\//, "");
|
normalized = normalized.replace(/^(https?|wss?):\/\//, "");
|
||||||
|
|
||||||
// Default to https:// (or http:// for local dev)
|
|
||||||
const isLocal = normalized.includes("localhost") || normalized.includes("127.0.0.1");
|
const isLocal = normalized.includes("localhost") || normalized.includes("127.0.0.1");
|
||||||
const protocol = isLocal ? "http://" : "https://";
|
const protocol = isLocal ? "http://" : "https://";
|
||||||
|
|
||||||
return `${protocol}${normalized}`;
|
return `${protocol}${normalized}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TokenStore = {
|
export const TokenStore = {
|
||||||
get: (host: string, dbName: string) => {
|
get: (host: string, dbName: string) => {
|
||||||
const key = `stdb_token:${normalizeHost(host)}:${dbName}`;
|
try {
|
||||||
return localStorage.getItem(key);
|
const dataStr = localStorage.getItem("stdb_connection_data");
|
||||||
|
if (!dataStr) return null;
|
||||||
|
const data = JSON.parse(dataStr);
|
||||||
|
|
||||||
|
const normalizedStoredHost = normalizeHost(data.host);
|
||||||
|
const normalizedCurrentHost = normalizeHost(host);
|
||||||
|
|
||||||
|
if (normalizedStoredHost === normalizedCurrentHost && data.dbName === dbName) {
|
||||||
|
// Simple expiration check: if the token is older than 30 days, ignore it
|
||||||
|
const thirtyDaysInMs = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
if (data.timestamp && Date.now() - data.timestamp > thirtyDaysInMs) {
|
||||||
|
console.warn("[TokenStore] Stored token is older than 30 days, treating as expired.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
console.log(`[TokenStore] Retrieved valid token for ${data.host}:${data.dbName} (age: ${data.timestamp ? Math.round((Date.now() - data.timestamp) / 1000 / 60) : 'unknown'} mins)`);
|
||||||
|
return data.token;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[TokenStore] Error parsing connection data:", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
set: (host: string, dbName: string, token: string) => {
|
set: (host: string, dbName: string, token: string) => {
|
||||||
const key = `stdb_token:${normalizeHost(host)}:${dbName}`;
|
const normalizedHostStr = normalizeHost(host);
|
||||||
console.log("TokenStore: Setting token for key:", key);
|
console.log(`[TokenStore] Persisting new token for ${normalizedHostStr}:${dbName}`);
|
||||||
localStorage.setItem(key, token);
|
localStorage.setItem("stdb_connection_data", JSON.stringify({
|
||||||
|
host: normalizedHostStr,
|
||||||
|
dbName,
|
||||||
|
token,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
clear: (host: string, dbName: string) => {
|
clear: () => {
|
||||||
const key = `stdb_token:${normalizeHost(host)}:${dbName}`;
|
console.log("[TokenStore] Clearing connection data.");
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem("stdb_connection_data");
|
||||||
},
|
|
||||||
listStoredConnections: () => {
|
|
||||||
const connections: string[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (!key) continue;
|
|
||||||
|
|
||||||
let connStr: string | null = null;
|
|
||||||
|
|
||||||
if (key.startsWith("stdb_token:")) {
|
|
||||||
// New format: stdb_token:protocol://host[:port]:database
|
|
||||||
const parts = key.split(":");
|
|
||||||
if (parts.length >= 4) {
|
|
||||||
const dbName = parts[parts.length - 1];
|
|
||||||
const hostParts = parts.slice(2, parts.length - 1);
|
|
||||||
const host = hostParts.join(":").replace(/^\/\//, "");
|
|
||||||
connStr = `${host}:${dbName}`;
|
|
||||||
}
|
|
||||||
} else if (key.endsWith("/auth_token")) {
|
|
||||||
// Legacy format: protocol://host[:port]/database/auth_token
|
|
||||||
// Example: http://localhost:3000/zep/auth_token
|
|
||||||
const parts = key.split("/");
|
|
||||||
// parts: ["http:", "", "localhost:3000", "zep", "auth_token"]
|
|
||||||
if (parts.length >= 5) {
|
|
||||||
const dbName = parts[parts.length - 2];
|
|
||||||
const host = parts[2];
|
|
||||||
if (dbName && host) {
|
|
||||||
connStr = `${host}:${dbName}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connStr && !seen.has(connStr)) {
|
|
||||||
connections.push(connStr);
|
|
||||||
seen.add(connStr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return connections;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStdbHost = () =>
|
export const getStdbHost = () =>
|
||||||
localStorage.getItem(HOST_KEY) ||
|
localStorage.getItem(HOST_KEY) ||
|
||||||
getEnv("VITE_SPACETIMEDB_HOST", "connect.zep.chat");
|
getEnv("VITE_SPACETIMEDB_HOST", "connect.zep.chat");
|
||||||
|
|
||||||
export const getStdbDbName = () =>
|
export const getStdbDbName = () =>
|
||||||
localStorage.getItem(DB_NAME_KEY) ||
|
localStorage.getItem(DB_NAME_KEY) ||
|
||||||
getEnv("VITE_SPACETIMEDB_DB_NAME", "zep");
|
getEnv("VITE_SPACETIMEDB_DB_NAME", "zep");
|
||||||
@@ -89,158 +68,127 @@ export const getStdbDbName = () =>
|
|||||||
let _connection: DbConnection | null = null;
|
let _connection: DbConnection | null = null;
|
||||||
export const getConnection = () => _connection;
|
export const getConnection = () => _connection;
|
||||||
|
|
||||||
let activeManager: ConnectionManager | null = null;
|
|
||||||
|
|
||||||
export const stopActiveConnection = () => {
|
export const stopActiveConnection = () => {
|
||||||
if (activeManager) {
|
|
||||||
activeManager.stop();
|
|
||||||
activeManager = null;
|
|
||||||
}
|
|
||||||
if (_connection) {
|
if (_connection) {
|
||||||
_connection.disconnect();
|
_connection.disconnect();
|
||||||
_connection = null;
|
_connection = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
class ConnectionManager {
|
let lastSyncedIdentity: string | null = null;
|
||||||
#retryCount = 0;
|
|
||||||
#reconnectTimeout: any = null;
|
|
||||||
#host: string;
|
|
||||||
#dbName: string;
|
|
||||||
#isStopped = false;
|
|
||||||
|
|
||||||
constructor(host: string, dbName: string) {
|
export const handleConnect = (conn: DbConnection, identity: any, token: string, isOIDC: boolean = false) => {
|
||||||
this.#host = host;
|
const host = getStdbHost();
|
||||||
this.#dbName = dbName;
|
const dbName = getStdbDbName();
|
||||||
|
const identityHex = identity?.toHexString();
|
||||||
|
|
||||||
|
console.log(`[Handshake] Connection established! Identity: ${identityHex}, Token received: ${!!token}, OIDC: ${isOIDC}`);
|
||||||
|
|
||||||
|
_connection = conn;
|
||||||
|
|
||||||
|
if (token && !isOIDC) {
|
||||||
|
console.log("[Handshake] Persisting Guest session token.");
|
||||||
|
TokenStore.set(host, dbName, token);
|
||||||
|
} else if (isOIDC) {
|
||||||
|
// For OIDC users, we DON'T store the SpacetimeDB session token in localStorage.
|
||||||
|
// We rely entirely on the OIDC provider's tokens in sessionStorage.
|
||||||
|
// We clear any legacy tokens to ensure OIDC is the single source of truth.
|
||||||
|
console.log("[Handshake] OIDC session active, ensuring localStorage is clean of stale tokens.");
|
||||||
|
TokenStore.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop = () => {
|
connectionState.status = "connected";
|
||||||
this.#isStopped = true;
|
connectionState.hasConnectedOnce = true;
|
||||||
if (this.#reconnectTimeout) {
|
connectionState.error = null;
|
||||||
clearTimeout(this.#reconnectTimeout);
|
|
||||||
this.#reconnectTimeout = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onConnect = (conn: DbConnection, identity: any, token: string) => {
|
if (identityHex !== lastSyncedIdentity) {
|
||||||
console.log("ConnectionManager: onConnect called! Identity:", identity?.toHexString(), "Token length:", token?.length);
|
console.log("[Handshake] New identity detected, syncing server-side auth metadata...");
|
||||||
if (this.#isStopped) {
|
lastSyncedIdentity = identityHex;
|
||||||
console.log("ConnectionManager: Manager is stopped, ignoring connect.");
|
|
||||||
conn.disconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_connection = conn;
|
|
||||||
console.log("ConnectionManager: Storing token for:", this.#host, this.#dbName);
|
|
||||||
TokenStore.set(this.#host, this.#dbName, token);
|
|
||||||
console.log(
|
|
||||||
"Connected to SpacetimeDB with identity:",
|
|
||||||
identity.toHexString(),
|
|
||||||
);
|
|
||||||
|
|
||||||
connectionState.status = "connected";
|
|
||||||
connectionState.hasConnectedOnce = true;
|
|
||||||
connectionState.error = null;
|
|
||||||
|
|
||||||
// Call the auth update reducer to ensure OIDC info is synced
|
|
||||||
// We use setTimeout to ensure the connection is fully processed by the SDK
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (_connection) {
|
if (_connection && (_connection.reducers as any).updateAuthInfo) {
|
||||||
console.log("ConnectionManager: Requesting auth info update...");
|
|
||||||
(_connection.reducers as any).updateAuthInfo({});
|
(_connection.reducers as any).updateAuthInfo({});
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
this.#retryCount = 0;
|
let lastRefreshAttempt = 0;
|
||||||
if (this.#reconnectTimeout) {
|
|
||||||
clearTimeout(this.#reconnectTimeout);
|
export const handleConnectError = async (err: Error) => {
|
||||||
this.#reconnectTimeout = null;
|
const host = getStdbHost();
|
||||||
|
const dbName = getStdbDbName();
|
||||||
|
console.log("[Handshake] Error connecting to SpacetimeDB:", err);
|
||||||
|
|
||||||
|
const errStr = err.message || "";
|
||||||
|
const isAuthError =
|
||||||
|
errStr.includes("401") ||
|
||||||
|
errStr.toLowerCase().includes("unauthorized") ||
|
||||||
|
errStr.toLowerCase().includes("timeout") ||
|
||||||
|
errStr.toLowerCase().includes("identity") ||
|
||||||
|
errStr.toLowerCase().includes("token");
|
||||||
|
|
||||||
|
if (isAuthError) {
|
||||||
|
console.warn(`[Handshake] Auth or Timeout error for ${host}:${dbName}: ${errStr}`);
|
||||||
|
|
||||||
|
const { auth } = await import("./auth/auth.svelte");
|
||||||
|
|
||||||
|
// Only clear TokenStore if we are NOT an OIDC user (Guest mode)
|
||||||
|
// or if we explicitly failed a refresh.
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
TokenStore.clear();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
onDisconnect = () => {
|
// If we are already refreshing or loading, don't trigger another one
|
||||||
if (this.#isStopped) return;
|
if (auth.isLoading || auth.isRefreshing) {
|
||||||
console.log("Disconnected from SpacetimeDB");
|
console.log("[Handshake] Auth is already loading/refreshing, skipping redundant recovery.");
|
||||||
_connection = null;
|
return;
|
||||||
connectionState.status = "disconnected";
|
}
|
||||||
this.#scheduleReconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
onConnectError = (_ctx: any, err: Error) => {
|
const now = Date.now();
|
||||||
if (this.#isStopped) return;
|
// Only attempt recovery if we haven't tried in the last 30 seconds to prevent loops
|
||||||
console.log("Error connecting to SpacetimeDB:", err);
|
if (auth.isAuthenticated && (now - lastRefreshAttempt > 30000)) {
|
||||||
connectionState.error = err.message;
|
console.log("[Handshake] User is authenticated with OIDC, attempting silent refresh...");
|
||||||
this.#scheduleReconnect();
|
lastRefreshAttempt = now;
|
||||||
};
|
const success = await auth.forceTokenRefresh();
|
||||||
|
if (success) {
|
||||||
|
console.log("[Handshake] Silent refresh triggered successfully. Connection should auto-rebuild.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#scheduleReconnect = () => {
|
console.warn("[Handshake] Recovery failed, not applicable, or looping. Purging session...");
|
||||||
if (this.#isStopped || this.#reconnectTimeout) return;
|
// auth.logout() will reload the page and clear oidc state
|
||||||
|
auth.logout();
|
||||||
const delay = Math.min(120000, Math.pow(5, this.#retryCount) * 1000);
|
return;
|
||||||
console.log(
|
}
|
||||||
`Scheduling reconnect in ${delay}ms (attempt ${this.#retryCount + 1})`,
|
connectionState.error = err.message;
|
||||||
);
|
};
|
||||||
|
|
||||||
this.#reconnectTimeout = setTimeout(() => {
|
|
||||||
this.#reconnectTimeout = null;
|
|
||||||
if (this.#isStopped) return;
|
|
||||||
this.#retryCount++;
|
|
||||||
console.log(
|
|
||||||
"ConnectionManager: Reconnect delay reached. Reloading window...",
|
|
||||||
);
|
|
||||||
window.location.reload();
|
|
||||||
}, delay);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const connectionBuilder = (oidcToken?: string) => {
|
export const connectionBuilder = (oidcToken?: string) => {
|
||||||
const rawHost = getStdbHost();
|
const host = normalizeHost(getStdbHost());
|
||||||
const host = normalizeHost(rawHost);
|
|
||||||
const dbName = getStdbDbName();
|
const dbName = getStdbDbName();
|
||||||
|
|
||||||
console.log(`connectionBuilder: Using host: ${host} (raw: ${rawHost}), database: ${dbName}`);
|
console.log(`[Builder] Creating handshake: host=${host}, database=${dbName}, mode=${oidcToken ? 'OIDC' : 'Token/Guest'}`);
|
||||||
|
|
||||||
connectionState.status = "connecting";
|
|
||||||
|
|
||||||
if (activeManager) {
|
|
||||||
console.log("connectionBuilder: Stopping previous activeManager");
|
|
||||||
activeManager.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
const manager = new ConnectionManager(host, dbName);
|
|
||||||
activeManager = manager;
|
|
||||||
|
|
||||||
const builder = DbConnection.builder()
|
const builder = DbConnection.builder()
|
||||||
.withUri(host)
|
.withUri(host)
|
||||||
.withDatabaseName(dbName);
|
.withDatabaseName(dbName);
|
||||||
|
|
||||||
const storedToken = TokenStore.get(host, dbName);
|
// CRITICAL: If we have an OIDC token, use it exclusively.
|
||||||
console.log("connectionBuilder: oidcToken:", oidcToken ? "present" : "absent");
|
// Stored tokens in TokenStore are ONLY for guest mode.
|
||||||
console.log("connectionBuilder: storedToken:", storedToken ? "present" : "absent");
|
|
||||||
|
|
||||||
if (oidcToken) {
|
if (oidcToken) {
|
||||||
console.log("connectionBuilder: Calling withToken with oidcToken");
|
console.log("[Builder] Using OIDC token for handshake.");
|
||||||
builder.withToken(oidcToken);
|
builder.withToken(oidcToken);
|
||||||
} else if (storedToken) {
|
|
||||||
console.log("connectionBuilder: Calling withToken with storedToken");
|
|
||||||
builder.withToken(storedToken);
|
|
||||||
} else {
|
} else {
|
||||||
console.log("connectionBuilder: No token, will connect anonymously");
|
const storedToken = TokenStore.get(host, dbName);
|
||||||
|
if (storedToken) {
|
||||||
|
console.log("[Builder] Using existing stored Guest token for handshake.");
|
||||||
|
builder.withToken(storedToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register our manager's listeners directly on the connection object.
|
builder.onConnect((conn, identity, token) => handleConnect(conn as any, identity, token, !!oidcToken));
|
||||||
// This ensures they coexist with the Svelte provider's listeners.
|
builder.onConnectError((_ctx, err) => handleConnectError(err));
|
||||||
builder.onConnect((c: any, id: any, token: string) => {
|
|
||||||
console.log("connectionBuilder: CONNECTION onConnect TRIGGERED!");
|
|
||||||
manager.onConnect(c as any, id, token);
|
|
||||||
});
|
|
||||||
builder.onDisconnect((_ctx: any, err: any) => {
|
|
||||||
console.log("connectionBuilder: CONNECTION onDisconnect TRIGGERED!", err);
|
|
||||||
manager.onDisconnect();
|
|
||||||
});
|
|
||||||
builder.onConnectError((ctx: any, err: any) => {
|
|
||||||
console.log("connectionBuilder: CONNECTION onConnectError TRIGGERED!", err);
|
|
||||||
manager.onConnectError(ctx, err);
|
|
||||||
});
|
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user