diff --git a/spacetimedb/Cargo.lock b/spacetimedb/Cargo.lock index f1c3b78..7ecc42e 100644 --- a/spacetimedb/Cargo.lock +++ b/spacetimedb/Cargo.lock @@ -749,6 +749,7 @@ name = "spacetimedb_rust" version = "0.1.0" dependencies = [ "log", + "rand 0.8.5", "spacetimedb", ] diff --git a/spacetimedb/Cargo.toml b/spacetimedb/Cargo.toml index 2427cc4..56190e9 100644 --- a/spacetimedb/Cargo.toml +++ b/spacetimedb/Cargo.toml @@ -11,3 +11,4 @@ crate-type = ["cdylib"] [dependencies] spacetimedb = { version = "2.1.0" } log = "0.4" +rand = { version = "0.8", features = ["small_rng"] } diff --git a/spacetimedb/src/lib.rs b/spacetimedb/src/lib.rs index 9dd4313..c618c16 100644 --- a/spacetimedb/src/lib.rs +++ b/spacetimedb/src/lib.rs @@ -125,7 +125,7 @@ pub fn on_connect(ctx: &ReducerContext) { }); // Minimal auto-join - join_server(ctx, 1); + join_server(ctx, Some(1), None); // System Welcome DM let system_identity = Identity::from_hex(SYSTEM_IDENTITY).unwrap(); @@ -167,6 +167,8 @@ pub fn on_disconnect(ctx: &ReducerContext) { ctx.db.typing_activity().delete(ta); } + ctx.db.join_server_status().identity().delete(ctx.sender()); + clear_user_presence(&ctx.db, ctx.sender()); } diff --git a/spacetimedb/src/reducers.rs b/spacetimedb/src/reducers.rs index 606ac70..dd4a229 100644 --- a/spacetimedb/src/reducers.rs +++ b/spacetimedb/src/reducers.rs @@ -242,15 +242,144 @@ pub fn create_server(ctx: &ReducerContext, name: String) { sync_server_access(&ctx.db, ctx.sender(), s.id); } -#[spacetimedb::reducer] -pub fn join_server(ctx: &ReducerContext, server_id: u64) { - let user = ctx.db.user().identity().find(ctx.sender()).expect("User not found"); - let s = ctx.db.server().id().find(server_id).expect("Server not found"); - if !s.public && user.subject.is_none() && user.name.is_none() { panic!("Name required for private server"); } +use rand::distributions::{Alphanumeric, DistString}; - if ctx.db.server_member().identity().filter(ctx.sender()).any(|m| m.server_id == server_id) { return; } - ctx.db.server_member().insert(ServerMember { id: 0, identity: ctx.sender(), server_id, name: user.name.clone(), avatar_id: user.avatar_id, online: user.online }); - sync_server_access(&ctx.db, ctx.sender(), server_id); +#[spacetimedb::reducer] +pub fn create_invite(ctx: &ReducerContext, server_id: u64, max_uses: Option, expires_in_hrs: Option) { + // Only members can invite + if !ctx.db.server_member().identity().filter(ctx.sender()).any(|m| m.server_id == server_id) { + panic!("Only server members can create invites"); + } + + // Generate a 12-character alphanumeric code using the provided deterministic RNG + let code = Alphanumeric.sample_string(&mut ctx.rng(), 12); + + let expires_at = expires_in_hrs.map(|h| { + let duration = spacetimedb::TimeDuration::from_micros(h as i64 * 3600 * 1000000); + ctx.timestamp + duration + }); + + ctx.db.invite().insert(Invite { + code: code.clone(), + server_id, + inviter: ctx.sender(), + expires_at, + uses_remaining: max_uses, + }); + + // Send the generated code back to the client via JoinServerStatus + ctx.db.join_server_status().identity().delete(ctx.sender()); + ctx.db.join_server_status().insert(JoinServerStatus { + identity: ctx.sender(), + status: "invite_created".to_string(), + server_id: Some(server_id), + error: Some(code), // We use the error field to carry the code for this status + }); +} + +#[spacetimedb::reducer] +pub fn join_server(ctx: &ReducerContext, server_id: Option, invite_code: Option) { + let sender = ctx.sender(); + + // Helper to record error and return + let fail = |db: &spacetimedb::Local, err: &str| { + db.join_server_status().identity().delete(sender); + db.join_server_status().insert(JoinServerStatus { + identity: sender, + status: "error".to_string(), + server_id: None, + error: Some(err.to_string()), + }); + }; + + let user = ctx.db.user().identity().find(sender).expect("User not found"); + + let target_server_id = if let Some(id) = server_id { + id + } else if let Some(ref code) = invite_code { + let invite = match ctx.db.invite().code().find(code.clone()) { + Some(i) => i, + None => { + fail(&ctx.db, "Invalid invite code"); + return; + } + }; + invite.server_id + } else { + fail(&ctx.db, "Either server_id or invite_code must be provided"); + return; + }; + + let s = match ctx.db.server().id().find(target_server_id) { + Some(s) => s, + None => { + fail(&ctx.db, "Server not found"); + return; + } + }; + + // Permission check: if private, must have a valid invite code + if !s.public { + if let Some(code) = invite_code { + let invite = match ctx.db.invite().code().find(code) { + Some(i) => i, + None => { + fail(&ctx.db, "Invalid invite code"); + return; + } + }; + + if invite.server_id != target_server_id { + fail(&ctx.db, "Invite code does not match server"); + return; + } + + // Expiry check + if let Some(expiry) = invite.expires_at { + if ctx.timestamp > expiry { + fail(&ctx.db, "Invite code expired"); + return; + } + } + + // Uses check + if let Some(mut uses) = invite.uses_remaining { + if uses == 0 { + fail(&ctx.db, "Invite code usage limit reached"); + return; + } + uses -= 1; + let mut invite = invite.clone(); + invite.uses_remaining = Some(uses); + ctx.db.invite().code().update(invite); + } + } else { + fail(&ctx.db, "Invite code required for private server"); + return; + } + } + + if !s.public && user.subject.is_none() && user.name.is_none() { + fail(&ctx.db, "DisplayName required for private server"); + return; + } + + if ctx.db.server_member().identity().filter(sender).any(|m| m.server_id == target_server_id) { + fail(&ctx.db, "Already a member of this server"); + return; + } + + ctx.db.server_member().insert(ServerMember { id: 0, identity: sender, server_id: target_server_id, name: user.name.clone(), avatar_id: user.avatar_id, online: user.online }); + sync_server_access(&ctx.db, sender, target_server_id); + + // Record success + ctx.db.join_server_status().identity().delete(sender); + ctx.db.join_server_status().insert(JoinServerStatus { + identity: sender, + status: "success".to_string(), + server_id: Some(target_server_id), + error: None, + }); } #[spacetimedb::reducer] diff --git a/spacetimedb/src/tables.rs b/spacetimedb/src/tables.rs index 87fe15c..953b83a 100644 --- a/spacetimedb/src/tables.rs +++ b/spacetimedb/src/tables.rs @@ -281,3 +281,24 @@ pub struct UploadStatus { pub status: String, // "pending", "success", "error" pub error: Option, } + +#[spacetimedb::table(accessor = join_server_status, public)] +#[derive(Clone)] +pub struct JoinServerStatus { + #[primary_key] + pub identity: Identity, + pub status: String, // "success", "error" + pub server_id: Option, + pub error: Option, +} + +#[spacetimedb::table(accessor = invite)] +#[derive(Clone)] +pub struct Invite { + #[primary_key] + pub code: String, + pub server_id: u64, + pub inviter: Identity, + pub expires_at: Option, + pub uses_remaining: Option, +} diff --git a/src/chat/ChatContainer.svelte b/src/chat/ChatContainer.svelte index c854298..c61547e 100644 --- a/src/chat/ChatContainer.svelte +++ b/src/chat/ChatContainer.svelte @@ -16,6 +16,7 @@ import ServerSettingsPanel from "./components/ServerSettingsPanel.svelte"; import ImageViewer from "./components/ImageViewer.svelte"; import ProfileModal from "./components/ProfileModal.svelte"; + import InviteModal from "./components/InviteModal.svelte"; import UserContextMenu from "./components/UserContextMenu.svelte"; import ConfirmModal from "./components/ConfirmModal.svelte"; @@ -27,6 +28,30 @@ const chat = new ChatService($spacetime.identity!); const webrtc = new WebRTCService($spacetime.identity, undefined); + // 0. Invite Link Handling + onMount(() => { + const params = new URLSearchParams(window.location.search); + const inviteCode = params.get("invite"); + if (inviteCode) { + console.log(`[Invite] Detected invite code in URL: ${inviteCode}`); + // Wait for chat to be ready before joining + const checkReady = setInterval(() => { + if (chat.isReady) { + clearInterval(checkReady); + chat.handleJoinServer(undefined, inviteCode); + + // Clear URL parameter without reloading + const url = new URL(window.location.href); + url.searchParams.delete("invite"); + window.history.replaceState({}, document.title, url.pathname + url.search); + } + }, 500); + } + }); + + setContext("chat", chat); + setContext("webrtc", webrtc); + $effect(() => { if ($spacetime.identity) { chat.identity = $spacetime.identity; @@ -41,8 +66,6 @@ webrtc.connectedChannelId = undefined; } }); - setContext("chat", chat); - setContext("webrtc", webrtc); let showSettings = $state(false); let showMemberList = $state(true); @@ -306,8 +329,11 @@ (chat.viewingProfileUser = null)} /> {/if} - {#if chat.userContextMenu} - + {/if} + + {#if chat.userContextMenu} (chat.userContextMenu = null)} onAction={closeSidebars} diff --git a/src/chat/components/ImageViewer.svelte b/src/chat/components/ImageViewer.svelte index 0da4de8..5a9909f 100644 --- a/src/chat/components/ImageViewer.svelte +++ b/src/chat/components/ImageViewer.svelte @@ -197,7 +197,7 @@ - diff --git a/src/chat/components/InviteModal.svelte b/src/chat/components/InviteModal.svelte new file mode 100644 index 0000000..7d74099 --- /dev/null +++ b/src/chat/components/InviteModal.svelte @@ -0,0 +1,119 @@ + + + (chat.showInviteModal = false)}> + (chat.showInviteModal = false)}> +
+

