Chat message edit, feature parity with flutter interface
This commit is contained in:
1 parent
569242b459
commit
0eab9ac111
5 files changed
+242
-13
No files matched your search
@@ -39,6 +39,7 @@ class ChatMixin:
|
||||
with self.sql_session() as session:
|
||||
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))
|
||||
# TODO: Delete messages from the chat
|
||||
session.commit()
|
||||
print(f"Deleted chat #{chat_id} and its memberships.")
|
||||
|
||||
@@ -103,7 +104,6 @@ class ChatMixin:
|
||||
session.refresh(message)
|
||||
|
||||
# Notify chat owner
|
||||
if sender != 'user':
|
||||
chat_owner = session.execute(select(db.Chat.user_id).where(db.Chat.id == chat_id)).scalar()
|
||||
self.notify_message_to_user(chat_owner, message)
|
||||
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ class Message(Base):
|
||||
|
||||
# Identifier of the sender: user / system / user-$ID / agent-$ID
|
||||
sender: Mapped[str]
|
||||
# Timestamp of this message
|
||||
# Timestamp when this message was sent
|
||||
time: Mapped[int]
|
||||
# Timestamp of this message's last edit
|
||||
#last_edit: Mapped[int]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
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 Alerts from './components/main/Alerts.vue'
|
||||
import ContextMenu from './components/main/ContextMenu.vue'
|
||||
|
||||
import * as ui from './ui';
|
||||
import * as backend from './backend';
|
||||
@@ -47,6 +47,7 @@ backend.authResult().then(handleAuthResult);
|
||||
|
||||
<template>
|
||||
<Alerts />
|
||||
<ContextMenu />
|
||||
|
||||
<div class="container" v-if="loginState=='loading'">
|
||||
<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
|
||||
const chat = ref({});
|
||||
const messages = ref([]);
|
||||
const unsentMessages = ref([]);
|
||||
const currentThreadId = ref(null);
|
||||
const chatEntries = computed(() => {
|
||||
return computeChatEntries(messages.value);
|
||||
return computeChatEntries(messages.value.concat(unsentMessages.value));
|
||||
});
|
||||
|
||||
// UI State
|
||||
@@ -30,6 +31,20 @@
|
||||
const editingMessageId = ref(null);
|
||||
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 unsubscribeChat = null;
|
||||
|
||||
@@ -59,7 +74,19 @@
|
||||
});
|
||||
|
||||
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({
|
||||
id: it.id,
|
||||
@@ -133,11 +160,14 @@
|
||||
const inputFieldElem = ref(null);
|
||||
const chatContainerElem = ref(null);
|
||||
|
||||
function addMessage (message) {
|
||||
function addMessage (message, options={}) {
|
||||
requestRescroll();
|
||||
message.id = _messageNonce++;
|
||||
if (options.unsent) {
|
||||
unsentMessages.value.push(message);
|
||||
} else {
|
||||
messages.value.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage () {
|
||||
let elem = inputFieldElem.value;
|
||||
@@ -167,9 +197,9 @@
|
||||
content,
|
||||
threadId,
|
||||
};
|
||||
addMessage(newMessage);
|
||||
addMessage(newMessage, {unsent: true});
|
||||
} else {
|
||||
const messageRef = messages.firstWhere((element) => element.id == messageId);
|
||||
const messageRef = messages.value.find((it) => it.id == messageId);
|
||||
|
||||
backend.sendAction("update-chat-message", {
|
||||
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 === //
|
||||
|
||||
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}={}) {
|
||||
const elem = chatContainerElem.value;
|
||||
|
||||
@@ -319,7 +391,7 @@
|
||||
<h1>{{ chat.name || 'Chat #' + chat.id }}</h1>
|
||||
|
||||
<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">
|
||||
<div v-if="msg.sender != 'user'" class="sender">{{msg.sender}}</div>
|
||||
<div class="content">{{msg.content}}</div>
|
||||
@@ -342,8 +414,8 @@
|
||||
</div>
|
||||
|
||||
<div class="input-container">
|
||||
<div v-if="noteMode" style="font-size: 75%; margin-bottom: 0.7em;">
|
||||
Editing Note
|
||||
<div v-if="editInfo" style="font-size: 75%; margin-bottom: 0.7em;">
|
||||
{{editInfo}}
|
||||
</div>
|
||||
|
||||
<div class="input-field" ref="inputFieldElem" @keydown="inputKeyDown($event)" contenteditable></div>
|
||||
@@ -355,6 +427,9 @@
|
||||
<button @click="noteMode=!noteMode">
|
||||
{{noteMode ? 'Cancel Note' : 'Make Note'}}
|
||||
</button>
|
||||
<button v-if="editingMessageId != null" @click="editMessage(null)">
|
||||
Cancel Edit
|
||||
</button>
|
||||
<button id="send-button" @click="sendMessage()">
|
||||
Send
|
||||
<span style="font-size: 66%;">(Ctrl+Enter)</span>
|
||||
|
||||
Reference in new issue
Block a user