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({
user,
server,
@@ -417,6 +428,7 @@ const spacetimedb = schema({
custom_emoji,
image,
typing_activity,
system_configuration,
});
export default spacetimedb;
@@ -425,6 +437,18 @@ function validateName(name: string) {
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(
{ channelId: t.u64(), typing: t.bool() },
(ctx, { channelId, typing }) => {
@@ -996,6 +1020,19 @@ function clearSignalingForUser(ctx: any, identity: any) {
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(
{ name: t.string(), channelId: t.u64(), parentMessageId: t.u64() },
(ctx, { name, channelId, parentMessageId }) => {
@@ -1068,6 +1105,10 @@ export const send_message = spacetimedb.reducer(
if ((!text || text.trim().length === 0) && imageIds.length === 0)
throw new SenderError("Messages must not be empty");
if (text) {
validateMessageLength(ctx, text);
}
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || !user.subject) {
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) => {
if (!ctx.db.system_configuration.key.find("max_message_length")) {
ctx.db.system_configuration.insert({ key: "max_message_length", value: "262144" });
}
let hasServers = false;
for (const _server of ctx.db.server.iter()) {
hasServers = true;
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { useSpacetimeDB } from "spacetimedb/svelte";
import { setContext, onMount } from "svelte";
import { setContext } from "svelte";
import { ChatService } from "./services/chat.svelte";
import { WebRTCService } from "./services/webrtc/webrtc.svelte";
import ServerList from "./components/ServerList.svelte";
+6
View File
@@ -175,6 +175,12 @@
const currentText = messageText;
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;
uploadError = null;
+2 -2
View File
@@ -21,7 +21,7 @@
<div class="profile-overlay" onclick={handleOverlayClick} role="presentation">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<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">
<i class="fas fa-times"></i>
</button>
@@ -89,7 +89,7 @@
}
.profile-banner {
height: 120px;
height: 140px;
position: relative;
background-size: cover;
background-position: center;
@@ -41,7 +41,7 @@
</script>
<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">
<i class="fas fa-camera"></i>
<span>Change Banner</span>
@@ -122,7 +122,7 @@
}
.profile-banner {
height: 100px;
height: 140px;
background-size: cover;
background-position: center;
position: relative;
@@ -1,9 +1,7 @@
<script lang="ts">
import { getContext } from "svelte";
import type { ChatService } from "../../services/chat.svelte";
import type { WebRTCService } from "../../services/webrtc/webrtc.svelte";
const chat = getContext<ChatService>("chat");
const webrtc = getContext<WebRTCService>("webrtc");
const resolutions = [
@@ -29,7 +27,7 @@
<div class="settings-section">
<div class="section-title">Screen Share Quality</div>
<div class="form-group">
<label for="resolution">Resolution</label>
<select id="resolution" bind:value={webrtc.localMedia.screenShareResolution}>
+5
View File
@@ -273,6 +273,11 @@ export class ChatService {
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
get currentUser() {
return this.#auth.currentUser;
+3
View File
@@ -17,6 +17,7 @@ export class DatabaseService {
voiceActivity = $state<readonly Types.VoiceActivity[]>([]);
typingActivity = $state<readonly Types.TypingActivity[]>([]);
watching = $state<readonly Types.Watching[]>([]);
systemConfiguration = $state<readonly Types.SystemConfiguration[]>([]);
isUsersReady = $state(false);
constructor() {
@@ -31,6 +32,7 @@ export class DatabaseService {
const [voiceActivityStore] = useTable(tables.voice_activity);
const [typingActivityStore] = useTable(tables.typing_activity);
const [watchingStore] = useTable(tables.watching);
const [systemConfigStore] = useTable(tables.system_configuration);
serversStore.subscribe((v) => (this.servers = v));
channelsStore.subscribe((v) => (this.channels = v));
@@ -44,5 +46,6 @@ export class DatabaseService {
voiceActivityStore.subscribe((v) => (this.voiceActivity = v));
typingActivityStore.subscribe((v) => (this.typingActivity = 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;
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
if (identity) {
@@ -373,7 +373,9 @@ export class LocalMediaService {
this.localScreenStream.getTracks().forEach((t) => t.stop());
this.localScreenStream = null;
this.#setSharingScreen({ sharing: false });
sounds.playStopWatching();
onTrackCleared(null);
}
};
}
+31 -2
View File
@@ -81,6 +81,37 @@ export class WebRTCService {
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
$effect(() => {
this.localMedia.connectedChannelId = this.connectedChannelId;
@@ -132,7 +163,6 @@ export class WebRTCService {
startWatching = (peerIdentity: Identity) => {
if (this.connectedChannelId) {
sounds.playStartWatching();
this.#startWatchingReducer({
watchee: peerIdentity,
channelId: this.connectedChannelId,
@@ -141,7 +171,6 @@ export class WebRTCService {
};
stopWatching = (peerIdentity: Identity) => {
sounds.playStopWatching();
this.#stopWatchingReducer({ watchee: peerIdentity });
};