Chat message edit, feature parity with flutter interface
This commit is contained in:
1 parent
569242b459
commit
0eab9ac111
5 files changed
+245
-16
No files matched your search
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user