Chat: threads, notes, better styles

This commit is contained in:
Shivering Ocelot committed 2025-07-13 22:40:15 -05:00
1 parent 0f7384cc1d
commit bace4b7136
1 file changed
+180 -38
+180 -38
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import { import {
ref, watch, ref, watch, computed,
onMounted, onUnmounted, onMounted, onUnmounted,
} from "vue"; } from "vue";
@@ -14,10 +14,15 @@
}, },
}); });
let chatId = null;
// Chat State // Chat State
const chat = ref({}); const chat = ref({});
const messages = ref([]); const messages = ref([]);
const currentThreadId = ref(null); const currentThreadId = ref(null);
const chatEntries = computed(() => {
return computeChatEntries(messages.value);
});
// UI State // UI State
const expandedThreads = ref({}); // Object<int: bool>, to simulate a Set<int> const expandedThreads = ref({}); // Object<int: bool>, to simulate a Set<int>
@@ -27,14 +32,26 @@
let _isMounted = false; let _isMounted = false;
let unsubscribeChat = null; let unsubscribeChat = null;
// TODO: user sent message is added without id.
// User should receive his own message notification, and find its message to assign it an id.
// Alternatively, return the message id in send-chat-message action. Think thats better.
let _messageNonce = 1;
// === State Management === // // === State Management === //
async function loadChatState (chatId) { async function loadChatState (newId) {
chatId = parseInt(newId);
if (chatId == null) {
console.error("Invalid chat id", chatId);
return;
}
unsubscribeChat = backend.onChatMessages(chatId, it => { unsubscribeChat = backend.onChatMessages(chatId, it => {
if (it.sender == 'user') return; if (it.sender == 'user') return;
addMessage({ addMessage({
id: it.id,
type: it.kind, type: it.kind,
sender: it.sender, sender: it.sender,
content: it.content, content: it.content,
@@ -44,20 +61,36 @@
let response = await backend.sendAction("get-chat-messages", {chatId}); let response = await backend.sendAction("get-chat-messages", {chatId});
// Always good to duplicate when assigning to a ref // Always good to duplicate when assigning to a ref
messages.value = response['messages'].map(it => ({ messages.value = response.messages.map(it => ({
//id: it.id,
id: _messageNonce++,
type: it.kind, type: it.kind,
sender: it.sender, sender: it.sender,
content: it.content, content: it.content,
threadId: it.threadId, threadId: it.threadId,
})); }));
// Always send messages in a thread, determine which one to start with
if (response.messages.length > 0) {
const lastMessage = response.messages[response.messages.length - 1];
const lastThread = lastMessage.threadId;
if (lastThread == null) {
startNewThread();
} else {
currentThreadId.value = lastMessage.threadId;
expandThread(lastMessage.threadId, true);
}
} else {
startNewThread();
}
// This must happen on the same async-thread that sets `messages` // This must happen on the same async-thread that sets `messages`
requestRescroll({forceBottom: true}); requestRescroll({forceBottom: true});
// Reset UI State // Reset UI State
editingMessageId.value = null; editingMessageId.value = null;
noteMode.value = false; noteMode.value = false;
// no need to reset expandedThreads, it's nice that it will be remembered // don't reset expandedThreads, it's nice to keep for the session
} }
async function unloadChatState () { async function unloadChatState () {
@@ -89,14 +122,9 @@
const inputFieldElem = ref(null); const inputFieldElem = ref(null);
const chatContainerElem = ref(null); const chatContainerElem = ref(null);
async function getChatMessages(chatId) {
let response = await backend.sendAction("get-chat-messages", {chatId});
let messageObjects = response['messages'];
return messageObjects;
}
function addMessage (message) { function addMessage (message) {
requestRescroll(); requestRescroll();
message.id = _messageNonce++;
messages.value.push(message); messages.value.push(message);
} }
@@ -114,10 +142,8 @@
if (messageId == null) { if (messageId == null) {
backend.sendAction("send-chat-message", { backend.sendAction("send-chat-message", {
'chatId': props.id, chatId, content, threadId,
'content': content, kind: isNote ? 'note' : null,
'threadId': threadId,
'kind': isNote ? 'note' : null,
}).then((response) => { }).then((response) => {
if (response['error']) { if (response['error']) {
print("Error sending chat message: ${response['error']}"); print("Error sending chat message: ${response['error']}");
@@ -135,8 +161,7 @@
const messageRef = messages.firstWhere((element) => element.id == messageId); const messageRef = messages.firstWhere((element) => element.id == messageId);
backend.sendAction("update-chat-message", { backend.sendAction("update-chat-message", {
'messageId': messageId, messageId, content,
'content': content,
'kind': isNote ? 'note' : null, 'kind': isNote ? 'note' : null,
}).then((response) => { }).then((response) => {
if (response['error']) { if (response['error']) {
@@ -151,6 +176,78 @@
if (isNote) noteMode.value = false; if (isNote) noteMode.value = false;
} }
function computeChatEntries (messages) {
let entries = [];
let threadGroups = {}; // Map<int, message[]>
function addThreadHeader (threadId, group) {
entries.push({
type: 'thread-header',
threadId,
messageCount: group.length,
expanded: Boolean(expandedThreads.value[threadId]),
});
}
// First pass: Group messages by thread
for (let message of messages) {
if (message.threadId != null) {
let group = threadGroups[message.threadId] ||
(threadGroups[message.threadId] = []);
group.push(message);
}
}
// Second pass: Build the display list with correct ordering
for (let message of messages) {
// Non-threaded messages and notes go directly to the entry list
if (message.threadId == null || message.type == 'note') {
entries.push(message);
} else {
let threadId = message.threadId;
let group = threadGroups[threadId];
// If the thread is expanded, add messages as they appear
if (expandedThreads.value[threadId]) {
// Add the thread header before the first message
if (message.id == group[0].id) {
addThreadHeader(threadId, group);
}
entries.push(message);
} else {
// Add collapsed thread header in place of the last message
if (message.id == group[group.length-1].id) {
addThreadHeader(threadId, group);
}
}
}
}
// Add current thread mannually if it has no messages
const current = currentThreadId.value;
if (current != null && threadGroups[current] == null) {
addThreadHeader(current, []);
}
return entries;
}
function expandThread (threadId, value) {
expandedThreads.value[threadId] = value;
requestRescroll();
}
async function startNewThread () {
const res = await backend.sendAction("create-chat-thread", {chatId});
console.log("Starting a new thread", res);
if (res.threadId != null) {
currentThreadId.value = res.threadId;
expandThread(res.threadId, true);
}
}
// === UI Helper Functions === // // === UI Helper Functions === //
@@ -176,11 +273,13 @@
}); });
} }
function msgClass (msg) { function chatEntryClass (msg) {
if (msg.sender == 'user') { if (msg.type == 'note') {
return ['user'] return ['note'];
} else if (msg.type == 'message') {
return ['message', msg.sender == 'user' ? 'sent' : 'received'];
} else { } else {
return []; return ['info'];
} }
} }
@@ -198,25 +297,42 @@
<h1>{{ chat.name || 'Chat #' + chat.id }}</h1> <h1>{{ chat.name || 'Chat #' + chat.id }}</h1>
<div class="messages-container" ref="chatContainerElem"> <div class="messages-container" ref="chatContainerElem">
<div v-for="msg in messages" class="message" :class="msgClass(msg)"> <div v-for="msg in chatEntries" :class="chatEntryClass(msg)">
<div v-if="msg.type == 'message'" class="sender">{{msg.sender}}</div> <template v-if="msg.type == 'message' || msg.type == null">
<div v-if="msg.sender != 'user'" class="sender">{{msg.sender}}</div>
<div class="content">{{msg.content}}</div>
</template>
<template v-if="msg.type == 'note'">
<div class="sender">{{msg.sender}}</div>
<div class="content">{{msg.content}}</div>
</template>
<template v-if="msg.type == 'thread-header'">
<div class="content">
Thread {{msg.threadId}} ({{msg.messageCount}} messages)
<a hred="#" @click.prevent="expandThread(msg.threadId, !msg.expanded)">{{msg.expanded ? "Hide" : "Show"}}</a>
</div>
</template>
<!--message v-if="!msg.collapse" :value="msg.content" :format="msg.format"></message--> <!--message v-if="!msg.collapse" :value="msg.content" :format="msg.format"></message-->
<div v-if="!msg.collapse" class="content">{{msg.content}}</div>
<div v-if="msg.collapse">
<a href="#" @click.prevent="msg.collapse = false">Show</a>
</div>
<div v-if="msg.collapse === false">
<a href="#" @click.prevent="msg.collapse = true">Hide</a>
</div>
</div> </div>
</div> </div>
<div class="input-container"> <div class="input-container">
<div v-if="noteMode" style="font-size: 75%; margin-bottom: 0.7em;">
Editing Note
</div>
<div class="input-field" ref="inputFieldElem" @keydown="inputKeyDown($event)" contenteditable></div> <div class="input-field" ref="inputFieldElem" @keydown="inputKeyDown($event)" contenteditable></div>
<div class="input-row"> <div class="input-row">
<button @click="startNewThread()">
New Thread
</button>
<button @click="noteMode=!noteMode">
{{noteMode ? 'Cancel Note' : 'Make Note'}}
</button>
<button id="send-button" @click="sendMessage()"> <button id="send-button" @click="sendMessage()">
Send Send
<span style="font-size: 66%;">(Ctrl+Enter)</span> <span style="font-size: 66%;">(Ctrl+Enter)</span>
@@ -236,28 +352,54 @@
flex: 1; flex: 1;
overflow-y: scroll; overflow-y: scroll;
padding: 20px; padding: 20px;
display: flex;
flex-direction: column;
}
.messages-container > div {
margin-bottom: 10px;
}
.info {
align-self: center;
} }
.message { .message {
margin-bottom: 10px; width: fit-content;
max-width: 50em;
padding: 10px; padding: 10px;
border-radius: 8px; border-radius: 8px;
background-color: var(--color-background-soft); background-color: var(--color-background-soft);
} }
.message .content { .note {
white-space: preserve width: fit-content;
padding: 10px;
background-color: var(--color-background-soft);
border: 1px solid var(--color-border);
} }
.message .sender { .message.sent {
align-self: end;
margin-left: 2em;
}
.message.received {
margin-right: 2em;
}
.message .content {
white-space: preserve;
width: fit-content;
}
.message .sender,
.note .sender {
font-weight: bold; font-weight: bold;
font-size: 100%; font-size: 100%;
margin-top: -5px; /* half message padding */
} }
.message.user .content {
right: 100%;
width: auto;
}
.input-container { .input-container {
padding: 10px; padding: 10px;
background-color: var(--color-background-soft); background-color: var(--color-background-soft);