implement database persistence for messages

This commit is contained in:
liamcottle
2025-02-13 12:50:47 +13:00
parent 28c2c1fd47
commit 7cc56ccc69
13 changed files with 3024 additions and 18 deletions

2237
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,10 @@
"click-outside-vue3": "^4.0.1",
"moment": "^2.30.1",
"postcss": "^8.5.1",
"rxdb": "^16.6.1",
"rxjs": "^7.8.1",
"tailwindcss": "^3.4.17",
"uuid": "^11.0.5",
"vite": "^6.1.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"

View File

@@ -31,7 +31,15 @@
</div>
</div>
<!-- node dropdown menu -->
<!-- unread messages count -->
<div v-if="unreadMessagesCount > 0" class="my-auto">
<div class="inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 rounded-full shadow">
<span v-if="unreadMessagesCount >= 100">99</span>
<span>{{ unreadMessagesCount }}</span>
</div>
</div>
<!-- contact dropdown menu -->
<div class="my-auto">
<ContactDropDownMenu :contact="contact"/>
</div>
@@ -44,6 +52,7 @@ import GlobalState from "../../js/GlobalState.js";
import IconButton from "../IconButton.vue";
import TimeUtils from "../../js/TimeUtils.js";
import ContactDropDownMenu from "./ContactDropDownMenu.vue";
import Database from "../../js/Database.js";
export default {
name: 'ContactListItem',
@@ -54,7 +63,42 @@ export default {
props: {
contact: Object,
},
data() {
return {
unreadMessagesCount: 0,
contactMessagesSubscription: null,
contactMessagesReadStateSubscription: null,
};
},
mounted() {
// listen for new messages so we can update read state
this.contactMessagesSubscription = Database.Message.getAllMessages().$.subscribe(async () => {
await this.onMessagesUpdated();
});
// listen for read state changes
this.contactMessagesReadStateSubscription = Database.ContactMessagesReadState.get(this.contact.publicKey).$.subscribe(async (contactMessagesReadState) => {
await this.onContactMessagesReadStateChange(contactMessagesReadState);
});
},
unmounted() {
this.contactMessagesSubscription?.unsubscribe();
this.contactMessagesReadStateSubscription?.unsubscribe();
},
methods: {
async onMessagesUpdated() {
const contactMessagesReadState = await Database.ContactMessagesReadState.get(this.contact.publicKey).exec();
await this.onContactMessagesReadStateChange(contactMessagesReadState);
},
async updateUnreadMessagesCount(lastReadTimestamp) {
this.unreadMessagesCount = await Database.Message.getContactMessagesUnreadCount(this.contact.publicKey, lastReadTimestamp).exec();
},
async onContactMessagesReadStateChange(contactMessagesReadState) {
const messagesLastReadTimestamp = contactMessagesReadState?.timestamp ?? 0;
await this.updateUnreadMessagesCount(messagesLastReadTimestamp);
},
formatUnixSecondsAgo(unixSeconds) {
return TimeUtils.formatUnixSecondsAgo(unixSeconds);
},

View File

@@ -0,0 +1,262 @@
<template>
<div class="flex flex-col w-full h-full">
<!-- messages -->
<div @scroll="onMessagesScroll" id="messages" class="h-full overflow-y-auto bg-gray-50">
<div v-if="messagesReversed.length > 0" class="flex flex-col-reverse p-3">
<div v-for="message of messagesReversed" :key="message.id" class="flex max-w-xl mt-3" :class="{ 'ml-auto pl-4 md:pl-16': isMessageOutbound(message), 'mr-auto pr-4 md:pr-16': isMessageInbound(message) }">
<div class="flex flex-col" :class="{ 'items-end': isMessageOutbound(message), 'items-start': isMessageInbound(message) }">
<!-- message content -->
<div class="flex">
<div class="border border-gray-300 rounded-xl shadow overflow-hidden" :class="[ isMessageFailed(message) ? 'bg-red-500 text-white' : isMessageOutbound(message) ? 'bg-[#3b82f6] text-white' : 'bg-[#efefef]' ]">
<div class="w-full space-y-0.5 px-2.5 py-1">
<!-- content -->
<div v-if="message.text" style="white-space:pre-wrap;word-break:break-word;font-family:inherit;">{{ message.text }}</div>
</div>
</div>
</div>
<!-- timestamp -->
<div v-if="isMessageInbound(message)" class="text-xs text-gray-500">
<span>{{ formatMessageTimestamp(message.timestamp) }}</span>
</div>
<!-- message state -->
<div v-if="isMessageOutbound(message)" class="flex text-right" :class="[ isMessageFailed(message) ? 'text-red-500' : 'text-gray-500' ]">
<div class="flex ml-auto space-x-1">
<!-- state label -->
<div class="my-auto">
<span v-if="isMessageFailed(message)">Failed</span>
<span v-else-if="isMessageDelivered(message)">Delivered</span>
<span v-else>Sending</span>
</div>
<!-- delivered icon -->
<div v-if="isMessageDelivered(message)" class="my-auto">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
</svg>
</div>
<!-- failed icon -->
<div v-else-if="isMessageFailed(message)" class="my-auto">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
</svg>
</div>
<!-- fallback icon -->
<div v-else class="my-auto">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- message composer -->
<div class="flex bg-gray-100 p-2 border-t space-x-2">
<!-- text input -->
<textarea
:readonly="isSendingMessage"
v-model="newMessageText"
@keydown.enter.exact.native="onEnterPressed"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
rows="3"
placeholder="Send a message..."></textarea>
<!-- send button -->
<div class="inline-flex rounded-md shadow-sm">
<button @click="sendMessage" :disabled="!canSendMessage" type="button" class="h-full my-auto inline-flex items-center rounded-md px-2.5 py-1.5 text-sm font-semibold text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" :class="[ canSendMessage ? 'bg-blue-500 hover:bg-blue-400 focus-visible:outline-blue-500' : 'bg-gray-400 focus-visible:outline-gray-500 cursor-not-allowed']">
Send
</button>
</div>
</div>
</div>
</template>
<script>
import Database from "../../js/Database.js";
import GlobalState from "../../js/GlobalState.js";
import Connection from "../../js/Connection.js";
import MessageUtils from "../../js/MessageUtils.js";
import DeviceUtils from "../../js/DeviceUtils.js";
import TimeUtils from "../../js/TimeUtils.js";
export default {
name: 'MessageViewer',
props: {
contact: Object,
},
data() {
return {
messages: [],
messagesSubscription: null,
newMessageText: "",
isSendingMessage: false,
autoScrollOnNewMessage: true,
};
},
mounted() {
// init database subscription for messages
this.messagesSubscription = Database.Message.getContactMessages(this.contact.publicKey).$.subscribe(this.onMessagesUpdated);
// update read state
this.updateMessagesLastReadAt();
},
unmounted() {
this.messagesSubscription?.unsubscribe();
},
methods: {
async sendMessage() {
// can't send if not connected
if(!GlobalState.connection){
alert("Not connected to device");
return false;
}
// do nothing if message is empty
const newMessageText = this.newMessageText;
if(newMessageText == null || newMessageText === ""){
return;
}
// todo validate message max length
// todo rate limit to 1 message per second, otherwise duplicate messages in same second have same packet hash and won't send/ack
// show loading
this.isSendingMessage = true;
try {
// send message
await Connection.sendMessage(this.contact.publicKey, newMessageText);
// clear new message input
this.newMessageText = "";
} catch(e) {
console.log(e);
alert("failed to send message");
}
// hide loading
this.isSendingMessage = false;
},
isMessageInbound: (message) => MessageUtils.isMessageInbound(message),
isMessageOutbound: (message) => MessageUtils.isMessageOutbound(message),
isMessageDelivered: (message) => MessageUtils.isMessageDelivered(message),
isMessageFailed: (message) => MessageUtils.isMessageFailed(message),
onMessagesUpdated(messages) {
// update messages in ui
this.messages = messages.map((message) => message.toJSON());
// check if we should auto scroll on new message
if(this.autoScrollOnNewMessage){
// auto scroll to bottom if we want to
this.scrollMessagesToBottom();
// update read state since we auto scrolled to bottom of new messages
this.updateMessagesLastReadAt();
}
},
onEnterPressed: function(event) {
// send message if not on mobile
if(!DeviceUtils.isMobile()){
event.preventDefault();
this.sendMessage();
return;
}
},
onMessagesScroll(event) {
// check if messages is scrolled to bottom
const element = event.target;
const isAtBottom = element.scrollTop === (element.scrollHeight - element.offsetHeight);
// we want to auto scroll if user is at bottom of messages list
this.autoScrollOnNewMessage = isAtBottom;
// update read state since we scrolled to bottom
if(isAtBottom){
this.updateMessagesLastReadAt();
}
},
scrollMessagesToBottom: function() {
this.$nextTick(() => {
var container = this.$el.querySelector("#messages");
container.scrollTop = container.scrollHeight;
});
},
updateMessagesLastReadAt() {
// update last read at for contact messages
Database.ContactMessagesReadState.touch(this.contact.publicKey);
},
formatMessageTimestamp(timestamp) {
return TimeUtils.formatMessageTimestamp(timestamp);
},
},
computed: {
canSendMessage() {
// can't send if contact is not selected
if(this.contact == null){
return false;
}
// can't send if empty message
const messageText = this.newMessageText.trim();
if(messageText == null || messageText === ""){
return false;
}
// can't send if already sending
if(this.isSendingMessage){
return false;
}
return true;
},
messagesReversed() {
// ensure a copy of the array is returned in reverse order
return this.messages.map((message) => message).reverse();
},
},
}
</script>

View File

@@ -0,0 +1,51 @@
<template>
<Page>
<!-- app bar -->
<AppBar title="Direct Messages" :subtitle="subtitle"/>
<!-- list -->
<div class="flex h-full w-full overflow-hidden">
<MessageViewer v-if="contact != null" :contact="contact"/>
</div>
</Page>
</template>
<script>
import Page from "./Page.vue";
import AppBar from "../AppBar.vue";
import MessageViewer from "../messages/MessageViewer.vue";
import GlobalState from "../../js/GlobalState.js";
import Utils from "../../js/Utils.js";
export default {
name: 'ContactMessagesPage',
components: {MessageViewer, AppBar, Page},
props: {
publicKey: String,
},
mounted() {
// redirect to main page if contact not found
if(!this.contact){
this.$router.push({
name: "main",
});
return;
}
},
computed: {
GlobalState() {
return GlobalState;
},
contact() {
return GlobalState.contacts.find((contact) => Utils.bytesToHex(contact.publicKey) === this.publicKey);
},
subtitle() {
return this.contact ? this.contact.advName : "Unknown Contact";
},
},
}
</script>

View File

@@ -23,7 +23,7 @@ import Page from "./Page.vue";
import GlobalState from "../../js/GlobalState.js";
import ConnectButtons from "../connect/ConnectButtons.vue";
import ContactsList from "../contacts/ContactsList.vue";
import Connection from "../../js/Connection.js";
import Utils from "../../js/Utils.js";
export default {
name: 'MainPage',
@@ -35,16 +35,12 @@ export default {
},
methods: {
async onContactClick(contact) {
// ask user for message
const message = prompt("Enter message to send");
if(!message){
return;
}
// send message
await Connection.sendMessage(contact.publicKey, message);
this.$router.push({
name: "contact.messages",
params: {
publicKey: Utils.bytesToHex(contact.publicKey),
},
});
},
},
computed: {

View File

@@ -1,5 +1,7 @@
import GlobalState from "./GlobalState.js";
import {BleConnection, Constants, SerialConnection} from "@liamcottle/meshcore.js";
import Database from "./Database.js";
import Utils from "./Utils.js";
class Connection {
@@ -29,22 +31,66 @@ class Connection {
static async onConnected() {
// weird way to allow us to lock all other callbacks from doing anything, until the database is ready...
// maybe we should use some sort of lock or mutex etc.
// basically, we need to wait for SelfInfo to be fetched before we can init the database.
// we use this to create a new database instance that is unique based on the devices public key.
// initDatabase is async, which means all the other callbacks could fire before the database is ready
// this means when we try to access the database when it isn't ready yet, we get fun errors...
// so we need to force the callbacks to wait until the database is ready
// we will just resolve this promise when the database is ready, and all the callbacks should be set to await it
var onDatabaseReady = null;
const databaseToBeReady = new Promise((resolve) => {
onDatabaseReady = resolve;
});
// clear previous connection state
GlobalState.contacts = [];
// listen for self info, and then init database
GlobalState.connection.once(Constants.ResponseCodes.SelfInfo, async (selfInfo) => {
await Database.initDatabase(Utils.bytesToHex(selfInfo.publicKey));
onDatabaseReady();
});
// listen for adverts
GlobalState.connection.on(Constants.PushCodes.Advert, async () => {
console.log("Advert");
await databaseToBeReady;
await this.loadContacts();
});
// listen for path updates
GlobalState.connection.on(Constants.PushCodes.PathUpdated, async (event) => {
console.log("PathUpdated", event);
await databaseToBeReady;
await this.loadContacts();
});
// listen for new message available event
GlobalState.connection.on(Constants.PushCodes.MsgWaiting, async () => {
console.log("MsgWaiting");
await databaseToBeReady;
await this.syncMessages();
});
// listen for message send confirmed events
GlobalState.connection.on(Constants.PushCodes.SendConfirmed, async (event) => {
console.log("SendConfirmed", event);
await databaseToBeReady;
await Database.Message.setMessageDeliveredByAckCode(event.ackCode);
});
// initial setup without needing database
await this.loadSelfInfo();
await this.syncDeviceTime();
// wait for database to be ready
await databaseToBeReady;
// sync messages
await this.loadContacts();
await this.syncMessages();
}
@@ -82,9 +128,75 @@ class Connection {
await GlobalState.connection.sendCommandRemoveContact(publicKey);
}
static async sendMessage(publicKey, message) {
await GlobalState.connection.sendTextMessage(publicKey, message);
// todo handle acks
static async sendMessage(publicKey, text) {
// send message
const message = await GlobalState.connection.sendTextMessage(publicKey, text);
// save to database
return await Database.Message.insert({
status: "sending",
to: publicKey,
from: GlobalState.selfInfo.publicKey,
path_len: null,
txt_type: Constants.TxtTypes.Plain,
sender_timestamp: Date.now(),
text: text,
timestamp: Date.now(),
expected_ack_crc: message.expectedAckCrc,
error: null,
});
}
static async syncMessages() {
while(true){
// sync messages until no more returned
const message = await GlobalState.connection.syncNextMessage();
if(!message){
break;
}
// handle received message
await this.onMessageReceived(message);
}
}
static async onMessageReceived(message) {
console.log("onMessageReceived", message);
// find first contact that matches this public key prefix
// todo, maybe use the most recently updated contact in case of collision? ideally we should be given the full hash by firmware anyway...
const contact = GlobalState.contacts.find((contact) => {
const messagePublicKeyPrefix = message.pubKeyPrefix;
const contactPublicKeyPrefix = contact.publicKey.slice(0, message.pubKeyPrefix.length);
return Utils.isUint8ArrayEqual(messagePublicKeyPrefix, contactPublicKeyPrefix);
});
// ensure contact exists
// shouldn't be possible to receive a message if firmware doesn't have the contact, since keys will be missing for decryption
// however, it could be possible that the contact doesn't exist in javascript memory when the message is received
if(!contact){
console.log("couldn't find contact, received message has been dropped");
return;
}
await Database.Message.insert({
status: "received",
to: GlobalState.selfInfo.publicKey,
from: contact.publicKey,
path_len: message.pathLen,
txt_type: message.txtType,
sender_timestamp: message.senderTimestamp,
text: message.text,
timestamp: Date.now(),
expected_ack_crc: null,
error: null,
});
}
}

220
src/js/Database.js Normal file
View File

@@ -0,0 +1,220 @@
import {v4} from 'uuid';
import {createRxDatabase} from 'rxdb/plugins/core';
import {getRxStorageDexie} from 'rxdb/plugins/storage-dexie';
import GlobalState from "./GlobalState.js";
import Utils from "./Utils.js";
var database = null;
async function initDatabase(publicKeyHex) {
// close any exsiting database connection
if(database){
await database.destroy();
}
// create a database with a unique name per identity
database = await createRxDatabase({
name: `meshcore_companion_db_${publicKeyHex}`,
storage: getRxStorageDexie(),
allowSlowCount: true,
});
// add database schemas
await database.addCollections({
messages: {
schema: {
version: 0,
primaryKey: 'id',
type: 'object',
properties: {
id: {
type: 'string',
maxLength: 36,
},
status: {
type: 'string',
},
to: {
type: 'string',
},
from: {
type: 'string',
},
path_len: {
type: 'integer',
},
txt_type: {
type: 'integer',
},
sender_timestamp: {
type: 'integer',
},
text: {
type: 'string',
},
timestamp: {
type: 'integer',
},
expected_ack_crc: {
type: 'integer',
},
error: {
type: 'string',
},
},
}
},
contact_messages_read_state: {
schema: {
version: 0,
primaryKey: 'id',
type: 'object',
properties: {
id: {
type: 'string',
maxLength: 36,
},
timestamp: {
type: 'integer',
},
},
}
},
});
}
class Message {
// insert a message into the database
static async insert(data) {
return await database.messages.insert({
id: v4(),
status: data.status,
to: Utils.bytesToHex(data.to),
from: Utils.bytesToHex(data.from),
path_len: data.pathLen,
txt_type: data.txtType,
sender_timestamp: data.sender_timestamp,
text: data.text,
timestamp: Date.now(),
expected_ack_crc: data.expected_ack_crc,
error: null,
});
}
// mark a message as delivered by its ack code
static async setMessageDeliveredByAckCode(ackCode) {
// find one latest message by ack code
// this will prevent updating older messages that might have the same ack code
const latestMessageByPacketId = database.messages.findOne({
selector: {
expected_ack_crc: {
$eq: ackCode,
},
},
sort: [
{
timestamp: "desc",
},
],
});
// patch the message state
return await latestMessageByPacketId.incrementalPatch({
status: "delivered",
});
}
// get all messages
static getAllMessages() {
return database.messages.find();
}
// get direct messages for the provided public key
static getContactMessages(publicKey) {
return database.messages.find({
selector: {
$or: [
// messages from us to other contact
{
from: {
$eq: Utils.bytesToHex(GlobalState.selfInfo.publicKey),
},
to: {
$eq: Utils.bytesToHex(publicKey),
},
},
// messages from other contact to us
{
from: {
$eq: Utils.bytesToHex(publicKey),
},
to: {
$eq: Utils.bytesToHex(GlobalState.selfInfo.publicKey),
},
},
]
},
sort: [
{
timestamp: "asc",
},
],
});
}
// get unread direct messages count for the provided public key
static getContactMessagesUnreadCount(publicKey, messagesLastReadTimestamp) {
return database.messages.count({
selector: {
timestamp: {
$gt: messagesLastReadTimestamp,
},
from: {
$eq: Utils.bytesToHex(publicKey),
},
to: {
$eq: Utils.bytesToHex(GlobalState.selfInfo.publicKey),
},
},
});
}
// delete direct messages for the provided public key
static async deleteContactMessages(publicKey) {
await this.getContactMessages(publicKey).remove();
}
}
class ContactMessagesReadState {
// update the read state of messages for the provided public key
static async touch(publicKey) {
return await database.contact_messages_read_state.upsert({
id: Utils.bytesToHex(publicKey),
timestamp: Date.now(),
});
}
// get the read state of messages for the provided public key
static get(publicKey) {
return database.contact_messages_read_state.findOne({
selector: {
id: {
$eq: Utils.bytesToHex(publicKey),
},
},
});
}
}
export default {
initDatabase,
Message,
ContactMessagesReadState,
};

9
src/js/DeviceUtils.js Normal file
View File

@@ -0,0 +1,9 @@
class DeviceUtils {
static isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
}
export default DeviceUtils;

26
src/js/MessageUtils.js Normal file
View File

@@ -0,0 +1,26 @@
import GlobalState from "./GlobalState.js";
import Utils from "./Utils.js";
class MessageUtils {
static isMessageInbound(message) {
// inbound messages are not from us
return message.from !== Utils.bytesToHex(GlobalState.selfInfo.publicKey);
}
static isMessageOutbound(message) {
// outbound messages are from us
return message.from === Utils.bytesToHex(GlobalState.selfInfo.publicKey);
}
static isMessageDelivered(message) {
return message.status === "delivered";
}
static isMessageFailed(message) {
return message.error != null;
}
}
export default MessageUtils;

View File

@@ -13,6 +13,24 @@ class TimeUtils {
}
static formatMessageTimestamp(unixMilliseconds) {
// convert millis to moment date
const date = moment.unix(unixMilliseconds / 1000);
// check if message date is same as today
const isSameDate = date.isSame(moment(), 'day');
// short format for messages from today
if(isSameDate){
return date.format("hh:mm A");
}
// long format for all other messages
return date.format("DD/MM/YYYY hh:mm A");
}
static getTimeAgoShortHand(date) {
// get duration between now and provided date

28
src/js/Utils.js Normal file
View File

@@ -0,0 +1,28 @@
class Utils {
static bytesToHex(uint8Array) {
return Array.from(uint8Array).map(byte => byte.toString(16).padStart(2, '0')).join('');
}
static isUint8ArrayEqual(a, b) {
// ensure they are the same length
if(a.length !== b.length){
return false;
}
// ensure each item is the same
for(let i = 0; i < a.length; i++){
if(a[i] !== b[i]){
return false;
}
}
// arrays are equal
return true;
}
}
export default Utils;

View File

@@ -18,6 +18,12 @@ const router = createRouter({
path: '/connect',
component: () => import("./components/pages/ConnectPage.vue"),
},
{
name: "contact.messages",
path: '/contacts/:publicKey/messages',
props: true,
component: () => import("./components/pages/ContactMessagesPage.vue"),
},
{
name: "settings.radio",
path: '/settings/radio',