Chat message edit, feature parity with flutter interface

This commit is contained in:
Shivering Ocelot committed 2025-07-14 16:11:17 -05:00
1 parent 569242b459
commit 0eab9ac111
5 files changed
+245 -16

No files matched your search

+3 -3
View File
@@ -39,6 +39,7 @@ class ChatMixin:
with self.sql_session() as session: with self.sql_session() as session:
session.execute(db.Membership.__table__.delete().where(db.Membership.chat_id == chat_id)) session.execute(db.Membership.__table__.delete().where(db.Membership.chat_id == chat_id))
session.execute(db.Chat.__table__.delete().where(db.Chat.id == chat_id)) session.execute(db.Chat.__table__.delete().where(db.Chat.id == chat_id))
# TODO: Delete messages from the chat
session.commit() session.commit()
print(f"Deleted chat #{chat_id} and its memberships.") print(f"Deleted chat #{chat_id} and its memberships.")
@@ -103,9 +104,8 @@ class ChatMixin:
session.refresh(message) session.refresh(message)
# Notify chat owner # Notify chat owner
if sender != 'user': chat_owner = session.execute(select(db.Chat.user_id).where(db.Chat.id == chat_id)).scalar()
chat_owner = session.execute(select(db.Chat.user_id).where(db.Chat.id == chat_id)).scalar() self.notify_message_to_user(chat_owner, message)
self.notify_message_to_user(chat_owner, message)
# Notify participant agents # Notify participant agents
agent_ids = session.execute(select(db.Membership.agent_id).where(db.Membership.chat_id == chat_id)).scalars().all() agent_ids = session.execute(select(db.Membership.agent_id).where(db.Membership.chat_id == chat_id)).scalars().all()
+1 -1
View File
@@ -82,7 +82,7 @@ class Message(Base):
# Identifier of the sender: user / system / user-$ID / agent-$ID # Identifier of the sender: user / system / user-$ID / agent-$ID
sender: Mapped[str] sender: Mapped[str]
# Timestamp of this message # Timestamp when this message was sent
time: Mapped[int] time: Mapped[int]
# Timestamp of this message's last edit # Timestamp of this message's last edit
#last_edit: Mapped[int] #last_edit: Mapped[int]
+3 -2
View File
@@ -1,9 +1,9 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { RouterLink, RouterView } from 'vue-router' import { RouterLink, RouterView } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
import Alerts from './components/main/Alerts.vue'
import Sidebar from './components/main/Sidebar.vue' import Sidebar from './components/main/Sidebar.vue'
import Alerts from './components/main/Alerts.vue'
import ContextMenu from './components/main/ContextMenu.vue'
import * as ui from './ui'; import * as ui from './ui';
import * as backend from './backend'; import * as backend from './backend';
@@ -47,6 +47,7 @@ backend.authResult().then(handleAuthResult);
<template> <template>
<Alerts /> <Alerts />
<ContextMenu />
<div class="container" v-if="loginState=='loading'"> <div class="container" v-if="loginState=='loading'">
<div class="content"> <div class="content">
@@ -0,0 +1,153 @@
<!--
How to use: import ui, then call like this:
let selectedValue = await ui.contextMenu([
{value: 'this-entry-value', icon: 'fa-symbol', title: "Name displayed"},
{title: "Entry with children (no value)", entries: [
{value: 'inner-entry', title: "Entry inside the group"}
]},
]);
And to trigger it from a document interaction, add this attribute:
<div @contextmenu.prevent="customContextMenu()"></div>
-->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import * as ui from '../../ui';
const entries = ref(null);
const menuElem = ref(null);
const menuStyle = ref({});
let callback = null;
const mousePos = {x: null, y: null};
const screenSize = {x: null, y: null};
function returnValue (value) {
entries.value = null;
// Send result back in two frames
requestAnimationFrame(() => {
requestAnimationFrame(() => {
callback(value);
});
});
}
function onMouseMove (event) {
mousePos.x = event.clientX;
mousePos.y = event.clientY;
}
function onMouseDown (event) {
// missfire
if (!menuElem.value) return;
if (!menuElem.value.contains(event.target)) {
returnValue(null);
}
}
ui.listen('context-menu', ev => {
if (!ev.entries || ev.entries.length == 0) {
ev.callback(null);
return;
}
entries.value = ev.entries;
callback = ev.callback;
screenSize.x = window.innerWidth;
screenSize.y = window.innerHeight;
computeStyle();
});
onMounted(() => {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mousedown', onMouseDown);
});
onUnmounted(() => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mousedown', onMouseDown);
});
function selectEntry (entry) {
if (entry.value != null) {
returnValue(entry.value);
}
if (entry.entries) {
let newEntries = Array.from(entry.entries);
if (!entry.backButton) {
newEntries.unshift({
title: 'Back',
icon: 'fa-caret-left',
entries: entries.value,
backButton: true,
});
}
entries.value = newEntries;
}
}
function computeStyle () {
let w = screenSize.x;
let h = screenSize.y;
let style = {};
if (mousePos.x < w/2) {
style.left = mousePos.x + 'px';
} else {
style.right = w-mousePos.x + 'px';
}
if (mousePos.y < h/2) {
style.top = mousePos.y + 'px';
} else {
style.bottom = h-mousePos.y + 'px';
}
menuStyle.value = style;
}
</script>
<template>
<div v-if="entries" ref="menuElem" class="context-menu" :style="menuStyle">
<div v-for="entry in entries" class="entry" @click.prevent="selectEntry(entry)">
<i v-if="typeof entry.icon == 'string' && entry.icon.startsWith('fa-')" :class="['fa-solid', entry.icon]"></i>
{{entry.title}}
<i v-if="entry.entries && !entry.backButton" class="fa-solid fa-angle-right" style="font-size: 70%; float: right; opacity: 50%; line-height: 200%;"></i>
</div>
</div>
</template>
<style scoped>
.context-menu {
position: absolute;
min-width: 10em;
background-color: var(--color-background);
border: 1px solid var(--color-border);
z-index: 500;
}
.entry {
padding: 0.2em 0.4em;
}
.entry:hover {
background-color: var(--color-green-dark);
}
i {
width: 1em;
text-align: center;
margin-right: 0.3em;
}
</style>
+85 -10
View File
@@ -20,9 +20,10 @@
// Chat State // Chat State
const chat = ref({}); const chat = ref({});
const messages = ref([]); const messages = ref([]);
const unsentMessages = ref([]);
const currentThreadId = ref(null); const currentThreadId = ref(null);
const chatEntries = computed(() => { const chatEntries = computed(() => {
return computeChatEntries(messages.value); return computeChatEntries(messages.value.concat(unsentMessages.value));
}); });
// UI State // UI State
@@ -30,6 +31,20 @@
const editingMessageId = ref(null); const editingMessageId = ref(null);
const noteMode = ref(false); const noteMode = ref(false);
const editInfo = computed(() => {
if (editingMessageId.value != null) {
if (noteMode.value) {
return `Editing Note ${editingMessageId.value}`;
} else {
return `Editing ${editingMessageId.value}`;
}
} else if (noteMode.value) {
return 'Editing Note';
} else {
return null;
}
});
let _isMounted = false; let _isMounted = false;
let unsubscribeChat = null; let unsubscribeChat = null;
@@ -59,7 +74,19 @@
}); });
unsubscribeChat = backend.onChatMessages(chatId, it => { unsubscribeChat = backend.onChatMessages(chatId, it => {
if (it.sender == 'user') return; if (it.sender == 'user') {
let unsent = unsentMessages.value.shift();
if (unsent) {
if (unsent.content == it.content) {
unsent.id = it.id;
addMessage(unsent);
return;
} else {
// Remove from unsent list and add message as received
ui.alert("Unsent messages received differently");
}
}
}
addMessage({ addMessage({
id: it.id, id: it.id,
@@ -133,10 +160,13 @@
const inputFieldElem = ref(null); const inputFieldElem = ref(null);
const chatContainerElem = ref(null); const chatContainerElem = ref(null);
function addMessage (message) { function addMessage (message, options={}) {
requestRescroll(); requestRescroll();
message.id = _messageNonce++; if (options.unsent) {
messages.value.push(message); unsentMessages.value.push(message);
} else {
messages.value.push(message);
}
} }
async function sendMessage () { async function sendMessage () {
@@ -167,9 +197,9 @@
content, content,
threadId, threadId,
}; };
addMessage(newMessage); addMessage(newMessage, {unsent: true});
} else { } else {
const messageRef = messages.firstWhere((element) => element.id == messageId); const messageRef = messages.value.find((it) => it.id == messageId);
backend.sendAction("update-chat-message", { backend.sendAction("update-chat-message", {
messageId, content, messageId, content,
@@ -270,9 +300,51 @@
} }
} }
function editMessage (message) {
let elem = inputFieldElem.value;
if (message) {
editingMessageId.value = message.id;
elem.innerText = message.content;
} else {
editingMessageId.value = null;
elem.innerText = "";
}
}
// === UI Helper Functions === // // === UI Helper Functions === //
async function chatEntryContextMenu (message) {
let selected = await ui.contextMenu([
{value: 'pin', title: "Pin message"},
{value: 'edit', title: "Edit message"},
{value: 'start-thread', title: "Start thread from here"},
{value: 'delete', icon: 'fa-trash', title: "Delete message"},
]);
switch (selected) {
case 'pin': {
alert("Pin Message (TODO)");
break;
}
case 'edit': {
editMessage(message);
break;
}
case 'start-thread': {
alert("Start Thread (TODO)");
break;
}
case 'delete': {
if (confirm("Are you sure you want to delete this message?")) {
alert("Delete Message (TODO)");
}
break;
}
}
}
function requestRescroll ({forceBottom=false, bottomRelative=false}={}) { function requestRescroll ({forceBottom=false, bottomRelative=false}={}) {
const elem = chatContainerElem.value; const elem = chatContainerElem.value;
@@ -319,7 +391,7 @@
<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 chatEntries" :class="chatEntryClass(msg)"> <div v-for="msg in chatEntries" :class="chatEntryClass(msg)" @contextmenu.prevent="chatEntryContextMenu(msg)">
<template v-if="msg.type == 'message' || msg.type == null"> <template v-if="msg.type == 'message' || msg.type == null">
<div v-if="msg.sender != 'user'" class="sender">{{msg.sender}}</div> <div v-if="msg.sender != 'user'" class="sender">{{msg.sender}}</div>
<div class="content">{{msg.content}}</div> <div class="content">{{msg.content}}</div>
@@ -342,8 +414,8 @@
</div> </div>
<div class="input-container"> <div class="input-container">
<div v-if="noteMode" style="font-size: 75%; margin-bottom: 0.7em;"> <div v-if="editInfo" style="font-size: 75%; margin-bottom: 0.7em;">
Editing Note {{editInfo}}
</div> </div>
<div class="input-field" ref="inputFieldElem" @keydown="inputKeyDown($event)" contenteditable></div> <div class="input-field" ref="inputFieldElem" @keydown="inputKeyDown($event)" contenteditable></div>
@@ -355,6 +427,9 @@
<button @click="noteMode=!noteMode"> <button @click="noteMode=!noteMode">
{{noteMode ? 'Cancel Note' : 'Make Note'}} {{noteMode ? 'Cancel Note' : 'Make Note'}}
</button> </button>
<button v-if="editingMessageId != null" @click="editMessage(null)">
Cancel Edit
</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>