typing activity
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user