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",
|
"click-outside-vue3": "^4.0.1",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
|
"rxdb": "^16.6.1",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"uuid": "^11.0.5",
|
||||||
"vite": "^6.1.0",
|
"vite": "^6.1.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.5.0"
|
||||||
|
|||||||
@@ -31,7 +31,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="my-auto">
|
||||||
<ContactDropDownMenu :contact="contact"/>
|
<ContactDropDownMenu :contact="contact"/>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,6 +52,7 @@ import GlobalState from "../../js/GlobalState.js";
|
|||||||
import IconButton from "../IconButton.vue";
|
import IconButton from "../IconButton.vue";
|
||||||
import TimeUtils from "../../js/TimeUtils.js";
|
import TimeUtils from "../../js/TimeUtils.js";
|
||||||
import ContactDropDownMenu from "./ContactDropDownMenu.vue";
|
import ContactDropDownMenu from "./ContactDropDownMenu.vue";
|
||||||
|
import Database from "../../js/Database.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ContactListItem',
|
name: 'ContactListItem',
|
||||||
@@ -54,7 +63,42 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
contact: Object,
|
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: {
|
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) {
|
formatUnixSecondsAgo(unixSeconds) {
|
||||||
return TimeUtils.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 GlobalState from "../../js/GlobalState.js";
|
||||||
import ConnectButtons from "../connect/ConnectButtons.vue";
|
import ConnectButtons from "../connect/ConnectButtons.vue";
|
||||||
import ContactsList from "../contacts/ContactsList.vue";
|
import ContactsList from "../contacts/ContactsList.vue";
|
||||||
import Connection from "../../js/Connection.js";
|
import Utils from "../../js/Utils.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'MainPage',
|
name: 'MainPage',
|
||||||
@@ -35,16 +35,12 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async onContactClick(contact) {
|
async onContactClick(contact) {
|
||||||
|
this.$router.push({
|
||||||
// ask user for message
|
name: "contact.messages",
|
||||||
const message = prompt("Enter message to send");
|
params: {
|
||||||
if(!message){
|
publicKey: Utils.bytesToHex(contact.publicKey),
|
||||||
return;
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
// send message
|
|
||||||
await Connection.sendMessage(contact.publicKey, message);
|
|
||||||
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import GlobalState from "./GlobalState.js";
|
import GlobalState from "./GlobalState.js";
|
||||||
import {BleConnection, Constants, SerialConnection} from "@liamcottle/meshcore.js";
|
import {BleConnection, Constants, SerialConnection} from "@liamcottle/meshcore.js";
|
||||||
|
import Database from "./Database.js";
|
||||||
|
import Utils from "./Utils.js";
|
||||||
|
|
||||||
class Connection {
|
class Connection {
|
||||||
|
|
||||||
@@ -29,22 +31,66 @@ class Connection {
|
|||||||
|
|
||||||
static async onConnected() {
|
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
|
// clear previous connection state
|
||||||
GlobalState.contacts = [];
|
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 () => {
|
GlobalState.connection.on(Constants.PushCodes.Advert, async () => {
|
||||||
console.log("Advert");
|
console.log("Advert");
|
||||||
|
await databaseToBeReady;
|
||||||
await this.loadContacts();
|
await this.loadContacts();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// listen for path updates
|
||||||
GlobalState.connection.on(Constants.PushCodes.PathUpdated, async (event) => {
|
GlobalState.connection.on(Constants.PushCodes.PathUpdated, async (event) => {
|
||||||
console.log("PathUpdated", event);
|
console.log("PathUpdated", event);
|
||||||
|
await databaseToBeReady;
|
||||||
await this.loadContacts();
|
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.loadSelfInfo();
|
||||||
await this.syncDeviceTime();
|
await this.syncDeviceTime();
|
||||||
|
|
||||||
|
// wait for database to be ready
|
||||||
|
await databaseToBeReady;
|
||||||
|
|
||||||
|
// sync messages
|
||||||
await this.loadContacts();
|
await this.loadContacts();
|
||||||
|
await this.syncMessages();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +128,75 @@ class Connection {
|
|||||||
await GlobalState.connection.sendCommandRemoveContact(publicKey);
|
await GlobalState.connection.sendCommandRemoveContact(publicKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async sendMessage(publicKey, message) {
|
static async sendMessage(publicKey, text) {
|
||||||
await GlobalState.connection.sendTextMessage(publicKey, message);
|
|
||||||
// todo handle acks
|
// 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) {
|
static getTimeAgoShortHand(date) {
|
||||||
|
|
||||||
// get duration between now and provided 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',
|
path: '/connect',
|
||||||
component: () => import("./components/pages/ConnectPage.vue"),
|
component: () => import("./components/pages/ConnectPage.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "contact.messages",
|
||||||
|
path: '/contacts/:publicKey/messages',
|
||||||
|
props: true,
|
||||||
|
component: () => import("./components/pages/ContactMessagesPage.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "settings.radio",
|
name: "settings.radio",
|
||||||
path: '/settings/radio',
|
path: '/settings/radio',
|
||||||
|
|||||||
Reference in New Issue
Block a user