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:
2026-04-20 21:47:03 -04:00
32 changed files with 2622 additions and 3777 deletions
-63
View File
@@ -1,63 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Dependency directory
node_modules/
jspm_packages/
# Build output
dist/**
out/
build/
# IDEs and editors
.idea
.vscode/
*.iml
*~
# Misc
.DS_Store
*.env.local
.env.development.local
.env.test.local
.env.production.local
# Vite
.vite/
# SpacetimeDB build output
spacetimedb/dist/
spacetimedb/target/
# SpacetimeDB generated module bindings
src/module_bindings/**
# Tauri & Rust
src-tauri/target/
src-tauri/gen/
# Rust's Cargo.lock is often committed in binary/app projects,
# but target/ is always ignored.
# Ignore this file
.gitignore
# wrangler files
.wrangler
.dev.vars*
!.dev.vars.example
!.env.example
+52 -14
View File
@@ -60,14 +60,12 @@ pub fn init(ctx: &ReducerContext) {
server_id: s.id,
name: "general".to_string(),
kind: ChannelKind::Text,
last_seq_id: 0,
});
let c2 = ctx.db.channel().insert(Channel {
id: 0,
server_id: s.id,
name: "Voice General".to_string(),
kind: ChannelKind::Voice,
last_seq_id: 0,
});
let mut s = ctx.db.server().id().find(s.id).unwrap();
@@ -75,15 +73,16 @@ pub fn init(ctx: &ReducerContext) {
id: c1.id,
name: c1.name,
kind: c1.kind,
last_seq_id: 0,
});
s.channels.push(ChannelMetadata {
id: c2.id,
name: c2.name,
kind: c2.kind,
last_seq_id: 0,
});
ctx.db.server().id().update(s);
ctx.db.server().id().update(s.clone());
// Grant access to system user
sync_server_access(&ctx.db, system_identity, s.id);
}
}
@@ -91,18 +90,33 @@ pub fn init(ctx: &ReducerContext) {
pub fn on_connect(ctx: &ReducerContext) {
log::info!("on_connect START: identity={}", ctx.sender().to_hex());
// We'll keep this extremely minimal to ensure connection stability
// Extract potential name from OIDC if available
let mut initial_name = None;
let mut is_anon = true;
if let Some(jwt) = ctx.sender_auth().jwt() {
let sub = jwt.subject();
let issuer = jwt.issuer();
// Use first 8 chars of sub if it's a long string/UUID
initial_name = Some(if sub.len() > 12 { sub[..8].to_string() } else { sub.to_string() });
is_anon = issuer.contains("localhost");
}
if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) {
user.online = true;
// Update name from OIDC if current user has no name
if user.name.is_none() && initial_name.is_some() {
user.name = initial_name;
}
user.anonymous = is_anon;
ctx.db.user().identity().update(user);
} else {
ctx.db.user().insert(User {
identity: ctx.sender(),
name: None,
name: initial_name,
online: true,
issuer: None,
subject: None,
anonymous: true,
anonymous: is_anon,
avatar_id: None,
banner_id: None,
biography: None,
@@ -111,13 +125,27 @@ pub fn on_connect(ctx: &ReducerContext) {
});
// Minimal auto-join
auto_join_community_server(&ctx.db, ctx.sender());
join_server(ctx, 1);
// System Welcome DM
let system_identity = Identity::from_hex(SYSTEM_IDENTITY).unwrap();
let channel_id = internal_open_direct_message(&ctx.db, system_identity, ctx.sender());
let welcome_text = "Welcome to Zep! We're glad to have you here.\n\nZep is a decentralized, private, and fast chat service built on SpacetimeDB. You can join servers, create channels, and message friends directly—all with the security and performance of a modern relational backend.";
internal_send_message(&ctx.db, system_identity, channel_id, welcome_text.to_string(), ctx.timestamp);
internal_send_message(
&ctx.db,
system_identity,
channel_id,
welcome_text.to_string(),
ctx.timestamp,
None,
vec![],
false,
);
}
// High Performance: Sync all channel access for this user
for member in ctx.db.server_member().identity().filter(ctx.sender()) {
sync_server_access(&ctx.db, ctx.sender(), member.server_id);
}
sync_server_member_info(&ctx.db, ctx.sender());
@@ -147,12 +175,22 @@ pub fn update_auth_info(ctx: &ReducerContext) {
log::info!("update_auth_info: identity={}", ctx.sender().to_hex());
if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) {
if let Some(jwt) = ctx.sender_auth().jwt() {
user.issuer = Some(jwt.issuer().to_string());
user.subject = Some(jwt.subject().to_string());
user.anonymous = false;
let sub = jwt.subject();
let issuer = jwt.issuer();
user.issuer = Some(issuer.to_string());
user.subject = Some(sub.to_string());
// Flag as anonymous if issuer is localhost
user.anonymous = issuer.contains("localhost");
// Also update name if they don't have a custom one yet
if user.name.is_none() {
user.name = Some(if sub.len() > 12 { sub[..8].to_string() } else { sub.to_string() });
}
log::info!("update_auth_info: updated user with OIDC info (anon={})", user.anonymous);
ctx.db.user().identity().update(user);
sync_server_member_info(&ctx.db, ctx.sender());
log::info!("update_auth_info: updated user with OIDC info");
}
}
}
File diff suppressed because it is too large Load Diff
+51 -26
View File
@@ -7,6 +7,7 @@ pub enum ChannelKind {
}
#[spacetimedb::table(accessor = user, public)]
#[derive(Clone)]
pub struct User {
#[primary_key]
pub identity: Identity,
@@ -28,10 +29,10 @@ pub struct ChannelMetadata {
pub id: u64,
pub name: String,
pub kind: ChannelKind,
pub last_seq_id: u64,
}
#[spacetimedb::table(accessor = server)]
#[derive(Clone)]
pub struct Server {
#[primary_key]
#[auto_inc]
@@ -45,6 +46,7 @@ pub struct Server {
}
#[spacetimedb::table(accessor = server_member)]
#[derive(Clone)]
pub struct ServerMember {
#[primary_key]
#[auto_inc]
@@ -58,7 +60,28 @@ pub struct ServerMember {
pub online: bool,
}
#[spacetimedb::table(accessor = channel_internal_state)]
#[derive(Clone)]
pub struct ChannelInternalState {
#[primary_key]
pub channel_id: u64,
pub last_seq_id: u64,
}
#[spacetimedb::table(accessor = user_channel_access)]
#[derive(Clone)]
pub struct UserChannelAccess {
#[primary_key]
#[auto_inc]
pub id: u64,
#[index(btree)]
pub identity: Identity,
#[index(btree)]
pub channel_id: u64,
}
#[spacetimedb::table(accessor = channel)]
#[derive(Clone)]
pub struct Channel {
#[primary_key]
#[auto_inc]
@@ -67,7 +90,6 @@ pub struct Channel {
pub server_id: u64, // 0 if no server (DM)
pub name: String,
pub kind: ChannelKind,
pub last_seq_id: u64,
}
#[spacetimedb::table(accessor = direct_message)]
@@ -116,6 +138,7 @@ pub enum MediaType {
}
#[spacetimedb::table(accessor = webrtc_signal)]
#[derive(Clone)]
pub struct WebRTCSignal {
#[primary_key]
#[auto_inc]
@@ -132,6 +155,7 @@ pub struct WebRTCSignal {
}
#[spacetimedb::table(accessor = channel_subscription)]
#[derive(Clone)]
pub struct ChannelSubscription {
#[primary_key]
pub identity: Identity,
@@ -142,6 +166,7 @@ pub struct ChannelSubscription {
}
#[spacetimedb::table(accessor = thread, public)]
#[derive(Clone)]
pub struct Thread {
#[primary_key]
#[auto_inc]
@@ -173,6 +198,8 @@ pub struct Message {
#[index(btree)]
pub channel_id: u64,
#[index(btree)]
pub server_id: u64,
#[index(btree)]
pub thread_id: Option<u64>,
pub reactions: Vec<Reaction>,
pub image_ids: Vec<u64>,
@@ -180,10 +207,12 @@ pub struct Message {
pub thread_reply_count: u32,
pub edited: bool,
pub is_encrypted: bool,
#[index(btree)]
pub seq_id: u64,
}
#[spacetimedb::table(accessor = custom_emoji, public)]
#[derive(Clone)]
pub struct CustomEmoji {
#[primary_key]
#[auto_inc]
@@ -196,16 +225,32 @@ pub struct CustomEmoji {
}
#[spacetimedb::table(accessor = image)]
#[derive(Clone)]
pub struct Image {
#[primary_key]
#[auto_inc]
#[index(btree)]
pub id: u64,
pub data: Vec<u8>,
pub mime_type: String,
pub name: Option<String>,
}
#[spacetimedb::table(accessor = image_data)]
#[derive(Clone)]
pub struct ImageData {
#[primary_key]
pub image_id: u64,
pub data: Vec<u8>,
}
#[spacetimedb::table(accessor = image_blob_request)]
#[derive(Clone)]
pub struct ImageBlobRequest {
#[primary_key]
pub identity: Identity,
#[index(btree)]
pub image_id: u64,
}
#[spacetimedb::table(accessor = typing_activity, public)]
#[derive(Clone)]
pub struct TypingActivity {
@@ -217,29 +262,8 @@ pub struct TypingActivity {
pub typing: bool,
}
#[spacetimedb::table(accessor = recent_message)]
pub struct RecentMessage {
#[primary_key]
pub id: u64, // This is the message_id
#[index(btree)]
pub server_id: u64, // 0 if DM
#[index(btree)]
pub channel_id: u64,
pub sender: Identity,
pub text: String,
pub thread_id: Option<u64>,
pub sent: Timestamp,
pub seq_id: u64,
pub reactions: Vec<Reaction>,
pub image_ids: Vec<u64>,
pub thread_name: Option<String>,
pub thread_reply_count: u32,
pub edited: bool,
pub is_encrypted: bool,
}
#[spacetimedb::table(accessor = system_configuration, public)]
#[derive(Clone)]
pub struct SystemConfiguration {
#[primary_key]
pub key: String,
@@ -247,6 +271,7 @@ pub struct SystemConfiguration {
}
#[spacetimedb::table(accessor = upload_status, public)]
#[derive(Clone)]
pub struct UploadStatus {
#[primary_key]
pub client_id: String,
+177 -328
View File
@@ -3,27 +3,26 @@ use spacetimedb::{Identity, Local, LocalReadOnly, Table};
use std::collections::{HashMap, HashSet};
pub fn validate_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("Names must not be empty".to_string());
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("Name cannot be empty".to_string());
}
if trimmed.len() > 32 {
return Err("Name too long".to_string());
}
Ok(())
}
pub fn validate_message_length(db: &Local, text: &str) -> Result<(), String> {
let max_length_conf = db
let limit = db
.system_configuration()
.key()
.find("max_message_length".to_string());
let max_length = max_length_conf
.find("max_message_length".to_string())
.and_then(|c| c.value.parse::<usize>().ok())
.unwrap_or(262144);
.unwrap_or(2000);
if text.len() > max_length {
return Err(format!(
"Message exceeds maximum length of {} bytes ({}KB).",
max_length,
max_length / 1024
));
if text.len() > limit {
return Err(format!("Message exceeds {} characters", limit));
}
Ok(())
}
@@ -36,375 +35,184 @@ pub fn get_recent_message_limit(db: &Local) -> u64 {
.unwrap_or(50)
}
pub fn get_recent_message_limit_read_only(db: &LocalReadOnly) -> u64 {
db.system_configuration()
.key()
.find("recent_message_limit".to_string())
.and_then(|c| c.value.parse::<u64>().ok())
.unwrap_or(50)
}
pub fn get_next_seq_id(db: &Local, channel_id: u64) -> u64 {
let mut channel = db.channel().id().find(channel_id).expect("Channel not found");
let next_seq_id = channel.last_seq_id + 1;
channel.last_seq_id = next_seq_id;
db.channel().id().update(channel);
next_seq_id
if let Some(mut state) = db.channel_internal_state().channel_id().find(channel_id) {
state.last_seq_id += 1;
let new_id = state.last_seq_id;
db.channel_internal_state().channel_id().update(state);
new_id
} else {
db.channel_internal_state().insert(ChannelInternalState {
channel_id,
last_seq_id: 1,
});
1
}
}
pub fn get_visible_message_ids(db: &Local, identity: Identity) -> HashMap<u64, u64> {
let mut result = HashMap::new();
// 1. Scrollback Path
if let Some(sub) = db.channel_subscription().identity().find(identity) {
for msg in db
.message()
.channel_id()
.filter(sub.channel_id)
{
if msg.seq_id >= sub.earliest_seq_id {
result.insert(msg.id, msg.seq_id);
}
}
}
// 2. Fast Path: Recent Messages
let my_server_ids: Vec<u64> = db
.server_member()
.identity()
.filter(identity)
.map(|m| m.server_id)
.collect();
for server_id in my_server_ids {
for rm in db.recent_message().server_id().filter(server_id) {
result.entry(rm.id).or_insert(rm.seq_id);
}
}
// 3. DM Fast Path
let my_dms: Vec<_> = db
.direct_message()
.sender()
.filter(identity)
.filter(|dm| dm.is_open_sender)
.chain(
db.direct_message()
.recipient()
.filter(identity)
.filter(|dm| dm.is_open_recipient),
)
.map(|dm| dm.channel_id)
.collect();
for channel_id in my_dms {
for rm in db.recent_message().channel_id().filter(channel_id) {
result.entry(rm.id).or_insert(rm.seq_id);
}
}
result
/// Simplified: uses UserChannelAccess table directly
pub fn get_visible_message_ids(db: &Local, identity: Identity) -> HashSet<u64> {
db.user_channel_access().identity().filter(identity).map(|a| a.channel_id).collect()
}
pub fn get_visible_message_ids_read_only(
db: &LocalReadOnly,
identity: Identity,
) -> HashMap<u64, u64> {
let mut result = HashMap::new();
// 1. Scrollback Path (Selective)
if let Some(sub) = db.channel_subscription().identity().find(identity) {
for msg in db
.message()
.channel_id()
.filter(sub.channel_id)
{
if msg.seq_id >= sub.earliest_seq_id {
result.insert(msg.id, msg.seq_id);
}
}
}
// 2. Fast Path: Recent Messages from my Servers
let my_server_ids: Vec<u64> = db
.server_member()
.identity()
.filter(identity)
.map(|m| m.server_id)
.collect();
for server_id in my_server_ids {
for rm in db.recent_message().server_id().filter(server_id) {
// entry().or_insert is faster than double lookup
result.entry(rm.id).or_insert(rm.seq_id);
}
}
// 3. Fast Path: Recent Messages from my Open DMs
let my_dms: Vec<_> = db
.direct_message()
.sender()
.filter(identity)
.filter(|dm| dm.is_open_sender)
.chain(
db.direct_message()
.recipient()
.filter(identity)
.filter(|dm| dm.is_open_recipient),
)
.map(|dm| dm.channel_id)
.collect();
for channel_id in my_dms {
for rm in db.recent_message().channel_id().filter(channel_id) {
result.entry(rm.id).or_insert(rm.seq_id);
}
}
result
pub fn get_visible_message_ids_read_only(db: &LocalReadOnly, identity: Identity) -> HashSet<u64> {
db.user_channel_access().identity().filter(identity).map(|a| a.channel_id).collect()
}
pub fn get_visible_image_ids(db: &Local, identity: Identity) -> HashSet<u64> {
let mut ids = HashSet::new();
let mut results = HashSet::new();
let accessible_channels = get_visible_message_ids(db, identity);
// 1. My Servers and their Members (Avatars/Banners)
let memberships: Vec<_> = db.server_member().identity().filter(identity).collect();
for member in memberships {
if let Some(s) = db.server().id().find(member.server_id) {
if let Some(avatar_id) = s.avatar_id {
ids.insert(avatar_id);
}
}
for peer in db.server_member().server_id().filter(member.server_id) {
if let Some(u) = db.user().identity().find(peer.identity) {
if let Some(avatar_id) = u.avatar_id {
ids.insert(avatar_id);
}
if let Some(banner_id) = u.banner_id {
ids.insert(banner_id);
}
for channel_id in accessible_channels {
for msg in db.message().channel_id().filter(channel_id) {
for id in msg.image_ids {
results.insert(id);
}
}
}
// 2. Custom Emojis (Global)
for ce in db.custom_emoji().name().filter(""..) {
ids.insert(ce.id);
// User's own avatar/banner
if let Some(user) = db.user().identity().find(identity) {
if let Some(id) = user.avatar_id { results.insert(id); }
if let Some(id) = user.banner_id { results.insert(id); }
}
// 3. Active Channel Images (Recent + Scrollback)
if let Some(sub) = db.channel_subscription().identity().find(identity) {
// From Recent Messages cache for this channel
for rm in db.recent_message().channel_id().filter(sub.channel_id) {
for id in &rm.image_ids {
ids.insert(*id);
}
}
// From Scrollback Messages for this channel
for msg in db
.message()
.channel_id()
.filter(sub.channel_id)
{
if msg.seq_id >= sub.earliest_seq_id {
for id in &msg.image_ids {
ids.insert(*id);
}
}
// Server avatars for servers I am a member of or are public
let my_server_ids: HashSet<u64> = db.server_member().identity().filter(identity).map(|m| m.server_id).collect();
for s in db.server().name().filter(""..) {
if s.public || my_server_ids.contains(&s.id) {
if let Some(id) = s.avatar_id { results.insert(id); }
}
}
ids
// Avatars for members of servers I am in (and redundant check for server avatars stored in membership)
for server_id in my_server_ids {
for member in db.server_member().server_id().filter(server_id) {
if let Some(id) = member.avatar_id { results.insert(id); }
}
// Also check if any server I'm in has an avatar id that might not have been caught in the name filter
if let Some(s) = db.server().id().find(server_id) {
if let Some(id) = s.avatar_id { results.insert(id); }
}
}
// Avatars for DM participants
for dm in db.direct_message().sender().filter(identity) {
if let Some(u) = db.user().identity().find(dm.recipient) {
if let Some(id) = u.avatar_id { results.insert(id); }
}
}
for dm in db.direct_message().recipient().filter(identity) {
if let Some(u) = db.user().identity().find(dm.sender) {
if let Some(id) = u.avatar_id { results.insert(id); }
}
}
results
}
pub fn get_visible_image_ids_read_only(db: &LocalReadOnly, identity: Identity) -> HashSet<u64> {
let mut ids = HashSet::new();
let mut results = HashSet::new();
let accessible_channels = get_visible_message_ids_read_only(db, identity);
// 1. My Servers and their Members (Avatars/Banners)
let memberships: Vec<_> = db.server_member().identity().filter(identity).collect();
for member in memberships {
if let Some(s) = db.server().id().find(member.server_id) {
if let Some(avatar_id) = s.avatar_id {
ids.insert(avatar_id);
}
}
for peer in db.server_member().server_id().filter(member.server_id) {
if let Some(u) = db.user().identity().find(peer.identity) {
if let Some(avatar_id) = u.avatar_id {
ids.insert(avatar_id);
}
if let Some(banner_id) = u.banner_id {
ids.insert(banner_id);
}
for channel_id in accessible_channels {
for msg in db.message().channel_id().filter(channel_id) {
for id in msg.image_ids {
results.insert(id);
}
}
}
// 2. Custom Emojis (Global)
for ce in db.custom_emoji().name().filter(""..) {
ids.insert(ce.id);
// User's own avatar/banner
if let Some(user) = db.user().identity().find(identity) {
if let Some(id) = user.avatar_id { results.insert(id); }
if let Some(id) = user.banner_id { results.insert(id); }
}
// 3. Active Channel Images (Recent + Scrollback)
if let Some(sub) = db.channel_subscription().identity().find(identity) {
// From Recent Messages cache for this channel
for rm in db.recent_message().channel_id().filter(sub.channel_id) {
for id in &rm.image_ids {
ids.insert(*id);
}
}
// From Scrollback Messages for this channel
for msg in db
.message()
.channel_id()
.filter(sub.channel_id)
{
if msg.seq_id >= sub.earliest_seq_id {
for id in &msg.image_ids {
ids.insert(*id);
}
}
// Server avatars for servers I am a member of or are public
let my_server_ids: HashSet<u64> = db.server_member().identity().filter(identity).map(|m| m.server_id).collect();
for s in db.server().name().filter(""..) {
if s.public || my_server_ids.contains(&s.id) {
if let Some(id) = s.avatar_id { results.insert(id); }
}
}
ids
}
// Avatars for members of servers I am in (and redundant check for server avatars stored in membership)
for server_id in my_server_ids {
for member in db.server_member().server_id().filter(server_id) {
if let Some(id) = member.avatar_id { results.insert(id); }
}
// Also check if any server I'm in has an avatar id that might not have been caught in the name filter
if let Some(s) = db.server().id().find(server_id) {
if let Some(id) = s.avatar_id { results.insert(id); }
}
}
// Avatars for DM participants
for dm in db.direct_message().sender().filter(identity) {
if let Some(u) = db.user().identity().find(dm.recipient) {
if let Some(id) = u.avatar_id { results.insert(id); }
}
}
for dm in db.direct_message().recipient().filter(identity) {
if let Some(u) = db.user().identity().find(dm.sender) {
if let Some(id) = u.avatar_id { results.insert(id); }
}
}
pub fn clear_signaling_for_user(db: &Local, identity: Identity) {
for row in db
.webrtc_signal()
.sender()
.filter(identity)
.collect::<Vec<_>>()
{
db.webrtc_signal().delete(row);
}
for row in db
.webrtc_signal()
.receiver()
.filter(identity)
.collect::<Vec<_>>()
{
db.webrtc_signal().delete(row);
}
}
pub fn clear_user_presence(db: &Local, identity: Identity) {
if let Some(state) = db.user_state().identity().find(identity) {
db.user_state().delete(state);
}
clear_signaling_for_user(db, identity);
}
pub fn auto_join_community_server(db: &Local, identity: Identity) {
let community_server = db.server().name().filter(&"Zep".to_string()).next();
if let Some(s) = community_server {
let user = db.user().identity().find(identity);
db.server_member().insert(ServerMember {
id: 0,
identity,
server_id: s.id,
name: user.as_ref().and_then(|u| u.name.clone()),
avatar_id: user.as_ref().and_then(|u| u.avatar_id),
online: user.as_ref().map(|u| u.online).unwrap_or(false),
});
}
results
}
pub fn internal_open_direct_message(db: &Local, sender: Identity, recipient: Identity) -> u64 {
// Check if a DM already exists
let existing = db
.direct_message()
.sender()
.filter(sender)
.find(|dm| dm.recipient == recipient)
.or_else(|| {
db.direct_message()
.recipient()
.filter(sender)
.find(|dm| dm.sender == recipient)
});
let existing = db.direct_message().sender().filter(sender).find(|dm| dm.recipient == recipient)
.or_else(|| db.direct_message().sender().filter(recipient).find(|dm| dm.recipient == sender));
if let Some(mut dm) = existing {
if dm.sender == sender {
dm.is_open_sender = true;
} else {
dm.is_open_recipient = true;
}
if dm.sender == sender { dm.is_open_sender = true; } else { dm.is_open_recipient = true; }
db.direct_message().id().update(dm.clone());
dm.channel_id
} else {
// Create a new DM channel
let chan = db.channel().insert(Channel {
id: 0,
server_id: 0,
name: "dm".to_string(),
kind: ChannelKind::Text,
last_seq_id: 0,
});
let chan = db.channel().insert(Channel { id: 0, server_id: 0, name: "dm".to_string(), kind: ChannelKind::Text });
db.direct_message().insert(DirectMessage {
id: 0,
channel_id: chan.id,
sender,
recipient,
is_open_sender: true,
is_open_recipient: true,
id: 0, channel_id: chan.id, sender, recipient, is_open_sender: true, is_open_recipient: true,
});
grant_user_channel_access(db, sender, chan.id);
grant_user_channel_access(db, recipient, chan.id);
chan.id
}
}
pub fn internal_send_message(db: &Local, sender: Identity, channel_id: u64, text: String, timestamp: spacetimedb::Timestamp) {
pub fn internal_send_message(
db: &Local,
sender: Identity,
channel_id: u64,
text: String,
timestamp: spacetimedb::Timestamp,
thread_id: Option<u64>,
image_ids: Vec<u64>,
is_encrypted: bool,
) {
let seq_id = get_next_seq_id(db, channel_id);
let server_id = db.channel().id().find(channel_id).map(|c| c.server_id).unwrap_or(0);
let msg = db.message().insert(Message {
id: 0,
sender,
sent: timestamp,
text,
channel_id,
thread_id: None,
reactions: Vec::new(),
image_ids: Vec::new(),
thread_name: None,
thread_reply_count: 0,
edited: false,
is_encrypted: false,
seq_id,
db.message().insert(Message {
id: 0, sender, sent: timestamp, text, channel_id, server_id, thread_id,
reactions: Vec::new(), image_ids, thread_name: None, thread_reply_count: 0,
edited: false, is_encrypted, seq_id,
});
db.recent_message().insert(RecentMessage {
id: msg.id,
sender: msg.sender,
sent: msg.sent,
text: msg.text,
channel_id: msg.channel_id,
thread_id: msg.thread_id,
seq_id,
reactions: msg.reactions,
image_ids: msg.image_ids,
thread_name: msg.thread_name,
thread_reply_count: msg.thread_reply_count,
edited: msg.edited,
server_id: 0, // DMs have server_id 0
is_encrypted: false,
});
let limit = get_recent_message_limit(db);
if seq_id > limit {
let old_seq_id = seq_id - limit;
let to_delete: Vec<_> = db
.recent_message()
.channel_id()
.filter(channel_id)
.filter(|m| m.seq_id <= old_seq_id)
.map(|m| m.id)
.collect();
for id in to_delete {
db.recent_message().id().delete(id);
}
}
}
pub fn sync_server_member_info(db: &Local, identity: Identity) {
if let Some(user) = db.user().identity().find(identity) {
let members: Vec<_> = db.server_member().identity().filter(identity).collect();
for mut member in members {
for mut member in db.server_member().identity().filter(identity) {
member.name = user.name.clone();
member.avatar_id = user.avatar_id;
member.online = user.online;
@@ -412,3 +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
View File
@@ -5,7 +5,6 @@ use spacetimedb::{Identity, Query, Table, Timestamp, ViewContext};
#[derive(spacetimedb::SpacetimeType)]
pub struct VisibleImageRow {
pub id: u64,
pub data: Vec<u8>,
pub mime_type: String,
pub name: Option<String>,
}
@@ -32,30 +31,8 @@ pub fn visible_typing_activity(ctx: &ViewContext) -> Vec<TypingActivity> {
let identity = ctx.sender();
let mut results = std::collections::HashMap::new();
// 1. Server channels
for member in ctx.db.server_member().identity().filter(identity) {
if let Some(s) = ctx.db.server().id().find(member.server_id) {
for chan_meta in s.channels {
for activity in ctx.db.typing_activity().channel_id().filter(chan_meta.id) {
if activity.typing {
results.insert(activity.identity, activity.clone());
}
}
}
}
}
// 2. DM channels
let mut dm_channel_ids = Vec::new();
for dm in ctx.db.direct_message().sender().filter(identity) {
dm_channel_ids.push(dm.channel_id);
}
for dm in ctx.db.direct_message().recipient().filter(identity) {
dm_channel_ids.push(dm.channel_id);
}
for channel_id in dm_channel_ids {
for activity in ctx.db.typing_activity().channel_id().filter(channel_id) {
for access in ctx.db.user_channel_access().identity().filter(identity) {
for activity in ctx.db.typing_activity().channel_id().filter(access.channel_id) {
if activity.typing {
results.insert(activity.identity, activity.clone());
}
@@ -71,6 +48,7 @@ pub struct MyChannelSubscriptionRow {
pub channel_id: u64,
pub earliest_seq_id: u64,
pub last_read_seq_id: u64,
pub last_seq_id: u64,
}
#[derive(spacetimedb::SpacetimeType)]
@@ -79,7 +57,6 @@ pub struct VisibleChannelRow {
pub server_id: u64,
pub name: String,
pub kind: ChannelKind,
pub last_seq_id: u64,
}
#[derive(spacetimedb::SpacetimeType)]
@@ -93,49 +70,20 @@ pub struct VisibleDirectMessageRow {
}
#[spacetimedb::view(accessor = visible_recent_activity, public)]
pub fn visible_recent_activity(ctx: &ViewContext) -> Vec<RecentMessage> {
pub fn visible_recent_activity(ctx: &ViewContext) -> Vec<Message> {
let identity = ctx.sender();
let mut results = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
// 1. Servers I'm a member of
let my_server_ids: Vec<u64> = ctx
.db
.server_member()
.identity()
.filter(identity)
.map(|m| m.server_id)
.collect();
for access in ctx.db.user_channel_access().identity().filter(identity) {
let last_seq_id = ctx.db.channel_internal_state().channel_id().find(access.channel_id)
.map(|s| s.last_seq_id).unwrap_or(0);
let limit = get_recent_message_limit_read_only(&ctx.db);
let min_seq = if last_seq_id > limit { last_seq_id - (limit - 1) } else { 1 };
for server_id in my_server_ids {
for rm in ctx.db.recent_message().server_id().filter(server_id) {
if seen_ids.insert(rm.id) {
results.push(rm);
}
}
}
// 2. Open DMs
let my_dms: Vec<_> = ctx
.db
.direct_message()
.sender()
.filter(identity)
.filter(|dm| dm.is_open_sender)
.chain(
ctx.db
.direct_message()
.recipient()
.filter(identity)
.filter(|dm| dm.is_open_recipient),
)
.map(|dm| dm.channel_id)
.collect();
for channel_id in my_dms {
for rm in ctx.db.recent_message().channel_id().filter(channel_id) {
if seen_ids.insert(rm.id) {
results.push(rm);
for msg in ctx.db.message().channel_id().filter(access.channel_id) {
if msg.seq_id >= min_seq {
results.push(msg.clone());
}
}
}
@@ -146,23 +94,12 @@ pub fn visible_recent_activity(ctx: &ViewContext) -> Vec<RecentMessage> {
#[spacetimedb::view(accessor = visible_servers, public)]
pub fn visible_servers(ctx: &ViewContext) -> Vec<Server> {
let identity = ctx.sender();
let mut results = Vec::new();
let my_server_ids: std::collections::HashSet<u64> = ctx.db.server_member().identity().filter(identity).map(|m| m.server_id).collect();
// Servers I'm a member of
let my_server_ids: std::collections::HashSet<u64> = ctx
.db
.server_member()
.identity()
.filter(identity)
.map(|m| m.server_id)
.collect();
for server in ctx.db.server().name().filter(""..) {
if server.public || my_server_ids.contains(&server.id) {
results.push(server);
}
}
results
ctx.db.server().name().filter(""..)
.filter(|s: &Server| s.public || my_server_ids.contains(&s.id))
.map(|s: Server| s.clone())
.collect()
}
#[spacetimedb::view(accessor = visible_server_members, public)]
@@ -171,19 +108,10 @@ pub fn visible_server_members(ctx: &ViewContext) -> Vec<ServerMember> {
let mut results = Vec::new();
let mut seen = std::collections::HashSet::new();
// 1. Find all server IDs I am in
let my_server_ids: std::collections::HashSet<u64> = ctx
.db
.server_member()
.identity()
.filter(identity)
.map(|m| m.server_id)
.collect();
for server_id in my_server_ids {
for peer in ctx.db.server_member().server_id().filter(server_id) {
for member in ctx.db.server_member().identity().filter(identity) {
for peer in ctx.db.server_member().server_id().filter(member.server_id) {
if seen.insert(peer.id) {
results.push(peer);
results.push(peer.clone());
}
}
}
@@ -192,47 +120,28 @@ pub fn visible_server_members(ctx: &ViewContext) -> Vec<ServerMember> {
#[spacetimedb::view(accessor = visible_channels, public)]
pub fn visible_channels(ctx: &ViewContext) -> Vec<VisibleChannelRow> {
let mut results = Vec::new();
let identity = ctx.sender();
let mut results = Vec::new();
// Server channels (from nested data)
// 1. Server channels
for member in ctx.db.server_member().identity().filter(identity) {
if let Some(s) = ctx.db.server().id().find(member.server_id) {
for chan_meta in s.channels {
if let Some(chan) = ctx.db.channel().id().find(chan_meta.id) {
results.push(VisibleChannelRow {
id: chan.id,
server_id: s.id,
name: chan.name,
kind: chan.kind,
last_seq_id: chan.last_seq_id,
});
}
results.push(VisibleChannelRow {
id: chan_meta.id, server_id: s.id, name: chan_meta.name.clone(), kind: chan_meta.kind,
});
}
}
}
// DM channels
for dm in ctx.db.direct_message().sender().filter(identity) {
if let Some(chan) = ctx.db.channel().id().find(dm.channel_id) {
results.push(VisibleChannelRow {
id: chan.id,
server_id: chan.server_id,
name: chan.name.clone(),
kind: chan.kind,
last_seq_id: chan.last_seq_id,
});
}
}
for dm in ctx.db.direct_message().recipient().filter(identity) {
if let Some(chan) = ctx.db.channel().id().find(dm.channel_id) {
results.push(VisibleChannelRow {
id: chan.id,
server_id: chan.server_id,
name: chan.name.clone(),
kind: chan.kind,
last_seq_id: chan.last_seq_id,
});
// 2. DM channels
for access in ctx.db.user_channel_access().identity().filter(identity) {
if let Some(chan) = ctx.db.channel().id().find(access.channel_id) {
if chan.server_id == 0 {
results.push(VisibleChannelRow {
id: chan.id, server_id: 0, name: chan.name.clone(), kind: chan.kind,
});
}
}
}
@@ -242,8 +151,7 @@ pub fn visible_channels(ctx: &ViewContext) -> Vec<VisibleChannelRow> {
#[spacetimedb::view(accessor = visible_direct_messages, public)]
pub fn visible_direct_messages(ctx: &ViewContext) -> impl Query<DirectMessage> {
let identity = ctx.sender();
ctx.from
.direct_message()
ctx.from.direct_message()
.r#where(move |dm| dm.sender.eq(identity).or(dm.recipient.eq(identity)))
}
@@ -254,132 +162,94 @@ pub fn visible_images(ctx: &ViewContext) -> Vec<VisibleImageRow> {
for id in image_ids {
if let Some(img) = ctx.db.image().id().find(id) {
results.push(VisibleImageRow {
id: img.id,
data: img.data.clone(),
mime_type: img.mime_type,
name: img.name,
id: img.id, mime_type: img.mime_type.clone(), name: img.name.clone(),
});
}
}
results
}
#[spacetimedb::view(accessor = visible_image_blobs, public)]
pub fn visible_image_blobs(ctx: &ViewContext) -> Vec<ImageData> {
let identity = ctx.sender();
if let Some(req) = ctx.db.image_blob_request().identity().find(identity) {
if let Some(data) = ctx.db.image_data().image_id().find(req.image_id) {
return vec![data.clone()];
}
}
vec![]
}
#[spacetimedb::view(accessor = visible_user_states, public)]
pub fn visible_user_states(ctx: &ViewContext) -> Vec<UserState> {
let identity = ctx.sender();
let mut results = std::collections::HashMap::new();
// 1. My own state
for access in ctx.db.user_channel_access().identity().filter(identity) {
for state in ctx.db.user_state().channel_id().filter(access.channel_id) {
results.insert(state.identity, state.clone());
}
}
if let Some(my_state) = ctx.db.user_state().identity().find(identity) {
results.insert(my_state.identity, my_state.clone());
}
// 2. States in my servers
for member in ctx.db.server_member().identity().filter(identity) {
if let Some(s) = ctx.db.server().id().find(member.server_id) {
for chan_meta in s.channels {
for state in ctx.db.user_state().channel_id().filter(chan_meta.id) {
results.insert(state.identity, state.clone());
}
}
}
}
// 3. States in my DMs
for dm in ctx.db.direct_message().sender().filter(identity) {
for state in ctx.db.user_state().channel_id().filter(dm.channel_id) {
results.insert(state.identity, state.clone());
}
}
for dm in ctx.db.direct_message().recipient().filter(identity) {
for state in ctx.db.user_state().channel_id().filter(dm.channel_id) {
results.insert(state.identity, state.clone());
}
}
results.into_values().collect()
}
#[spacetimedb::view(accessor = visible_webrtc_signals, public)]
pub fn visible_webrtc_signals(ctx: &ViewContext) -> Vec<WebRTCSignal> {
let identity = ctx.sender();
let mut results = Vec::new();
for signal in ctx.db.webrtc_signal().sender().filter(identity) {
results.push(signal);
}
for signal in ctx.db.webrtc_signal().receiver().filter(identity) {
results.push(signal);
}
results
ctx.db.webrtc_signal().sender().filter(identity)
.chain(ctx.db.webrtc_signal().receiver().filter(identity))
.map(|s: WebRTCSignal| s.clone())
.collect()
}
#[spacetimedb::view(accessor = visible_scrollback_messages, public)]
pub fn visible_scrollback_messages(ctx: &ViewContext) -> Vec<VisibleMessageRow> {
let mut results = Vec::new();
pub fn visible_scrollback_messages(ctx: &ViewContext) -> impl Query<Message> {
let identity = ctx.sender();
// Only for the active channel subscription
if let Some(sub) = ctx.db.channel_subscription().identity().find(identity) {
// Security: Ensure I have access to this channel
let has_access = if let Some(chan) = ctx.db.channel().id().find(sub.channel_id) {
if chan.server_id != 0 {
// Server channel: Check membership
ctx.db
.server_member()
.identity()
.filter(identity)
.any(|m| m.server_id == chan.server_id)
} else {
// DM channel: Check DM participants
ctx.db
.direct_message()
.channel_id()
.filter(sub.channel_id)
.any(|dm| dm.sender == identity || dm.recipient == identity)
}
} else {
false
};
let cid = sub.channel_id;
let min_seq = sub.earliest_seq_id;
ctx.from.message().r#where(move |m| m.channel_id.eq(cid).and(m.seq_id.gte(min_seq)))
} else {
ctx.from.message().r#where(|m| m.id.eq(0))
}
}
if has_access {
for msg in ctx
.db
.message()
.channel_id()
.filter(sub.channel_id)
{
if msg.seq_id >= sub.earliest_seq_id {
results.push(VisibleMessageRow {
id: msg.id,
sender: msg.sender,
sent: msg.sent,
text: msg.text,
channel_id: msg.channel_id,
thread_id: msg.thread_id,
seq_id: msg.seq_id,
reactions: msg.reactions.clone(),
image_ids: msg.image_ids.clone(),
thread_name: msg.thread_name.clone(),
thread_reply_count: msg.thread_reply_count,
edited: msg.edited,
is_encrypted: msg.is_encrypted,
});
}
#[spacetimedb::view(accessor = visible_scrollback_thread_messages, public)]
pub fn visible_scrollback_thread_messages(ctx: &ViewContext) -> Vec<VisibleMessageRow> {
let identity = ctx.sender();
let mut results = Vec::new();
if let Some(sub) = ctx.db.channel_subscription().identity().find(identity) {
for msg in ctx.db.message().channel_id().filter(sub.channel_id) {
if msg.thread_id.is_some() {
results.push(VisibleMessageRow {
id: msg.id, sender: msg.sender, sent: msg.sent, text: msg.text.clone(),
channel_id: msg.channel_id, thread_id: msg.thread_id, seq_id: msg.seq_id,
reactions: msg.reactions.clone(), image_ids: msg.image_ids.clone(),
thread_name: msg.thread_name.clone(), thread_reply_count: msg.thread_reply_count,
edited: msg.edited, is_encrypted: msg.is_encrypted,
});
}
}
}
results
}
#[spacetimedb::view(accessor = my_channel_subscriptions, public)]
pub fn my_channel_subscriptions(ctx: &ViewContext) -> Vec<MyChannelSubscriptionRow> {
if let Some(sub) = ctx.db.channel_subscription().identity().find(ctx.sender()) {
let last_seq_id = ctx.db.channel_internal_state().channel_id().find(sub.channel_id)
.map(|s| s.last_seq_id).unwrap_or(0);
vec![MyChannelSubscriptionRow {
identity: sub.identity,
channel_id: sub.channel_id,
earliest_seq_id: sub.earliest_seq_id,
last_read_seq_id: sub.last_read_seq_id,
identity: sub.identity, channel_id: sub.channel_id,
earliest_seq_id: sub.earliest_seq_id, last_read_seq_id: sub.last_read_seq_id,
last_seq_id,
}]
} else {
vec![]
+88 -1589
View File
File diff suppressed because it is too large Load Diff
+201
View File
@@ -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>
-57
View File
@@ -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
View File
@@ -1,23 +1,138 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { stopActiveConnection } from "./config";
import InnerSpacetimeProvider from "./InnerSpacetimeProvider.svelte";
import { untrack } from "svelte";
import { auth } from "./auth/auth.svelte";
import { connectionState } from "./connection.svelte";
import { connectionBuilder, getStdbHost, getStdbDbName } from "./config";
import InnerSpacetimeDBProvider from "./InnerSpacetimeDBProvider.svelte";
let { children, onCancel } = $props<{
children: any,
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(() => {
console.log("SpacetimeProvider: Destroying, stopping connection...");
stopActiveConnection();
$effect(() => {
// Hold off until OIDC is settled for the first time
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>
{#key reconnectKey}
<InnerSpacetimeProvider {onCancel}>
{@render children()}
</InnerSpacetimeProvider>
{/key}
{#if !builder || (auth.isLoading && !auth.user)}
<div class="login-screen">
<div class="login-card" style="text-align: center;">
<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;">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
View File
@@ -17,7 +17,6 @@
let stdbHost = $state("");
let stdbDbName = $state("");
let storedConnections = $state<string[]>([]);
let combinedConnection = $state("");
let userWantsToConnect = $state(false);
@@ -26,25 +25,41 @@
stdbHost = getStdbHost();
stdbDbName = getStdbDbName();
storedConnections = TokenStore.listStoredConnections();
combinedConnection = `${stdbHost.replace(/^(https?|wss?):\/\//, "")}:${stdbDbName}`;
const isChanging = localStorage.getItem("zep_changing_server") === "true";
if (isChanging) {
userWantsToConnect = false;
} else if (TokenStore.get(stdbHost, stdbDbName)) {
// Auto-connect if we have a token and NOT changing server
// Auto-connect if we have a stored SpacetimeDB token.
userWantsToConnect = true;
}
});
// Split combined connection if it changes
// Handle auto-connect for OIDC users once the session is loaded/settled
$effect(() => {
if (combinedConnection.includes(":")) {
if (auth.isAuthenticated && !auth.isLoading) {
const isChanging = localStorage.getItem("zep_changing_server") === "true";
if (!isChanging && !userWantsToConnect) {
untrack(() => {
console.log("AuthGate: Auto-connecting active OIDC session.");
userWantsToConnect = true;
});
}
}
});
// 1. One-way Sync: Parse combined connection into fields
$effect(() => {
// Only parse if we are NOT currently connecting and there is something to parse
if (!userWantsToConnect && combinedConnection.includes(":")) {
const lastColon = combinedConnection.lastIndexOf(":");
const host = combinedConnection.substring(0, lastColon);
const db = combinedConnection.substring(lastColon + 1);
if (host && db) {
// Update internal state only if it actually changed to prevent loops
if (host && db && (host !== stdbHost || db !== stdbDbName)) {
untrack(() => {
stdbHost = host;
stdbDbName = db;
@@ -53,20 +68,26 @@
}
});
// Update combined connection if individual fields change (e.g. on mount)
// 2. State persistence
$effect(() => {
const hostPart = stdbHost.replace(/^(https?|wss?):\/\//, "");
combinedConnection = `${hostPart}:${stdbDbName}`;
});
const hasStoredToken = $derived(!!TokenStore.get(stdbHost, stdbDbName));
$effect(() => {
if (stdbHost) localStorage.setItem(HOST_KEY, stdbHost);
if (stdbHost && localStorage.getItem(HOST_KEY) !== stdbHost) {
localStorage.setItem(HOST_KEY, stdbHost);
}
});
$effect(() => {
if (stdbDbName) localStorage.setItem(DB_NAME_KEY, stdbDbName);
if (stdbDbName && localStorage.getItem(DB_NAME_KEY) !== stdbDbName) {
localStorage.setItem(DB_NAME_KEY, stdbDbName);
}
});
let hasStoredToken = $state(false);
$effect(() => {
// Check for token when connection params change
if (stdbHost && stdbDbName) {
hasStoredToken = !!TokenStore.get(stdbHost, stdbDbName);
}
});
const isBypassEnabled =
@@ -176,16 +197,150 @@
id="stdb-connection"
bind:value={combinedConnection}
placeholder="connect.zep.chat:zep"
options={storedConnections}
/>
</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>
{/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;
}
.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 {
position: relative;
display: inline-block;
+141 -13
View File
@@ -7,17 +7,21 @@ import { getEnv } from "../env";
// OIDC Configuration - User should replace these with their own provider values
export const oidcConfig: UserManagerSettings = {
authority: getEnv("VITE_OIDC_AUTHORITY", "https://accounts.google.com"),
client_id: getEnv("VITE_OIDC_CLIENT_ID", "REPLACE_ME"),
authority: getEnv("VITE_OIDC_AUTHORITY", "https://sso.adamlamers.com/application/o/truenas-zep/"),
client_id: getEnv("VITE_OIDC_CLIENT_ID", "Omb5jIeYY2rJqkxK52qZgLRGejOBCK5Km2BcA5RO"),
redirect_uri: window.location.origin,
scope: "openid profile email",
scope: "openid profile email offline_access",
response_type: "code",
automaticSilentRenew: true,
loadUserInfo: true,
};
class AuthStore {
#userManager: UserManager;
#user = $state<User | null | undefined>(null);
#isLoading = $state(true);
#isRefreshing = $state(false);
#isProcessingCallback = false;
constructor(settings: UserManagerSettings) {
this.#userManager = new UserManager(settings);
@@ -27,12 +31,48 @@ class AuthStore {
window.location.search.includes("code=") &&
window.location.search.includes("state=")
) {
this.signinCallback();
if (!this.#isProcessingCallback) {
this.signinCallback();
}
} else {
// Not in a callback, safe to clear any stale/redundant OIDC state keys
// that might have accumulated from partial attempts
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("oidc.")) {
localStorage.removeItem(key);
}
}
this.#userManager
.getUser()
.then((user) => {
this.#user = user;
.then(async (user) => {
if (user && !user.expired) {
console.log("[AuthStore] Found valid session in storage.");
this.#user = user;
} else if (user && user.expired) {
console.log("[AuthStore] Found expired session, attempting silent renew...");
this.#isRefreshing = true;
try {
const renewedUser = await this.#userManager.signinSilent();
this.#user = renewedUser;
console.log("[AuthStore] Silent renew successful on load.");
} catch (err) {
console.warn("[AuthStore] Silent renew failed on load, clearing expired session:", err);
this.#user = null;
// CRITICAL: If silent renew fails, remove the invalid user from storage to prevent re-auth loops on page reload.
await this.#userManager.removeUser();
} finally {
this.#isRefreshing = false;
}
} else {
console.log("[AuthStore] No session found in storage.");
this.#user = null;
}
})
.catch((err) => {
console.error("[AuthStore] Error retrieving user from storage:", err);
this.#user = null;
})
.finally(() => {
this.#isLoading = false;
@@ -40,12 +80,34 @@ class AuthStore {
}
this.#userManager.events.addUserLoaded((user) => {
console.log(`[AuthStore] User loaded: ${user.profile.preferred_username || user.profile.sub} (ID Token present: ${!!user.id_token})`);
this.#user = user;
});
this.#userManager.events.addUserUnloaded(() => {
console.log("[AuthStore] User unloaded");
this.#user = null;
});
this.#userManager.events.addAccessTokenExpiring(() => {
console.log("[AuthStore] Access token is expiring soon... triggering silent renew.");
});
this.#userManager.events.addAccessTokenExpired(() => {
console.warn("[AuthStore] Access token has expired!");
});
this.#userManager.events.addUserSessionChanged(() => {
console.log("[AuthStore] User session changed at the provider.");
});
this.#userManager.events.addUserSignedOut(() => {
console.warn("[AuthStore] User signed out at the provider.");
});
this.#userManager.events.addSilentRenewError((err) => {
console.error("[AuthStore] Silent renew error:", err);
});
}
get user() {
@@ -56,6 +118,10 @@ class AuthStore {
return this.#isLoading;
}
get isRefreshing() {
return this.#isRefreshing;
}
get isAuthenticated() {
return !!this.#user;
}
@@ -63,7 +129,11 @@ class AuthStore {
async signinRedirect() {
this.#isLoading = true;
try {
await this.#userManager.signinRedirect();
await this.#userManager.signinRedirect({
extraQueryParams: {
prompt: "consent"
}
});
} catch (error) {
console.error("Signin redirect error:", error);
this.#isLoading = false;
@@ -71,15 +141,21 @@ class AuthStore {
}
async signinCallback() {
if (this.#isProcessingCallback) return;
this.#isProcessingCallback = true;
this.#isLoading = true;
try {
const user = await this.#userManager.signinCallback();
this.#user = user;
window.history.replaceState({}, document.title, window.location.pathname);
} catch (error) {
console.error("Signin callback error:", error);
} finally {
// Always clear the URL parameters to prevent infinite loops if the callback fails
if (window.location.search.includes("code=") && window.location.search.includes("state=")) {
window.history.replaceState({}, document.title, window.location.pathname);
}
this.#isLoading = false;
this.#isProcessingCallback = false;
}
}
@@ -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() {
this.#isLoading = true;
try {
// Clear all potential SpacetimeDB tokens from local storage
const keysToRemove: string[] = [];
console.log("AuthStore: Initiating full session purge...");
// 1. Purge LocalStorage
const localKeysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.includes("auth_token")) {
keysToRemove.push(key);
if (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) {
await this.#userManager.signoutRedirect();
+314 -123
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { useSpacetimeDB } from "spacetimedb/svelte";
import { setContext } from "svelte";
import { setContext, onMount, untrack } from "svelte";
import { ChatService } from "./services/chat.svelte";
import { WebRTCService } from "./services/webrtc/webrtc.svelte";
import ServerList from "./components/ServerList.svelte";
@@ -24,7 +24,6 @@
const spacetime = useSpacetimeDB();
// 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 webrtc = new WebRTCService($spacetime.identity, undefined);
@@ -47,10 +46,48 @@
let showSettings = $state(false);
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>
<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">
<ServerList onShowServerSettings={_onShowServerSettings} />
<ChannelList />
@@ -66,7 +103,7 @@
{chat.connectedVoiceChannel.name} / {chat.connectedVoiceServer?.name || "Server"}
</div>
</div>
<div class="voice-status-actions" style="display: flex; gap: 4px;">
<div class="voice-status-actions">
<button
class="icon-btn {webrtc.isSharingScreen ? 'active danger' : ''}"
onclick={() => webrtc.isSharingScreen ? webrtc.stopScreenShare() : webrtc.startScreenShare()}
@@ -86,17 +123,16 @@
{/if}
<div class="user-info-bar">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="user-info-main"
oncontextmenu={(e) => {
e.preventDefault();
e.stopPropagation();
onclick={(e) => {
if (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} />
<div class="user-details">
@@ -138,21 +174,33 @@
</button>
</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">
<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}
<span style="color: var(--text-muted); font-size: 1.2rem;">
<i class="fas {chat.isActiveChannelVoice ? 'fa-volume-up' : 'fa-hashtag'}"></i>
</span>
<h2 style="margin: 0; font-size: 1rem;">{chat.activeChannel?.name || "Select a channel"}</h2>
<span class="header-icon"><i class="fas {chat.isActiveChannelVoice ? 'fa-volume-up' : 'fa-hashtag'}"></i></span>
<h2 class="header-title">{chat.activeChannel?.name || "Select a channel"}</h2>
{#if chat.activeChannelId !== undefined}
<button
class="icon-btn notification-toggle {chat.isChannelNotificationsEnabled(chat.activeChannelId) ? 'subscribed' : ''}"
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>
</button>
@@ -165,32 +213,26 @@
{@const recipient = chat.users.find(u => u.identity.toHexString() === otherIdentity.toHexString())}
{#if recipient}
<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
class="icon-btn notification-toggle {chat.isChannelNotificationsEnabled(chat.activeChannelId!) ? 'subscribed' : ''}"
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>
</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}
{:else}
<h2 style="margin: 0; font-size: 1rem;">Select a conversation</h2>
<h2 class="header-title">Select a conversation</h2>
{/if}
</div>
<div class="chat-header-actions" style="display: flex; align-items: center; gap: 16px;">
<div class="header-actions">
{#if chat.activeServer}
<button
class="icon-btn {showMemberList ? 'active' : ''}"
onclick={() => (showMemberList = !showMemberList)}
title="Member List"
title="Toggle Member List"
>
<i class="fas fa-users"></i>
</button>
@@ -198,118 +240,267 @@
</div>
</header>
{#if chat.isActiveChannelVoice}
<VideoGrid />
{:else}
<MessageList />
<div class="chat-input-container">
<div class="typing-indicator">
{#if chat.typingUsers.length > 0}
<div class="dots">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
<span style="font-weight: bold;">
{#if chat.typingUsers.length === 1}
{chat.typingUsers[0].name || "Someone"}
{:else if chat.typingUsers.length === 2}
{chat.typingUsers[0].name || "Someone"} and {chat.typingUsers[1].name || "Someone"}
{:else if chat.typingUsers.length === 3}
{chat.typingUsers[0].name || "Someone"}, {chat.typingUsers[1].name || "Someone"} and {chat.typingUsers[2].name || "Someone"}
{:else}
Several people
{/if}
</span>
<span>{chat.typingUsers.length === 1 ? "is" : "are"} typing...</span>
{/if}
<div class="chat-view-container">
{#if chat.isActiveChannelVoice}
<VideoGrid />
{:else}
<MessageList />
<div class="chat-input-container">
<div class="typing-indicator">
{#if chat.typingUsers.length > 0}
<div class="dots"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
<span class="typing-text">
<strong>{chat.typingUsers[0].name || "Someone"}</strong>
{chat.typingUsers.length > 1 ? ` and ${chat.typingUsers.length - 1} more` : ""}
{chat.typingUsers.length === 1 ? "is" : "are"} typing...
</span>
{/if}
</div>
<ChatInput
activeChannelId={chat.activeChannelId}
activeThreadId={null}
isFullyAuthenticated={chat.isFullyAuthenticated}
/>
</div>
<ChatInput
activeChannelId={chat.activeChannelId}
activeThreadId={null}
isFullyAuthenticated={chat.isFullyAuthenticated}
/>
</div>
{/if}
</div>
</main>
<!-- 3. Right Sidebar (Members or Threads) -->
<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}
</div>
</aside>
{#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 chat.showDiscoveryModal}
<ServerDiscovery />
{/if}
{#if showSettings}
<SettingsPanel
currentUser={chat.currentUser}
onClose={() => (showSettings = false)}
/>
{/if}
{#if chat.showServerSettings}
<ServerSettingsPanel
onClose={() => (chat.showServerSettings = false)}
/>
{/if}
<!-- Overlays & Modals -->
{#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}
{@const image = chat.images.find(img => img.id === chat.viewingImageId)}
{#if image}
<ImageViewer
{image}
onClose={() => (chat.viewingImageId = null)}
/>
{/if}
{#if image} <ImageViewer {image} onClose={() => (chat.viewingImageId = null)} /> {/if}
{/if}
{#if chat.viewingProfileUser}
<ProfileModal
user={chat.viewingProfileUser}
onClose={() => (chat.viewingProfileUser = null)}
/>
<ProfileModal user={chat.viewingProfileUser} onClose={() => (chat.viewingProfileUser = null)} />
{/if}
{#if chat.userContextMenu}
<UserContextMenu
x={chat.userContextMenu.x}
y={chat.userContextMenu.y}
user={chat.userContextMenu.user}
{...chat.userContextMenu}
onClose={() => (chat.userContextMenu = null)}
onAction={closeSidebars}
/>
{/if}
{#if chat.confirmModal}
<ConfirmModal
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;
}}
/>
<ConfirmModal {...chat.confirmModal} onConfirm={() => { chat.confirmModal?.onConfirm(); chat.confirmModal = null; }} onCancel={() => { chat.confirmModal?.onCancel?.(); chat.confirmModal = null; }} />
{/if}
</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>
+12 -16
View File
@@ -724,6 +724,16 @@
.chat-input, .chat-input-wrapper {
transition: border-radius 0.2s;
display: flex;
align-items: flex-end;
padding: 0 12px;
background-color: var(--background-tertiary);
border-radius: 8px;
gap: 4px;
}
.chat-input-wrapper {
background-color: var(--background-secondary);
}
.has-staged {
@@ -731,21 +741,7 @@
border-top-right-radius: 0 !important;
}
.thread-input-inner .chat-input-wrapper textarea {
font-size: 1rem;
background: none;
border: none;
outline: none;
color: var(--text-normal);
width: 100%;
resize: none;
padding: 11px 0;
font-family: inherit;
line-height: 1.375rem;
box-sizing: border-box;
}
.chat-input textarea {
.chat-input textarea, .chat-input-wrapper textarea {
background: none;
border: none;
outline: none;
@@ -761,7 +757,7 @@
width: 100%;
}
.chat-input {
.chat-input, .chat-input-wrapper {
height: auto !important;
min-height: 44px;
}
+54 -66
View File
@@ -1,14 +1,19 @@
<script lang="ts">
import { getContext } from "svelte";
import type { ChatService } from "../services/chat.svelte";
import type * as Types from "../../module_bindings/types";
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 baseScale = $state(1.0); // Scale required to fit image in shadowbox (90% viewport)
let zoomLevel = $state(1.0); // Absolute scale (e.g. 0.1 to 3.0)
let baseScale = $state(1.0);
let zoomLevel = $state(1.0);
let isDragging = $state(false);
let isChangingZoom = $state(false); // New flag to enable animation during programmatic jumps
let isChangingZoom = $state(false);
let startX = 0;
let startY = 0;
let translateX = $state(0);
@@ -21,9 +26,7 @@
const isZoomed = $derived(zoomLevel > baseScale + 0.001);
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
}
if (e.key === "Escape") onClose();
}
function onImageLoad(e: Event) {
@@ -33,9 +36,7 @@
const viewportW = window.innerWidth * 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);
// Initial zoom is "fitted"
zoomLevel = baseScale;
}
@@ -44,7 +45,6 @@
if (isZoomed) {
resetZoom();
} else {
// Zoom to 2x fit size
zoomTo(Math.max(1.0, baseScale * 2.0));
}
}
@@ -52,10 +52,8 @@
function resetZoom() {
isChangingZoom = true;
zoomLevel = baseScale;
translateX = 0;
translateY = 0;
lastTranslateX = 0;
lastTranslateY = 0;
translateX = 0; translateY = 0;
lastTranslateX = 0; lastTranslateY = 0;
setTimeout(() => { isChangingZoom = false; }, 150);
}
@@ -67,7 +65,6 @@
function handleImageClick(e: MouseEvent) {
e.stopPropagation();
const clickDuration = Date.now() - mousedownTime;
if (hasMoved || clickDuration > 300) return;
@@ -76,10 +73,8 @@
} else {
const target = e.currentTarget as HTMLImageElement;
const rect = target.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
const w = target.clientWidth;
const h = target.clientHeight;
@@ -88,10 +83,8 @@
isChangingZoom = true;
zoomLevel = targetScale;
translateX = (w / 2 - clickX) * (S - 1);
translateY = (h / 2 - clickY) * (S - 1);
lastTranslateX = translateX;
lastTranslateY = translateY;
setTimeout(() => { isChangingZoom = false; }, 150);
@@ -112,14 +105,9 @@
function handleMouseMove(e: MouseEvent) {
if (!isDragging) return;
const deltaX = e.clientX - startX;
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;
translateY = lastTranslateY + deltaY;
}
@@ -136,18 +124,17 @@
function handleDownload(e: MouseEvent) {
e.stopPropagation();
const blob = new Blob([image.data], { type: image.mimeType });
const url = URL.createObjectURL(blob);
if (!imageUrl) return;
const a = document.createElement("a");
a.href = url;
a.href = imageUrl;
a.download = image.name || "download.png";
document.body.appendChild(a);
a.click();
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 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
@@ -167,8 +154,6 @@
onclick={handleOverlayClick}
onmousedown={handleMouseDown}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="image-viewer-info"
onclick={(e) => e.stopPropagation()}
@@ -176,32 +161,20 @@
>
<div class="info-filename">{image.name || "Untitled Image"}</div>
<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>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="image-viewer-actions"
onclick={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
>
<button
class="action-btn"
onclick={handleDownload}
title="Save Image"
aria-label="Save image"
>
<button class="action-btn" onclick={handleDownload} title="Save Image">
<i class="fas fa-download"></i>
</button>
<div class="zoom-control-container">
<button
class="action-btn"
onclick={toggleZoom}
title={isZoomed ? "Reset Zoom" : "Zoom In"}
aria-label={isZoomed ? "Reset zoom" : "Zoom in"}
>
<button class="action-btn" onclick={toggleZoom} title={isZoomed ? "Reset Zoom" : "Zoom In"}>
<i class="fas {isZoomed ? 'fa-search-minus' : 'fa-search-plus'}"></i>
</button>
<div class="zoom-slider-drawer">
@@ -217,35 +190,37 @@
onmouseup={() => { isChangingZoom = false; }}
oninput={() => {
if (zoomLevel <= baseScale + 0.001) {
translateX = 0;
translateY = 0;
translateX = 0; translateY = 0;
}
}}
/>
</div>
</div>
</div>
<button
class="action-btn close"
onclick={(e) => { e.stopPropagation(); onClose(); }}
aria-label="Close image viewer"
>
<button class="action-btn close" onclick={(e) => { e.stopPropagation(); onClose(); }}>
<i class="fas fa-times"></i>
</button>
</div>
<div class="image-viewer-content">
<img
bind:this={imgRef}
src={URL.createObjectURL(new Blob([image.data], { type: image.mimeType }))}
alt="Full resolution"
class="full-image"
class:animate={isChangingZoom}
style="transform: translate({translateX}px, {translateY}px) scale({zoomLevel / baseScale}); cursor: {isZoomed ? (isDragging ? 'grabbing' : 'zoom-out') : 'zoom-in'}"
onclick={handleImageClick}
onload={onImageLoad}
draggable="false"
/>
{#if imageUrl}
<img
bind:this={imgRef}
src={imageUrl}
alt="Full resolution"
class="full-image"
class:animate={isChangingZoom}
style="transform: translate({translateX}px, {translateY}px) scale({zoomLevel / baseScale}); cursor: {isZoomed ? (isDragging ? 'grabbing' : 'zoom-out') : 'zoom-in'}"
onclick={handleImageClick}
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>
@@ -405,11 +380,11 @@
align-items: center;
justify-content: center;
overflow: hidden;
pointer-events: none; /* Let clicks pass through to overlay */
pointer-events: none;
}
.image-viewer-content img {
pointer-events: auto; /* Re-enable for the image itself */
pointer-events: auto;
}
.full-image {
@@ -430,4 +405,17 @@
.full-image.animate {
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>
+71
View File
@@ -131,4 +131,75 @@
text-overflow: ellipsis;
line-height: 1;
}
.right-sidebar {
width: 240px;
background-color: var(--background-secondary);
display: flex;
flex-direction: column;
flex-shrink: 0;
border-left: 1px solid rgba(0, 0, 0, 0.2);
position: relative;
z-index: 100;
}
.member-list {
padding: 16px 8px;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
height: 100%;
}
.member-item {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
color: var(--interactive-normal);
transition: all 0.1s;
}
.member-item:hover {
background-color: var(--background-modifier-hover);
color: var(--interactive-hover);
}
.member-item.offline {
opacity: 0.45;
}
.member-name {
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-name.talking {
color: var(--status-positive);
}
.member-list-section-header {
padding: 16px 8px 8px 8px;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.sharing-badge {
background-color: var(--status-danger);
color: white;
font-size: 0.6rem;
padding: 1px 4px;
border-radius: 2px;
font-weight: bold;
margin-right: 4px;
}
</style>
+57 -2
View File
@@ -454,7 +454,7 @@
aria-expanded={!collapsedImages}
>
<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' : ''}
</div>
<i class="fas fa-chevron-{collapsedImages ? 'down' : 'up'}" style="color: var(--text-muted); font-size: 0.8rem;"></i>
@@ -480,7 +480,7 @@
src={imageUrl}
alt="Uploaded"
class="message-image"
style="cursor: pointer; max-width: {isThread ? '200px' : '300px'}; max-height: {isThread ? '200px' : '300px'}; border-radius: 4px;"
style="cursor: pointer; max-width: {isThread ? '300px' : '400px'}; max-height: 400px; width: auto; height: auto; border-radius: 4px; object-fit: contain;"
onload={handleImageLoad}
/>
</div>
@@ -764,6 +764,61 @@
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 {
background-color: var(--background-accent);
border: 1px solid transparent;
+10
View File
@@ -414,4 +414,14 @@
max-width: 400px;
line-height: 1.4;
}
.message-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
padding-top: 24px;
min-height: 0;
}
</style>
+85 -16
View File
@@ -191,9 +191,9 @@
{:else if embed.type === 'facebook'}
<i class="fab fa-facebook" style="color: #1877F2;"></i> Facebook
{:else if embed.type === 'image'}
<i class="fas fa-image" style="color: var(--brand);"></i> Image
<i class="fas fa-image"></i> Image
{:else}
<i class="fas fa-link" style="color: var(--text-normal);"></i> Link
<i class="fas fa-link"></i> Link
{/if}
</div>
<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"}
<div class="media-embed-container">
<iframe
width="640"
height="360"
src="https://www.youtube.com/embed/{embed.videoId}"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
class="media-iframe"
style="aspect-ratio: 16/9; width: 640px;"
></iframe>
</div>
{:else if embed.type === "youtube-shorts"}
<div class="media-embed-container">
<iframe
width="315"
height="560"
src="https://www.youtube.com/embed/{embed.videoId}"
title="YouTube Shorts player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
class="media-iframe"
style="aspect-ratio: 9/16; width: 315px; max-height: 80vh;"
></iframe>
</div>
{:else if embed.type === "twitch"}
<div class="media-embed-container">
<iframe
width="640"
height="360"
src="https://player.twitch.tv/?{embed.videoId ? `video=${embed.videoId}` : `channel=${embed.channelName}`}&parent={hostname}"
title="Twitch player"
frameborder="0"
allowfullscreen
class="media-iframe"
style="aspect-ratio: 16/9; width: 640px;"
></iframe>
</div>
{:else if embed.type === "kick"}
<div class="media-embed-container">
<iframe
width="640"
height="360"
src="https://player.kick.com/{embed.videoId ? `video/${embed.videoId}` : embed.channelName}"
title="Kick player"
frameborder="0"
allowfullscreen
class="media-iframe"
style="aspect-ratio: 16/9; width: 640px;"
></iframe>
</div>
{:else if embed.type === "tiktok"}
<div class="media-embed-container">
<iframe
width="325"
height="580"
src="https://www.tiktok.com/embed/v2/{embed.videoId}"
title="TikTok video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
allowfullscreen
class="media-iframe"
style="aspect-ratio: 9/16; width: 325px; max-height: 80vh;"
></iframe>
</div>
{:else if embed.type === "instagram"}
<div class="media-embed-container" style="background: white;">
<iframe
width="400"
height="480"
src="https://www.instagram.com/reel/{embed.videoId}/embed"
title="Instagram Reel player"
frameborder="0"
allowfullscreen
class="media-iframe"
style="aspect-ratio: 400/480; width: 400px; max-height: 80vh;"
></iframe>
</div>
{:else if embed.type === "facebook"}
<div class="media-embed-container" style="background: white;">
<iframe
width="400"
height="480"
src="https://www.facebook.com/plugins/video.php?href=https://www.facebook.com/reel/{embed.videoId}"
title="Facebook Reel player"
frameborder="0"
allowfullscreen
class="media-iframe"
style="aspect-ratio: 400/480; width: 400px; max-height: 80vh;"
></iframe>
</div>
{:else if embed.type === "link"}
@@ -322,5 +315,81 @@
.message-image {
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>
+26
View File
@@ -261,4 +261,30 @@
background-color: var(--brand);
color: white;
}
.server-list {
width: var(--server-sidebar-width);
background-color: var(--background-tertiary);
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0;
gap: 8px;
flex-shrink: 0;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.server-icon.active::before {
content: "";
position: absolute;
left: -4px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 40px;
background-color: white;
border-radius: 0 4px 4px 0;
}
</style>
+8 -3
View File
@@ -5,11 +5,12 @@
import type * as Types from "../../module_bindings/types";
import { portal } from "../../portal";
let { x, y, user, onClose }: {
let { x, y, user, onClose, onAction }: {
x: number,
y: number,
user: Types.User,
onClose: () => void
onClose: () => void,
onAction?: () => void
} = $props();
const chat = getContext<ChatService>("chat");
@@ -108,6 +109,7 @@
{#if !isMe}
<button class="menu-item" onclick={() => {
chat.handleOpenDirectMessage(user.identity);
onAction?.();
onClose();
}}>
<span>Message</span>
@@ -116,7 +118,10 @@
{/if}
{#if !isMe}
<button class="menu-item" onclick={mentionUser}>
<button class="menu-item" onclick={() => {
mentionUser();
onAction?.();
}}>
<span>Mention</span>
<i class="fas fa-at"></i>
</button>
+192 -108
View File
@@ -8,138 +8,222 @@
const chat = getContext<ChatService>("chat");
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(
chat.userStates.filter((s) => s.channelId === webrtc.connectedChannelId),
);
const results = chat.userStates.filter(s => s.channelId === channelId);
console.log(`[VideoGrid] Rendering ${results.length} participants for channel ${channelId}`);
return results;
});
const localSharing = $derived(!!webrtc.localScreenStream);
const remoteSharerVs = $derived(
participants.find((s) => {
if (s.identity.isEqual(webrtc.identity!)) return false;
return s.isSharingScreen;
}),
);
const sharer = $derived(participants.find(s => s.isSharingScreen));
const localSharing = $derived(webrtc.isSharingScreen);
const defaultSharerIdentity = $derived(
localSharing ? webrtc.identity : remoteSharerVs?.identity,
);
// Explicit check for local user existence in participants
const isMeInChannel = $derived(participants.some(p => p.identity.isEqual(chat.identity!)));
const primarySharerIdentity = $derived(focusedIdentity || defaultSharerIdentity);
const effectiveSharer = $derived(localSharing ? { identity: chat.identity } : sharer);
function isWatchingPeer(peerIdHex: string) {
const s = participants.find(p => p.identity.toHexString() === peerIdHex);
// I am watching the peer if my state's watching field points to them
return chat.currentVoiceState?.watching?.toHexString() === peerIdHex;
}
function toggleWatch(peerIdentity: Identity) {
if (isWatchingPeer(peerIdentity.toHexString())) {
function toggleWatch(identity: Identity) {
if (chat.currentVoiceState?.watching?.isEqual(identity)) {
webrtc.stopWatching();
} else {
webrtc.startWatching(peerIdentity);
webrtc.startWatching(identity);
}
}
const heroVs = $derived(
participants.find((s) =>
s.identity.isEqual(primarySharerIdentity || Identity.zero()),
),
);
const rowParticipants = $derived(
participants.filter(
(s) => !s.identity.isEqual(primarySharerIdentity || Identity.zero()),
),
);
</script>
<div class="video-grid {primarySharerIdentity ? 'has-sharer' : ''}">
<div class="video-grid-content">
{#if primarySharerIdentity}
{#if heroVs}
<div
class="video-tile-container is-hero"
onclick={() => (focusedIdentity = heroVs.identity)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Enter" && (focusedIdentity = heroVs.identity)}
style="cursor: pointer;"
>
<div class="video-grid" class:has-sharer={!!effectiveSharer}>
{#if participants.length === 0}
<div class="empty-channel-state">
<i class="fas fa-microphone-alt-slash"></i>
<p>Synchronizing channel participants...</p>
</div>
{:else}
<div class="video-grid-content">
{#if effectiveSharer}
<div class="hero-container">
<VideoTile
identity={heroVs.identity}
stream={heroVs.identity.isEqual(webrtc.identity!)
? webrtc.localScreenStream || undefined
: webrtc.peers.get(heroVs.identity.toHexString())?.videoStream}
isLocal={heroVs.identity.isEqual(webrtc.identity!)}
isTalking={heroVs.isTalking}
isWatching={isWatchingPeer(heroVs.identity.toHexString())}
isSharing={heroVs.identity.isEqual(webrtc.identity!)
? localSharing
: heroVs.isSharingScreen}
onToggleWatch={() => toggleWatch(heroVs.identity)}
identity={effectiveSharer.identity!}
isLocal={effectiveSharer.identity!.isEqual(chat.identity!)}
stream={effectiveSharer.identity!.isEqual(chat.identity!)
? webrtc.localMedia.screenStream
: webrtc.getRemoteStream(effectiveSharer.identity!.toHexString(), 'screen')}
isSharing={true}
isWatching={true}
onToggleWatch={() => toggleWatch(effectiveSharer.identity!)}
isHero={true}
users={chat.users}
isTalking={participants.find(p => p.identity.isEqual(effectiveSharer.identity!))?.isTalking}
/>
</div>
{/if}
{#if rowParticipants.length > 0}
<div class="video-participants-row">
{#each rowParticipants as s (s.identity.toHexString())}
<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;"
>
<div class="participants-row">
{#each participants.filter(s => !effectiveSharer.identity!.isEqual(s.identity)) as s (s.identity.toHexString())}
<div class="row-tile-wrapper">
<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}
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 !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>
{/if}
{:else}
{#each participants as s (s.identity.toHexString())}
<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>
{/if}
</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>
+4 -3
View File
@@ -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>
<div
@@ -128,7 +129,7 @@
{:else}
<div class="avatar-placeholder-container">
<Avatar user={users.find(u => u.identity.isEqual(identity))} size="large" />
{#if !isLocal && isSharing}
{#if showWatchButton}
<button
class="watch-btn"
onclick={(e) => {
@@ -199,7 +200,7 @@
}
.video-tile.talking {
border-color: #23a559;
border-color: var(--status-positive);
}
video {
@@ -76,4 +76,52 @@
background-color: rgba(255, 255, 255, 0.1);
color: white;
}
.channel-section {
padding: 16px 0 8px 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 16px;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 4px;
}
.add-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 0.9rem;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.1s, color 0.1s;
}
.section-header:hover .add-btn {
opacity: 1;
}
.add-btn:hover {
color: var(--interactive-hover);
}
.channel-item-hash {
font-size: 1rem;
color: var(--text-muted);
margin-right: 4px;
width: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
</style>
@@ -257,4 +257,203 @@
background-color: rgba(255, 255, 255, 0.1);
color: white;
}
.channel-section {
padding: 16px 0 8px 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 16px;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 4px;
}
.add-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 0.9rem;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.1s, color 0.1s;
}
.section-header:hover .add-btn {
opacity: 1;
}
.add-btn:hover {
color: var(--interactive-hover);
}
.channel-item-hash {
font-size: 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>
+80 -16
View File
@@ -1,8 +1,8 @@
import { Identity } from "spacetimedb";
import { SvelteMap, SvelteSet } from "svelte/reactivity";
import * as Types from "../../module_bindings/types";
import { reducers } from "../../module_bindings";
import { getUsername, formatTime } from "../utils";
import { getConnection } from "../../config";
import { DatabaseService } from "./database.svelte";
import { NavigationService } from "./navigation.svelte";
import { ThemeService, themeService } from "./theme.svelte";
@@ -38,6 +38,7 @@ export class ChatService {
#bannerUrls = new SvelteMap<string, string>();
#serverAvatarUrls = new SvelteMap<string, string>();
#messageImageUrls = new SvelteMap<string, string>();
imageSizes = new SvelteMap<string, number>();
constructor(initialIdentity: Identity | null) {
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.
// This ditched the persistent IndexedDB cache to prevent stale data between reloads.
// 1. Lazy Image Requesting: Track which images should be visible and request missing BLOBs
$effect(() => {
const currentImages = this.#db.images;
const conn = getConnection();
if (!conn || !this.identity) return;
const currentIds = new Set(currentImages.map(img => img.id.toString()));
// 1. Cleanup old Blob URLs no longer in visible_images
// Cleanup old Blob URLs no longer in visible_images
for (const [idStr, url] of this.#blobUrls.entries()) {
if (!currentIds.has(idStr)) {
console.log(`[ChatService] Revoking Blob URL for ${idStr}`);
@@ -153,20 +156,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) {
const idStr = img.id.toString();
if (!this.#blobUrls.has(idStr)) {
// Use a copy of the data to ensure no buffer sharing issues
const dataCopy = img.data.slice();
const blob = new Blob([dataCopy], { type: img.mimeType });
const url = URL.createObjectURL(blob);
console.log(`[ChatService] Created Blob URL for ${idStr}: ${url} (size: ${dataCopy.length} bytes)`);
this.#blobUrls.set(idStr, url);
conn.reducers.requestImageBlob({ imageId: img.id });
}
}
});
// 2. Lazy Image Processing: Process BLOBs as they arrive in visible_image_blobs
$effect(() => {
const blobs = this.#db.imageBlobs;
const currentImages = this.#db.images;
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
for (const user of this.users) {
if (user.avatarId) {
@@ -190,11 +212,18 @@ export class ChatService {
const idStr = server.avatarId.toString();
const url = this.#blobUrls.get(idStr);
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);
} 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) {
const idStr = img.id.toString();
const url = this.#blobUrls.get(idStr);
@@ -482,7 +511,7 @@ export class ChatService {
return this.#db.isUsersReady;
}
get isReady() {
return this.#db.isReady && this.#msg.isGlobalSyncDone;
return this.#db.isReady;
}
get isMessagesReady() {
@@ -562,10 +591,45 @@ export class ChatService {
// Derived Helpers
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() {
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() {
+9 -8
View File
@@ -13,6 +13,7 @@ export class DatabaseService {
serverMembers = $state<readonly Types.ServerMember[]>([]);
allThreads = $state<readonly Types.Thread[]>([]);
images = $state<readonly Types.VisibleImageRow[]>([]);
imageBlobs = $state<readonly Types.ImageData[]>([]);
customEmojis = $state<readonly Types.CustomEmoji[]>([]);
userStates = $state<readonly Types.UserState[]>([]);
typingActivity = $state<readonly Types.TypingActivity[]>([]);
@@ -45,6 +46,7 @@ export class DatabaseService {
const [serverMembersStore, membersReadyStore] = useTable(tables.visible_server_members);
const [threadsStore] = useTable(tables.thread);
const [imagesStore, imagesReadyStore] = useTable(tables.visible_images);
const [imageBlobsStore] = useTable(tables.visible_image_blobs);
const [customEmojisStore] = useTable(tables.custom_emoji);
const [typingActivityStore] = useTable(tables.visible_typing_activity);
const [systemConfigStore] = useTable(tables.system_configuration);
@@ -61,17 +63,16 @@ export class DatabaseService {
serverMembersStore.subscribe((v) => (this.serverMembers = v));
membersReadyStore.subscribe((v) => (this.isMembersReady = v));
threadsStore.subscribe((v) => (this.allThreads = v));
imagesStore.subscribe((v) => {
imagesStore.subscribe((v) => (this.images = v));
imagesReadyStore.subscribe((v) => (this.isImagesReady = v));
imageBlobsStore.subscribe((v) => {
// CRITICAL: We MUST copy the Uint8Array data immediately.
// SpacetimeDB JS SDK uses a shared WASM buffer for byteArrays,
// so if we don't copy it here, all image rows will eventually
// point to the data of the last image fetched.
this.images = v.map(img => ({
...img,
data: new Uint8Array(img.data)
// SpacetimeDB JS SDK uses a shared WASM buffer for byteArrays.
this.imageBlobs = v.map(blob => ({
...blob,
data: new Uint8Array(blob.data)
}));
});
imagesReadyStore.subscribe((v) => (this.isImagesReady = v));
customEmojisStore.subscribe((v) => (this.customEmojis = v));
typingActivityStore.subscribe((v) => (this.typingActivity = v));
systemConfigStore.subscribe((v) => (this.systemConfiguration = v));
+26 -30
View File
@@ -37,18 +37,17 @@ export class MessagingService {
onMessageReceived?: (params: { channelId: bigint, senderIdentity: Identity, id: bigint, text: string, isEncrypted: boolean }) => void;
// Internal reactive state from SpacetimeDB
#mySubscriptions = $state<readonly Types.MyChannelSubscriptionRow[]>([]);
// Optimized Per-Channel/Per-Message Buckets
#channelBuckets = new SvelteMap<bigint, {
map: Map<bigint, Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] }>,
sorted: (Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] })[]
map: Map<bigint, Types.Message>,
sorted: Types.Message[]
}>();
isLoadingMore = $state(false);
#readyChannels = new SvelteSet<bigint>();
isGlobalSyncDone = $state(false);
encryptionOptIn = $state(new SvelteSet<string>());
#mySubscriptions = $state<readonly Types.MyChannelSubscriptionRow[]>([]);
get isMessagesReady() {
const cid = this.#nav.activeChannelId;
@@ -92,10 +91,10 @@ export class MessagingService {
const [visibleScrollbackStore] = useTable(tables.visible_scrollback_messages);
const [mySubscriptionsStore] = useTable(tables.my_channel_subscriptions);
type CombinedMessageRow = Types.RecentMessage | Types.VisibleMessageRow;
mySubscriptionsStore.subscribe((v) => (this.#mySubscriptions = v));
let recentMessages: readonly Types.RecentMessage[] = [];
let scrollbackMessages: readonly Types.VisibleMessageRow[] = [];
let recentMessages: readonly Types.Message[] = [];
let scrollbackMessages: readonly Types.Message[] = [];
// Incremental update logic for visible messages
const seenMessageIds = new Set<bigint>();
@@ -157,8 +156,6 @@ export class MessagingService {
this.#updateBuckets([...recentMessages, ...scrollbackMessages]);
});
mySubscriptionsStore.subscribe((v) => (this.#mySubscriptions = v));
$effect(() => {
const channelId = this.#nav.activeChannelId;
const identity = this.#identity();
@@ -172,6 +169,7 @@ export class MessagingService {
// 1. Global/Session-long queries
queries.push("SELECT * FROM upload_status");
queries.push("SELECT * FROM visible_images");
queries.push("SELECT * FROM visible_image_blobs");
if (identity) {
const idHex = identity.toHexString();
@@ -185,10 +183,7 @@ export class MessagingService {
queries.push(`SELECT * FROM visible_direct_messages`);
queries.push(`SELECT * FROM my_channel_subscriptions`);
// Recent messages for all joined channels/DMs
queries.push(`SELECT * FROM visible_recent_activity`);
// WebRTC Signaling
// WebRTC Signaling (Needs to stay global for incoming calls)
queries.push(`SELECT * FROM visible_webrtc_signals`);
}
@@ -198,7 +193,10 @@ export class MessagingService {
queries.push(`SELECT * FROM visible_scrollback_messages`);
queries.push(`SELECT * FROM thread WHERE channel_id = ${channelId}`);
queries.push(`SELECT * FROM visible_user_states`);
queries.push(`SELECT * FROM visible_typing_activity WHERE channel_id = ${channelId}`);
queries.push(`SELECT * FROM visible_typing_activity`);
// Fast-path recent activity for the ACTIVE channel only
queries.push(`SELECT * FROM visible_recent_activity WHERE channel_id = ${channelId}`);
}
console.log(`[MessagingService] Updating subscriptions: ${queries.length} queries`);
@@ -213,8 +211,8 @@ export class MessagingService {
});
}
#updateBuckets(newMessages: readonly (Types.RecentMessage | Types.VisibleMessageRow)[]) {
const tempBuckets = new Map<bigint, Map<bigint, Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] }>>();
#updateBuckets(newMessages: readonly Types.Message[]) {
const tempBuckets = new Map<bigint, Map<bigint, Types.Message>>();
for (const m of newMessages) {
let bucketMap = tempBuckets.get(m.channelId);
@@ -222,23 +220,15 @@ export class MessagingService {
bucketMap = new Map();
tempBuckets.set(m.channelId, bucketMap);
}
bucketMap.set(m.id, {
...(m as unknown as Types.Message),
seqId: m.seqId,
reactions: m.reactions,
imageIds: m.imageIds
});
bucketMap.set(m.id, m);
}
this.#channelBuckets.clear();
for (const [chanId, messagesMap] of tempBuckets.entries()) {
const sorted = Array.from(messagesMap.values()).sort((a, b) => {
if (a.seqId !== undefined && b.seqId !== undefined) {
if (a.seqId < b.seqId) return -1;
if (a.seqId > b.seqId) return 1;
return 0;
}
if (a.seqId < b.seqId) return -1;
if (a.seqId > b.seqId) return 1;
return a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1;
});
@@ -247,7 +237,7 @@ export class MessagingService {
}
get synchronizedMessages() {
const all: (Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] })[] = [];
const all: Types.Message[] = [];
for (const bucket of this.#channelBuckets.values()) {
all.push(...bucket.sorted);
}
@@ -308,12 +298,18 @@ export class MessagingService {
const channelId = this.#nav.activeChannelId;
if (!channelId) return false;
const sub = this.#mySubscriptions.find(s => s.channelId === channelId);
const sub = this.#mySubscriptions.find((s) => s.channelId === channelId);
if (!sub) return false;
// Check if the earliest message we have is > 1
// OR if we don't have all messages up to the latest head
const bucket = this.#channelBuckets.get(channelId);
if (!bucket || bucket.sorted.length === 0) return sub.lastSeqId > 0n;
return sub.earliestSeqId > 1n;
}
handleStartThread = (msg: Types.Message) => {
const existing = this.#db.allThreads.find((t) => t.parentMessageId === msg.id);
if (existing) {
@@ -373,7 +369,7 @@ export class MessagingService {
const msgs = this.channelMessages;
if (msgs.length === 0) return false;
const oldestMsg = msgs[0] as any;
const oldestMsg = msgs[0];
const oldestSeq = oldestMsg.seqId;
if (oldestSeq === undefined || oldestSeq <= 1n) {
+16 -7
View File
@@ -1,6 +1,7 @@
import { DatabaseService } from "./database.svelte";
import { Identity } from "spacetimedb";
import { untrack } from "svelte";
import * as Types from "../../module_bindings/types";
export class NavigationService {
activeServerId = $state<bigint | null>(null);
@@ -134,16 +135,24 @@ export class NavigationService {
const channelId = this.activeChannelId;
if (!channelId) return undefined;
const channel = this.#db.channels.find(c => c.id === channelId);
if (!channel) return undefined;
// 1. Try to find in the synchronized channel rows
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 {
...channel,
serverId: channel.serverId
};
id: meta.id,
serverId: server.id,
name: meta.name,
kind: meta.kind
} as Types.VisibleChannelRow;
}
}
return channel;
return undefined;
}
}
+6
View File
@@ -34,6 +34,12 @@ export class VoiceService {
}
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();
this.#joinVoiceReducer({ channelId });
};
+15
View File
@@ -268,4 +268,19 @@ export class WebRTCService {
toggleDeafen = () => this.localMedia.toggleDeafen();
setPeerAudioPreference = (peerIdHex: string, pref: any) =>
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
View File
@@ -6,82 +6,61 @@ import { connectionState } from "./connection.svelte";
export { HOST_KEY, DB_NAME_KEY, getEnv };
/**
* Normalizes the host URL for SpacetimeDB.
* This field now takes a hostname without a protocol and assumes https://.
* The SDK automatically handles the upgrade from https:// to wss://.
* Normalizes the host URL for SpacetimeDB URI construction.
*/
export const normalizeHost = (host: string) => {
let normalized = host.trim().replace(/\/+$/, "");
// Remove any existing protocol (http, https, ws, 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 protocol = isLocal ? "http://" : "https://";
return `${protocol}${normalized}`;
};
export const TokenStore = {
get: (host: string, dbName: string) => {
const key = `stdb_token:${normalizeHost(host)}:${dbName}`;
return localStorage.getItem(key);
try {
const dataStr = localStorage.getItem("stdb_connection_data");
if (!dataStr) return null;
const data = JSON.parse(dataStr);
const normalizedStoredHost = normalizeHost(data.host);
const normalizedCurrentHost = normalizeHost(host);
if (normalizedStoredHost === normalizedCurrentHost && data.dbName === dbName) {
// Simple expiration check: if the token is older than 30 days, ignore it
const thirtyDaysInMs = 30 * 24 * 60 * 60 * 1000;
if (data.timestamp && Date.now() - data.timestamp > thirtyDaysInMs) {
console.warn("[TokenStore] Stored token is older than 30 days, treating as expired.");
return null;
}
console.log(`[TokenStore] Retrieved valid token for ${data.host}:${data.dbName} (age: ${data.timestamp ? Math.round((Date.now() - data.timestamp) / 1000 / 60) : 'unknown'} mins)`);
return data.token;
}
} catch (e) {
console.error("[TokenStore] Error parsing connection data:", e);
}
return null;
},
set: (host: string, dbName: string, token: string) => {
const key = `stdb_token:${normalizeHost(host)}:${dbName}`;
console.log("TokenStore: Setting token for key:", key);
localStorage.setItem(key, token);
const normalizedHostStr = normalizeHost(host);
console.log(`[TokenStore] Persisting new token for ${normalizedHostStr}:${dbName}`);
localStorage.setItem("stdb_connection_data", JSON.stringify({
host: normalizedHostStr,
dbName,
token,
timestamp: Date.now()
}));
},
clear: (host: string, dbName: string) => {
const key = `stdb_token:${normalizeHost(host)}:${dbName}`;
localStorage.removeItem(key);
},
listStoredConnections: () => {
const connections: string[] = [];
const seen = new Set<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;
clear: () => {
console.log("[TokenStore] Clearing connection data.");
localStorage.removeItem("stdb_connection_data");
}
};
export const getStdbHost = () =>
localStorage.getItem(HOST_KEY) ||
getEnv("VITE_SPACETIMEDB_HOST", "connect.zep.chat");
export const getStdbDbName = () =>
localStorage.getItem(DB_NAME_KEY) ||
getEnv("VITE_SPACETIMEDB_DB_NAME", "zep");
@@ -89,158 +68,127 @@ export const getStdbDbName = () =>
let _connection: DbConnection | null = null;
export const getConnection = () => _connection;
let activeManager: ConnectionManager | null = null;
export const stopActiveConnection = () => {
if (activeManager) {
activeManager.stop();
activeManager = null;
}
if (_connection) {
_connection.disconnect();
_connection = null;
}
};
class ConnectionManager {
#retryCount = 0;
#reconnectTimeout: any = null;
#host: string;
#dbName: string;
#isStopped = false;
let lastSyncedIdentity: string | null = null;
constructor(host: string, dbName: string) {
this.#host = host;
this.#dbName = dbName;
export const handleConnect = (conn: DbConnection, identity: any, token: string, isOIDC: boolean = false) => {
const host = getStdbHost();
const dbName = getStdbDbName();
const identityHex = identity?.toHexString();
console.log(`[Handshake] Connection established! Identity: ${identityHex}, Token received: ${!!token}, 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 = () => {
this.#isStopped = true;
if (this.#reconnectTimeout) {
clearTimeout(this.#reconnectTimeout);
this.#reconnectTimeout = null;
}
};
connectionState.status = "connected";
connectionState.hasConnectedOnce = true;
connectionState.error = null;
onConnect = (conn: DbConnection, identity: any, token: string) => {
console.log("ConnectionManager: onConnect called! Identity:", identity?.toHexString(), "Token length:", token?.length);
if (this.#isStopped) {
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
if (identityHex !== lastSyncedIdentity) {
console.log("[Handshake] New identity detected, syncing server-side auth metadata...");
lastSyncedIdentity = identityHex;
setTimeout(() => {
if (_connection) {
console.log("ConnectionManager: Requesting auth info update...");
if (_connection && (_connection.reducers as any).updateAuthInfo) {
(_connection.reducers as any).updateAuthInfo({});
}
}, 100);
}
};
this.#retryCount = 0;
if (this.#reconnectTimeout) {
clearTimeout(this.#reconnectTimeout);
this.#reconnectTimeout = null;
let lastRefreshAttempt = 0;
export const handleConnectError = async (err: Error) => {
const host = getStdbHost();
const dbName = getStdbDbName();
console.log("[Handshake] Error connecting to SpacetimeDB:", err);
const errStr = err.message || "";
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 (this.#isStopped) return;
console.log("Disconnected from SpacetimeDB");
_connection = null;
connectionState.status = "disconnected";
this.#scheduleReconnect();
};
// If we are already refreshing or loading, don't trigger another one
if (auth.isLoading || auth.isRefreshing) {
console.log("[Handshake] Auth is already loading/refreshing, skipping redundant recovery.");
return;
}
onConnectError = (_ctx: any, err: Error) => {
if (this.#isStopped) return;
console.log("Error connecting to SpacetimeDB:", err);
connectionState.error = err.message;
this.#scheduleReconnect();
};
const now = Date.now();
// Only attempt recovery if we haven't tried in the last 30 seconds to prevent loops
if (auth.isAuthenticated && (now - lastRefreshAttempt > 30000)) {
console.log("[Handshake] User is authenticated with OIDC, attempting silent refresh...");
lastRefreshAttempt = now;
const success = await auth.forceTokenRefresh();
if (success) {
console.log("[Handshake] Silent refresh triggered successfully. Connection should auto-rebuild.");
return;
}
}
#scheduleReconnect = () => {
if (this.#isStopped || this.#reconnectTimeout) return;
const delay = Math.min(120000, Math.pow(5, this.#retryCount) * 1000);
console.log(
`Scheduling reconnect in ${delay}ms (attempt ${this.#retryCount + 1})`,
);
this.#reconnectTimeout = setTimeout(() => {
this.#reconnectTimeout = null;
if (this.#isStopped) return;
this.#retryCount++;
console.log(
"ConnectionManager: Reconnect delay reached. Reloading window...",
);
window.location.reload();
}, delay);
};
}
console.warn("[Handshake] Recovery failed, not applicable, or looping. Purging session...");
// auth.logout() will reload the page and clear oidc state
auth.logout();
return;
}
connectionState.error = err.message;
};
export const connectionBuilder = (oidcToken?: string) => {
const rawHost = getStdbHost();
const host = normalizeHost(rawHost);
const host = normalizeHost(getStdbHost());
const dbName = getStdbDbName();
console.log(`connectionBuilder: Using host: ${host} (raw: ${rawHost}), database: ${dbName}`);
connectionState.status = "connecting";
if (activeManager) {
console.log("connectionBuilder: Stopping previous activeManager");
activeManager.stop();
}
const manager = new ConnectionManager(host, dbName);
activeManager = manager;
console.log(`[Builder] Creating handshake: host=${host}, database=${dbName}, mode=${oidcToken ? 'OIDC' : 'Token/Guest'}`);
const builder = DbConnection.builder()
.withUri(host)
.withDatabaseName(dbName);
const storedToken = TokenStore.get(host, dbName);
console.log("connectionBuilder: oidcToken:", oidcToken ? "present" : "absent");
console.log("connectionBuilder: storedToken:", storedToken ? "present" : "absent");
// CRITICAL: If we have an OIDC token, use it exclusively.
// Stored tokens in TokenStore are ONLY for guest mode.
if (oidcToken) {
console.log("connectionBuilder: Calling withToken with oidcToken");
console.log("[Builder] Using OIDC token for handshake.");
builder.withToken(oidcToken);
} else if (storedToken) {
console.log("connectionBuilder: Calling withToken with storedToken");
builder.withToken(storedToken);
} 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.
// This ensures they coexist with the Svelte provider's listeners.
builder.onConnect((c: any, id: any, token: string) => {
console.log("connectionBuilder: CONNECTION onConnect TRIGGERED!");
manager.onConnect(c as any, id, token);
});
builder.onDisconnect((_ctx: any, err: any) => {
console.log("connectionBuilder: CONNECTION onDisconnect TRIGGERED!", err);
manager.onDisconnect();
});
builder.onConnectError((ctx: any, err: any) => {
console.log("connectionBuilder: CONNECTION onConnectError TRIGGERED!", err);
manager.onConnectError(ctx, err);
});
builder.onConnect((conn, identity, token) => handleConnect(conn as any, identity, token, !!oidcToken));
builder.onConnectError((_ctx, err) => handleConnectError(err));
return builder;
};