This commit is contained in:
2026-04-05 02:08:33 -04:00
parent 0c1235f94e
commit 3c70838582
11 changed files with 99 additions and 11 deletions
+45
View File
@@ -396,6 +396,17 @@ const typing_activity = table(
}, },
); );
const system_configuration = table(
{
name: "system_configuration",
public: true,
},
{
key: t.string().primaryKey(),
value: t.string(),
},
);
const spacetimedb = schema({ const spacetimedb = schema({
user, user,
server, server,
@@ -417,6 +428,7 @@ const spacetimedb = schema({
custom_emoji, custom_emoji,
image, image,
typing_activity, typing_activity,
system_configuration,
}); });
export default spacetimedb; export default spacetimedb;
@@ -425,6 +437,18 @@ function validateName(name: string) {
throw new SenderError("Names must not be empty"); throw new SenderError("Names must not be empty");
} }
function validateMessageLength(ctx: any, text: string) {
const maxLengthConf = ctx.db.system_configuration.key.find("max_message_length");
const maxLength = maxLengthConf ? parseInt(maxLengthConf.value) : 262144;
// Approximate byte length check
const byteLength = new TextEncoder().encode(text).length;
if (byteLength > maxLength) {
throw new SenderError(`Message exceeds maximum length of ${maxLength} bytes (${Math.round(maxLength / 1024)}KB).`);
}
}
export const set_typing = spacetimedb.reducer( export const set_typing = spacetimedb.reducer(
{ channelId: t.u64(), typing: t.bool() }, { channelId: t.u64(), typing: t.bool() },
(ctx, { channelId, typing }) => { (ctx, { channelId, typing }) => {
@@ -996,6 +1020,19 @@ function clearSignalingForUser(ctx: any, identity: any) {
ctx.db.screen_ice_candidate.id.delete(row.id); ctx.db.screen_ice_candidate.id.delete(row.id);
} }
export const set_configuration = spacetimedb.reducer(
{ key: t.string(), value: t.string() },
(ctx, { key, value }) => {
// Basic auth check: only a known 'admin' or perhaps just anyone for now as requested
const existing = ctx.db.system_configuration.key.find(key);
if (existing) {
ctx.db.system_configuration.key.update({ key, value });
} else {
ctx.db.system_configuration.insert({ key, value });
}
}
);
export const create_thread = spacetimedb.reducer( export const create_thread = spacetimedb.reducer(
{ name: t.string(), channelId: t.u64(), parentMessageId: t.u64() }, { name: t.string(), channelId: t.u64(), parentMessageId: t.u64() },
(ctx, { name, channelId, parentMessageId }) => { (ctx, { name, channelId, parentMessageId }) => {
@@ -1068,6 +1105,10 @@ export const send_message = spacetimedb.reducer(
if ((!text || text.trim().length === 0) && imageIds.length === 0) if ((!text || text.trim().length === 0) && imageIds.length === 0)
throw new SenderError("Messages must not be empty"); throw new SenderError("Messages must not be empty");
if (text) {
validateMessageLength(ctx, text);
}
const user = ctx.db.user.identity.find(ctx.sender); const user = ctx.db.user.identity.find(ctx.sender);
if (!user || !user.subject) { if (!user || !user.subject) {
throw new SenderError("You must be logged in via OIDC to send messages"); throw new SenderError("You must be logged in via OIDC to send messages");
@@ -1093,6 +1134,10 @@ export const send_message = spacetimedb.reducer(
); );
export const init = spacetimedb.init((ctx) => { export const init = spacetimedb.init((ctx) => {
if (!ctx.db.system_configuration.key.find("max_message_length")) {
ctx.db.system_configuration.insert({ key: "max_message_length", value: "262144" });
}
let hasServers = false; let hasServers = false;
for (const _server of ctx.db.server.iter()) { for (const _server of ctx.db.server.iter()) {
hasServers = true; hasServers = true;
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { useSpacetimeDB } from "spacetimedb/svelte"; import { useSpacetimeDB } from "spacetimedb/svelte";
import { setContext, onMount } from "svelte"; import { setContext } from "svelte";
import { ChatService } from "./services/chat.svelte"; import { ChatService } from "./services/chat.svelte";
import { WebRTCService } from "./services/webrtc/webrtc.svelte"; import { WebRTCService } from "./services/webrtc/webrtc.svelte";
import ServerList from "./components/ServerList.svelte"; import ServerList from "./components/ServerList.svelte";
+6
View File
@@ -175,6 +175,12 @@
const currentText = messageText; const currentText = messageText;
const currentStaged = [...stagedImages]; const currentStaged = [...stagedImages];
const byteLength = new TextEncoder().encode(currentText).length;
if (byteLength > chat.maxMessageLength) {
uploadError = `Message exceeds maximum length of ${chat.maxMessageLength} bytes (${Math.round(chat.maxMessageLength / 1024)}KB).`;
return;
}
isUploading = true; isUploading = true;
uploadError = null; uploadError = null;
+2 -2
View File
@@ -21,7 +21,7 @@
<div class="profile-overlay" onclick={handleOverlayClick} role="presentation"> <div class="profile-overlay" onclick={handleOverlayClick} role="presentation">
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="profile-modal" onclick={(e) => e.stopPropagation()} role="presentation"> <div class="profile-modal" onclick={(e) => e.stopPropagation()} role="presentation">
<div class="profile-banner" style="background-image: {bannerUrl ? `url(${bannerUrl})` : 'none'}; background-color: {bannerUrl ? 'transparent' : 'var(--brand)'};"> <div class="profile-banner" style="background-image: {bannerUrl ? `url(${bannerUrl})` : 'none'}; background-color: {bannerUrl ? 'transparent' : 'var(--background-tertiary)'};">
<button class="close-btn" onclick={onClose} aria-label="Close profile"> <button class="close-btn" onclick={onClose} aria-label="Close profile">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
@@ -89,7 +89,7 @@
} }
.profile-banner { .profile-banner {
height: 120px; height: 140px;
position: relative; position: relative;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
@@ -41,7 +41,7 @@
</script> </script>
<div class="account-profile-card"> <div class="account-profile-card">
<div class="profile-banner" style="background-image: {bannerPreview ? `url(${bannerPreview})` : 'none'}; background-color: {bannerPreview ? 'transparent' : 'var(--brand)'};"> <div class="profile-banner" style="background-image: {bannerPreview ? `url(${bannerPreview})` : 'none'}; background-color: {bannerPreview ? 'transparent' : 'var(--background-tertiary)'};">
<label class="banner-upload-overlay"> <label class="banner-upload-overlay">
<i class="fas fa-camera"></i> <i class="fas fa-camera"></i>
<span>Change Banner</span> <span>Change Banner</span>
@@ -122,7 +122,7 @@
} }
.profile-banner { .profile-banner {
height: 100px; height: 140px;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
position: relative; position: relative;
@@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { ChatService } from "../../services/chat.svelte";
import type { WebRTCService } from "../../services/webrtc/webrtc.svelte"; import type { WebRTCService } from "../../services/webrtc/webrtc.svelte";
const chat = getContext<ChatService>("chat");
const webrtc = getContext<WebRTCService>("webrtc"); const webrtc = getContext<WebRTCService>("webrtc");
const resolutions = [ const resolutions = [
@@ -29,7 +27,7 @@
<div class="settings-section"> <div class="settings-section">
<div class="section-title">Screen Share Quality</div> <div class="section-title">Screen Share Quality</div>
<div class="form-group"> <div class="form-group">
<label for="resolution">Resolution</label> <label for="resolution">Resolution</label>
<select id="resolution" bind:value={webrtc.localMedia.screenShareResolution}> <select id="resolution" bind:value={webrtc.localMedia.screenShareResolution}>
+5
View File
@@ -273,6 +273,11 @@ export class ChatService {
return this.#db.isUsersReady; return this.#db.isUsersReady;
} }
get maxMessageLength() {
const config = this.#db.systemConfiguration.find(c => c.key === "max_message_length");
return config ? parseInt(config.value) : 262144;
}
// Auth Context // Auth Context
get currentUser() { get currentUser() {
return this.#auth.currentUser; return this.#auth.currentUser;
+3
View File
@@ -17,6 +17,7 @@ export class DatabaseService {
voiceActivity = $state<readonly Types.VoiceActivity[]>([]); voiceActivity = $state<readonly Types.VoiceActivity[]>([]);
typingActivity = $state<readonly Types.TypingActivity[]>([]); typingActivity = $state<readonly Types.TypingActivity[]>([]);
watching = $state<readonly Types.Watching[]>([]); watching = $state<readonly Types.Watching[]>([]);
systemConfiguration = $state<readonly Types.SystemConfiguration[]>([]);
isUsersReady = $state(false); isUsersReady = $state(false);
constructor() { constructor() {
@@ -31,6 +32,7 @@ export class DatabaseService {
const [voiceActivityStore] = useTable(tables.voice_activity); const [voiceActivityStore] = useTable(tables.voice_activity);
const [typingActivityStore] = useTable(tables.typing_activity); const [typingActivityStore] = useTable(tables.typing_activity);
const [watchingStore] = useTable(tables.watching); const [watchingStore] = useTable(tables.watching);
const [systemConfigStore] = useTable(tables.system_configuration);
serversStore.subscribe((v) => (this.servers = v)); serversStore.subscribe((v) => (this.servers = v));
channelsStore.subscribe((v) => (this.channels = v)); channelsStore.subscribe((v) => (this.channels = v));
@@ -44,5 +46,6 @@ export class DatabaseService {
voiceActivityStore.subscribe((v) => (this.voiceActivity = v)); voiceActivityStore.subscribe((v) => (this.voiceActivity = v));
typingActivityStore.subscribe((v) => (this.typingActivity = v)); typingActivityStore.subscribe((v) => (this.typingActivity = v));
watchingStore.subscribe((v) => (this.watching = v)); watchingStore.subscribe((v) => (this.watching = v));
systemConfigStore.subscribe((v) => (this.systemConfiguration = v));
} }
} }
+1 -1
View File
@@ -65,7 +65,7 @@ export class MessagingService {
if (!conn) return; if (!conn) return;
untrack(() => { untrack(() => {
const queries = ["SELECT * FROM server", "SELECT * FROM custom_emoji"]; const queries = ["SELECT * FROM server", "SELECT * FROM custom_emoji", "SELECT * FROM system_configuration"];
// 1. Surgical Membership & Identity Pruning // 1. Surgical Membership & Identity Pruning
if (identity) { if (identity) {
@@ -373,7 +373,9 @@ export class LocalMediaService {
this.localScreenStream.getTracks().forEach((t) => t.stop()); this.localScreenStream.getTracks().forEach((t) => t.stop());
this.localScreenStream = null; this.localScreenStream = null;
this.#setSharingScreen({ sharing: false }); this.#setSharingScreen({ sharing: false });
sounds.playStopWatching();
onTrackCleared(null); onTrackCleared(null);
} }
}; };
} }
+31 -2
View File
@@ -81,6 +81,37 @@ export class WebRTCService {
this.localMedia.localScreenStream, this.localMedia.localScreenStream,
); );
// Sound for streams I am watching
let lastWatched = new Set<string>();
$effect(() => {
const currentWatched = new Set(
this.watching
.filter((w) => this.identity && w.watcher.isEqual(this.identity))
.map((w) => w.watchee.toHexString()),
);
if (this.identity) {
// I started watching someone (manual or auto)
for (const watchee of currentWatched) {
if (!lastWatched.has(watchee)) {
untrack(() => {
sounds.playStartWatching();
});
}
}
// I stopped watching someone (or they stopped sharing)
for (const watchee of lastWatched) {
if (!currentWatched.has(watchee)) {
untrack(() => {
sounds.playStopWatching();
});
}
}
}
lastWatched = currentWatched;
});
// Sync state to sub-services // Sync state to sub-services
$effect(() => { $effect(() => {
this.localMedia.connectedChannelId = this.connectedChannelId; this.localMedia.connectedChannelId = this.connectedChannelId;
@@ -132,7 +163,6 @@ export class WebRTCService {
startWatching = (peerIdentity: Identity) => { startWatching = (peerIdentity: Identity) => {
if (this.connectedChannelId) { if (this.connectedChannelId) {
sounds.playStartWatching();
this.#startWatchingReducer({ this.#startWatchingReducer({
watchee: peerIdentity, watchee: peerIdentity,
channelId: this.connectedChannelId, channelId: this.connectedChannelId,
@@ -141,7 +171,6 @@ export class WebRTCService {
}; };
stopWatching = (peerIdentity: Identity) => { stopWatching = (peerIdentity: Identity) => {
sounds.playStopWatching();
this.#stopWatchingReducer({ watchee: peerIdentity }); this.#stopWatchingReducer({ watchee: peerIdentity });
}; };