+ Share this link with others so they can join your private server. +

+ +
+ {#if !inviteCode} + +

Links are unique to you and the current server.

+ {:else} +
+ + +
+ {/if} +
+
+
+
+ + diff --git a/src/chat/components/ServerDiscovery.svelte b/src/chat/components/ServerDiscovery.svelte index ea81fbe..78e2af6 100644 --- a/src/chat/components/ServerDiscovery.svelte +++ b/src/chat/components/ServerDiscovery.svelte @@ -9,49 +9,105 @@ const chat = getContext("chat"); let searchTerm = $state(""); + let inviteCode = $state(""); + let joinError = $state(null); + + $effect(() => { + const status = chat.joinServerStatus; + if (status?.status === "error") { + joinError = status.error || "Failed to join server"; + } else if (status?.status === "success") { + joinError = null; + inviteCode = ""; + + // Navigate to the joined server + if (status.serverId) { + console.log(`[Discovery] Success! Navigating to server ${status.serverId}`); + chat.activeServerId = status.serverId; + } + + chat.showDiscoveryModal = false; + } + }); let filteredServers = $derived( chat.availableServers.filter((s) => s.name.toLowerCase().includes(searchTerm.toLowerCase()) ) ); + + function handleJoinWithCode() { + if (inviteCode.trim()) { + joinError = null; + chat.handleJoinServer(undefined, inviteCode.trim()); + } + } (chat.showDiscoveryModal = false)}> (chat.showDiscoveryModal = false)}>
- + {#if joinError} +
+ {joinError} +
+ {/if} -
- {#if filteredServers.length === 0} -

- No servers found. -

- {:else} - {#each filteredServers as server (server.id.toString())} -
-
-
- {server.name.substring(0, 2).toUpperCase()} +
+

Join with Invite Code

+
+ + +
+
+ + + +
+

Public Servers

+ + +
+ {#if filteredServers.length === 0} +

+ No public servers found. +

+ {:else} + {#each filteredServers as server (server.id.toString())} +
+
+
+ {server.name.substring(0, 2).toUpperCase()} +
+ {server.name}
- {server.name} +
- -
- {/each} - {/if} + {/each} + {/if} +
@@ -64,6 +120,20 @@ max-height: 60vh; } + h3 { + font-size: 0.75rem; + font-weight: 800; + color: var(--text-muted); + text-transform: uppercase; + margin-bottom: 12px; + } + + .invite-row { + display: flex; + gap: 12px; + align-items: flex-start; + } + .server-list-container { overflow-y: auto; flex-grow: 1; diff --git a/src/chat/components/channels/ServerHeader.svelte b/src/chat/components/channels/ServerHeader.svelte index 7bd278f..921af1a 100644 --- a/src/chat/components/channels/ServerHeader.svelte +++ b/src/chat/components/channels/ServerHeader.svelte @@ -31,6 +31,19 @@ {#if showServerDropdown}
+ + + {#if isOwner}