Chat: threads, notes, better styles
This commit is contained in:
1 parent
0f7384cc1d
commit
bace4b7136
1 file changed
+180
-38
+180
-38
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user