refactor spacetimedb tables, and fix some stuff
This commit is contained in:
+4
-1582
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,955 @@
|
||||
import { t, SenderError } from "spacetimedb/server";
|
||||
import { spacetimedb } from "./schema";
|
||||
import {
|
||||
validateName,
|
||||
validateMessageLength,
|
||||
getNextSeqId,
|
||||
clearSignalingForUser,
|
||||
autoJoinCommunityServer
|
||||
} from "./utils";
|
||||
|
||||
export const set_typing = spacetimedb.reducer(
|
||||
{ channelId: t.u64(), typing: t.bool() },
|
||||
(ctx, { channelId, typing }) => {
|
||||
const activity = ctx.db.typing_activity.identity.find(ctx.sender);
|
||||
if (activity) {
|
||||
ctx.db.typing_activity.identity.update({
|
||||
identity: ctx.sender,
|
||||
channel_id: channelId,
|
||||
is_typing: typing,
|
||||
});
|
||||
} else {
|
||||
ctx.db.typing_activity.insert({
|
||||
identity: ctx.sender,
|
||||
channel_id: channelId,
|
||||
is_typing: typing,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const upload_image = spacetimedb.reducer(
|
||||
{ data: t.byteArray(), mimeType: t.string(), name: t.string().optional(), clientId: t.string().optional() },
|
||||
(ctx, { data, mimeType, name, clientId }) => {
|
||||
if (clientId) {
|
||||
const existing = ctx.db.upload_status.client_id.find(clientId);
|
||||
if (existing) {
|
||||
ctx.db.upload_status.client_id.delete(clientId);
|
||||
}
|
||||
ctx.db.upload_status.insert({
|
||||
client_id: clientId,
|
||||
identity: ctx.sender,
|
||||
status: "pending",
|
||||
image_id: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (data.length > 4 * 1024 * 1024) {
|
||||
throw new Error("Image exceeds 4MB limit");
|
||||
}
|
||||
const img = ctx.db.image.insert({ id: 0n, data, mime_type: mimeType, name });
|
||||
|
||||
if (clientId) {
|
||||
ctx.db.upload_status.client_id.update({
|
||||
client_id: clientId,
|
||||
identity: ctx.sender,
|
||||
image_id: img.id,
|
||||
status: "success",
|
||||
error: undefined,
|
||||
});
|
||||
}
|
||||
return img;
|
||||
} catch (e: any) {
|
||||
if (clientId) {
|
||||
ctx.db.upload_status.client_id.update({
|
||||
client_id: clientId,
|
||||
identity: ctx.sender,
|
||||
image_id: undefined,
|
||||
status: "error",
|
||||
error: e.message || "Unknown error",
|
||||
});
|
||||
// We return normally here so the "error" status is committed to the table
|
||||
// and visible to the client.
|
||||
return;
|
||||
}
|
||||
throw new SenderError(e.message || "Unknown error");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const clear_upload_status = spacetimedb.reducer(
|
||||
{ clientId: t.string() },
|
||||
(ctx, { clientId }) => {
|
||||
const status = ctx.db.upload_status.client_id.find(clientId);
|
||||
if (status && status.identity.isEqual(ctx.sender)) {
|
||||
ctx.db.upload_status.client_id.delete(clientId);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const upload_custom_emoji = spacetimedb.reducer(
|
||||
{ name: t.string(), category: t.string(), data: t.byteArray() },
|
||||
(ctx, { name, category, data }) => {
|
||||
if (data.length > 256 * 1024) {
|
||||
throw new SenderError("Emoji image exceeds 256KB limit");
|
||||
}
|
||||
ctx.db.custom_emoji.insert({ id: 0n, name, category, data });
|
||||
},
|
||||
);
|
||||
|
||||
export const upload_avatar = spacetimedb.reducer(
|
||||
{ data: t.byteArray(), mimeType: t.string() },
|
||||
(ctx, { data, mimeType }) => {
|
||||
if (data.length > 1024 * 1024) {
|
||||
throw new SenderError("Avatar exceeds 1MB limit");
|
||||
}
|
||||
const img = ctx.db.image.insert({
|
||||
id: 0n,
|
||||
data,
|
||||
mime_type: mimeType,
|
||||
name: "avatar",
|
||||
});
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user) throw new SenderError("User not found");
|
||||
ctx.db.user.identity.update({ ...user, avatar_id: img.id });
|
||||
},
|
||||
);
|
||||
|
||||
export const upload_banner = spacetimedb.reducer(
|
||||
{ data: t.byteArray(), mimeType: t.string() },
|
||||
(ctx, { data, mimeType }) => {
|
||||
if (data.length > 2 * 1024 * 1024) {
|
||||
throw new SenderError("Banner exceeds 2MB limit");
|
||||
}
|
||||
const img = ctx.db.image.insert({
|
||||
id: 0n,
|
||||
data,
|
||||
mime_type: mimeType,
|
||||
name: "banner",
|
||||
});
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user) throw new SenderError("User not found");
|
||||
ctx.db.user.identity.update({ ...user, banner_id: img.id });
|
||||
},
|
||||
);
|
||||
|
||||
export const upload_server_avatar = spacetimedb.reducer(
|
||||
{ serverId: t.u64(), data: t.byteArray(), mimeType: t.string() },
|
||||
(ctx, { serverId, data, mimeType }) => {
|
||||
if (data.length > 1024 * 1024) {
|
||||
throw new SenderError("Avatar exceeds 1MB limit");
|
||||
}
|
||||
const s = ctx.db.server.id.find(serverId);
|
||||
if (!s) throw new SenderError("Server not found");
|
||||
const img = ctx.db.image.insert({
|
||||
id: 0n,
|
||||
data,
|
||||
mime_type: mimeType,
|
||||
name: "server_avatar",
|
||||
});
|
||||
ctx.db.server.id.update({ ...s, avatar_id: img.id });
|
||||
},
|
||||
);
|
||||
|
||||
export const update_server_name = spacetimedb.reducer(
|
||||
{ serverId: t.u64(), name: t.string() },
|
||||
(ctx, { serverId, name }) => {
|
||||
validateName(name);
|
||||
const s = ctx.db.server.id.find(serverId);
|
||||
if (!s) throw new SenderError("Server not found");
|
||||
ctx.db.server.id.update({ ...s, name });
|
||||
},
|
||||
);
|
||||
|
||||
export const delete_server = spacetimedb.reducer(
|
||||
{ serverId: t.u64() },
|
||||
(ctx, { serverId }) => {
|
||||
const s = ctx.db.server.id.find(serverId);
|
||||
if (!s) throw new SenderError("Server not found");
|
||||
|
||||
for (const c of ctx.db.channel.by_server_id.filter(serverId)) {
|
||||
for (const m of ctx.db.message.by_channel_id.filter(c.id)) {
|
||||
ctx.db.message.id.delete(m.id);
|
||||
}
|
||||
for (const rm of ctx.db.recent_message.by_channel.filter(c.id)) {
|
||||
ctx.db.recent_message.id.delete(rm.id);
|
||||
}
|
||||
ctx.db.channel.id.delete(c.id);
|
||||
}
|
||||
|
||||
for (const m of ctx.db.server_member.by_server_id.filter(serverId)) {
|
||||
ctx.db.server_member.id.delete(m.id);
|
||||
}
|
||||
|
||||
ctx.db.server.id.delete(serverId);
|
||||
},
|
||||
);
|
||||
|
||||
export const toggle_reaction = spacetimedb.reducer(
|
||||
{
|
||||
messageId: t.u64(),
|
||||
emoji: t.string().optional(),
|
||||
customEmojiId: t.u64().optional(),
|
||||
},
|
||||
(ctx, { messageId, emoji, customEmojiId }) => {
|
||||
if (!emoji && !customEmojiId) {
|
||||
throw new SenderError("Emoji or CustomEmojiId required");
|
||||
}
|
||||
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user || !user.subject) {
|
||||
throw new SenderError("You must be logged in via OIDC to react");
|
||||
}
|
||||
|
||||
const existing = [
|
||||
...ctx.db.message_reaction.by_message_id.filter(messageId),
|
||||
].find((r) => {
|
||||
if (!r.identity.isEqual(ctx.sender)) return false;
|
||||
if (emoji && r.emoji === emoji) return true;
|
||||
if (customEmojiId && r.custom_emoji_id === customEmojiId) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
ctx.db.message_reaction.id.delete(existing.id);
|
||||
} else {
|
||||
ctx.db.message_reaction.insert({
|
||||
id: 0n,
|
||||
message_id: messageId,
|
||||
identity: ctx.sender,
|
||||
emoji,
|
||||
custom_emoji_id: customEmojiId,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const set_name = spacetimedb.reducer(
|
||||
{ name: t.string() },
|
||||
(ctx, { name }) => {
|
||||
validateName(name);
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user) throw new SenderError("Cannot set name for unknown user");
|
||||
ctx.db.user.identity.update({ ...user, name });
|
||||
},
|
||||
);
|
||||
|
||||
export const set_avatar = spacetimedb.reducer(
|
||||
{ avatarId: t.u64().optional() },
|
||||
(ctx, { avatarId }) => {
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user) throw new SenderError("Cannot set avatar for unknown user");
|
||||
ctx.db.user.identity.update({ ...user, avatar_id: avatarId });
|
||||
},
|
||||
);
|
||||
|
||||
export const set_banner = spacetimedb.reducer(
|
||||
{ bannerId: t.u64().optional() },
|
||||
(ctx, { bannerId }) => {
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user) throw new SenderError("Cannot set banner for unknown user");
|
||||
ctx.db.user.identity.update({ ...user, banner_id: bannerId });
|
||||
},
|
||||
);
|
||||
|
||||
export const set_biography = spacetimedb.reducer(
|
||||
{ biography: t.string().optional() },
|
||||
(ctx, { biography }) => {
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user) throw new SenderError("User not found");
|
||||
ctx.db.user.identity.update({ ...user, biography });
|
||||
},
|
||||
);
|
||||
|
||||
export const set_status = spacetimedb.reducer(
|
||||
{ status: t.string().optional() },
|
||||
(ctx, { status }) => {
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user) throw new SenderError("User not found");
|
||||
ctx.db.user.identity.update({ ...user, status });
|
||||
},
|
||||
);
|
||||
|
||||
export const subscribe_to_channel = spacetimedb.reducer(
|
||||
{ channelId: t.u64() },
|
||||
(ctx, { channelId }) => {
|
||||
const hwm = ctx.db.channel_high_water_mark.channel_id.find(channelId);
|
||||
const currentMax = hwm ? hwm.last_seq_id : 0n;
|
||||
const earliest = currentMax > 100n ? currentMax - 100n : 0n;
|
||||
|
||||
const existing = ctx.db.channel_subscription.identity.find(ctx.sender);
|
||||
if (existing) {
|
||||
if (existing.channel_id !== channelId) {
|
||||
ctx.db.channel_subscription.identity.update({
|
||||
...existing,
|
||||
channel_id: channelId,
|
||||
earliest_seq_id: earliest,
|
||||
last_read_seq_id: currentMax,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
ctx.db.channel_subscription.insert({
|
||||
identity: ctx.sender,
|
||||
channel_id: channelId,
|
||||
earliest_seq_id: earliest,
|
||||
last_read_seq_id: currentMax,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const extend_subscription = spacetimedb.reducer(
|
||||
{ channelId: t.u64(), earliestSeqId: t.u64() },
|
||||
(ctx, { channelId, earliestSeqId }) => {
|
||||
const existing = ctx.db.channel_subscription.identity.find(ctx.sender);
|
||||
if (existing && existing.channel_id === channelId) {
|
||||
ctx.db.channel_subscription.identity.update({
|
||||
...existing,
|
||||
earliest_seq_id: earliestSeqId,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const set_talking = spacetimedb.reducer(
|
||||
{ talking: t.bool(), channelId: t.u64() },
|
||||
(ctx, { talking, channelId }) => {
|
||||
const activity = ctx.db.voice_activity.identity.find(ctx.sender);
|
||||
if (activity) {
|
||||
ctx.db.voice_activity.identity.update({
|
||||
identity: ctx.sender,
|
||||
channel_id: channelId,
|
||||
is_talking: talking,
|
||||
});
|
||||
} else {
|
||||
ctx.db.voice_activity.insert({
|
||||
identity: ctx.sender,
|
||||
channel_id: channelId,
|
||||
is_talking: talking,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const create_server = spacetimedb.reducer(
|
||||
{ name: t.string() },
|
||||
(ctx, { name }) => {
|
||||
validateName(name);
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user || !user.subject) {
|
||||
throw new SenderError(
|
||||
"You must be logged in via OIDC to create a server",
|
||||
);
|
||||
}
|
||||
const s = ctx.db.server.insert({ id: 0n, name, owner: ctx.sender, avatar_id: undefined });
|
||||
ctx.db.server_member.insert({
|
||||
id: 0n,
|
||||
identity: ctx.sender,
|
||||
server_id: s.id,
|
||||
});
|
||||
ctx.db.channel.insert({
|
||||
id: 0n,
|
||||
server_id: s.id,
|
||||
name: "general",
|
||||
kind: { tag: "Text" },
|
||||
});
|
||||
ctx.db.channel.insert({
|
||||
id: 0n,
|
||||
server_id: s.id,
|
||||
name: "Voice General",
|
||||
kind: { tag: "Voice" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const join_server = spacetimedb.reducer(
|
||||
{ serverId: t.u64() },
|
||||
(ctx, { serverId }) => {
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user || !user.subject) {
|
||||
throw new SenderError("You must be logged in via OIDC to join a server");
|
||||
}
|
||||
const s = ctx.db.server.id.find(serverId);
|
||||
if (!s) throw new SenderError("Server not found");
|
||||
|
||||
for (const m of ctx.db.server_member.by_identity.filter(ctx.sender)) {
|
||||
if (m.server_id === serverId) return;
|
||||
}
|
||||
|
||||
ctx.db.server_member.insert({
|
||||
id: 0n,
|
||||
identity: ctx.sender,
|
||||
server_id: serverId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const leave_server = spacetimedb.reducer(
|
||||
{ serverId: t.u64() },
|
||||
(ctx, { serverId }) => {
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user) return;
|
||||
|
||||
for (const m of ctx.db.server_member.by_identity.filter(ctx.sender)) {
|
||||
if (m.server_id === serverId) {
|
||||
ctx.db.server_member.id.delete(m.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const create_channel = spacetimedb.reducer(
|
||||
{ name: t.string(), serverId: t.u64(), isVoice: t.bool() },
|
||||
(ctx, { name, serverId, isVoice }) => {
|
||||
validateName(name);
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user || !user.subject) {
|
||||
throw new SenderError(
|
||||
"You must be logged in via OIDC to create a channel",
|
||||
);
|
||||
}
|
||||
const s = ctx.db.server.id.find(serverId);
|
||||
if (!s) throw new SenderError("Server not found");
|
||||
ctx.db.channel.insert({
|
||||
id: 0n,
|
||||
server_id: serverId,
|
||||
name,
|
||||
kind: isVoice ? { tag: "Voice" } : { tag: "Text" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const join_voice = spacetimedb.reducer(
|
||||
{ channelId: t.u64() },
|
||||
(ctx, { channelId }) => {
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user || !user.subject) {
|
||||
throw new SenderError("You must be logged in via OIDC to join voice");
|
||||
}
|
||||
const chan = ctx.db.channel.id.find(channelId);
|
||||
if (!chan || chan.kind.tag !== "Voice")
|
||||
throw new SenderError("Invalid voice channel");
|
||||
|
||||
const existing = ctx.db.voice_state.identity.find(ctx.sender);
|
||||
if (existing) {
|
||||
if (existing.channel_id !== channelId) {
|
||||
clearSignalingForUser(ctx, ctx.sender);
|
||||
ctx.db.voice_state.identity.update({
|
||||
identity: ctx.sender,
|
||||
channel_id: channelId,
|
||||
is_sharing_screen: false,
|
||||
is_muted: false,
|
||||
is_deafened: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
ctx.db.voice_state.insert({
|
||||
identity: ctx.sender,
|
||||
channel_id: channelId,
|
||||
is_sharing_screen: false,
|
||||
is_muted: false,
|
||||
is_deafened: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const set_sharing_screen = spacetimedb.reducer(
|
||||
{ sharing: t.bool() },
|
||||
(ctx, { sharing }) => {
|
||||
const state = ctx.db.voice_state.identity.find(ctx.sender);
|
||||
if (state) {
|
||||
ctx.db.voice_state.identity.update({
|
||||
...state,
|
||||
is_sharing_screen: sharing,
|
||||
});
|
||||
|
||||
if (!sharing) {
|
||||
for (const w of ctx.db.watching.by_watchee.filter(ctx.sender)) {
|
||||
ctx.db.watching.id.delete(w.id);
|
||||
}
|
||||
for (const row of ctx.db.screen_sdp_offer.by_sender.filter(ctx.sender))
|
||||
ctx.db.screen_sdp_offer.id.delete(row.id);
|
||||
for (const row of ctx.db.screen_sdp_answer.by_sender.filter(ctx.sender))
|
||||
ctx.db.screen_sdp_answer.id.delete(row.id);
|
||||
for (const row of ctx.db.screen_ice_candidate.by_sender.filter(
|
||||
ctx.sender,
|
||||
))
|
||||
ctx.db.screen_ice_candidate.id.delete(row.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const set_mute = spacetimedb.reducer(
|
||||
{ muted: t.bool() },
|
||||
(ctx, { muted }) => {
|
||||
const state = ctx.db.voice_state.identity.find(ctx.sender);
|
||||
if (state) {
|
||||
ctx.db.voice_state.identity.update({
|
||||
...state,
|
||||
is_muted: muted,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const set_deafen = spacetimedb.reducer(
|
||||
{ deafened: t.bool() },
|
||||
(ctx, { deafened }) => {
|
||||
const state = ctx.db.voice_state.identity.find(ctx.sender);
|
||||
if (state) {
|
||||
ctx.db.voice_state.identity.update({
|
||||
...state,
|
||||
is_deafened: deafened,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const start_watching = spacetimedb.reducer(
|
||||
{ watchee: t.identity(), channelId: t.u64() },
|
||||
(ctx, { watchee, channelId }) => {
|
||||
if (ctx.sender.isEqual(watchee)) return;
|
||||
|
||||
for (const w of ctx.db.watching.by_watcher.filter(ctx.sender)) {
|
||||
if (w.watchee.isEqual(watchee)) return;
|
||||
}
|
||||
ctx.db.watching.insert({
|
||||
id: 0n,
|
||||
watcher: ctx.sender,
|
||||
watchee,
|
||||
channel_id: channelId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const stop_watching = spacetimedb.reducer(
|
||||
{ watchee: t.identity() },
|
||||
(ctx, { watchee }) => {
|
||||
for (const w of ctx.db.watching.by_watcher.filter(ctx.sender)) {
|
||||
if (w.watchee.isEqual(watchee)) {
|
||||
ctx.db.watching.id.delete(w.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const leave_voice = spacetimedb.reducer((ctx) => {
|
||||
const existing = ctx.db.voice_state.identity.find(ctx.sender);
|
||||
if (existing) {
|
||||
ctx.db.voice_state.identity.delete(ctx.sender);
|
||||
}
|
||||
clearSignalingForUser(ctx, ctx.sender);
|
||||
});
|
||||
|
||||
export const send_voice_sdp_offer = spacetimedb.reducer(
|
||||
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
|
||||
(ctx, { receiver, sdp, channelId }) => {
|
||||
ctx.db.voice_sdp_offer.insert({
|
||||
id: 0n,
|
||||
sender: ctx.sender,
|
||||
receiver,
|
||||
sdp,
|
||||
channel_id: channelId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const send_voice_sdp_answer = spacetimedb.reducer(
|
||||
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
|
||||
(ctx, { receiver, sdp, channelId }) => {
|
||||
ctx.db.voice_sdp_answer.insert({
|
||||
id: 0n,
|
||||
sender: ctx.sender,
|
||||
receiver,
|
||||
sdp,
|
||||
channel_id: channelId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const send_voice_ice_candidate = spacetimedb.reducer(
|
||||
{ receiver: t.identity(), candidate: t.string(), channelId: t.u64() },
|
||||
(ctx, { receiver, candidate, channelId }) => {
|
||||
ctx.db.voice_ice_candidate.insert({
|
||||
id: 0n,
|
||||
sender: ctx.sender,
|
||||
receiver,
|
||||
candidate,
|
||||
channel_id: channelId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const send_screen_sdp_offer = spacetimedb.reducer(
|
||||
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
|
||||
(ctx, { receiver, sdp, channelId }) => {
|
||||
ctx.db.screen_sdp_offer.insert({
|
||||
id: 0n,
|
||||
sender: ctx.sender,
|
||||
receiver,
|
||||
sdp,
|
||||
channel_id: channelId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const send_screen_sdp_answer = spacetimedb.reducer(
|
||||
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
|
||||
(ctx, { receiver, sdp, channelId }) => {
|
||||
ctx.db.screen_sdp_answer.insert({
|
||||
id: 0n,
|
||||
sender: ctx.sender,
|
||||
receiver,
|
||||
sdp,
|
||||
channel_id: channelId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const send_screen_ice_candidate = spacetimedb.reducer(
|
||||
{ receiver: t.identity(), candidate: t.string(), channelId: t.u64() },
|
||||
(ctx, { receiver, candidate, channelId }) => {
|
||||
ctx.db.screen_ice_candidate.insert({
|
||||
id: 0n,
|
||||
sender: ctx.sender,
|
||||
receiver,
|
||||
candidate,
|
||||
channel_id: channelId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const set_configuration = spacetimedb.reducer(
|
||||
{ key: t.string(), value: t.string() },
|
||||
(ctx, { key, value }) => {
|
||||
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 }) => {
|
||||
let threadName = name;
|
||||
if (!threadName || threadName.trim().length === 0) {
|
||||
const parentMsg = ctx.db.message.id.find(parentMessageId);
|
||||
threadName = (parentMsg?.text && parentMsg.text.trim().length > 0)
|
||||
? parentMsg.text.substring(0, 32)
|
||||
: "New Thread";
|
||||
}
|
||||
|
||||
validateName(threadName);
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user || !user.subject) {
|
||||
throw new SenderError(
|
||||
"You must be logged in via OIDC to create a thread",
|
||||
);
|
||||
}
|
||||
const parentMsg = ctx.db.message.id.find(parentMessageId);
|
||||
if (!parentMsg) throw new SenderError("Parent message not found");
|
||||
|
||||
ctx.db.thread.insert({
|
||||
id: 0n,
|
||||
channel_id: channelId,
|
||||
parent_message_id: parentMessageId,
|
||||
name: threadName,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const create_thread_with_message = spacetimedb.reducer(
|
||||
{
|
||||
name: t.string(),
|
||||
channelId: t.u64(),
|
||||
parentMessageId: t.u64(),
|
||||
text: t.string(),
|
||||
imageIds: t.array(t.u64()),
|
||||
},
|
||||
(ctx, { name, channelId, parentMessageId, text, imageIds }) => {
|
||||
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 create a thread",
|
||||
);
|
||||
}
|
||||
const parentMsg = ctx.db.message.id.find(parentMessageId);
|
||||
if (!parentMsg) throw new SenderError("Parent message not found");
|
||||
|
||||
const t = ctx.db.thread.insert({
|
||||
id: 0n,
|
||||
channel_id: channelId,
|
||||
parent_message_id: parentMessageId,
|
||||
name,
|
||||
});
|
||||
|
||||
const msg = ctx.db.message.insert({
|
||||
id: 0n,
|
||||
sender: ctx.sender,
|
||||
text,
|
||||
sent: ctx.timestamp,
|
||||
channel_id: channelId,
|
||||
thread_id: t.id,
|
||||
});
|
||||
|
||||
const seqId = getNextSeqId(ctx, channelId);
|
||||
ctx.db.channel_message_sequence.insert({
|
||||
message_id: msg.id,
|
||||
channel_id: channelId,
|
||||
seq_id: seqId,
|
||||
});
|
||||
|
||||
for (const imageId of imageIds) {
|
||||
ctx.db.message_image.insert({
|
||||
id: 0n,
|
||||
message_id: msg.id,
|
||||
image_id: imageId,
|
||||
});
|
||||
}
|
||||
|
||||
// Maintain recent_message (last 100)
|
||||
ctx.db.recent_message.insert({
|
||||
id: 0n,
|
||||
channel_id: channelId,
|
||||
message_id: msg.id,
|
||||
sender: ctx.sender,
|
||||
text,
|
||||
thread_id: t.id,
|
||||
sent: ctx.timestamp,
|
||||
seq_id: seqId,
|
||||
});
|
||||
const recent = [...ctx.db.recent_message.by_channel.filter(channelId)].sort(
|
||||
(a, b) => (a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1),
|
||||
);
|
||||
if (recent.length > 100) {
|
||||
ctx.db.recent_message.id.delete(recent[0].id);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const send_message = spacetimedb.reducer(
|
||||
{
|
||||
text: t.string(),
|
||||
channelId: t.u64(),
|
||||
threadId: t.u64().optional(),
|
||||
imageIds: t.array(t.u64()),
|
||||
},
|
||||
(ctx, { text, channelId, threadId, imageIds }) => {
|
||||
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) {
|
||||
throw new SenderError("You must be registered to send messages");
|
||||
}
|
||||
|
||||
const msg = ctx.db.message.insert({
|
||||
id: 0n,
|
||||
sender: ctx.sender,
|
||||
text,
|
||||
sent: ctx.timestamp,
|
||||
channel_id: channelId,
|
||||
thread_id: threadId,
|
||||
});
|
||||
|
||||
const seqId = getNextSeqId(ctx, channelId);
|
||||
ctx.db.channel_message_sequence.insert({
|
||||
message_id: msg.id,
|
||||
channel_id: channelId,
|
||||
seq_id: seqId,
|
||||
});
|
||||
|
||||
for (const imageId of imageIds) {
|
||||
ctx.db.message_image.insert({
|
||||
id: 0n,
|
||||
message_id: msg.id,
|
||||
image_id: imageId,
|
||||
});
|
||||
}
|
||||
|
||||
// Maintain recent_message (last 100)
|
||||
ctx.db.recent_message.insert({
|
||||
id: 0n,
|
||||
channel_id: channelId,
|
||||
message_id: msg.id,
|
||||
sender: ctx.sender,
|
||||
text,
|
||||
thread_id: threadId,
|
||||
sent: ctx.timestamp,
|
||||
seq_id: seqId,
|
||||
});
|
||||
const recent2 = [...ctx.db.recent_message.by_channel.filter(channelId)].sort(
|
||||
(a, b) => (a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1),
|
||||
);
|
||||
if (recent2.length > 100) {
|
||||
ctx.db.recent_message.id.delete(recent2[0].id);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const bootstrap_sequences = spacetimedb.reducer((ctx) => {
|
||||
for (const row of ctx.db.channel_message_sequence.iter()) {
|
||||
ctx.db.channel_message_sequence.message_id.delete(row.message_id);
|
||||
}
|
||||
for (const row of ctx.db.channel_high_water_mark.iter()) {
|
||||
ctx.db.channel_high_water_mark.channel_id.delete(row.channel_id);
|
||||
}
|
||||
|
||||
const allMessages = [...ctx.db.message.iter()].sort((a, b) =>
|
||||
a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1
|
||||
);
|
||||
|
||||
for (const msg of allMessages) {
|
||||
const seqId = getNextSeqId(ctx, msg.channel_id);
|
||||
ctx.db.channel_message_sequence.insert({
|
||||
message_id: msg.id,
|
||||
channel_id: msg.channel_id,
|
||||
seq_id: seqId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
if (!ctx.db.system_configuration.key.find("initial_identity")) {
|
||||
ctx.db.system_configuration.insert({ key: "initial_identity", value: ctx.sender.toHexString() });
|
||||
}
|
||||
|
||||
let hasServers = false;
|
||||
for (const _server of ctx.db.server.iter()) {
|
||||
hasServers = true;
|
||||
break;
|
||||
}
|
||||
if (!hasServers) {
|
||||
const s = ctx.db.server.insert({
|
||||
id: 0n,
|
||||
name: "Zep",
|
||||
owner: undefined,
|
||||
avatar_id: undefined,
|
||||
});
|
||||
ctx.db.channel.insert({
|
||||
id: 0n,
|
||||
server_id: s.id,
|
||||
name: "general",
|
||||
kind: { tag: "Text" },
|
||||
});
|
||||
ctx.db.channel.insert({
|
||||
id: 0n,
|
||||
server_id: s.id,
|
||||
name: "Voice General",
|
||||
kind: { tag: "Voice" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const onConnect = spacetimedb.clientConnected((ctx) => {
|
||||
ctx.db.channel_subscription.identity.delete(ctx.sender);
|
||||
|
||||
// Clear any stale upload statuses for this identity
|
||||
for (const status of ctx.db.upload_status.by_identity.filter(ctx.sender)) {
|
||||
ctx.db.upload_status.client_id.delete(status.client_id);
|
||||
}
|
||||
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
|
||||
if (ctx.senderAuth.hasJWT && ctx.senderAuth.jwt) {
|
||||
const jwt = ctx.senderAuth.jwt;
|
||||
const issuer = jwt.issuer;
|
||||
const subject = jwt.subject;
|
||||
const payload = jwt.fullPayload;
|
||||
const name =
|
||||
(payload.name as string) ||
|
||||
(payload.nickname as string) ||
|
||||
(payload.preferred_username as string) ||
|
||||
(payload.email as string);
|
||||
|
||||
if (user) {
|
||||
ctx.db.user.identity.update({
|
||||
...user,
|
||||
online: true,
|
||||
name: user.name || name,
|
||||
issuer,
|
||||
subject,
|
||||
anonymous: false,
|
||||
});
|
||||
} else {
|
||||
ctx.db.user.insert({
|
||||
name,
|
||||
identity: ctx.sender,
|
||||
online: true,
|
||||
issuer,
|
||||
subject,
|
||||
anonymous: false,
|
||||
avatar_id: undefined,
|
||||
banner_id: undefined,
|
||||
biography: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
autoJoinCommunityServer(ctx);
|
||||
}
|
||||
} else if (user) {
|
||||
ctx.db.user.identity.update({ ...user, online: true });
|
||||
} else {
|
||||
// New anonymous user
|
||||
ctx.db.user.insert({
|
||||
name: undefined,
|
||||
identity: ctx.sender,
|
||||
online: true,
|
||||
issuer: undefined,
|
||||
subject: undefined,
|
||||
anonymous: true,
|
||||
avatar_id: undefined,
|
||||
banner_id: undefined,
|
||||
biography: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
autoJoinCommunityServer(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
export const onDisconnect = spacetimedb.clientDisconnected((ctx) => {
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (user) {
|
||||
ctx.db.user.identity.update({ ...user, online: false });
|
||||
}
|
||||
const existing = ctx.db.voice_state.identity.find(ctx.sender);
|
||||
if (existing) {
|
||||
ctx.db.voice_state.identity.delete(ctx.sender);
|
||||
}
|
||||
const activity = ctx.db.typing_activity.identity.find(ctx.sender);
|
||||
if (activity) {
|
||||
ctx.db.typing_activity.identity.update({
|
||||
identity: ctx.sender,
|
||||
channel_id: activity.channel_id,
|
||||
is_typing: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Clear upload statuses for this user
|
||||
for (const status of ctx.db.upload_status.by_identity.filter(ctx.sender)) {
|
||||
ctx.db.upload_status.client_id.delete(status.client_id);
|
||||
}
|
||||
|
||||
clearSignalingForUser(ctx, ctx.sender);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { schema } from "spacetimedb/server";
|
||||
import * as tables from "./tables";
|
||||
|
||||
export const spacetimedb = schema({
|
||||
user: tables.user,
|
||||
server: tables.server,
|
||||
server_member: tables.server_member,
|
||||
channel: tables.channel,
|
||||
channel_subscription: tables.channel_subscription,
|
||||
channel_message_sequence: tables.channel_message_sequence,
|
||||
channel_high_water_mark: tables.channel_high_water_mark,
|
||||
voice_state: tables.voice_state,
|
||||
voice_activity: tables.voice_activity,
|
||||
watching: tables.watching,
|
||||
voice_sdp_offer: tables.voice_sdp_offer,
|
||||
voice_sdp_answer: tables.voice_sdp_answer,
|
||||
voice_ice_candidate: tables.voice_ice_candidate,
|
||||
screen_sdp_offer: tables.screen_sdp_offer,
|
||||
screen_sdp_answer: tables.screen_sdp_answer,
|
||||
screen_ice_candidate: tables.screen_ice_candidate,
|
||||
thread: tables.thread,
|
||||
message: tables.message,
|
||||
message_image: tables.message_image,
|
||||
message_reaction: tables.message_reaction,
|
||||
custom_emoji: tables.custom_emoji,
|
||||
image: tables.image,
|
||||
typing_activity: tables.typing_activity,
|
||||
recent_message: tables.recent_message,
|
||||
system_configuration: tables.system_configuration,
|
||||
upload_status: tables.upload_status,
|
||||
});
|
||||
|
||||
export default spacetimedb;
|
||||
@@ -0,0 +1,490 @@
|
||||
import { t, table } from "spacetimedb/server";
|
||||
|
||||
export const channel_kind = t.enum("ChannelKind", { Text: t.unit(), Voice: t.unit() });
|
||||
|
||||
export const user = table(
|
||||
{
|
||||
name: "user",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: "by_online", algorithm: "btree", columns: ["online"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
identity: t.identity().primaryKey(),
|
||||
name: t.string().optional(),
|
||||
online: t.bool(),
|
||||
issuer: t.string().optional(),
|
||||
subject: t.string().optional(),
|
||||
anonymous: t.bool(),
|
||||
avatar_id: t.u64().optional(),
|
||||
banner_id: t.u64().optional(),
|
||||
biography: t.string().optional(),
|
||||
status: t.string().optional(),
|
||||
},
|
||||
);
|
||||
|
||||
export const server = table(
|
||||
{ name: "server", public: true },
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
name: t.string(),
|
||||
owner: t.identity().optional(),
|
||||
avatar_id: t.u64().optional(),
|
||||
},
|
||||
);
|
||||
|
||||
export const server_member = table(
|
||||
{
|
||||
name: "server_member",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: "by_identity", algorithm: "btree", columns: ["identity"] },
|
||||
{ accessor: "by_server_id", algorithm: "btree", columns: ["server_id"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
identity: t.identity(),
|
||||
server_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
export const channel = table(
|
||||
{
|
||||
name: "channel",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: "by_server_id", algorithm: "btree", columns: ["server_id"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
server_id: t.u64(),
|
||||
name: t.string(),
|
||||
kind: channel_kind,
|
||||
},
|
||||
);
|
||||
|
||||
export const voice_state = table(
|
||||
{
|
||||
name: "voice_state",
|
||||
public: true,
|
||||
indexes: [
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
identity: t.identity().primaryKey(),
|
||||
channel_id: t.u64(),
|
||||
is_sharing_screen: t.bool(),
|
||||
is_muted: t.bool().default(false),
|
||||
is_deafened: t.bool().default(false),
|
||||
},
|
||||
);
|
||||
|
||||
export const voice_activity = table(
|
||||
{
|
||||
name: "voice_activity",
|
||||
public: true,
|
||||
indexes: [
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
identity: t.identity().primaryKey(),
|
||||
channel_id: t.u64(),
|
||||
is_talking: t.bool(),
|
||||
},
|
||||
);
|
||||
|
||||
export const watching = table(
|
||||
{
|
||||
name: "watching",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: "by_watcher", algorithm: "btree", columns: ["watcher"] },
|
||||
{ accessor: "by_watchee", algorithm: "btree", columns: ["watchee"] },
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
watcher: t.identity(),
|
||||
watchee: t.identity(),
|
||||
channel_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
export const voice_sdp_offer = table(
|
||||
{
|
||||
name: "voice_sdp_offer",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
|
||||
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
sender: t.identity(),
|
||||
receiver: t.identity(),
|
||||
sdp: t.string(),
|
||||
channel_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
export const voice_sdp_answer = table(
|
||||
{
|
||||
name: "voice_sdp_answer",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
|
||||
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
sender: t.identity(),
|
||||
receiver: t.identity(),
|
||||
sdp: t.string(),
|
||||
channel_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
export const voice_ice_candidate = table(
|
||||
{
|
||||
name: "voice_ice_candidate",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
|
||||
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
sender: t.identity(),
|
||||
receiver: t.identity(),
|
||||
candidate: t.string(),
|
||||
channel_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
export const screen_sdp_offer = table(
|
||||
{
|
||||
name: "screen_sdp_offer",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
|
||||
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
sender: t.identity(),
|
||||
receiver: t.identity(),
|
||||
sdp: t.string(),
|
||||
channel_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
export const screen_sdp_answer = table(
|
||||
{
|
||||
name: "screen_sdp_answer",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
|
||||
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
sender: t.identity(),
|
||||
receiver: t.identity(),
|
||||
sdp: t.string(),
|
||||
channel_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
export const screen_ice_candidate = table(
|
||||
{
|
||||
name: "screen_ice_candidate",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
|
||||
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
sender: t.identity(),
|
||||
receiver: t.identity(),
|
||||
candidate: t.string(),
|
||||
channel_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
export const channel_message_sequence = table(
|
||||
{
|
||||
name: "channel_message_sequence",
|
||||
public: false,
|
||||
indexes: [
|
||||
{ accessor: "by_channel", algorithm: "btree", columns: ["channel_id"] },
|
||||
{
|
||||
accessor: "by_channel_seq",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id", "seq_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
message_id: t.u64().primaryKey(),
|
||||
channel_id: t.u64(),
|
||||
seq_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
export const channel_high_water_mark = table(
|
||||
{
|
||||
name: "channel_high_water_mark",
|
||||
public: false,
|
||||
},
|
||||
{
|
||||
channel_id: t.u64().primaryKey(),
|
||||
last_seq_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
export const channel_subscription = table(
|
||||
{
|
||||
name: "channel_subscription",
|
||||
public: false,
|
||||
indexes: [
|
||||
{ accessor: "by_channel_id", algorithm: "btree", columns: ["channel_id"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
identity: t.identity().primaryKey(),
|
||||
channel_id: t.u64(),
|
||||
earliest_seq_id: t.u64(),
|
||||
last_read_seq_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
export const thread = table(
|
||||
{
|
||||
name: "thread",
|
||||
public: true,
|
||||
indexes: [
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
channel_id: t.u64(),
|
||||
parent_message_id: t.u64().unique(),
|
||||
name: t.string(),
|
||||
},
|
||||
);
|
||||
|
||||
export const message = table(
|
||||
{
|
||||
name: "message",
|
||||
public: false,
|
||||
indexes: [
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
{ accessor: "by_thread_id", algorithm: "btree", columns: ["thread_id"] },
|
||||
{ accessor: "by_sent", algorithm: "btree", columns: ["sent"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
sender: t.identity(),
|
||||
sent: t.timestamp(),
|
||||
text: t.string(),
|
||||
channel_id: t.u64(),
|
||||
thread_id: t.u64().optional(),
|
||||
},
|
||||
);
|
||||
|
||||
export const message_image = table(
|
||||
{
|
||||
name: "message_image",
|
||||
public: false,
|
||||
indexes: [
|
||||
{
|
||||
accessor: "by_message_id",
|
||||
algorithm: "btree",
|
||||
columns: ["message_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
message_id: t.u64(),
|
||||
image_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
export const message_reaction = table(
|
||||
{
|
||||
name: "message_reaction",
|
||||
public: false,
|
||||
indexes: [
|
||||
{
|
||||
accessor: "by_message_id",
|
||||
algorithm: "btree",
|
||||
columns: ["message_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
message_id: t.u64(),
|
||||
identity: t.identity(),
|
||||
emoji: t.string().optional(),
|
||||
custom_emoji_id: t.u64().optional(),
|
||||
},
|
||||
);
|
||||
|
||||
export const custom_emoji = table(
|
||||
{
|
||||
name: "custom_emoji",
|
||||
public: true,
|
||||
indexes: [{ accessor: "by_name", algorithm: "btree", columns: ["name"] }],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
name: t.string(),
|
||||
category: t.string(),
|
||||
data: t.byteArray(),
|
||||
},
|
||||
);
|
||||
|
||||
export const image = table(
|
||||
{
|
||||
name: "image",
|
||||
public: false,
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
data: t.byteArray(),
|
||||
mime_type: t.string(),
|
||||
name: t.string().optional(),
|
||||
},
|
||||
);
|
||||
|
||||
export const typing_activity = table(
|
||||
{
|
||||
name: "typing_activity",
|
||||
public: true,
|
||||
indexes: [
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
identity: t.identity().primaryKey(),
|
||||
channel_id: t.u64(),
|
||||
is_typing: t.bool(),
|
||||
},
|
||||
);
|
||||
|
||||
export const recent_message = table(
|
||||
{
|
||||
name: "recent_message",
|
||||
public: false,
|
||||
indexes: [{ accessor: "by_channel", algorithm: "btree", columns: ["channel_id"] }],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
channel_id: t.u64(),
|
||||
message_id: t.u64(),
|
||||
sender: t.identity(),
|
||||
text: t.string(),
|
||||
thread_id: t.u64().optional(),
|
||||
sent: t.timestamp(),
|
||||
seq_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
export const system_configuration = table(
|
||||
{
|
||||
name: "system_configuration",
|
||||
public: true,
|
||||
},
|
||||
{
|
||||
key: t.string().primaryKey(),
|
||||
value: t.string(),
|
||||
},
|
||||
);
|
||||
|
||||
export const upload_status = table(
|
||||
{
|
||||
name: "upload_status",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: "by_identity", algorithm: "btree", columns: ["identity"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
client_id: t.string().primaryKey(),
|
||||
identity: t.identity(),
|
||||
image_id: t.u64().optional(),
|
||||
status: t.string(), // "pending", "success", "error"
|
||||
error: t.string().optional(),
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,112 @@
|
||||
import { SenderError } from "spacetimedb/server";
|
||||
|
||||
export function validateName(name: string) {
|
||||
if (!name || name.trim().length === 0)
|
||||
throw new SenderError("Names must not be empty");
|
||||
}
|
||||
|
||||
export function validateMessageLength(ctx: any, text: string) {
|
||||
const maxLengthConf = ctx.db.system_configuration.key.find("max_message_length");
|
||||
const maxLength = maxLengthConf ? parseInt(maxLengthConf.value) : 262144;
|
||||
|
||||
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 function getNextSeqId(ctx: any, channelId: bigint): bigint {
|
||||
const hwm = ctx.db.channel_high_water_mark.channel_id.find(channelId);
|
||||
const nextSeqId = hwm ? hwm.last_seq_id + 1n : 1n;
|
||||
|
||||
if (hwm) {
|
||||
ctx.db.channel_high_water_mark.channel_id.update({ ...hwm, last_seq_id: nextSeqId });
|
||||
} else {
|
||||
ctx.db.channel_high_water_mark.insert({ channel_id: channelId, last_seq_id: nextSeqId });
|
||||
}
|
||||
|
||||
return nextSeqId;
|
||||
}
|
||||
|
||||
export function getVisibleMessageIds(ctx: any): Map<bigint, bigint> {
|
||||
const result = new Map<bigint, bigint>();
|
||||
|
||||
for (const member of ctx.db.server_member.by_identity.filter(ctx.sender)) {
|
||||
for (const chan of ctx.db.channel.by_server_id.filter(member.server_id)) {
|
||||
for (const rm of ctx.db.recent_message.by_channel.filter(chan.id)) {
|
||||
result.set(rm.message_id, rm.seq_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sub = ctx.db.channel_subscription.identity.find(ctx.sender);
|
||||
if (sub) {
|
||||
for (const cms of ctx.db.channel_message_sequence.by_channel.filter(sub.channel_id)) {
|
||||
if (cms.seq_id >= sub.earliest_seq_id) {
|
||||
result.set(cms.message_id, cms.seq_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getVisibleImageIds(ctx: any): Set<bigint> {
|
||||
const ids = new Set<bigint>();
|
||||
|
||||
for (const member of ctx.db.server_member.by_identity.filter(ctx.sender)) {
|
||||
const s = ctx.db.server.id.find(member.server_id);
|
||||
if (s && s.avatar_id) ids.add(s.avatar_id);
|
||||
|
||||
for (const peer of ctx.db.server_member.by_server_id.filter(member.server_id)) {
|
||||
const u = ctx.db.user.identity.find(peer.identity);
|
||||
if (u) {
|
||||
if (u.avatar_id) ids.add(u.avatar_id);
|
||||
if (u.banner_id) ids.add(u.banner_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visibleMsgIds = getVisibleMessageIds(ctx);
|
||||
for (const msgId of visibleMsgIds.keys()) {
|
||||
for (const mi of ctx.db.message_image.by_message_id.filter(msgId)) {
|
||||
ids.add(mi.image_id);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function clearSignalingForUser(ctx: any, identity: any) {
|
||||
ctx.db.voice_activity.identity.delete(identity);
|
||||
for (const w of ctx.db.watching.by_watcher.filter(identity)) {
|
||||
ctx.db.watching.id.delete(w.id);
|
||||
}
|
||||
for (const w of ctx.db.watching.by_watchee.filter(identity)) {
|
||||
ctx.db.watching.id.delete(w.id);
|
||||
}
|
||||
for (const row of ctx.db.voice_sdp_offer.by_sender.filter(identity))
|
||||
ctx.db.voice_sdp_offer.id.delete(row.id);
|
||||
for (const row of ctx.db.voice_sdp_offer.by_receiver.filter(identity))
|
||||
ctx.db.voice_sdp_offer.id.delete(row.id);
|
||||
for (const row of ctx.db.voice_sdp_answer.by_sender.filter(identity))
|
||||
ctx.db.voice_sdp_answer.id.delete(row.id);
|
||||
for (const row of ctx.db.voice_sdp_answer.by_receiver.filter(identity))
|
||||
ctx.db.voice_sdp_answer.id.delete(row.id);
|
||||
for (const row of ctx.db.voice_ice_candidate.by_sender.filter(identity))
|
||||
ctx.db.voice_ice_candidate.id.delete(row.id);
|
||||
for (const row of ctx.db.voice_ice_candidate.by_receiver.filter(identity))
|
||||
ctx.db.voice_ice_candidate.id.delete(row.id);
|
||||
}
|
||||
|
||||
export function autoJoinCommunityServer(ctx: any) {
|
||||
const communityServer = [...ctx.db.server.iter()].find(
|
||||
(s) => s.name === "Zep",
|
||||
);
|
||||
if (communityServer) {
|
||||
ctx.db.server_member.insert({
|
||||
id: 0n,
|
||||
identity: ctx.sender,
|
||||
server_id: communityServer.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { t } from "spacetimedb/server";
|
||||
import { spacetimedb } from "./schema";
|
||||
import { getVisibleImageIds, getVisibleMessageIds } from "./utils";
|
||||
|
||||
export const visible_images = spacetimedb.view(
|
||||
{ name: "visible_images", public: true },
|
||||
t.array(
|
||||
t.row("visible_image_row", {
|
||||
id: t.u64(),
|
||||
data: t.byteArray(),
|
||||
mime_type: t.string(),
|
||||
name: t.string().optional(),
|
||||
})
|
||||
),
|
||||
(ctx) => {
|
||||
const imageIds = getVisibleImageIds(ctx);
|
||||
const results: any[] = [];
|
||||
for (const id of imageIds) {
|
||||
const img = ctx.db.image.id.find(id);
|
||||
if (img) {
|
||||
results.push({
|
||||
id: img.id,
|
||||
// Explicitly copy the data to a new Uint8Array to ensure no buffer sharing issues
|
||||
data: new Uint8Array(img.data),
|
||||
mime_type: img.mime_type,
|
||||
name: img.name ?? undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
export const visible_messages = spacetimedb.view(
|
||||
{ name: "visible_messages", public: true },
|
||||
t.array(
|
||||
t.row("visible_message_row", {
|
||||
id: t.u64(),
|
||||
sender: t.identity(),
|
||||
sent: t.timestamp(),
|
||||
text: t.string(),
|
||||
channel_id: t.u64(),
|
||||
thread_id: t.u64().optional(),
|
||||
seq_id: t.u64(),
|
||||
})
|
||||
),
|
||||
(ctx) => {
|
||||
const visibleSeqMap = getVisibleMessageIds(ctx);
|
||||
const results: any[] = [];
|
||||
for (const [msgId, seqId] of visibleSeqMap.entries()) {
|
||||
const msg = ctx.db.message.id.find(msgId);
|
||||
if (msg) {
|
||||
results.push({
|
||||
id: msg.id,
|
||||
sender: msg.sender,
|
||||
sent: msg.sent,
|
||||
text: msg.text,
|
||||
channel_id: msg.channel_id,
|
||||
thread_id: msg.thread_id ?? undefined,
|
||||
seq_id: seqId
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
export const my_channel_subscriptions = spacetimedb.view(
|
||||
{ name: "my_channel_subscriptions", public: true },
|
||||
t.array(
|
||||
t.row("my_channel_subscription_row", {
|
||||
identity: t.identity(),
|
||||
channel_id: t.u64(),
|
||||
earliest_seq_id: t.u64(),
|
||||
last_read_seq_id: t.u64(),
|
||||
})
|
||||
),
|
||||
(ctx) => {
|
||||
const sub = ctx.db.channel_subscription.identity.find(ctx.sender);
|
||||
if (!sub) return [];
|
||||
return [{
|
||||
identity: sub.identity,
|
||||
channel_id: sub.channel_id,
|
||||
earliest_seq_id: sub.earliest_seq_id,
|
||||
last_read_seq_id: sub.last_read_seq_id
|
||||
}];
|
||||
}
|
||||
);
|
||||
|
||||
export const visible_message_images = spacetimedb.view(
|
||||
{ name: "visible_message_images", public: true },
|
||||
t.array(
|
||||
t.row("visible_message_image_row", {
|
||||
id: t.u64(),
|
||||
message_id: t.u64(),
|
||||
image_id: t.u64(),
|
||||
})
|
||||
),
|
||||
(ctx) => {
|
||||
const visibleSeqMap = getVisibleMessageIds(ctx);
|
||||
const results: any[] = [];
|
||||
for (const msgId of visibleSeqMap.keys()) {
|
||||
for (const mi of ctx.db.message_image.by_message_id.filter(msgId)) {
|
||||
results.push({
|
||||
id: mi.id,
|
||||
message_id: mi.message_id,
|
||||
image_id: mi.image_id
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
export const visible_message_reactions = spacetimedb.view(
|
||||
{ name: "visible_message_reactions", public: true },
|
||||
t.array(
|
||||
t.row("visible_message_reaction_row", {
|
||||
id: t.u64(),
|
||||
message_id: t.u64(),
|
||||
identity: t.identity(),
|
||||
emoji: t.string().optional(),
|
||||
custom_emoji_id: t.u64().optional(),
|
||||
})
|
||||
),
|
||||
(ctx) => {
|
||||
const visibleSeqMap = getVisibleMessageIds(ctx);
|
||||
const results: any[] = [];
|
||||
for (const msgId of visibleSeqMap.keys()) {
|
||||
for (const mr of ctx.db.message_reaction.by_message_id.filter(msgId)) {
|
||||
results.push({
|
||||
id: mr.id,
|
||||
message_id: mr.message_id,
|
||||
identity: mr.identity,
|
||||
emoji: mr.emoji ?? undefined,
|
||||
custom_emoji_id: mr.custom_emoji_id ?? undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user