Merge pull request 'a bunch of changes' (#1) from feat/optimizations into master
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
-63
@@ -1,63 +0,0 @@
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Dependency directory
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Build output
|
||||
dist/**
|
||||
out/
|
||||
build/
|
||||
|
||||
# IDEs and editors
|
||||
.idea
|
||||
.vscode/
|
||||
*.iml
|
||||
*~
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Vite
|
||||
.vite/
|
||||
|
||||
# SpacetimeDB build output
|
||||
spacetimedb/dist/
|
||||
spacetimedb/target/
|
||||
|
||||
# SpacetimeDB generated module bindings
|
||||
src/module_bindings/**
|
||||
|
||||
# Tauri & Rust
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
# Rust's Cargo.lock is often committed in binary/app projects,
|
||||
# but target/ is always ignored.
|
||||
|
||||
# Ignore this file
|
||||
.gitignore
|
||||
|
||||
|
||||
# wrangler files
|
||||
.wrangler
|
||||
.dev.vars*
|
||||
!.dev.vars.example
|
||||
!.env.example
|
||||
+52
-14
@@ -60,14 +60,12 @@ pub fn init(ctx: &ReducerContext) {
|
||||
server_id: s.id,
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+173
-870
File diff suppressed because it is too large
Load Diff
+51
-26
@@ -7,6 +7,7 @@ pub enum ChannelKind {
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = user, public)]
|
||||
#[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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
import { createSpacetimeDBProvider } from "spacetimedb/svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { auth } from "./auth/auth.svelte";
|
||||
import { connectionState } from "./connection.svelte";
|
||||
import { handleConnect, handleConnectError } from "./config";
|
||||
import type { ConnectionBuilder } from "spacetimedb";
|
||||
|
||||
let { builder, children, onCancel, host, dbName, oidcToken }: {
|
||||
builder: ConnectionBuilder<any>,
|
||||
children: any,
|
||||
onCancel?: () => void,
|
||||
host: string,
|
||||
dbName: string,
|
||||
oidcToken?: string
|
||||
} = $props();
|
||||
|
||||
// 1. Initialize the provider instance for this specific builder
|
||||
const db = createSpacetimeDBProvider(builder);
|
||||
|
||||
// 1.1 Connection Timeout
|
||||
// If we stay in "connecting" state for too long without an identity or error,
|
||||
// we'll force a timeout error to trigger recovery/logout.
|
||||
let connectionTimeout = $state<any>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!$db.identity && !$db.error) {
|
||||
if (!connectionTimeout) {
|
||||
connectionTimeout = setTimeout(() => {
|
||||
console.warn("[Handshake] Connection attempt timed out after 10s.");
|
||||
handleConnectError(new Error("Connection timeout: Server did not respond to handshake. This may be due to an expired token or network issues."));
|
||||
}, 10000);
|
||||
}
|
||||
} else {
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Handshake Synchronization
|
||||
// Ensures session persistence happens when a valid identity and token are available
|
||||
let lastPersistedToken = $state<string | undefined>(undefined);
|
||||
let lastUsedOidcTokenForUpgrade = $state<string | undefined>(oidcToken);
|
||||
|
||||
$effect(() => {
|
||||
const conn = $db.connection;
|
||||
const isActive = $db.isActive;
|
||||
const identity = $db.identity;
|
||||
const token = $db.token;
|
||||
|
||||
if (isActive && identity && conn && token) {
|
||||
// If the token from the server is different than the one we last saved,
|
||||
// trigger persistence. This covers both initial connection and upgrades.
|
||||
if (token !== lastPersistedToken) {
|
||||
untrack(() => {
|
||||
console.log("[Handshake] Identity and Token established, syncing persistence...");
|
||||
handleConnect(conn as any, identity, token, !!oidcToken);
|
||||
lastPersistedToken = token;
|
||||
});
|
||||
}
|
||||
} else if (!isActive) {
|
||||
lastPersistedToken = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Background Token Upgrades (In-place)
|
||||
// If we receive a new OIDC token while already connected, upgrade the session in-place.
|
||||
// This is crucial for zero-flicker background updates.
|
||||
$effect(() => {
|
||||
if (oidcToken && oidcToken !== lastUsedOidcTokenForUpgrade && $db.isActive && $db.connection) {
|
||||
console.log("[Handshake] Background upgrade with new OIDC token (In-place)...");
|
||||
untrack(() => {
|
||||
lastUsedOidcTokenForUpgrade = oidcToken;
|
||||
// Upgrade the existing connection with the new credentials.
|
||||
// SpacetimeDB will emit a new token, which the effect above will catch.
|
||||
($db.connection as any).withToken(oidcToken);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Error Monitoring
|
||||
$effect(() => {
|
||||
if ($db.error) {
|
||||
untrack(() => handleConnectError(new Error($db.error!)));
|
||||
}
|
||||
});
|
||||
|
||||
// Update global connection status for UI visibility
|
||||
$effect(() => {
|
||||
if ($db.isActive) {
|
||||
connectionState.status = "connected";
|
||||
} else {
|
||||
connectionState.status = "connecting";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $db.identity}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<div class="login-screen">
|
||||
<div class="login-card" style="text-align: center;">
|
||||
{#if $db.error}
|
||||
<i class="fas fa-exclamation-triangle" style="font-size: 3rem; color: var(--status-danger); margin-bottom: 20px;"></i>
|
||||
<h1>Connection Failed</h1>
|
||||
<p style="color: var(--status-danger); margin-top: 8px; margin-bottom: 24px;">{$db.error}</p>
|
||||
{:else if auth.isLoading}
|
||||
<i class="fas fa-id-card fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
|
||||
<h1>Authenticating...</h1>
|
||||
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Verifying your identity with the provider.</p>
|
||||
{:else}
|
||||
<i class="fas fa-circle-notch fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
|
||||
<h1>Connecting to SpacetimeDB...</h1>
|
||||
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Establishing a secure connection to the chat server.</p>
|
||||
{/if}
|
||||
|
||||
<div style="background-color: var(--background-tertiary); padding: 16px; border-radius: 8px; margin-bottom: 24px; text-align: left; font-size: 0.8rem; border: 1px solid var(--background-modifier-accent); display: flex; flex-direction: column; gap: 12px; width: 100%; box-sizing: border-box;">
|
||||
<div class="connection-detail">
|
||||
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Host</div>
|
||||
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{host}</div>
|
||||
</div>
|
||||
<div class="connection-detail">
|
||||
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Database</div>
|
||||
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{dbName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
|
||||
{#if $db.error}
|
||||
<button
|
||||
onclick={() => window.location.reload()}
|
||||
class="btn-primary"
|
||||
style="width: 100%;"
|
||||
>
|
||||
Retry Connection
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if onCancel}
|
||||
<button
|
||||
onclick={onCancel}
|
||||
class="btn-secondary"
|
||||
style="width: 100%;"
|
||||
>
|
||||
{ $db.error ? "Back to Login" : "Cancel" }
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.login-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
var(--background-secondary) 0%,
|
||||
var(--background-tertiary) 100%
|
||||
);
|
||||
background-color: var(--background-tertiary);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: var(--background-primary);
|
||||
padding: 32px;
|
||||
border-radius: 8px;
|
||||
width: 480px;
|
||||
box-shadow: 0 2px 10px 0 rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
color: var(--header-primary);
|
||||
margin-bottom: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
color: var(--text-normal);
|
||||
margin-bottom: 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,57 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createSpacetimeDBProvider } from "spacetimedb/svelte";
|
||||
import { connectionBuilder, getStdbHost, getStdbDbName } from "./config";
|
||||
import { auth } from "./auth/auth.svelte";
|
||||
|
||||
let { children, onCancel } = $props<{
|
||||
children: any,
|
||||
onCancel?: () => void
|
||||
}>();
|
||||
|
||||
const host = getStdbHost();
|
||||
const dbName = getStdbDbName();
|
||||
|
||||
// Initialize SpacetimeDB provider
|
||||
const db = createSpacetimeDBProvider(connectionBuilder(
|
||||
auth.user?.id_token
|
||||
));
|
||||
|
||||
$effect(() => {
|
||||
console.log("InnerSpacetimeProvider: $db.isActive:", $db.isActive);
|
||||
console.log("InnerSpacetimeProvider: $db.identity:", $db.identity?.toHexString());
|
||||
console.log("InnerSpacetimeProvider: $db.token:", $db.token ? "present" : "absent");
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $db.identity}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<div class="login-screen">
|
||||
<div class="login-card" style="text-align: center;">
|
||||
<i class="fas fa-circle-notch fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
|
||||
<h1>Connecting to SpacetimeDB...</h1>
|
||||
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Establishing a secure connection to the chat server.</p>
|
||||
|
||||
<div style="background-color: var(--background-tertiary); padding: 16px; border-radius: 8px; margin-bottom: 24px; text-align: left; font-size: 0.8rem; border: 1px solid var(--background-modifier-accent); display: flex; flex-direction: column; gap: 12px; width: 100%; box-sizing: border-box;">
|
||||
<div class="connection-detail">
|
||||
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Host</div>
|
||||
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{host}</div>
|
||||
</div>
|
||||
<div class="connection-detail">
|
||||
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Database</div>
|
||||
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{dbName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if onCancel}
|
||||
<button
|
||||
onclick={onCancel}
|
||||
class="btn-secondary"
|
||||
style="width: 100%;"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
+127
-12
@@ -1,23 +1,138 @@
|
||||
<script lang="ts">
|
||||
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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -13,6 +13,7 @@ export class DatabaseService {
|
||||
serverMembers = $state<readonly Types.ServerMember[]>([]);
|
||||
allThreads = $state<readonly Types.Thread[]>([]);
|
||||
images = $state<readonly Types.VisibleImageRow[]>([]);
|
||||
imageBlobs = $state<readonly Types.ImageData[]>([]);
|
||||
customEmojis = $state<readonly Types.CustomEmoji[]>([]);
|
||||
userStates = $state<readonly Types.UserState[]>([]);
|
||||
typingActivity = $state<readonly Types.TypingActivity[]>([]);
|
||||
@@ -45,6 +46,7 @@ export class DatabaseService {
|
||||
const [serverMembersStore, membersReadyStore] = useTable(tables.visible_server_members);
|
||||
const [threadsStore] = useTable(tables.thread);
|
||||
const [imagesStore, imagesReadyStore] = useTable(tables.visible_images);
|
||||
const [imageBlobsStore] = useTable(tables.visible_image_blobs);
|
||||
const [customEmojisStore] = useTable(tables.custom_emoji);
|
||||
const [typingActivityStore] = useTable(tables.visible_typing_activity);
|
||||
const [systemConfigStore] = useTable(tables.system_configuration);
|
||||
@@ -61,17 +63,16 @@ export class DatabaseService {
|
||||
serverMembersStore.subscribe((v) => (this.serverMembers = v));
|
||||
membersReadyStore.subscribe((v) => (this.isMembersReady = v));
|
||||
threadsStore.subscribe((v) => (this.allThreads = v));
|
||||
imagesStore.subscribe((v) => {
|
||||
imagesStore.subscribe((v) => (this.images = v));
|
||||
imagesReadyStore.subscribe((v) => (this.isImagesReady = v));
|
||||
imageBlobsStore.subscribe((v) => {
|
||||
// CRITICAL: We MUST copy the Uint8Array data immediately.
|
||||
// SpacetimeDB JS SDK uses a shared WASM buffer for byteArrays,
|
||||
// so if we don't copy it here, all image rows will eventually
|
||||
// point to the data of the last image fetched.
|
||||
this.images = v.map(img => ({
|
||||
...img,
|
||||
data: new Uint8Array(img.data)
|
||||
// SpacetimeDB JS SDK uses a shared WASM buffer for byteArrays.
|
||||
this.imageBlobs = v.map(blob => ({
|
||||
...blob,
|
||||
data: new Uint8Array(blob.data)
|
||||
}));
|
||||
});
|
||||
imagesReadyStore.subscribe((v) => (this.isImagesReady = v));
|
||||
customEmojisStore.subscribe((v) => (this.customEmojis = v));
|
||||
typingActivityStore.subscribe((v) => (this.typingActivity = v));
|
||||
systemConfigStore.subscribe((v) => (this.systemConfiguration = v));
|
||||
|
||||
@@ -37,18 +37,17 @@ export class MessagingService {
|
||||
onMessageReceived?: (params: { channelId: bigint, senderIdentity: Identity, id: bigint, text: string, isEncrypted: boolean }) => void;
|
||||
|
||||
// Internal reactive state from SpacetimeDB
|
||||
#mySubscriptions = $state<readonly Types.MyChannelSubscriptionRow[]>([]);
|
||||
|
||||
// Optimized Per-Channel/Per-Message Buckets
|
||||
#channelBuckets = new SvelteMap<bigint, {
|
||||
map: Map<bigint, Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] }>,
|
||||
sorted: (Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] })[]
|
||||
map: Map<bigint, Types.Message>,
|
||||
sorted: Types.Message[]
|
||||
}>();
|
||||
|
||||
isLoadingMore = $state(false);
|
||||
#readyChannels = new SvelteSet<bigint>();
|
||||
isGlobalSyncDone = $state(false);
|
||||
encryptionOptIn = $state(new SvelteSet<string>());
|
||||
#mySubscriptions = $state<readonly Types.MyChannelSubscriptionRow[]>([]);
|
||||
|
||||
get isMessagesReady() {
|
||||
const cid = this.#nav.activeChannelId;
|
||||
@@ -92,10 +91,10 @@ export class MessagingService {
|
||||
const [visibleScrollbackStore] = useTable(tables.visible_scrollback_messages);
|
||||
const [mySubscriptionsStore] = useTable(tables.my_channel_subscriptions);
|
||||
|
||||
type CombinedMessageRow = Types.RecentMessage | Types.VisibleMessageRow;
|
||||
mySubscriptionsStore.subscribe((v) => (this.#mySubscriptions = v));
|
||||
|
||||
let recentMessages: readonly Types.RecentMessage[] = [];
|
||||
let scrollbackMessages: readonly Types.VisibleMessageRow[] = [];
|
||||
let recentMessages: readonly Types.Message[] = [];
|
||||
let scrollbackMessages: readonly Types.Message[] = [];
|
||||
|
||||
// Incremental update logic for visible messages
|
||||
const seenMessageIds = new Set<bigint>();
|
||||
@@ -157,8 +156,6 @@ export class MessagingService {
|
||||
this.#updateBuckets([...recentMessages, ...scrollbackMessages]);
|
||||
});
|
||||
|
||||
mySubscriptionsStore.subscribe((v) => (this.#mySubscriptions = v));
|
||||
|
||||
$effect(() => {
|
||||
const channelId = this.#nav.activeChannelId;
|
||||
const identity = this.#identity();
|
||||
@@ -172,6 +169,7 @@ export class MessagingService {
|
||||
// 1. Global/Session-long queries
|
||||
queries.push("SELECT * FROM upload_status");
|
||||
queries.push("SELECT * FROM visible_images");
|
||||
queries.push("SELECT * FROM visible_image_blobs");
|
||||
|
||||
if (identity) {
|
||||
const idHex = identity.toHexString();
|
||||
@@ -185,10 +183,7 @@ export class MessagingService {
|
||||
queries.push(`SELECT * FROM visible_direct_messages`);
|
||||
queries.push(`SELECT * FROM my_channel_subscriptions`);
|
||||
|
||||
// Recent messages for all joined channels/DMs
|
||||
queries.push(`SELECT * FROM visible_recent_activity`);
|
||||
|
||||
// WebRTC Signaling
|
||||
// WebRTC Signaling (Needs to stay global for incoming calls)
|
||||
queries.push(`SELECT * FROM visible_webrtc_signals`);
|
||||
}
|
||||
|
||||
@@ -198,7 +193,10 @@ export class MessagingService {
|
||||
queries.push(`SELECT * FROM visible_scrollback_messages`);
|
||||
queries.push(`SELECT * FROM thread WHERE channel_id = ${channelId}`);
|
||||
queries.push(`SELECT * FROM visible_user_states`);
|
||||
queries.push(`SELECT * FROM visible_typing_activity WHERE channel_id = ${channelId}`);
|
||||
queries.push(`SELECT * FROM visible_typing_activity`);
|
||||
|
||||
// Fast-path recent activity for the ACTIVE channel only
|
||||
queries.push(`SELECT * FROM visible_recent_activity WHERE channel_id = ${channelId}`);
|
||||
}
|
||||
|
||||
console.log(`[MessagingService] Updating subscriptions: ${queries.length} queries`);
|
||||
@@ -213,8 +211,8 @@ export class MessagingService {
|
||||
});
|
||||
}
|
||||
|
||||
#updateBuckets(newMessages: readonly (Types.RecentMessage | Types.VisibleMessageRow)[]) {
|
||||
const tempBuckets = new Map<bigint, Map<bigint, Types.Message & { seqId?: bigint, reactions: Types.Reaction[], imageIds: bigint[] }>>();
|
||||
#updateBuckets(newMessages: readonly Types.Message[]) {
|
||||
const tempBuckets = new Map<bigint, Map<bigint, Types.Message>>();
|
||||
|
||||
for (const m of newMessages) {
|
||||
let bucketMap = tempBuckets.get(m.channelId);
|
||||
@@ -222,23 +220,15 @@ export class MessagingService {
|
||||
bucketMap = new Map();
|
||||
tempBuckets.set(m.channelId, bucketMap);
|
||||
}
|
||||
bucketMap.set(m.id, {
|
||||
...(m as unknown as Types.Message),
|
||||
seqId: m.seqId,
|
||||
reactions: m.reactions,
|
||||
imageIds: m.imageIds
|
||||
});
|
||||
bucketMap.set(m.id, m);
|
||||
}
|
||||
|
||||
this.#channelBuckets.clear();
|
||||
|
||||
for (const [chanId, messagesMap] of tempBuckets.entries()) {
|
||||
const sorted = Array.from(messagesMap.values()).sort((a, b) => {
|
||||
if (a.seqId !== undefined && b.seqId !== undefined) {
|
||||
if (a.seqId < b.seqId) return -1;
|
||||
if (a.seqId > b.seqId) return 1;
|
||||
return 0;
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user