Files
reticulum-meshchatX/public/index.html
2024-04-30 23:55:07 +12:00

619 lines
31 KiB
HTML

<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Reticulum WebChat</title>
<!-- scripts -->
<script src="assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script>
<script src="assets/js/vue@3.4.26/dist/vue.global.js"></script>
</head>
<body class="bg-gray-100">
<div id="app" class="h-screen w-full flex flex-col">
<!-- header -->
<div class="flex bg-white p-2 border-gray-300 border-b">
<div class="flex w-full">
<div class="hidden sm:flex my-auto border border-gray-300 rounded-md w-10 h-10 mr-3 shadow bg-gray-50">
<div class="flex mx-auto my-auto">
<img class="w-9 h-9" src="assets/images/logo.png "/>
</div>
</div>
<div class="my-auto">
<div class="font-bold">Reticulum WebChat</div>
<div class="text-sm">Developed by <a target="_blank" href="https://liamcottle.com" class="text-blue-500">Liam Cottle</a></div>
</div>
<div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
<div class="rounded-full">
<div class="flex text-gray-700 bg-gray-100 px-2 py-1 rounded-full">
<div>
<svg v-if="isWebsocketConnected" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</div>
<div class="my-auto mx-1 text-sm">
<span v-if="isWebsocketConnected">Connected</span>
<span v-else>Disconnected</span>
</div>
</div>
</div>
<a @click="sendAnnounce" href="javascript:void(0)" class="rounded-full">
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z" />
</svg>
</div>
<div class="my-auto mx-1 text-sm">Announce</div>
</div>
</a>
</div>
</div>
</div>
<!-- middle -->
<div class="flex h-full w-full px-2 space-x-2 overflow-auto">
<!-- sidebar -->
<div class="flex flex-col w-80 h-full py-2 space-y-2">
<!-- peers -->
<div class="flex-1 flex flex-col border rounded-xl bg-white shadow overflow-hidden">
<div class="flex border-b border-gray-300 text-gray-700 p-2">
<div class="mr-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />
</svg>
</div>
<div>Peers Discovered ({{ peersCount }})</div>
</div>
<div v-if="peersCount > 0" class="p-1 border-b border-gray-300">
<input v-model="peersSearchTerm" type="text" placeholder="Search peers..." 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">
</div>
<div v-if="searchedPeers.length > 0" class="overflow-y-scroll">
<div @click="onPeerClick(peer)" v-for="peer of searchedPeers" class="flex cursor-pointer p-2 border-l-2 border-transparent" :class="[ peer.destination_hash === selectedPeer?.destination_hash ? 'bg-gray-100 border-blue-500' : 'bg-white hover:bg-gray-50 hover:border-gray-200' ]">
<div class="my-auto mr-2">
<img class="w-9 h-9 rounded-full" src="assets/images/user.png"/>
</div>
<div>
<div class="text-gray-900">{{ peer.app_data || "Anonymous Peer" }}</div>
<div class="text-gray-500 text-sm">{{ formatTimeAgo(peer.last_announce_timestamp) }}</div>
</div>
</div>
</div>
<div v-else class="mx-auto my-auto text-center leading-5">
<!-- no peers at all -->
<div v-if="peersCount === 0" class="flex flex-col">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" />
</svg>
</div>
<div class="font-semibold">No Peers Discovered</div>
<div>Waiting for someone to announce!</div>
</div>
<!-- is searching, but no results -->
<div v-if="peersSearchTerm !== '' && peersCount > 0" class="flex flex-col">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div>
<div class="font-semibold">No Search Results</div>
<div>Your search didn't match any Peers!</div>
</div>
</div>
</div>
<!-- my identity -->
<div v-if="config" class="border rounded-xl bg-white shadow">
<div class="flex border-b border-gray-300 text-gray-700 p-2">
<div class="my-auto mr-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</div>
<div class="my-auto">My Identity</div>
<div class="ml-auto">
<button @click="saveDisplayName" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
Save
</button>
</div>
</div>
<div class="divide-y text-gray-900">
<div class="p-1">
<input v-model="displayName" type="text" 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">
</div>
<div class="p-1">
<div>Identity Hash</div>
<div class="text-sm text-gray-700">{{ config.identity_hash }}</div>
</div>
<div class="p-1">
<div>LXMF Address Hash</div>
<div class="text-sm text-gray-700">{{ config.lxmf_address_hash }}</div>
</div>
</div>
</div>
</div>
<!-- chat view -->
<div class="flex flex-col flex-1">
<!-- peer selected -->
<div v-if="selectedPeer" class="flex flex-col h-full my-2 border rounded-xl bg-white shadow">
<!-- header -->
<div class="flex p-2 border-b border-gray-300">
<!-- peer info -->
<div>
<div class="font-semibold">{{ selectedPeer.app_data || "Unknown" }}</div>
<div class="text-sm">@<{{ selectedPeer.destination_hash }}></div>
</div>
<!-- close button -->
<div class="ml-auto my-auto mr-2">
<div @click="selectedPeer = null" href="javascript:void(0)" class="cursor-pointer">
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</div>
</div>
</div>
</div>
</div>
<!-- chat items -->
<div id="messages" class="h-full overflow-y-scroll px-3 sm:px-0">
<div v-if="selectedPeerChatItems.length > 0" class="flex flex-col space-y-3 p-3">
<div v-for="chatItem of selectedPeerChatItems" class="flex flex-col max-w-xl" :class="{ 'ml-auto pl-4 md:pl-16 items-end': chatItem.is_outbound, 'mr-auto pr-4 md:pr-16 items-start': !chatItem.is_outbound }">
<!-- sender name -->
<div v-if="!chatItem.is_outbound" class="text-xs text-gray-500 ml-2">{{ selectedPeer.app_data || "Unknown" }}</div>
<!-- message content -->
<div class="flex space-x-2 border border-gray-300 rounded-xl shadow px-2.5 py-1 bg-white" :class="{ 'bg-[#efefef]': !chatItem.is_outbound, 'bg-[#3b82f6] text-white': chatItem.is_outbound }">
<div class="w-full">
<div v-if="chatItem.lxmf_message.content" style="white-space:pre-wrap;word-wrap:break-word;font-family:inherit;">{{ chatItem.lxmf_message.content }}</div>
<div v-if="chatItem.lxmf_message.fields?.image" class="grid grid-cols-3 gap-2">
<img @click="openImage(`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`)" :src="`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`" class="w-full rounded-md shadow-md cursor-pointer"/>
</div>
<div v-if="chatItem.lxmf_message.fields?.file_attachments">
<a target="_blank" :download="file_attachment.file_name" :href="`data:application/octet-stream;base64,${file_attachment.file_bytes}`" v-for="file_attachment of chatItem.lxmf_message.fields?.file_attachments ?? []" class="flex border border-gray-200 hover:bg-gray-100 rounded px-2 py-1 text-sm text-gray-700 font-semibold cursor-pointer">
<div class="mr-2 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-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"></path>
</svg>
</div>
<div class="my-auto">{{ file_attachment.file_name }}</div>
<div class="ml-auto 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-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</div>
</a>
</div>
</div>
</div>
<!-- message state -->
<div v-if="chatItem.is_outbound" class="flex text-gray-500 text-right">
<div class="flex ml-auto space-x-1">
<!-- state label -->
<div class="my-auto">{{ chatItem.lxmf_message.state }}</div>
<!-- delivered icon -->
<div v-if="chatItem.lxmf_message.state === 'delivered'" 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="chatItem.lxmf_message.state === 'failed'" 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="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
</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>
<!-- send message -->
<div class="w-full border-gray-300 border-t p-2">
<div class=mx-auto">
<!-- message composer -->
<div>
<textarea id="message-input" :readonly="isSendingMessage" v-model="newMessageText" @keydown.enter.exact.native.prevent="onEnterPressed" @keydown.enter.shift.exact.native.prevent="onShiftEnterPressed" 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>
<div class="flex">
<button @click="sendMessage" type="button" class="ml-auto mt-2 my-auto inline-flex items-center gap-x-1 rounded-md bg-blue-500 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
Send
</button>
</div>
</div>
</div>
</div>
</div>
<!-- no peer selected -->
<div v-else class="flex flex-col mx-auto my-auto text-center leading-5">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z" />
</svg>
</div>
<div class="font-semibold">No Active Chat</div>
<div>Select a Peer to start chatting!</div>
</div>
</div>
</div>
</div>
<script>
Vue.createApp({
data() {
return {
isWebsocketConnected: false,
autoReconnectWebsocket: true,
newMessageText: "",
isSendingMessage: false,
autoScrollOnNewMessage: true,
displayName: "Anonymous Peer",
config: null,
peers: {},
peersSearchTerm: "",
selectedPeer: null,
chatItems: [],
};
},
mounted: function() {
this.connectWebsocket();
},
methods: {
connectWebsocket: function() {
// connect to websocket
this.ws = new WebSocket(location.origin.replace(/^http/, 'ws') + "/ws");
this.ws.addEventListener('open', () => {
this.isWebsocketConnected = true;
});
this.ws.addEventListener('close', () => {
this.isWebsocketConnected = false;
if(this.autoReconnectWebsocket){
setTimeout(() => {
this.connectWebsocket();
}, 1000);
}
});
// handle data from reticulum
this.ws.onmessage = (message) => {
const json = JSON.parse(message.data);
switch(json.type){
case 'config': {
this.config = json.config;
this.displayName = json.config.display_name;
break;
}
case 'announce': {
this.peers[json.announce.destination_hash] = {
destination_hash: json.announce.destination_hash,
app_data: json.announce.app_data,
last_announce_timestamp: json.announce.last_announce_timestamp,
}
break;
}
case 'known_peers': {
for(const known_peer of json.known_peers){
this.peers[known_peer.destination_hash] = {
destination_hash: known_peer.destination_hash,
app_data: known_peer.app_data,
last_announce_timestamp: known_peer.last_announce_timestamp,
}
}
break;
}
case 'lxmf.delivery': {
// add inbound message to ui
this.chatItems.push({
"type": "lxmf_message",
"lxmf_message": json.lxmf_message,
});
// auto scroll to bottom if we want to
if(this.autoScrollOnNewMessage){
this.scrollMessagesToBottom();
}
break;
}
case 'lxmf_outbound_message_created': {
// add outbound message to ui
this.chatItems.push({
"type": "lxmf_message",
"lxmf_message": json.lxmf_message,
"is_outbound": true,
});
// always scroll to bottom since we just sent a message
this.scrollMessagesToBottom();
break;
}
case 'lxmf_message_state_updated': {
// find existing chat item by lxmf message hash
const lxmfMessageHash = json.lxmf_message.hash;
const chatItemIndex = this.chatItems.findIndex((chatItem) => chatItem.lxmf_message?.hash === lxmfMessageHash);
if(chatItemIndex === -1){
console.log("did not find existing chat item index for lxmf message hash: " + json.lxmf_message.hash);
return;
}
// update lxmf message from server, while ensuring ui updates from nested object change
this.chatItems[chatItemIndex].lxmf_message = json.lxmf_message;
break;
}
}
};
},
disconnectWebsocket: function() {
if(this.ws){
this.ws.close();
}
},
scrollMessagesToBottom: function() {
Vue.nextTick(() => {
const container = document.getElementById("messages");
container.scrollTop = container.scrollHeight;
});
},
async sendAnnounce() {
// do nothing if not connected to websocket
if(!this.isWebsocketConnected){
alert("Not connected to WebSocket!");
return;
}
try {
// ask reticulum to announce
this.ws.send(JSON.stringify({
"type": "announce",
}));
} catch(e) {
console.error(e);
}
},
async sendMessage() {
// do nothing if empty message
const messageText = this.newMessageText.trim();
if(messageText == null || messageText === ""){
return;
}
// do nothing if not connected to websocket
if(!this.isWebsocketConnected){
alert("Not connected to WebSocket!");
return;
}
// do nothing if no peer selected
if(!this.selectedPeer){
return;
}
this.isSendingMessage = true;
try {
// send message to reticulum via websocket
this.ws.send(JSON.stringify({
"type": "lxmf.delivery",
"destination_hash": this.selectedPeer.destination_hash,
"message": messageText,
}));
// clear message input
this.newMessageText = "";
} catch(e) {
// todo handle error
console.error(e);
} finally {
this.isSendingMessage = false;
}
// scroll to bottom
this.scrollMessagesToBottom();
},
async updateConfig(config) {
// do nothing if not connected to websocket
if(!this.isWebsocketConnected){
return;
}
try {
this.ws.send(JSON.stringify({
"type": "config.set",
"config": config,
}));
} catch(e) {
console.error(e);
}
},
async saveDisplayName() {
await this.updateConfig({
"display_name": this.displayName,
});
},
addNewLine: function() {
this.newMessageText += "\n";
},
onEnterPressed: function() {
// add new line on mobile
if(this.isMobile){
this.addNewLine();
return;
}
// send message on desktop
this.sendMessage();
},
onShiftEnterPressed: function() {
this.addNewLine();
},
openImage: async function(url) {
// convert data uri to blob
const blob = await (await fetch(url)).blob();
// create blob url
const fileUrl = window.URL.createObjectURL(blob);
// open new tab
window.open(fileUrl);
},
onPeerClick: function(peer) {
this.selectedPeer = peer;
},
parseSeconds: function(secondsToFormat) {
secondsToFormat = Number(secondsToFormat);
var days = Math.floor(secondsToFormat / (3600 * 24));
var hours = Math.floor((secondsToFormat % (3600 * 24)) / 3600);
var minutes = Math.floor((secondsToFormat % 3600) / 60);
var seconds = Math.floor(secondsToFormat % 60);
return {
days: days,
hours: hours,
minutes: minutes,
seconds: seconds,
};
},
formatTimeAgo: function(seconds) {
const secondsAgo = Math.round((Date.now() / 1000) - seconds);
const parsedSeconds = this.parseSeconds(secondsAgo);
if(parsedSeconds.days > 0){
return parsedSeconds.days + " days ago";
}
if(parsedSeconds.hours > 0){
return parsedSeconds.hours + " hours ago";
}
if(parsedSeconds.minutes > 0){
return parsedSeconds.minutes + " minutes ago";
}
return parsedSeconds.seconds + " seconds ago";
},
},
computed: {
isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
},
peersCount() {
return Object.keys(this.peers).length;
},
peersOrderedByLatestAnnounce() {
const peers = Object.values(this.peers);
return peers.sort(function(peerA, peerB) {
// order by last_announce_timestamp desc
return peerB.last_announce_timestamp - peerA.last_announce_timestamp;
});
},
searchedPeers() {
return this.peersOrderedByLatestAnnounce.filter((peer) => {
const search = this.peersSearchTerm.toLowerCase();
const matchesAppData = (peer.app_data || "").toLowerCase().includes(search);
return matchesAppData;
});
},
selectedPeerChatItems() {
// get all chat items related to the selected peer
if(this.selectedPeer){
return this.chatItems.filter((chatItem) => {
if(chatItem.type === "lxmf_message"){
const isFromSelectedPeer = chatItem.lxmf_message.source_hash === this.selectedPeer.destination_hash;
const isToSelectedPeer = chatItem.lxmf_message.destination_hash === this.selectedPeer.destination_hash;
return isFromSelectedPeer || isToSelectedPeer;
}
return false;
});
}
// no peer, so no chat items!
return [];
}
},
}).mount('#app');
</script>
</body>
</html>