762 lines
38 KiB
HTML
762 lines
38 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: [],
|
|
|
|
nomadnetPageDownloadCallbacks: {},
|
|
nomadnetFileDownloadCallbacks: {},
|
|
|
|
};
|
|
},
|
|
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;
|
|
|
|
}
|
|
case 'nomadnet.page.download': {
|
|
|
|
// get data from server
|
|
const nomadnetPageDownload = json.nomadnet_page_download;
|
|
|
|
// find download callbacks
|
|
const getNomadnetPageDownloadCallbackKey = this.getNomadnetPageDownloadCallbackKey(nomadnetPageDownload.destination_hash, nomadnetPageDownload.page_path);
|
|
const nomadnetPageDownloadCallback = this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
|
|
if(!nomadnetPageDownloadCallback){
|
|
console.log("did not find nomadnet page download callback for key: " + getNomadnetPageDownloadCallbackKey);
|
|
return;
|
|
}
|
|
|
|
// handle success
|
|
if(nomadnetPageDownload.status === "success" && nomadnetPageDownloadCallback.onSuccessCallback){
|
|
nomadnetPageDownloadCallback.onSuccessCallback(nomadnetPageDownload.page_content);
|
|
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
|
|
return;
|
|
}
|
|
|
|
// handle failure
|
|
if(nomadnetPageDownload.status === "failure" && nomadnetPageDownloadCallback.onFailureCallback){
|
|
nomadnetPageDownloadCallback.onFailureCallback(nomadnetPageDownload.failure_reason);
|
|
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
|
|
return;
|
|
}
|
|
|
|
// handle progress
|
|
if(nomadnetPageDownload.status === "progress" && nomadnetPageDownloadCallback.onProgressCallback){
|
|
nomadnetPageDownloadCallback.onProgressCallback(nomadnetPageDownload.progress);
|
|
return;
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
case 'nomadnet.file.download': {
|
|
|
|
// get data from server
|
|
const nomadnetFileDownload = json.nomadnet_file_download;
|
|
|
|
// find download callbacks
|
|
const getNomadnetFileDownloadCallbackKey = this.getNomadnetFileDownloadCallbackKey(nomadnetFileDownload.destination_hash, nomadnetFileDownload.file_path);
|
|
const nomadnetFileDownloadCallback = this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
|
|
if(!nomadnetFileDownloadCallback){
|
|
console.log("did not find nomadnet file download callback for key: " + getNomadnetFileDownloadCallbackKey);
|
|
return;
|
|
}
|
|
|
|
// handle success
|
|
if(nomadnetFileDownload.status === "success" && nomadnetFileDownloadCallback.onSuccessCallback){
|
|
nomadnetFileDownloadCallback.onSuccessCallback(nomadnetFileDownload.file_name, nomadnetFileDownload.file_bytes);
|
|
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
|
|
return;
|
|
}
|
|
|
|
// handle failure
|
|
if(nomadnetFileDownload.status === "failure" && nomadnetFileDownloadCallback.onFailureCallback){
|
|
nomadnetFileDownloadCallback.onFailureCallback(nomadnetFileDownload.failure_reason);
|
|
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
|
|
return;
|
|
}
|
|
|
|
// handle progress
|
|
if(nomadnetFileDownload.status === "progress" && nomadnetFileDownloadCallback.onProgressCallback){
|
|
nomadnetFileDownloadCallback.onProgressCallback(nomadnetFileDownload.progress);
|
|
return;
|
|
}
|
|
|
|
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,
|
|
});
|
|
},
|
|
async downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
|
|
|
|
// do nothing if not connected to websocket
|
|
if(!this.isWebsocketConnected){
|
|
alert("Not connected to WebSocket!");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
|
|
// set callbacks for nomadnet filePath download
|
|
this.nomadnetFileDownloadCallbacks[this.getNomadnetFileDownloadCallbackKey(destinationHash, filePath)] = {
|
|
onSuccessCallback: onSuccessCallback,
|
|
onFailureCallback: onFailureCallback,
|
|
onProgressCallback: onProgressCallback,
|
|
};
|
|
|
|
// ask reticulum to download file from nomadnet
|
|
this.ws.send(JSON.stringify({
|
|
"type": "nomadnet.file.download",
|
|
"nomadnet_file_download": {
|
|
"destination_hash": destinationHash,
|
|
"file_path": filePath,
|
|
},
|
|
}));
|
|
|
|
} catch(e) {
|
|
console.error(e);
|
|
}
|
|
|
|
},
|
|
async downloadNomadNetPage(destinationHash, pagePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
|
|
|
|
// do nothing if not connected to websocket
|
|
if(!this.isWebsocketConnected){
|
|
alert("Not connected to WebSocket!");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
|
|
// set callbacks for nomadnet page download
|
|
this.nomadnetPageDownloadCallbacks[this.getNomadnetPageDownloadCallbackKey(destinationHash, pagePath)] = {
|
|
onSuccessCallback: onSuccessCallback,
|
|
onFailureCallback: onFailureCallback,
|
|
onProgressCallback: onProgressCallback,
|
|
};
|
|
|
|
// ask reticulum to download page from nomadnet
|
|
this.ws.send(JSON.stringify({
|
|
"type": "nomadnet.page.download",
|
|
"nomadnet_page_download": {
|
|
"destination_hash": destinationHash,
|
|
"page_path": pagePath,
|
|
},
|
|
}));
|
|
|
|
} catch(e) {
|
|
console.error(e);
|
|
}
|
|
|
|
},
|
|
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";
|
|
|
|
},
|
|
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
|
|
return `${destinationHash}:${pagePath}`;
|
|
},
|
|
getNomadnetFileDownloadCallbackKey: function(destinationHash, filePath) {
|
|
return `${destinationHash}:${filePath}`;
|
|
},
|
|
},
|
|
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> |