typing activity

This commit is contained in:
2026-04-03 10:39:02 -04:00
parent 9379d6813f
commit c4abf0f4ce
10 changed files with 302 additions and 16 deletions
+43
View File
@@ -373,6 +373,25 @@ const image = table(
},
);
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(),
},
);
const spacetimedb = schema({
user,
server,
@@ -393,6 +412,7 @@ const spacetimedb = schema({
message_reaction,
custom_emoji,
image,
typing_activity,
});
export default spacetimedb;
@@ -401,6 +421,26 @@ function validateName(name: string) {
throw new SenderError("Names must not be empty");
}
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() },
(ctx, { data, mimeType, name }) => {
@@ -1111,6 +1151,9 @@ export const onDisconnect = spacetimedb.clientDisconnected((ctx) => {
if (existing) {
ctx.db.voice_state.identity.delete(ctx.sender);
}
// Clear typing activity
ctx.db.typing_activity.identity.delete(ctx.sender);
// Clean up signaling messages associated with the disconnected user
clearSignalingForUser(ctx, ctx.sender);
});
+48
View File
@@ -746,6 +746,54 @@ body {
/* Chat Input */
.chat-input-container {
padding: 0 16px 24px 16px;
position: relative;
display: flex;
flex-direction: column;
}
.typing-indicator {
height: 20px;
margin-left: 16px;
margin-bottom: 2px;
font-size: 0.75rem;
color: var(--text-normal);
display: flex;
align-items: center;
gap: 8px;
pointer-events: none;
z-index: 20;
}
.typing-indicator .dots {
display: flex;
gap: 2px;
}
.typing-indicator .dot {
width: 4px;
height: 4px;
background-color: var(--text-muted);
border-radius: 50%;
animation: typing-dot 1.4s infinite ease-in-out;
}
.typing-indicator .dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator .dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing-dot {
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-4px);
}
}
.chat-input {
+28 -5
View File
@@ -184,11 +184,34 @@
<VideoGrid />
{:else}
<MessageList />
<MessageInput
activeChannelId={chat.activeChannelId}
activeThreadId={chat.activeThreadId}
isFullyAuthenticated={chat.isFullyAuthenticated}
/>
<div class="chat-input-container">
<div class="typing-indicator">
{#if chat.typingUsers.length > 0}
<div class="dots">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
<span style="font-weight: bold;">
{#if chat.typingUsers.length === 1}
{chat.typingUsers[0].name || "Someone"}
{:else if chat.typingUsers.length === 2}
{chat.typingUsers[0].name || "Someone"} and {chat.typingUsers[1].name || "Someone"}
{:else if chat.typingUsers.length === 3}
{chat.typingUsers[0].name || "Someone"}, {chat.typingUsers[1].name || "Someone"} and {chat.typingUsers[2].name || "Someone"}
{:else}
Several people
{/if}
</span>
<span>{chat.typingUsers.length === 1 ? "is" : "are"} typing...</span>
{/if}
</div>
<MessageInput
activeChannelId={chat.activeChannelId}
activeThreadId={chat.activeThreadId}
isFullyAuthenticated={chat.isFullyAuthenticated}
/>
</div>
{/if}
</div>
+49 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { getContext } from "svelte";
import { getContext, untrack } from "svelte";
import type { ChatService } from "../services/chat.svelte";
import { optimizeImage } from "../utils";
@@ -20,6 +20,52 @@
let uploadError = $state<string | null>(null);
let isUploading = $state(false);
let typingTimeout: any;
let isTyping = $state(false);
$effect(() => {
// We only care about messageText changes here
const text = messageText;
const channelId = activeChannelId;
if (text.trim().length > 0 && channelId) {
// Start typing if not already
if (!untrack(() => isTyping)) {
isTyping = true;
chat.handleSetTyping(true, channelId);
}
// Refresh timeout regardless
if (typingTimeout) clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
isTyping = false;
chat.handleSetTyping(false, channelId);
}, 3000);
} else if (untrack(() => isTyping)) {
// Text was cleared, stop typing immediately
isTyping = false;
if (typingTimeout) clearTimeout(typingTimeout);
if (channelId) {
chat.handleSetTyping(false, channelId);
}
}
return () => {
if (typingTimeout) clearTimeout(typingTimeout);
};
});
// Clear typing on unmount or channel change
$effect(() => {
const _chan = activeChannelId;
return () => {
if (isTyping) {
isTyping = false;
chat.handleSetTyping(false, _chan);
}
};
});
async function handlePaste(e: ClipboardEvent) {
if (!isFullyAuthenticated) return;
const items = e.clipboardData?.items;
@@ -125,6 +171,8 @@
messageText = "";
stagedImages = [];
isUploading = false;
isTyping = false;
chat.handleSetTyping(false, activeChannelId);
// 2. Send message with all image IDs
chat.handleSendMessage(currentText, undefined, imageIds);
+55 -8
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { getContext } from "svelte";
import { getContext, untrack } from "svelte";
import type { ChatService } from "../services/chat.svelte";
import { optimizeImage } from "../utils";
let {
activeChannelId,
activeThreadId,
let {
activeChannelId,
activeThreadId,
isFullyAuthenticated
}: {
activeChannelId: bigint | null,
@@ -20,6 +20,51 @@
let uploadError = $state<string | null>(null);
let isUploading = $state(false);
let typingTimeout: any;
let isTyping = $state(false);
$effect(() => {
const text = threadMessageText;
const channelId = activeChannelId;
if (text.trim().length > 0 && channelId) {
// Start typing if not already
if (!untrack(() => isTyping)) {
isTyping = true;
chat.handleSetTyping(true, channelId);
}
// Refresh timeout regardless
if (typingTimeout) clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
isTyping = false;
chat.handleSetTyping(false, channelId);
}, 3000);
} else if (untrack(() => isTyping)) {
// Text was cleared, stop typing immediately
isTyping = false;
if (typingTimeout) clearTimeout(typingTimeout);
if (channelId) {
chat.handleSetTyping(false, channelId);
}
}
return () => {
if (typingTimeout) clearTimeout(typingTimeout);
};
});
// Clear typing on unmount or channel/thread change
$effect(() => {
const _chan = activeChannelId;
return () => {
if (isTyping) {
isTyping = false;
chat.handleSetTyping(false, _chan);
}
};
});
async function handlePaste(e: ClipboardEvent) {
if (!isFullyAuthenticated) return;
const items = e.clipboardData?.items;
@@ -44,7 +89,7 @@
console.error("Failed to optimize image:", err);
const buffer = await file.arrayBuffer();
const data = new Uint8Array(buffer);
let fileName = file.name || "image.png";
if (fileName === "image.png" || fileName === "blob") {
const now = new Date();
@@ -77,7 +122,7 @@
const currentText = threadMessageText;
const currentStaged = [...stagedImages];
isUploading = true;
uploadError = null;
@@ -88,7 +133,7 @@
for (const staged of currentStaged) {
const beforeCount = chat.images.length;
chat.uploadImage(staged.data, staged.mimeType, staged.name);
const imageId = await new Promise<bigint>((resolve) => {
const checkInterval = setInterval(() => {
if (chat.images.length > beforeCount) {
@@ -124,6 +169,8 @@
threadMessageText = "";
stagedImages = [];
isUploading = false;
isTyping = false;
chat.handleSetTyping(false, activeChannelId);
// We MUST have either activeThreadId OR pendingThreadParentMessageId to be in ThreadView
chat.handleSendMessage(currentText, activeThreadId || undefined, imageIds);
@@ -141,7 +188,7 @@
{#each stagedImages as staged, i}
<div class="staged-image-wrapper" style="position: relative; width: 48px; height: 48px;">
<img src={staged.previewUrl} alt="Staged" style="width: 100%; height: 100%; object-fit: cover; border-radius: 4px;" />
<button
<button
onclick={() => removeStagedImage(i)}
style="position: absolute; top: -4px; right: -4px; background: #f23f43; color: white; border: none; border-radius: 50%; width: 14px; height: 14px; font-size: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center;"
title="Remove"
+24
View File
@@ -34,6 +34,8 @@
allImages: readonly Types.Image[]
} = $props();
const chat = getContext<ChatService>("chat");
const activeThread = $derived(allThreads.find((t) => t.id === activeThreadId));
const pendingParentMessage = $derived(allMessages.find(m => m.id === pendingThreadParentMessageId));
@@ -104,6 +106,28 @@
images={allImages}
/>
<div class="typing-indicator" style="margin-left: 12px; margin-top: 4px;">
{#if chat.typingUsers.length > 0}
<div class="dots">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
<span style="font-weight: bold;">
{#if chat.typingUsers.length === 1}
{chat.typingUsers[0].name || "Someone"}
{:else if chat.typingUsers.length === 2}
{chat.typingUsers[0].name || "Someone"} and {chat.typingUsers[1].name || "Someone"}
{:else if chat.typingUsers.length === 3}
{chat.typingUsers[0].name || "Someone"}, {chat.typingUsers[1].name || "Someone"} and {chat.typingUsers[2].name || "Someone"}
{:else}
Several people
{/if}
</span>
<span>{chat.typingUsers.length === 1 ? "is" : "are"} typing...</span>
{/if}
</div>
<ThreadMessageInput
{activeChannelId}
{activeThreadId}
+29
View File
@@ -237,6 +237,9 @@ export class ChatService {
get voiceActivity() {
return this.#db.voiceActivity;
}
get typingActivity() {
return this.#db.typingActivity;
}
get watching() {
return this.#db.watching;
}
@@ -395,6 +398,32 @@ export class ChatService {
this.#voice.handleLeaveVoice();
};
handleSetTyping = (typing: boolean, channelId: bigint | null = null) => {
const targetChannelId = channelId || this.activeChannelId;
if (targetChannelId) {
this.#msg.setTyping(targetChannelId, typing);
}
};
get typingUsers() {
if (!this.activeChannelId) return [];
const myId = this.identity;
const activeChannelId = this.activeChannelId;
return this.typingActivity
.filter((ta) => {
const isSameChannel = ta.channelId === activeChannelId;
const isNotMe = myId ? !ta.identity.isEqual(myId) : true;
const currentlyTyping = ta.isTyping;
return isSameChannel && isNotMe && currentlyTyping;
})
.map((ta) => {
const user = this.users.find((u) => u.identity.isEqual(ta.identity));
return user || { name: `User ${ta.identity.toHexString().substring(0, 8)}` } as any as Types.User;
});
}
handleSetName = (e: Event) => {
e.preventDefault();
this.#account.handleSetName(this.newName);
+3
View File
@@ -15,6 +15,7 @@ export class DatabaseService {
messageReactions = $state<readonly Types.MessageReaction[]>([]);
voiceStates = $state<readonly Types.VoiceState[]>([]);
voiceActivity = $state<readonly Types.VoiceActivity[]>([]);
typingActivity = $state<readonly Types.TypingActivity[]>([]);
watching = $state<readonly Types.Watching[]>([]);
isUsersReady = $state(false);
@@ -28,6 +29,7 @@ export class DatabaseService {
const [imagesStore] = useTable(tables.image);
const [customEmojisStore] = useTable(tables.custom_emoji);
const [voiceActivityStore] = useTable(tables.voice_activity);
const [typingActivityStore] = useTable(tables.typing_activity);
const [watchingStore] = useTable(tables.watching);
serversStore.subscribe((v) => (this.servers = v));
@@ -40,6 +42,7 @@ export class DatabaseService {
imagesStore.subscribe((v) => (this.images = v));
customEmojisStore.subscribe((v) => (this.customEmojis = v));
voiceActivityStore.subscribe((v) => (this.voiceActivity = v));
typingActivityStore.subscribe((v) => (this.typingActivity = v));
watchingStore.subscribe((v) => (this.watching = v));
}
}
+11 -2
View File
@@ -18,6 +18,7 @@ export class MessagingService {
#uploadImageReducer: any;
#uploadCustomEmojiReducer: any;
#toggleReactionReducer: any;
#setTypingReducer: any;
#allMessages = $state<readonly Types.Message[]>([]);
#messageImages = $state<readonly Types.MessageImage[]>([]);
@@ -37,6 +38,7 @@ export class MessagingService {
this.#uploadImageReducer = useReducer(reducers.uploadImage);
this.#uploadCustomEmojiReducer = useReducer(reducers.uploadCustomEmoji);
this.#toggleReactionReducer = useReducer(reducers.toggleReaction);
this.#setTypingReducer = useReducer(reducers.setTyping);
const [messagesStore] = useTable(tables.message);
const [messageImagesStore] = useTable(tables.message_image);
@@ -78,6 +80,7 @@ export class MessagingService {
// Voice states and activity are lightweight and indexed by channel_id
queries.push(`SELECT * FROM voice_state WHERE channel_id IN (SELECT id FROM channel WHERE server_id = ${serverId})`);
queries.push(`SELECT * FROM voice_activity WHERE channel_id IN (SELECT id FROM channel WHERE server_id = ${serverId})`);
queries.push(`SELECT * FROM typing_activity WHERE channel_id IN (SELECT id FROM channel WHERE server_id = ${serverId})`);
queries.push(`SELECT * FROM watching WHERE channel_id IN (SELECT id FROM channel WHERE server_id = ${serverId})`);
// 3. Server-Scoped Message Subquery
@@ -114,15 +117,17 @@ export class MessagingService {
`);
// 5. Indexed User Population
// Only pull users who are: Online OR Members of this server OR Senders/Reactors of visible messages
// Only pull users who are: Online OR Members of this server OR Senders/Reactors/Typers of visible messages
const reactorSubquery = `(SELECT identity FROM message_reaction WHERE message_id IN ${visibleMsgSubquery})`;
const typerSubquery = `(SELECT identity FROM typing_activity WHERE channel_id IN (SELECT id FROM channel WHERE server_id = ${serverId}))`;
queries.push(`
SELECT * FROM user WHERE
online = true OR
identity IN (SELECT identity FROM server_member WHERE server_id = ${serverId}) OR
identity IN (SELECT sender FROM message WHERE id IN ${visibleMsgSubquery}) OR
identity IN ${reactorSubquery}
identity IN ${reactorSubquery} OR
identity IN ${typerSubquery}
`);
}
@@ -224,4 +229,8 @@ export class MessagingService {
toggleReaction = (messageId: bigint, emoji?: string, customEmojiId?: bigint) => {
this.#toggleReactionReducer({ messageId, emoji, customEmojiId });
};
setTyping = (channelId: bigint, typing: boolean) => {
this.#setTypingReducer({ channelId, typing });
};
}
+12
View File
@@ -15,6 +15,18 @@ export class NavigationService {
this.#db = db;
this.#identity = identity;
let prevChannelId = this.activeChannelId;
$effect(() => {
const currentChannelId = this.activeChannelId;
if (currentChannelId !== prevChannelId) {
untrack(() => {
this.activeThreadId = null;
this.pendingThreadParentMessageId = null;
});
}
prevChannelId = currentChannelId;
});
$effect(() => {
if (this.pendingThreadParentMessageId) {
const newThread = this.#db.allThreads.find(