mirror of
https://github.com/aljazceru/meshcore-web.git
synced 2025-12-17 16:24:18 +01:00
implement database persistence for messages
This commit is contained in:
2237
package-lock.json
generated
2237
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
262
src/components/messages/MessageViewer.vue
Normal file
262
src/components/messages/MessageViewer.vue
Normal 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>
|
||||
51
src/components/pages/ContactMessagesPage.vue
Normal file
51
src/components/pages/ContactMessagesPage.vue
Normal 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>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
220
src/js/Database.js
Normal 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
9
src/js/DeviceUtils.js
Normal 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
26
src/js/MessageUtils.js
Normal 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;
|
||||
@@ -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
28
src/js/Utils.js
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user