first pass at server invites

This commit is contained in:
2026-04-21 17:15:03 -04:00
parent e5c94318c1
commit 2b7d4b873c
16 changed files with 468 additions and 68 deletions
+1
View File
@@ -749,6 +749,7 @@ name = "spacetimedb_rust"
version = "0.1.0"
dependencies = [
"log",
"rand 0.8.5",
"spacetimedb",
]
+1
View File
@@ -11,3 +11,4 @@ crate-type = ["cdylib"]
[dependencies]
spacetimedb = { version = "2.1.0" }
log = "0.4"
rand = { version = "0.8", features = ["small_rng"] }
+3 -1
View File
@@ -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());
}
+137 -8
View File
@@ -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<u32>, expires_in_hrs: Option<u32>) {
// 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<u64>, invite_code: Option<String>) {
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]
+21
View File
@@ -281,3 +281,24 @@ pub struct UploadStatus {
pub status: String, // "pending", "success", "error"
pub error: Option<String>,
}
#[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<u64>,
pub error: Option<String>,
}
#[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<Timestamp>,
pub uses_remaining: Option<u32>,
}
+30 -4
View File
@@ -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 @@
<ProfileModal user={chat.viewingProfileUser} onClose={() => (chat.viewingProfileUser = null)} />
{/if}
{#if chat.userContextMenu}
<UserContextMenu
{#if chat.showInviteModal}
<InviteModal />
{/if}
{#if chat.userContextMenu} <UserContextMenu
{...chat.userContextMenu}
onClose={() => (chat.userContextMenu = null)}
onAction={closeSidebars}
+1 -1
View File
@@ -197,7 +197,7 @@
</div>
</div>
</div>
<button class="action-btn close" onclick={(e) => { e.stopPropagation(); onClose(); }}>
<button class="action-btn close" onclick={(e) => { e.stopPropagation(); onClose(); }} aria-label="Close viewer">
<i class="fas fa-times"></i>
</button>
</div>
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import { getContext } from "svelte";
import type { ChatService } from "../services/chat.svelte";
import Modal from "./Modal.svelte";
import ModalLayout from "./ui/ModalLayout.svelte";
import Button from "./ui/Button.svelte";
import Input from "./ui/Input.svelte";
const chat = getContext<ChatService>("chat");
let inviteCode = $state("");
let copied = $state(false);
const inviteLink = $derived(inviteCode ? `${window.location.origin}${window.location.pathname}?invite=${inviteCode}` : "");
// Reset code when the modal is opened or the server changes
$effect(() => {
const _ = chat.activeServerId;
inviteCode = "";
});
$effect(() => {
const status = chat.joinServerStatus;
// Only update if the status is for this server
if (status?.status === "invite_created" && status.error) {
const statusServerId = status.serverId?.toString();
const currentServerId = chat.activeServerId?.toString();
console.log(`[InviteModal] Received code. Comparing: ${statusServerId} === ${currentServerId}`);
if (statusServerId === currentServerId) {
inviteCode = status.error;
}
}
});
function handleGenerate() {
if (chat.activeServerId) {
inviteCode = ""; // Clear existing before generating
chat.handleCreateInvite(chat.activeServerId);
}
}
function copyToClipboard() {
if (inviteLink) {
navigator.clipboard.writeText(inviteLink);
copied = true;
setTimeout(() => copied = false, 2000);
}
}
</script>
<Modal onClose={() => (chat.showInviteModal = false)}>
<ModalLayout title="Invite friends to {chat.activeServer?.name}" onClose={() => (chat.showInviteModal = false)}>
<div class="invite-content">
<p class="invite-description">
Share this link with others so they can join your private server.
</p>
<div class="generate-section">
{#if !inviteCode}
<Button onclick={handleGenerate} style="width: 100%;">
Generate Invite Link
</Button>
<p class="help-text">Links are unique to you and the current server.</p>
{:else}
<div class="code-box-wrapper">
<Input
id="generated-link"
value={inviteLink}
disabled
label="Your Invite Link"
/>
<Button
variant={copied ? "success" : "primary"}
size="small"
onclick={copyToClipboard}
style="height: 38px; margin-bottom: 2px;"
>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
{/if}
</div>
</div>
</ModalLayout>
</Modal>
<style>
.invite-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.invite-description {
color: var(--text-normal);
font-size: 0.95rem;
line-height: 1.4;
margin: 0;
}
.generate-section {
margin-top: 8px;
}
.help-text {
font-size: 0.75rem;
color: var(--text-muted);
text-align: center;
margin-top: 12px;
}
.code-box-wrapper {
display: flex;
gap: 12px;
align-items: flex-end;
width: 100%;
}
</style>
+99 -29
View File
@@ -9,49 +9,105 @@
const chat = getContext<ChatService>("chat");
let searchTerm = $state("");
let inviteCode = $state("");
let joinError = $state<string | null>(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());
}
}
</script>
<Modal onClose={() => (chat.showDiscoveryModal = false)}>
<ModalLayout title="Discover Servers" onClose={() => (chat.showDiscoveryModal = false)}>
<div class="discovery-content">
<Input
id="server-search"
autofocus
placeholder="Search for servers..."
bind:value={searchTerm}
style="margin-bottom: 16px;"
/>
{#if joinError}
<div class="form-error" style="color: var(--status-danger); background-color: rgba(219, 64, 64, 0.1); padding: 12px; border-radius: 4px; margin-bottom: 16px; font-size: 0.85rem; display: flex; align-items: center; gap: 8px;">
<i class="fas fa-exclamation-circle"></i> {joinError}
</div>
{/if}
<div class="server-list-container">
{#if filteredServers.length === 0}
<p class="no-results">
No servers found.
</p>
{:else}
{#each filteredServers as server (server.id.toString())}
<div class="server-item">
<div class="server-info">
<div class="server-icon-small">
{server.name.substring(0, 2).toUpperCase()}
<div class="invite-code-section">
<h3>Join with Invite Code</h3>
<div class="invite-row">
<Input
id="invite-code"
placeholder="e.g. sender-timestamp"
bind:value={inviteCode}
/>
<Button
onclick={handleJoinWithCode}
disabled={!inviteCode.trim() || !chat.isFullyAuthenticated}
size="small"
style="height: 38px; align-self: flex-end;"
>
Join
</Button>
</div>
</div>
<div class="sidebar-separator" style="margin: 24px 0;"></div>
<div class="public-servers-section">
<h3>Public Servers</h3>
<Input
id="server-search"
placeholder="Search for servers..."
bind:value={searchTerm}
style="margin-bottom: 16px;"
/>
<div class="server-list-container">
{#if filteredServers.length === 0}
<p class="no-results">
No public servers found.
</p>
{:else}
{#each filteredServers as server (server.id.toString())}
<div class="server-item">
<div class="server-info">
<div class="server-icon-small">
{server.name.substring(0, 2).toUpperCase()}
</div>
<span class="server-name">{server.name}</span>
</div>
<span class="server-name">{server.name}</span>
<Button
onclick={() => chat.handleJoinServer(server.id)}
disabled={!chat.isFullyAuthenticated}
size="small"
>
Join
</Button>
</div>
<Button
onclick={() => chat.handleJoinServer(server.id)}
disabled={!chat.isFullyAuthenticated}
size="small"
>
Join
</Button>
</div>
{/each}
{/if}
{/each}
{/if}
</div>
</div>
</div>
</ModalLayout>
@@ -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;
@@ -31,6 +31,19 @@
{#if showServerDropdown}
<div class="server-dropdown shadow-box">
<button
class="server-dropdown-item"
onclick={() => {
chat.showInviteModal = true;
showServerDropdown = false;
}}
style="color: var(--brand); font-weight: bold;"
>
<i class="fas fa-user-plus" style="width: 16px; margin-right: 8px;"></i>
Invite People
</button>
<div class="dropdown-separator"></div>
{#if isOwner}
<button
class="server-dropdown-item"
@@ -371,24 +371,6 @@
text-transform: uppercase;
}
.input-wrapper {
display: flex;
align-items: center;
background-color: var(--background-primary);
border-radius: 4px;
padding-right: 8px;
}
.form-group input[type="text"] {
flex: 1;
padding: 10px;
background: none;
color: var(--text-normal);
border: none;
font-size: 1rem;
outline: none;
}
.emoji-prefix, .emoji-suffix {
padding: 0 8px;
color: var(--text-muted);
@@ -179,7 +179,7 @@
margin-bottom: 20px;
}
.form-group label, .label-heading {
.label-heading {
display: block;
margin-bottom: 8px;
color: var(--header-secondary);
+31 -4
View File
@@ -1,3 +1,4 @@
import { untrack } from "svelte";
import { Identity } from "spacetimedb";
import { SvelteSet } from "svelte/reactivity";
import * as Types from "../../module_bindings/types";
@@ -48,6 +49,17 @@ export class ChatService {
this.#encryption = new EncryptionService(this.#db, () => this.identity);
this.#media = new MediaCacheService(this.#db, () => this.identity);
// Global Join Handler: Automatically navigate when we successfully join a server
$effect(() => {
const status = this.joinServerStatus;
if (status?.status === "success" && status.serverId) {
console.log(`[ChatService] Global join success detected for server ${status.serverId}. Navigating...`);
untrack(() => {
this.activeServerId = status.serverId!;
});
}
});
// Load notification preferences from localStorage
$effect(() => {
const myId = this.identity?.toHexString();
@@ -241,6 +253,12 @@ export class ChatService {
set showServerSettings(v) {
this.#ui.showServerSettings = v;
}
get showInviteModal() {
return this.#ui.showInviteModal;
}
set showInviteModal(v) {
this.#ui.showInviteModal = v;
}
get authError() {
return this.#ui.authError;
}
@@ -717,8 +735,12 @@ export class ChatService {
this.showSetNameModal = false;
};
handleJoinServer = (serverId: bigint) => {
this.#server.handleJoinServer(serverId);
handleJoinServer = (serverId?: bigint, inviteCode?: string) => {
this.#server.handleJoinServer(serverId, inviteCode);
};
handleCreateInvite = (serverId: bigint, maxUses?: number, expiresInHrs?: number) => {
this.#server.handleCreateInvite(serverId, maxUses, expiresInHrs);
};
handleLeaveServer = (serverId: bigint) => {
@@ -733,8 +755,8 @@ export class ChatService {
this.#server.handleUpdateServerName(serverId, name);
};
handleSetServerPublic = (serverId: bigint, isPublic: boolean) => {
this.#server.handleSetServerPublic(serverId, isPublic);
handleSetServerPublic = (serverId: bigint, publicFlag: boolean) => {
this.#server.handleSetServerPublic(serverId, publicFlag);
};
get activeDms() {
@@ -743,6 +765,11 @@ export class ChatService {
get directMessages() {
return this.#db.directMessages;
}
get joinServerStatus() {
const myId = this.identity;
if (!myId) return null;
return this.#db.joinServerStatus.find(s => s.identity.isEqual(myId)) || null;
}
handleDeleteServer = (serverId: bigint) => {
this.#server.handleDeleteServer(serverId);
+3
View File
@@ -17,6 +17,7 @@ export class DatabaseService {
typingActivity = $state<readonly Types.TypingActivity[]>([]);
systemConfiguration = $state<readonly Types.SystemConfiguration[]>([]);
uploadStatus = $state<readonly Types.UploadStatus[]>([]);
joinServerStatus = $state<readonly Types.JoinServerStatus[]>([]);
isUsersReady = $state(false);
isServersReady = $state(false);
isChannelsReady = $state(false);
@@ -87,6 +88,7 @@ export class DatabaseService {
const [typingActivityStore] = useTable(tables.visible_typing_activity);
const [systemConfigStore] = useTable(tables.system_configuration);
const [uploadStatusStore] = useTable(tables.upload_status);
const [joinServerStatusStore] = useTable(tables.join_server_status);
serversStore.subscribe((v) => (this.servers = v));
serversReadyStore.subscribe((v) => (this.isServersReady = v));
@@ -113,5 +115,6 @@ export class DatabaseService {
typingActivityStore.subscribe((v) => (this.typingActivity = v));
systemConfigStore.subscribe((v) => (this.systemConfiguration = v));
uploadStatusStore.subscribe((v) => (this.uploadStatus = v));
joinServerStatusStore.subscribe((v) => (this.joinServerStatus = v));
}
}
@@ -10,6 +10,7 @@ export class ServerManagementService {
#updateServerNameReducer = useReducer(reducers.updateServerName);
#setServerPublicReducer = useReducer(reducers.setServerPublic);
#deleteServerReducer = useReducer(reducers.deleteServer);
#createInviteReducer = useReducer(reducers.createInvite);
handleCreateServer = (name: string) => {
if (name.trim()) {
@@ -27,8 +28,12 @@ export class ServerManagementService {
}
};
handleJoinServer = (serverId: bigint) => {
this.#joinServerReducer({ serverId });
handleJoinServer = (serverId?: bigint, inviteCode?: string) => {
this.#joinServerReducer({ serverId, inviteCode });
};
handleCreateInvite = (serverId: bigint, maxUses?: number, expiresInHrs?: number) => {
this.#createInviteReducer({ serverId, maxUses, expiresInHrs });
};
handleLeaveServer = (serverId: bigint) => {
+1
View File
@@ -22,6 +22,7 @@ export class ThemeService {
threadMessageText = $state("");
showDiscoveryModal = $state(false);
showServerSettings = $state(false);
showInviteModal = $state(false);
authError = $state("");
viewingImageId = $state<bigint | null>(null);
viewingProfileUser = $state<any | null>(null);