357 lines
13 KiB
HTML
357 lines
13 KiB
HTML
<html>
|
|
<head>
|
|
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
|
<title>Meshtastic Messages</title>
|
|
|
|
<!-- tailwind css -->
|
|
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
|
|
|
|
<!-- moment -->
|
|
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.min.js"></script>
|
|
|
|
<!-- vuejs -->
|
|
<script src="https://unpkg.com/vue@3"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
|
|
|
<style>
|
|
|
|
/* used to prevent ui flicker before vuejs loads */
|
|
[v-cloak] {
|
|
display: none;
|
|
}
|
|
|
|
</style>
|
|
|
|
</head>
|
|
<body class="h-full">
|
|
<div id="app" v-cloak>
|
|
<div class="h-full flex flex-col overflow-hidden">
|
|
|
|
<!-- empty state -->
|
|
<div v-if="messages.length === 0" class="flex h-full">
|
|
<div class="flex flex-col mx-auto my-auto p-4 text-gray-500 text-center">
|
|
<div class="mb-2 mx-auto">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-10">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
|
</svg>
|
|
</div>
|
|
<div class="font-semibold">No Messages</div>
|
|
<div>There's no messages yet...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- note: must use flex-col-reverse to prevent ui scrolling when adding older messages to ui -->
|
|
<div v-show="messages.length > 0" id="messages" class="h-full flex flex-col-reverse p-3 overflow-y-auto">
|
|
|
|
<!-- messages -->
|
|
<div :key="message.id" v-for="message of reversedMessages" class="max-w-xl items-start my-1.5">
|
|
|
|
<div class="flex">
|
|
|
|
<div class="mr-2 mt-2">
|
|
<div class="flex rounded-full h-12 w-12 text-white" :class="[ `bg-[${getNodeColour(message.from)}]` ]">
|
|
<div class="mx-auto my-auto">{{ getNodeShortName(message.from) }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col">
|
|
|
|
<!-- sender -->
|
|
<div class="text-xs text-gray-500">
|
|
<a target="_blank" :href="`/?node_id=${message.from}`" class="hover:text-blue-500">
|
|
<span>{{ getNodeLongName(message.from) }}</span>
|
|
</a>
|
|
<span v-if="message.to.toString() !== '4294967295'">
|
|
<span> → </span>
|
|
<a target="_blank" :href="`/?node_id=${message.to}`" class="hover:text-blue-500">{{ getNodeName(message.to) }}</a>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- message -->
|
|
<div class="flex">
|
|
<div class="border border-gray-300 rounded-xl shadow overflow-hidden bg-[#efefef]">
|
|
<div class="w-full space-y-0.5 px-2.5 py-1" v-html="escapeMessageText(message.text)" style="white-space:pre-wrap;word-break:break-word;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- load previous -->
|
|
<button v-show="!isLoadingPrevious && hasMorePrevious" id="load-previous" @click="loadPrevious" type="button" class="flex space-x-2 mx-auto bg-gray-200 px-3 py-1 hover:bg-gray-300 rounded-full shadow">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m15 11.25-3-3m0 0-3 3m3-3v7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
|
</svg>
|
|
<span>Load Previous</span>
|
|
</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
Vue.createApp({
|
|
data() {
|
|
return {
|
|
|
|
to: null,
|
|
from: null,
|
|
channelId: null,
|
|
gatewayId: null,
|
|
|
|
isLoadingPrevious: false,
|
|
isLoadingMore: false,
|
|
shouldAutoScroll: true,
|
|
loadPreviousObserver: null,
|
|
hasMorePrevious: true,
|
|
|
|
messages: [],
|
|
nodesById: {},
|
|
|
|
moment: window.moment,
|
|
|
|
};
|
|
},
|
|
mounted: function() {
|
|
|
|
// parse url params
|
|
const queryParams = new URLSearchParams(window.location.search);
|
|
this.to = queryParams.get('to');
|
|
this.from = queryParams.get('from');
|
|
this.channelId = queryParams.get('channel_id');
|
|
this.gatewayId = queryParams.get('gateway_id');
|
|
this.directMessageNodeIds = queryParams.get('direct_message_node_ids');
|
|
this.count = queryParams.get('count');
|
|
|
|
// listen for scrolling of messages list
|
|
document.getElementById("messages").addEventListener("scroll", (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.shouldAutoScroll = isAtBottom;
|
|
|
|
});
|
|
|
|
// setup intersection observer
|
|
this.loadPreviousObserver = new IntersectionObserver((entries) => {
|
|
const loadMoreElement = entries[0];
|
|
if(loadMoreElement && loadMoreElement.isIntersecting){
|
|
this.loadPrevious();
|
|
}
|
|
});
|
|
|
|
this.initialLoad();
|
|
|
|
},
|
|
methods: {
|
|
async initialLoad() {
|
|
|
|
// load 1 page of previous messages
|
|
await this.loadPrevious();
|
|
|
|
// scroll to bottom
|
|
this.scrollToBottom();
|
|
|
|
// setup auto loading previous
|
|
this.loadPreviousObserver.observe(document.querySelector("#load-previous"));
|
|
|
|
// load more every few seconds
|
|
setInterval(async () => {
|
|
await this.loadMore();
|
|
}, 2500);
|
|
|
|
},
|
|
async loadPrevious() {
|
|
|
|
// do nothing if already loading
|
|
if(this.isLoadingPrevious){
|
|
return;
|
|
}
|
|
|
|
this.isLoadingPrevious = true;
|
|
|
|
try {
|
|
|
|
const response = await window.axios.get('/api/v1/text-messages', {
|
|
params: {
|
|
to: this.to,
|
|
from: this.from,
|
|
channel_id: this.channelId,
|
|
gateway_id: this.gatewayId,
|
|
direct_message_node_ids: this.directMessageNodeIds,
|
|
count: this.count,
|
|
order: "desc",
|
|
last_id: this.oldestMessageId,
|
|
},
|
|
});
|
|
|
|
// add messages to start of existing messages
|
|
const messages = response.data.text_messages;
|
|
for(const message of messages){
|
|
this.messages.unshift(message);
|
|
}
|
|
|
|
// no more previous to load if previous list is empty
|
|
if(messages.length === 0){
|
|
this.hasMorePrevious = false;
|
|
}
|
|
|
|
// fetch node info
|
|
for(const message of messages){
|
|
await this.fetchNodeInfo(message.to);
|
|
await this.fetchNodeInfo(message.from);
|
|
}
|
|
|
|
} catch(e) {
|
|
// do nothing
|
|
} finally {
|
|
this.isLoadingPrevious = false;
|
|
}
|
|
|
|
},
|
|
async loadMore() {
|
|
|
|
// do nothing if already loading
|
|
if(this.isLoadingMore){
|
|
return;
|
|
}
|
|
|
|
this.isLoadingMore = true;
|
|
|
|
try {
|
|
|
|
const response = await window.axios.get('/api/v1/text-messages', {
|
|
params: {
|
|
to: this.to,
|
|
from: this.from,
|
|
channel_id: this.channelId,
|
|
gateway_id: this.gatewayId,
|
|
direct_message_node_ids: this.directMessageNodeIds,
|
|
count: this.count,
|
|
order: "asc",
|
|
last_id: this.latestMessageId,
|
|
},
|
|
});
|
|
|
|
// add messages to end of existing messages
|
|
const messages = response.data.text_messages;
|
|
for(const message of messages){
|
|
this.messages.push(message);
|
|
}
|
|
|
|
// scroll to bottom
|
|
if(this.shouldAutoScroll){
|
|
this.scrollToBottom();
|
|
}
|
|
|
|
// fetch node info
|
|
for(const message of messages){
|
|
await this.fetchNodeInfo(message.to);
|
|
await this.fetchNodeInfo(message.from);
|
|
}
|
|
|
|
} catch(e) {
|
|
// do nothing
|
|
} finally {
|
|
this.isLoadingMore = false;
|
|
}
|
|
|
|
},
|
|
async fetchNodeInfo(nodeId) {
|
|
|
|
// do nothing if already fetched
|
|
if(nodeId in this.nodesById){
|
|
return;
|
|
}
|
|
|
|
// do nothing if broadcast address
|
|
if(nodeId.toString() === "4294967295"){
|
|
return;
|
|
}
|
|
|
|
try {
|
|
|
|
const response = await window.axios.get(`/api/v1/nodes/${nodeId}`);
|
|
const node = response.data.node;
|
|
|
|
if(node){
|
|
this.nodesById[node.node_id] = node;
|
|
}
|
|
|
|
} catch(e) {
|
|
// do nothing
|
|
}
|
|
|
|
},
|
|
scrollToBottom: function() {
|
|
this.$nextTick(() => {
|
|
var container = this.$el.querySelector("#messages");
|
|
container.scrollTop = container.scrollHeight;
|
|
});
|
|
},
|
|
getNodeName(nodeId) {
|
|
|
|
// find node by id
|
|
const node = this.nodesById[nodeId];
|
|
if(!node){
|
|
return "Unknown";
|
|
}
|
|
|
|
return `[${node.short_name}] ${node.long_name}`;
|
|
|
|
},
|
|
getNodeShortName(nodeId) {
|
|
return this.nodesById[nodeId]?.short_name?.substring(0, 4) ?? "?";
|
|
},
|
|
getNodeLongName(nodeId) {
|
|
return this.nodesById[nodeId]?.long_name ?? "???";
|
|
},
|
|
getNodeColour(nodeId) {
|
|
// convert node id to a hex colour
|
|
return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0');
|
|
},
|
|
escapeMessageText(text) {
|
|
return text.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('\n', '<br/>');
|
|
},
|
|
},
|
|
computed: {
|
|
reversedMessages() {
|
|
// ensure a copy of the array is returned in reverse order
|
|
return this.messages.map((message) => message).reverse();
|
|
},
|
|
oldestMessageId() {
|
|
|
|
if(this.messages.length > 0){
|
|
return this.messages[0].id;
|
|
}
|
|
|
|
return null;
|
|
|
|
},
|
|
latestMessageId() {
|
|
|
|
if(this.messages.length > 0){
|
|
return this.messages[this.messages.length - 1].id;
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
},
|
|
}).mount('#app');
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|