2024-04-29 01:09:06 +12:00
< 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" >
2024-06-02 14:42:19 +12:00
< title > Reticulum MeshChat< / title >
2024-04-29 01:09:06 +12:00
<!-- scripts -->
2024-04-30 16:13:28 +12:00
< script src = "assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js" > < / script >
2024-05-04 03:57:57 +12:00
< script src = "assets/js/axios@1.6.8/dist/axios.min.js" > < / script >
2024-04-30 16:13:28 +12:00
< script src = "assets/js/vue@3.4.26/dist/vue.global.js" > < / script >
2024-05-08 01:20:03 +12:00
< script src = "assets/js/micron-parser.js" > < / script >
2024-04-29 01:09:06 +12:00
2024-06-03 01:17:05 +12:00
<!-- codec2 -->
< script src = "assets/js/codec2-emscripten/c2enc.js" > < / script >
< script src = "assets/js/codec2-emscripten/c2dec.js" > < / script >
< script src = "assets/js/codec2-emscripten/sox.js" > < / script >
< script src = "assets/js/codec2-emscripten/codec2-lib.js" > < / script >
2024-06-03 02:51:39 +12:00
< script src = "assets/js/codec2-emscripten/wav-encoder.js" > < / script >
< script src = "assets/js/codec2-emscripten/codec2-microphone-recorder.js" > < / script >
2024-06-03 01:17:05 +12:00
2024-04-29 01:09:06 +12:00
< / head >
< body class = "bg-gray-100" >
2024-04-30 23:21:04 +12:00
< div id = "app" class = "h-screen w-full flex flex-col" >
2024-04-29 01:09:06 +12:00
<!-- header -->
< div class = "flex bg-white p-2 border-gray-300 border-b" >
2024-04-29 22:06:06 +12:00
< div class = "flex w-full" >
2024-04-30 18:13:17 +12:00
< div class = "hidden sm:flex my-auto border border-gray-300 rounded-md w-10 h-10 mr-3 shadow bg-gray-50" >
2024-04-29 01:09:06 +12:00
< div class = "flex mx-auto my-auto" >
2024-04-30 18:13:17 +12:00
< img class = "w-9 h-9" src = "assets/images/logo.png " / >
2024-04-29 01:09:06 +12:00
< / div >
< / div >
< div class = "my-auto" >
2024-06-02 14:42:19 +12:00
< div class = "font-bold" > Reticulum MeshChat< / div >
2024-04-29 01:09:06 +12:00
< 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" >
2024-05-19 17:11:23 +12:00
< div class = "rounded-full hidden sm:block" >
2024-04-29 03:22:48 +12:00
< 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" >
2024-04-29 03:25:01 +12:00
< span v-if = "isWebsocketConnected" > Connected< / span >
< span v-else > Disconnected< / span >
2024-04-29 03:22:48 +12:00
< / div >
< / div >
< / div >
2024-05-28 21:14:41 +12:00
< button @ click = "startNewLXMFConversation" type = "button" class = "rounded-full" >
< span class = "flex text-gray-700 bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded-full" >
< span >
< 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.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" / >
< / svg >
< / span >
< span class = "my-auto mx-1 text-sm" > Compose< / span >
< / span >
< / button >
2024-04-29 01:09:06 +12:00
< / div >
< / div >
< / div >
2024-04-29 22:06:06 +12:00
<!-- middle -->
2024-05-29 15:28:23 +12:00
< div class = "flex h-full w-full overflow-auto" >
2024-04-29 20:41:50 +12:00
2024-04-29 22:28:34 +12:00
<!-- sidebar -->
2024-05-29 19:57:20 +12:00
< div class = "bg-white flex w-72 min-w-72 flex-col" >
2024-05-29 15:28:23 +12:00
< div class = "flex grow flex-col overflow-y-auto border-r border-gray-200 bg-white" >
<!-- navigation -->
< div class = "flex-1" >
< ul class = "py-2 pr-2 space-y-1" >
<!-- messages -->
< li >
< button @ click = "tab = 'messages'" type = "button" :class = "[ tab === 'messages' ? 'bg-blue-100 text-blue-800 group:text-blue-800 hover:bg-blue-100' : '']" class = "w-full text-gray-800 hover:bg-gray-100 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" >
< span class = "my-auto" >
2024-05-29 19:57:20 +12:00
< 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" >
2024-05-29 15:28:23 +12:00
< 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 >
< / span >
< span class = "my-auto" > Messages< / span >
2024-06-01 19:45:19 +12:00
< span v-if = "unreadConversationsCount > 0" class = "my-auto ml-auto mr-2" > {{ unreadConversationsCount }}< / span >
2024-05-29 15:28:23 +12:00
< / button >
< / li >
2024-05-29 19:57:20 +12:00
<!-- nomad network -->
2024-05-29 15:28:23 +12:00
< li >
< button @ click = "tab = 'nomadnetwork'" type = "button" :class = "[ tab === 'nomadnetwork' ? 'bg-blue-100 text-blue-800 group:text-blue-800 hover:bg-blue-100' : '']" class = "w-full text-gray-800 hover:bg-gray-100 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" >
< span class = "my-auto" >
2024-05-29 19:57:20 +12:00
< 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" >
2024-05-29 15:28:23 +12:00
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" / >
< / svg >
< / span >
< span class = "my-auto" > Nomad Network< / span >
< / button >
< / li >
2024-04-29 22:28:34 +12:00
2024-05-29 21:20:35 +12:00
<!-- interfaces -->
< li >
< button @ click = "tab = 'interfaces'" type = "button" :class = "[ tab === 'interfaces' ? 'bg-blue-100 text-blue-800 group:text-blue-800 hover:bg-blue-100' : '']" class = "w-full text-gray-800 hover:bg-gray-100 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" >
< span class = "my-auto" >
< svg xmlns = "http://www.w3.org/2000/svg" class = "w-6 h-6" fill = "currentColor" viewBox = "0 0 256 256" >
< path d = "M232,112H136V88h8a16,16,0,0,0,16-16V40a16,16,0,0,0-16-16H112A16,16,0,0,0,96,40V72a16,16,0,0,0,16,16h8v24H24a8,8,0,0,0,0,16H56v32H48a16,16,0,0,0-16,16v32a16,16,0,0,0,16,16H80a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H72V128H184v32h-8a16,16,0,0,0-16,16v32a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16h-8V128h32a8,8,0,0,0,0-16ZM112,40h32V72H112ZM80,208H48V176H80Zm128,0H176V176h32Z" > < / path >
< / svg >
< / span >
< span class = "my-auto" > Interfaces< / span >
< / button >
< / li >
2024-05-30 00:21:52 +12:00
<!-- network -->
< li >
< a href = "network.html" target = "_blank" class = "w-full text-gray-800 hover:bg-gray-100 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" >
< span class = "my-auto" >
2024-05-30 00:23:18 +12:00
< svg xmlns = "http://www.w3.org/2000/svg" fill = "currentColor" viewBox = "0 0 256 256" class = "w-6 h-6" >
< path d = "M200,152a31.84,31.84,0,0,0-19.53,6.68l-23.11-18A31.65,31.65,0,0,0,160,128c0-.74,0-1.48-.08-2.21l13.23-4.41A32,32,0,1,0,168,104c0,.74,0,1.48.08,2.21l-13.23,4.41A32,32,0,0,0,128,96a32.59,32.59,0,0,0-5.27.44L115.89,81A32,32,0,1,0,96,88a32.59,32.59,0,0,0,5.27-.44l6.84,15.4a31.92,31.92,0,0,0-8.57,39.64L73.83,165.44a32.06,32.06,0,1,0,10.63,12l25.71-22.84a31.91,31.91,0,0,0,37.36-1.24l23.11,18A31.65,31.65,0,0,0,168,184a32,32,0,1,0,32-32Zm0-64a16,16,0,1,1-16,16A16,16,0,0,1,200,88ZM80,56A16,16,0,1,1,96,72,16,16,0,0,1,80,56ZM56,208a16,16,0,1,1,16-16A16,16,0,0,1,56,208Zm56-80a16,16,0,1,1,16,16A16,16,0,0,1,112,128Zm88,72a16,16,0,1,1,16-16A16,16,0,0,1,200,200Z" > < / path >
2024-05-30 00:21:52 +12:00
< / svg >
< / span >
< span class = "my-auto" > Network Visualiser< / span >
< / a >
< / li >
2024-07-28 15:36:37 +12:00
<!-- settings -->
< li >
< button @ click = "showSettingsTab" type = "button" :class = "[ tab === 'settings' ? 'bg-blue-100 text-blue-800 group:text-blue-800 hover:bg-blue-100' : '']" class = "w-full text-gray-800 hover:bg-gray-100 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" >
< span 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 = "size-6" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" / >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" / >
< / svg >
< / span >
< span class = "my-auto" > Settings< / span >
< / button >
< / li >
2024-06-02 13:56:04 +12:00
<!-- info -->
< li >
< button @ click = "showAboutTab" type = "button" :class = "[ tab === 'about' ? 'bg-blue-100 text-blue-800 group:text-blue-800 hover:bg-blue-100' : '']" class = "w-full text-gray-800 hover:bg-gray-100 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" >
< span 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 = "size-6" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" / >
< / svg >
< / span >
< span class = "my-auto" > About< / span >
< / button >
< / li >
2024-05-29 15:28:23 +12:00
< / ul >
< / div >
2024-05-05 23:51:15 +12:00
2024-05-29 15:28:23 +12:00
< div >
<!-- my identity -->
< div v-if = "config" class = "bg-white border-t" >
2024-07-30 22:04:59 +12:00
< div @ click = "isShowingMyIdentitySection = !isShowingMyIdentitySection" class = "flex text-gray-700 p-2 cursor-pointer" >
2024-05-29 15:28:23 +12:00
< 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" >
2024-07-30 22:04:59 +12:00
< button @ click . stop = "saveIdentitySettings" 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" >
2024-05-29 15:28:23 +12:00
Save
< / button >
< / div >
< / div >
2024-07-30 22:04:59 +12:00
< div v-if = "isShowingMyIdentitySection" class = "divide-y text-gray-900 border-t border-gray-300" >
2024-05-29 15:28:23 +12:00
< div class = "p-1" >
< input v-model = "displayName" type = "text" placeholder = "Display Name" 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< / div >
< div class = "text-sm text-gray-700" > {{ config.lxmf_address_hash }}< / div >
< / div >
2024-06-02 11:58:04 +12:00
< / div >
< / div >
<!-- auto announce -->
< div v-if = "config" class = "bg-white border-t" >
2024-07-30 22:04:59 +12:00
< div @ click = "isShowingAnnounceSection = !isShowingAnnounceSection" class = "flex text-gray-700 p-2 cursor-pointer" >
2024-06-02 11:58:04 +12:00
< div class = "my-auto mr-2" >
2024-07-30 23:29:19 +12:00
< 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 = "M9.348 14.652a3.75 3.75 0 0 1 0-5.304m5.304 0a3.75 3.75 0 0 1 0 5.304m-7.425 2.121a6.75 6.75 0 0 1 0-9.546m9.546 0a6.75 6.75 0 0 1 0 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" / >
2024-06-02 11:58:04 +12:00
< / svg >
< / div >
< div class = "my-auto" > Announce< / div >
< div class = "ml-auto" >
2024-07-30 22:04:59 +12:00
< button @ click . stop = "sendAnnounce" 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" >
2024-06-02 11:58:04 +12:00
Announce Now
< / button >
< / div >
< / div >
2024-07-30 22:04:59 +12:00
< div v-if = "isShowingAnnounceSection" class = "divide-y text-gray-900 border-t border-gray-300" >
2024-06-02 11:58:04 +12:00
< div class = "p-1" >
< select v-model = "config.auto_announce_interval_seconds" @ change = "onAnnounceIntervalSecondsChange" 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" >
2024-06-02 11:50:58 +12:00
< option value = "0" > Disabled< / option >
2024-06-02 11:58:04 +12:00
< option value = "900" > Every 15 Minutes< / option >
< option value = "1800" > Every 30 Minutes< / option >
< option value = "3600" > Every 1 Hour< / option >
< option value = "10800" > Every 3 Hours< / option >
< option value = "21600" > Every 6 Hours< / option >
< option value = "43200" > Every 12 Hours< / option >
< option value = "86400" > Every 24 Hours< / option >
2024-06-02 11:50:58 +12:00
< / select >
< div class = "text-sm text-gray-700" >
< span v-if = "config.last_announced_at" > Last announced: {{ formatSecondsAgo(config.last_announced_at) }}< / span >
< span v-else > Last announced: Never< / span >
< / div >
< / div >
2024-05-29 15:28:23 +12:00
< / div >
2024-04-29 22:06:06 +12:00
< / div >
2024-05-29 15:28:23 +12:00
<!-- audio calls -->
< div v-if = "config" class = "bg-white border-t" >
2024-07-30 22:04:59 +12:00
< div @ click = "isShowingCallsSection = !isShowingCallsSection" class = "flex text-gray-700 p-2 cursor-pointer" >
2024-05-29 15:28:23 +12:00
< 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 = "M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" / >
< / svg >
< / div >
< div class = "my-auto" > Calls< / div >
< div class = "ml-auto" >
2024-07-30 22:04:59 +12:00
< a @ click . stop href = "call.html" target = "_blank" 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" >
2024-05-29 15:28:23 +12:00
< span > Open Phone< / span >
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 20 20" fill = "currentColor" class = "w-5 h-5" >
< path fill-rule = "evenodd" d = "M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Z" clip-rule = "evenodd" / >
< path fill-rule = "evenodd" d = "M6.194 12.753a.75.75 0 0 0 1.06.053L16.5 4.44v2.81a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.553l-9.056 8.194a.75.75 0 0 0-.053 1.06Z" clip-rule = "evenodd" / >
< / svg >
< / a >
< / div >
< / div >
2024-07-30 22:04:59 +12:00
< div v-if = "isShowingCallsSection" class = "divide-y text-gray-900 border-t border-gray-300" >
2024-05-29 15:28:23 +12:00
< div class = "p-1 flex" >
< div >
< div > Status< / div >
< div class = "text-sm text-gray-700" >
< div v-if = "activeAudioCalls.length > 0" class = "flex space-x-2" >
< span v-if = "activeInboundAudioCalls.length > 0" > {{ activeInboundAudioCalls.length }} Incoming {{ activeInboundAudioCalls.length === 1 ? 'Call' : 'Calls' }}< / span >
< span v-else > {{ activeOutboundAudioCalls.length }} Outgoing {{ activeOutboundAudioCalls.length === 1 ? 'Call' : 'Calls' }}< / span >
< / div >
< div v-else > Hung up, waiting for call...< / div >
< / div >
< / div >
< div v-if = "activeAudioCalls.length > 0" class = "ml-auto my-auto mr-1 space-x-2" >
<!-- view incoming calls -->
< a href = "call.html" target = "_blank" title = "View Incoming Calls" class = "my-auto inline-flex items-center gap-x-1 rounded-full bg-green-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-green-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-500" >
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 20 20" fill = "currentColor" class = "size-5" >
< path fill-rule = "evenodd" d = "M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Z" clip-rule = "evenodd" / >
< path fill-rule = "evenodd" d = "M6.194 12.753a.75.75 0 0 0 1.06.053L16.5 4.44v2.81a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.553l-9.056 8.194a.75.75 0 0 0-.053 1.06Z" clip-rule = "evenodd" / >
< / svg >
< / a >
<!-- hangup all calls -->
< button title = "Hangup all Calls" @ click = "hangupAllCalls" type = "button" class = "my-auto inline-flex items-center gap-x-1 rounded-full bg-red-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500" >
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 20 20" fill = "currentColor" class = "w-5 h-5 rotate-[135deg] translate-y-0.5" >
< path fill-rule = "evenodd" d = "M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z" clip-rule = "evenodd" / >
< / svg >
< / button >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
2024-05-29 21:20:35 +12:00
<!-- messages sidebar -->
< div v-if = "tab === 'messages'" class = "flex flex-col w-80 min-w-80" >
2024-06-01 19:07:11 +12:00
<!-- tabs -->
< div class = "bg-white border-b border-r border-gray-200" >
< div class = "-mb-px flex" >
<!-- Current: "border - blue - 500 text - blue - 600", Default: "border - transparent text - gray - 500 hover:border - gray - 300 hover:text - gray - 700" -->
< div @ click = "messagesTab = 'conversations'" class = "w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class = "[ messagesTab === 'conversations' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700']" > Conversations< / div >
< div @ click = "messagesTab = 'announces'" class = "w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class = "[ messagesTab === 'announces' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700']" > Announces< / div >
< / div >
< / div >
<!-- conversations -->
< div v-if = "messagesTab == 'conversations'" class = "flex-1 flex flex-col bg-white border-r overflow-hidden" >
<!-- search -->
< div v-if = "conversations.length > 0" class = "p-1 border-b border-gray-300" >
< input v-model = "conversationsSearchTerm" type = "text" :placeholder = "`Search ${conversations.length} Conversations...`" 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 >
<!-- peers -->
< div class = "flex h-full overflow-y-auto" >
< div v-if = "searchedConversations.length > 0" class = "w-full" >
< div @ click = "onConversationClick(conversation)" v-for = "conversation of searchedConversations" class = "flex cursor-pointer p-2 border-l-2" :class = "[ conversation.destination_hash === selectedPeer?.destination_hash ? 'bg-gray-100 border-blue-500' : 'bg-white border-transparent hover:bg-gray-50 hover:border-gray-200' ]" >
< div class = "my-auto mr-2" >
< div class = "bg-gray-200 text-gray-500 p-2 rounded" >
< 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 = "M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" / >
< / svg >
< / div >
< / div >
2024-06-01 19:45:19 +12:00
< div class = "mr-auto" >
2024-07-28 16:52:57 +12:00
< div class = "text-gray-900" :class = "{ 'font-semibold': conversation.is_unread || conversation.failed_messages_count > 0 }" > {{ conversation.name }}< / div >
2024-06-01 19:07:11 +12:00
< div class = "text-gray-500 text-sm" > {{ formatTimeAgo(conversation.updated_at) }}< / div >
< / div >
2024-06-01 19:45:19 +12:00
< div v-if = "conversation.is_unread" class = "my-auto ml-2 mr-2" >
< div class = "bg-blue-500 rounded-full p-1" > < / div >
< / div >
2024-07-28 16:52:57 +12:00
< div v-else-if = "conversation.failed_messages_count" class = "my-auto ml-2 mr-2" >
< div class = "bg-red-500 rounded-full p-1" > < / div >
< / div >
2024-06-01 19:07:11 +12:00
< / div >
< / div >
< div v-else class = "mx-auto my-auto text-center leading-5" >
<!-- no conversations at all -->
< div v-if = "conversations.length === 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 = "size-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 Conversations< / div >
< div > Discover peers on the Announces tab< / div >
< / div >
<!-- is searching, but no results -->
< div v-if = "conversationsSearchTerm !== '' && conversations.length > 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 Conversations!< / div >
< / div >
< / div >
< / div >
< / div >
<!-- discover -->
< div v-if = "messagesTab == 'announces'" class = "flex-1 flex flex-col bg-white border-r overflow-hidden" >
2024-05-29 15:28:23 +12:00
<!-- search -->
< div v-if = "peersCount > 0" class = "p-1 border-b border-gray-300" >
< input v-model = "peersSearchTerm" type = "text" :placeholder = "`Search ${peersCount} 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" >
2024-04-29 22:06:06 +12:00
< / div >
2024-04-30 23:55:07 +12:00
2024-05-06 00:10:48 +12:00
<!-- peers -->
2024-05-29 15:28:23 +12:00
< div class = "flex h-full overflow-y-auto" >
< div v-if = "searchedPeers.length > 0" class = "w-full" >
2024-05-05 23:54:39 +12:00
< div @ click = "onPeerClick(peer)" v-for = "peer of searchedPeers" class = "flex cursor-pointer p-2 border-l-2" :class = "[ peer.destination_hash === selectedPeer?.destination_hash ? 'bg-gray-100 border-blue-500' : 'bg-white border-transparent hover:bg-gray-50 hover:border-gray-200' ]" >
2024-05-05 23:51:15 +12:00
< div class = "my-auto mr-2" >
2024-05-05 23:54:39 +12:00
< div class = "bg-gray-200 text-gray-500 p-2 rounded" >
< 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 = "M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" / >
< / svg >
< / div >
2024-05-05 23:51:15 +12:00
< / div >
< div >
< div class = "text-gray-900" > {{ peer.name }}< / div >
< div class = "text-gray-500 text-sm" > {{ formatTimeAgo(peer.updated_at) }}< / div >
< / div >
2024-04-30 23:55:07 +12:00
< / div >
< / div >
2024-05-05 23:51:15 +12:00
< 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 >
2024-04-30 23:55:07 +12:00
2024-05-05 23:51:15 +12:00
<!-- 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 >
2024-04-30 23:55:07 +12:00
< / div >
2024-05-05 23:51:15 +12:00
< / div >
2024-05-29 15:28:23 +12:00
< / div >
< / div >
2024-06-01 19:07:11 +12:00
2024-05-29 21:20:35 +12:00
< / div >
2024-05-29 15:28:23 +12:00
2024-05-29 21:20:35 +12:00
<!-- nomadnetwork sidebar -->
< div v-if = "tab === 'nomadnetwork'" class = "flex flex-col w-80 min-w-80" >
< div class = "flex-1 flex flex-col bg-white border-r overflow-hidden" >
2024-05-29 15:28:23 +12:00
<!-- search -->
< div v-if = "nodesCount > 0" class = "p-1 border-b border-gray-300" >
< input v-model = "nodesSearchTerm" type = "text" :placeholder = "`Search ${nodesCount} Nodes...`" 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 >
2024-05-05 23:51:15 +12:00
2024-05-06 00:10:48 +12:00
<!-- nodes -->
2024-05-29 15:28:23 +12:00
< div class = "flex h-full overflow-y-auto" >
< div v-if = "searchedNodes.length > 0" class = "w-full" >
2024-05-06 00:10:48 +12:00
< div @ click = "onNodeClick(node)" v-for = "node of searchedNodes" class = "flex cursor-pointer p-2 border-l-2" :class = "[ node.destination_hash === selectedNode?.destination_hash ? 'bg-gray-100 border-blue-500' : 'bg-white border-transparent hover:bg-gray-50 hover:border-gray-200' ]" >
< div class = "my-auto mr-2" >
< div class = "bg-gray-200 text-gray-500 p-2 rounded" >
< 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 = "M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z" / >
< / svg >
< / div >
< / div >
< div >
< div class = "text-gray-900" > {{ node.name }}< / div >
< div class = "text-gray-500 text-sm" > {{ formatTimeAgo(node.updated_at) }}< / div >
< / div >
< / div >
< / div >
< div v-else class = "mx-auto my-auto text-center leading-5" >
<!-- no nodes at all -->
< div v-if = "nodesCount === 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 Nodes Discovered< / div >
< div > Waiting for a node to announce!< / div >
< / div >
<!-- is searching, but no results -->
< div v-if = "nodesSearchTerm !== '' && nodesCount > 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 Nodes!< / div >
< / div >
2024-04-29 22:28:34 +12:00
< / div >
< / div >
2024-05-21 18:29:50 +12:00
< / div >
2024-04-29 22:06:06 +12:00
< / div >
2024-04-29 01:09:06 +12:00
2024-05-08 01:20:03 +12:00
<!-- main view -->
2024-05-29 21:23:56 +12:00
< div class = "flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px]" >
2024-04-29 01:09:06 +12:00
2024-05-29 15:28:23 +12:00
<!-- messages tab -->
< template v-if = "tab === 'messages'" >
2024-04-29 01:09:06 +12:00
2024-05-06 00:43:25 +12:00
<!-- peer selected -->
2024-05-30 01:50:04 +12:00
< div v-if = "selectedPeer" class = "m-2 flex flex-col h-full border rounded-xl bg-white shadow overflow-hidden" >
2024-04-29 22:06:06 +12:00
2024-05-06 00:43:25 +12:00
<!-- header -->
< div class = "flex p-2 border-b border-gray-300" >
2024-04-29 22:06:06 +12:00
2024-05-06 00:43:25 +12:00
<!-- peer info -->
< div >
< div class = "font-semibold" > {{ selectedPeer.name }}< / div >
2024-05-18 21:19:22 +12:00
< div class = "text-sm" > < {{ selectedPeer.destination_hash }}> < span v-if = "selectedPeerPath" @ click = "onDestinationPathClick(selectedPeerPath)" class = "cursor-pointer" > {{ selectedPeerPath.hops }} {{ selectedPeerPath.hops === 1 ? 'hop' : 'hops' }} away< / span > < / div >
2024-05-06 00:43:25 +12:00
< / div >
2024-05-23 22:15:03 +12:00
<!-- call button -->
2024-05-06 00:43:25 +12:00
< div class = "ml-auto my-auto mr-2" >
2024-05-23 22:15:03 +12:00
< a :href = "`call.html?destination_hash=${selectedPeer.destination_hash}`" target = "_blank" 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" 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 = "M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" / >
< / svg >
< / div >
< / div >
< / a >
< / div >
<!-- delete button -->
< div class = "my-auto mr-2" >
2024-05-06 00:43:25 +12:00
< div @ click = "deleteConversation" 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" 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 = "m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" / >
< / svg >
< / div >
2024-05-05 02:40:03 +12:00
< / div >
< / div >
< / div >
2024-05-06 00:43:25 +12:00
<!-- close button -->
< div class = "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 >
2024-04-29 22:06:06 +12:00
< / div >
2024-04-29 01:09:06 +12:00
< / div >
2024-04-29 22:06:06 +12:00
< / div >
2024-05-06 00:43:25 +12:00
2024-04-29 22:06:06 +12:00
< / div >
2024-05-06 00:43:25 +12:00
<!-- chat items -->
2024-07-12 21:13:43 +12:00
< div id = "messages" class = "h-full overflow-y-scroll" >
2024-05-06 00:43:25 +12:00
< 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 }" >
2024-04-29 22:06:06 +12:00
2024-05-06 00:43:25 +12:00
<!-- message content -->
< div @ click = "onChatItemClick(chatItem)" class = "border border-gray-300 rounded-xl shadow overflow-hidden" :class = "[ chatItem.lxmf_message.state === 'failed' ? 'bg-red-500 text-white' : chatItem.is_outbound ? 'bg-[#3b82f6] text-white' : 'bg-[#efefef]' ]" >
2024-04-30 04:03:01 +12:00
2024-05-06 00:43:25 +12:00
< div class = "w-full space-y-0.5 px-2.5 py-1" >
2024-05-05 20:06:04 +12:00
2024-05-06 00:43:25 +12:00
<!-- content -->
< div v-if = "chatItem.lxmf_message.content" style = "white-space:pre-wrap;word-wrap:break-word;font-family:inherit;" > {{ chatItem.lxmf_message.content }}< / div >
2024-05-05 02:17:37 +12:00
2024-05-06 00:43:25 +12:00
<!-- image field -->
< div v-if = "chatItem.lxmf_message.fields?.image" >
< 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 cursor-pointer" / >
< / div >
2024-05-05 02:17:37 +12:00
2024-06-03 01:17:05 +12:00
<!-- audio field -->
< div v-if = "chatItem.lxmf_message.fields?.audio" >
<!-- audio is loaded -->
< audio v-if = "lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]" controls class = "shadow rounded-full mb-1" >
< source :src = "lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]" type = "audio/wav" / >
< / audio >
<!-- audio is not yet loaded -->
< div v-else >
< button @ click = "downloadFileFromBase64('audio.bin', chatItem.lxmf_message.fields.audio.audio_bytes)" type = "button" class = "flex border border-gray-300 hover:bg-gray-100 rounded px-2 py-1 text-sm text-gray-700 font-semibold cursor-pointer space-x-2 bg-[#efefef]" >
< span 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 = "size-6" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" / >
< / svg >
< / span >
< span class = "my-auto w-full" >
Unsupported Audio (mode {{ chatItem.lxmf_message.fields.audio.audio_mode }})
< / span >
< span 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-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 >
< / span >
< / button >
< / div >
< / div >
2024-05-06 00:43:25 +12:00
<!-- file attachment fields -->
< div v-if = "chatItem.lxmf_message.fields?.file_attachments" class = "space-y-1" >
< 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-300 hover:bg-gray-100 rounded px-2 py-1 text-sm text-gray-700 font-semibold cursor-pointer space-x-2 bg-[#efefef]" >
< div 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-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 w-full" > {{ file_attachment.file_name }}< / div >
< div 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-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 >
2024-05-05 02:17:37 +12:00
2024-04-29 03:15:47 +12:00
< / div >
2024-05-05 02:17:37 +12:00
2024-05-06 00:43:25 +12:00
<!-- actions -->
< div v-if = "chatItem.is_actions_expanded" class = "border-t p-1 bg-[#efefef] text-white" >
2024-05-05 20:06:04 +12:00
2024-05-06 00:43:25 +12:00
<!-- delete message -->
< button @ click . stop = "deleteChatItem(chatItem)" type = "button" class = "inline-flex items-center gap-x-1 rounded-md bg-red-500 px-2 py-1 text-xs font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500" >
Delete
< / button >
2024-05-05 20:06:04 +12:00
2024-05-06 00:43:25 +12:00
< / div >
2024-05-05 20:06:04 +12:00
< / div >
2024-05-06 00:43:25 +12:00
<!-- message state -->
< div v-if = "chatItem.is_outbound" class = "flex text-right" :class = "[ chatItem.lxmf_message.state === 'failed' ? 'text-red-500' : 'text-gray-500' ]" >
< div class = "flex ml-auto space-x-1" >
2024-04-30 03:15:42 +12:00
2024-05-06 00:43:25 +12:00
<!-- state label -->
< div class = "my-auto space-x-1" >
< span > {{ chatItem.lxmf_message.state }}< / span >
2024-07-08 17:26:45 +12:00
< span v-if = "chatItem.lxmf_message.state === 'outbound' && chatItem.lxmf_message.delivery_attempts >= 1" > (attempt {{ chatItem.lxmf_message.delivery_attempts + 1 }})< / span >
2024-05-06 00:43:25 +12:00
< span v-if = "chatItem.lxmf_message.state === 'sending'" > {{ chatItem.lxmf_message.progress.toFixed(0) }}%< / span >
2024-07-08 22:04:22 +12:00
< a v-if = "chatItem.lxmf_message.state === 'failed'" @ click = "retrySendingMessage(chatItem)" class = "cursor-pointer underline text-blue-500" > retry?< / a >
2024-05-06 00:43:25 +12:00
< / div >
2024-04-30 04:45:14 +12:00
2024-05-06 00:43:25 +12:00
<!-- 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 >
2024-04-30 04:45:14 +12:00
2024-05-06 00:43:25 +12:00
<!-- failed icon -->
< div v-else-if = "chatItem.lxmf_message.state === 'failed'" 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 >
2024-04-30 04:45:14 +12:00
2024-05-06 00:43:25 +12:00
<!-- 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 >
2024-04-30 04:45:14 +12:00
< / div >
< / div >
2024-04-30 03:15:42 +12:00
2024-07-28 17:56:23 +12:00
<!-- received timestamp -->
< div v-if = "!chatItem.is_outbound" class = "text-xs text-gray-500 mt-0.5" >
{{ formatSecondsAgo(chatItem.lxmf_message.timestamp) }}
< / div >
2024-05-06 00:43:25 +12:00
< / div >
2024-04-29 22:06:06 +12:00
< / div >
< / div >
2024-05-05 00:49:09 +12:00
2024-05-06 00:43:25 +12:00
<!-- send message -->
< div class = "w-full border-gray-300 border-t p-2" >
< div class = mx-auto" >
2024-05-05 02:11:47 +12:00
2024-05-06 00:43:25 +12:00
<!-- message composer -->
< div >
2024-05-05 02:11:47 +12:00
2024-05-06 00:43:25 +12:00
<!-- image attachment -->
< div v-if = "newMessageImage" class = "mb-2" >
< div class = "w-32 h-32 rounded shadow border relative overflow-hidden" >
<!-- image preview -->
< img v-if = "newMessageImageUrl" :src = "newMessageImageUrl" class = "w-full h-full" / >
<!-- remove button (top right) -->
< div class = "absolute top-0 right-0 p-1" >
< div @ click = "removeImageAttachment" href = "javascript:void(0)" class = "cursor-pointer" >
< div class = "flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-1 rounded-full" >
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 20 20" fill = "currentColor" class = "w-4 h-4" >
< 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 >
2024-05-05 02:11:47 +12:00
< / div >
< / div >
2024-05-06 00:43:25 +12:00
<!-- image size (bottom left) -->
< div class = "absolute bottom-0 left-0 p-1" >
< div class = "bg-gray-100 rounded border text-sm px-1" > {{ formatBytes(newMessageImage.size) }}< / div >
< / div >
2024-05-05 02:11:47 +12:00
2024-05-06 00:43:25 +12:00
< / div >
2024-05-05 02:11:47 +12:00
< / div >
2024-06-03 02:51:39 +12:00
<!-- audio attachment -->
< div v-if = "newMessageAudio" class = "mb-2" >
< div class = "flex flex-wrap gap-1" >
< div class = "flex border border-gray-300 rounded text-gray-700 divide-x divide-gray-300 overflow-hidden" >
2024-06-03 14:24:07 +12:00
< div class = "flex p-1" >
<!-- audio preview -->
< div >
< audio controls class = "h-10" >
< source :src = "newMessageAudio.audio_wav_url" type = "audio/wav" / >
< / audio >
< / div >
<!-- encoded file size -->
< div class = "my-auto px-1 text-sm text-gray-500" >
{{ formatBytes(newMessageAudio.audio_blob.size) }}
< / div >
2024-06-03 02:51:39 +12:00
< / div >
2024-06-03 14:24:07 +12:00
<!-- remove audio attachment -->
2024-06-03 02:51:39 +12:00
< div @ click = "removeAudioAttachment" class = "flex my-auto text-sm text-gray-500 h-full px-1 hover:bg-gray-200 cursor-pointer" >
< svg class = "w-5 h-5 my-auto" xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M6 18 18 6M6 6l12 12" / >
< / svg >
< / div >
2024-06-03 14:24:07 +12:00
2024-06-03 02:51:39 +12:00
< / div >
< / div >
< / div >
2024-05-06 00:43:25 +12:00
<!-- file attachments -->
< div v-if = "newMessageFiles.length > 0" class = "mb-2" >
< div class = "flex flex-wrap gap-1" >
< div v-for = "file in newMessageFiles" class = "flex border border-gray-300 rounded text-gray-700 divide-x divide-gray-300 overflow-hidden" >
< div class = "my-auto px-1" >
< span class = "mr-1" > {{ file.name }}< / span >
< span class = "my-auto text-sm text-gray-500" > {{ formatBytes(file.size) }}< / span >
< / div >
< div @ click = "removeFileAttachment(file)" class = "flex my-auto text-sm text-gray-500 h-full px-1 hover:bg-gray-200 cursor-pointer" >
< svg class = "w-5 h-5 my-auto" xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M6 18 18 6M6 6l12 12" / >
< / svg >
< / div >
2024-05-05 00:49:09 +12:00
< / div >
< / div >
< / div >
2024-05-06 00:43:25 +12:00
<!-- text input -->
< 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 >
2024-05-05 00:49:09 +12:00
2024-05-06 00:43:25 +12:00
<!-- action button -->
< div class = "flex mt-2" >
2024-05-05 00:49:09 +12:00
2024-05-06 00:43:25 +12:00
<!-- add files -->
< button @ click = "addFilesToMessage" type = "button" class = "my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 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" >
< 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 = "M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875ZM12.75 12a.75.75 0 0 0-1.5 0v2.25H9a.75.75 0 0 0 0 1.5h2.25V18a.75.75 0 0 0 1.5 0v-2.25H15a.75.75 0 0 0 0-1.5h-2.25V12Z" clip-rule = "evenodd" / >
< path d = "M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z" / >
< / svg >
2024-07-12 20:58:34 +12:00
< span class = "ml-1 hidden sm:inline-block" > Add Files< / span >
2024-05-06 00:43:25 +12:00
< / button >
2024-05-05 00:49:09 +12:00
2024-05-06 00:43:25 +12:00
<!-- add image -->
< button @ click = "addImageToMessage" type = "button" class = "my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 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" >
< 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 = "M1.5 6a2.25 2.25 0 0 1 2.25-2.25h16.5A2.25 2.25 0 0 1 22.5 6v12a2.25 2.25 0 0 1-2.25 2.25H3.75A2.25 2.25 0 0 1 1.5 18V6ZM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0 0 21 18v-1.94l-2.69-2.689a1.5 1.5 0 0 0-2.12 0l-.88.879.97.97a.75.75 0 1 1-1.06 1.06l-5.16-5.159a1.5 1.5 0 0 0-2.12 0L3 16.061Zm10.125-7.81a1.125 1.125 0 1 1 2.25 0 1.125 1.125 0 0 1-2.25 0Z" clip-rule = "evenodd" / >
< / svg >
2024-07-12 20:58:34 +12:00
< span class = "ml-1 hidden sm:inline-block" > Add Image< / span >
2024-05-06 00:43:25 +12:00
< / button >
2024-05-05 02:11:47 +12:00
2024-06-03 02:51:39 +12:00
<!-- add audio -->
< div >
< button v-if = "isRecordingAudioAttachment" @ click = "stopRecordingAudioAttachment" type = "button" class = "my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-red-500 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500" >
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 20 20" fill = "currentColor" class = "size-5" >
< path d = "M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" / >
< path d = "M5.5 9.643a.75.75 0 0 0-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-1.546A6.001 6.001 0 0 0 16 10v-.357a.75.75 0 0 0-1.5 0V10a4.5 4.5 0 0 1-9 0v-.357Z" / >
< / svg >
< span class = "ml-1" > Recording: {{ audioAttachmentRecordingDuration }}< / span >
< / button >
< button v-else @ click = "startRecordingAudioAttachment" type = "button" class = "my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 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" >
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 20 20" fill = "currentColor" class = "size-5" >
< path d = "M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" / >
< path d = "M5.5 9.643a.75.75 0 0 0-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-1.546A6.001 6.001 0 0 0 16 10v-.357a.75.75 0 0 0-1.5 0V10a4.5 4.5 0 0 1-9 0v-.357Z" / >
< / svg >
2024-07-12 20:58:34 +12:00
< span class = "ml-1 hidden sm:inline-block" > Add Voice< / span >
2024-06-03 02:51:39 +12:00
< / button >
< / div >
2024-05-06 00:43:25 +12:00
<!-- send message -->
< button @ click = "sendMessage" :disabled = "!canSendMessage" type = "button" class = "ml-auto my-auto inline-flex items-center gap-x-1 rounded-md px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm 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']" >
2024-07-12 20:54:07 +12:00
< span v-if = "isSendingMessage" > Sending...< / span >
< span v-else > Send< / span >
2024-05-06 00:43:25 +12:00
< / button >
< / div >
2024-05-05 00:49:09 +12:00
2024-04-29 03:15:47 +12:00
< / div >
2024-05-05 00:49:09 +12:00
2024-04-29 01:09:06 +12:00
< / div >
2024-05-06 00:43:25 +12:00
< / 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" >
2024-05-29 15:28:23 +12:00
< 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" / >
2024-05-06 00:43:25 +12:00
< / svg >
< / div >
< div class = "font-semibold" > No Active Chat< / div >
< div > Select a Peer to start chatting!< / div >
< / div >
2024-04-29 22:06:06 +12:00
2024-05-06 00:43:25 +12:00
< / template >
2024-05-29 15:28:23 +12:00
<!-- nomadnetwork tab -->
< template v-if = "tab === 'nomadnetwork'" >
2024-05-06 00:43:25 +12:00
2024-05-29 15:28:23 +12:00
<!-- node -->
2024-05-29 21:23:56 +12:00
< div v-if = "selectedNode" class = "m-2 flex flex-col h-full border rounded-xl bg-white shadow overflow-hidden" >
2024-05-06 00:43:25 +12:00
2024-05-29 15:28:23 +12:00
<!-- header -->
< div class = "flex p-2 border-b border-gray-300" >
2024-05-06 00:43:25 +12:00
2024-05-29 15:28:23 +12:00
<!-- node info -->
< div class = "my-auto" >
2024-07-30 22:23:20 +12:00
< span class = "font-semibold" > {{ selectedNode.name }}< / span >
< span v-if = "selectedNodePath" @ click = "onDestinationPathClick(selectedNodePath)" class = "text-sm cursor-pointer" > - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away< / span >
2024-05-29 15:28:23 +12:00
< / div >
<!-- close button -->
< div class = "my-auto ml-auto mr-2" >
2024-07-30 22:23:20 +12:00
< div @ click = "selectedNode = null" class = "cursor-pointer" >
< div class = "flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-1 rounded-full" >
2024-05-29 15:28:23 +12:00
< 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 >
2024-05-06 00:43:25 +12:00
< / div >
< / div >
< / div >
2024-05-29 15:28:23 +12:00
2024-05-06 00:43:25 +12:00
< / div >
2024-05-29 15:28:23 +12:00
<!-- browser navigation -->
< div class = "flex w-full border-gray-300 border-b p-2" >
< button @ click = "loadNodePage(selectedNode.destination_hash, '/page/index.mu')" type = "button" class = "my-auto text-gray-500 bg-gray-200 hover:bg-gray-300 rounded p-1 cursor-pointer" >
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 20 20" fill = "currentColor" class = "w-5 h-5" >
< path fill-rule = "evenodd" d = "M9.293 2.293a1 1 0 0 1 1.414 0l7 7A1 1 0 0 1 17 11h-1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6H3a1 1 0 0 1-.707-1.707l7-7Z" clip-rule = "evenodd" / >
< / svg >
< / button >
< button @ click = "reloadNodePage" type = "button" class = "ml-1 my-auto text-gray-500 bg-gray-200 hover:bg-gray-300 rounded p-1 cursor-pointer" >
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 20 20" fill = "currentColor" class = "w-5 h-5" >
< path fill-rule = "evenodd" d = "M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z" clip-rule = "evenodd" / >
< / svg >
< / button >
< button @ click = "loadPreviousNodePage" type = "button" :disabled = "nodePagePathHistory.length === 0" :class = "[ nodePagePathHistory.length > 0 ? 'text-gray-500 bg-gray-200 hover:bg-gray-300' : 'text-gray-400 bg-gray-100']" class = "ml-1 my-auto rounded p-1 cursor-pointer" >
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 20 20" fill = "currentColor" class = "w-5 h-5" >
< path fill-rule = "evenodd" d = "M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule = "evenodd" / >
< / svg >
< / button >
< div class = "my-auto mx-2 w-full" >
< input v-model = "nodePagePathUrlInput" @ keyup . enter = "onNodePageUrlClick(nodePagePathUrlInput)" type = "text" placeholder = "Enter Destination URL" 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 px-2.5 py-1.5" >
< / div >
< button @ click = "onNodePageUrlClick(nodePagePathUrlInput)" type = "button" class = "my-auto text-gray-500 bg-gray-200 hover:bg-gray-300 rounded p-1 cursor-pointer" >
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 20 20" fill = "currentColor" class = "w-5 h-5" >
< path fill-rule = "evenodd" d = "M3 10a.75.75 0 0 1 .75-.75h10.638L10.23 5.29a.75.75 0 1 1 1.04-1.08l5.5 5.25a.75.75 0 0 1 0 1.08l-5.5 5.25a.75.75 0 1 1-1.04-1.08l4.158-3.96H3.75A.75.75 0 0 1 3 10Z" clip-rule = "evenodd" / >
< / svg >
< / button >
< / div >
2024-04-29 22:06:06 +12:00
2024-05-29 15:28:23 +12:00
<!-- page content -->
< div class = "h-full overflow-y-scroll p-3 bg-black text-white" >
< div class = "flex" v-if = "isLoadingNodePage" >
< div class = "my-auto" >
< svg class = "animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" >
< circle class = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" stroke-width = "4" > < / circle >
< path class = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" > < / path >
< / svg >
< / div >
< div class = "my-auto" > Loading {{ nodePageProgress }}%< / div >
< / div >
< pre v-else v-html = "nodePageContent" class = "h-full text-wrap" > < / pre >
2024-05-26 21:21:17 +12:00
< / div >
2024-04-29 01:09:06 +12:00
2024-05-29 15:28:23 +12:00
<!-- file download bottom bar -->
< div v-if = "isDownloadingNodeFile" class = "flex w-full border-gray-300 border-t p-2" >
< div class = "my-auto mr-2" >
< svg class = "animate-spin h-5 w-5" xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" >
2024-05-08 01:20:03 +12:00
< circle class = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" stroke-width = "4" > < / circle >
< path class = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" > < / path >
< / svg >
< / div >
2024-05-29 15:28:23 +12:00
< div class = "my-auto" > Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)< / div >
2024-05-06 00:43:25 +12:00
< / div >
2024-05-29 15:28:23 +12:00
2024-04-29 01:09:06 +12:00
< / div >
2024-05-06 00:43:25 +12:00
2024-05-29 15:28:23 +12:00
<!-- no node 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 = "M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" / >
2024-05-10 23:04:39 +12:00
< / svg >
< / div >
2024-05-29 15:28:23 +12:00
< div class = "font-semibold" > No Active Node< / div >
< div > Select a Node to start browsing!< / div >
2024-05-10 23:04:39 +12:00
< / div >
2024-05-29 15:28:23 +12:00
< / template >
2024-04-29 01:09:06 +12:00
2024-05-29 21:20:35 +12:00
<!-- interfaces tab -->
2024-05-29 21:23:56 +12:00
< div v-if = "tab === 'interfaces'" class = "overflow-y-auto p-2 space-y-2" >
2024-05-29 21:43:33 +12:00
<!-- warning -->
2024-07-09 17:24:23 +12:00
< div class = "flex bg-orange-500 p-2 text-sm font-semibold leading-6 text-white rounded shadow" >
< div class = "my-auto" >
2024-05-29 21:43:33 +12:00
< 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 = "m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" / >
< / svg >
< / div >
2024-07-09 17:24:23 +12:00
< div class = "ml-2 my-auto" > Reticulum MeshChat must be restarted for any interface changes to take effect.< / div >
< button v-if = "isElectron" @ click = "relaunch" type = "button" class = "ml-auto my-auto inline-flex items-center gap-x-1 rounded-md bg-white px-2 py-1 text-sm font-semibold text-black shadow-sm hover:bg-gray-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" >
< span > Restart Now< / span >
< / button >
2024-05-29 21:43:33 +12:00
< / div >
2024-05-30 01:16:46 +12:00
< button @ click = "showAddInterfaceForm" 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" >
2024-05-30 00:03:13 +12:00
< svg xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" class = "size-5" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M12 4.5v15m7.5-7.5h-15" / >
< / svg >
< span > Add Interface< / span >
< / button >
2024-05-29 21:43:33 +12:00
<!-- interface list -->
2024-05-31 11:47:21 +12:00
< div v-for = "iface of interfacesWithStats" class = "border rounded bg-white shadow overflow-hidden" >
2024-05-29 21:20:35 +12:00
2024-05-31 11:47:21 +12:00
<!-- IFAC info -->
< div v-if = "iface._stats?.ifac_signature != null" class = "bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-b" >
2024-05-30 02:41:22 +12:00
< div class = "flex text-sm" >
2024-05-31 11:19:20 +12:00
< div class = "my-auto" >
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 20 20" fill = "currentColor" class = "size-4 text-green-500" >
< path fill-rule = "evenodd" d = "M10 1a4.5 4.5 0 0 0-4.5 4.5V9H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2h-.5V5.5A4.5 4.5 0 0 0 10 1Zm3 8V5.5a3 3 0 1 0-6 0V9h6Z" clip-rule = "evenodd" / >
< / svg >
< / div >
2024-05-30 02:41:22 +12:00
< span class = "ml-1 my-auto" >
2024-05-31 11:47:21 +12:00
< span class = "text-green-500" > {{ iface._stats.ifac_size * 8 }}-bit IFAC< / span > with sig < span @ click = "onIFACSignatureClick(iface._stats.ifac_signature)" class = "cursor-pointer" > < {{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}> < / span >
2024-05-30 02:41:22 +12:00
< / span >
< / div >
< / div >
< div class = "flex py-2" >
2024-05-29 21:20:35 +12:00
<!-- icon -->
< div class = "my-auto mx-2" >
< svg v-if = "iface.type === 'AutoInterface'" xmlns = "http://www.w3.org/2000/svg" fill = "currentColor" viewBox = "0 0 256 256" class = "size-6" >
< path d = "M219.31,108.68l-80-80a16,16,0,0,0-22.62,0l-80,80A15.87,15.87,0,0,0,32,120v96a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V160h32v56a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V120A15.87,15.87,0,0,0,219.31,108.68ZM208,208H160V152a8,8,0,0,0-8-8H104a8,8,0,0,0-8,8v56H48V120l80-80,80,80Z" > < / path >
< / svg >
< svg v-else-if = "iface.type === 'RNodeInterface'" xmlns = "http://www.w3.org/2000/svg" fill = "currentColor" viewBox = "0 0 256 256" class = "size-6" >
< path d = "M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z" > < / path >
< / svg >
2024-07-12 11:30:05 +12:00
< svg v-else-if = "iface.type === 'TCPClientInterface' || iface.type === 'TCPServerInterface' || iface.type === 'UDPInterface'" xmlns = "http://www.w3.org/2000/svg" fill = "currentColor" viewBox = "0 0 256 256" class = "size-6" >
2024-05-29 21:20:35 +12:00
< path d = "M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm88,104a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.84a157.44,157.44,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154,88H102a115.11,115.11,0,0,1,26-45A115.27,115.27,0,0,1,154,88Zm52.33,0H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM107.59,42.4A135.28,135.28,0,0,0,85.29,88H49.63A88.29,88.29,0,0,1,107.59,42.4ZM49.63,168H85.29a135.28,135.28,0,0,0,22.3,45.6A88.29,88.29,0,0,1,49.63,168Zm98.78,45.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z" > < / path >
< / svg >
< svg v-else-if = "iface.type === 'SerialInterface'" xmlns = "http://www.w3.org/2000/svg" fill = "currentColor" viewBox = "0 0 256 256" class = "size-6" >
< path d = "M252.44,121.34l-48-32A8,8,0,0,0,192,96v24H72V72h33a32,32,0,1,0,0-16H72A16,16,0,0,0,56,72v48H8a8,8,0,0,0,0,16H56v48a16,16,0,0,0,16,16h32v8a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H120a16,16,0,0,0-16,16v8H72V136H192v24a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM136,48a16,16,0,1,1-16,16A16,16,0,0,1,136,48ZM120,176h32v32H120Zm88-30.95V111l25.58,17Z" > < / path >
< / svg >
< svg v-else xmlns = "http://www.w3.org/2000/svg" fill = "currentColor" viewBox = "0 0 256 256" class = "size-6" >
< path d = "M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z" > < / path >
< / svg >
< / div >
2024-05-29 23:17:40 +12:00
<!-- interface details -->
2024-05-29 21:20:35 +12:00
< div >
2024-05-31 11:47:21 +12:00
< div class = "font-semibold leading-5" > {{ iface._name }}< / div >
2024-05-29 23:17:40 +12:00
< div class = "text-sm flex space-x-1" >
<!-- auto interface -->
< span v-if = "iface.type === 'AutoInterface'" >
{{ iface.type }} • Ethernet and WiFi
< / span >
<!-- tcp client interface -->
2024-05-30 00:03:13 +12:00
< span v-else-if = "iface.type === 'TCPClientInterface'" >
2024-05-29 23:17:40 +12:00
{{ iface.type }} • {{ iface.target_host }}:{{ iface.target_port }}
< / span >
2024-07-12 11:30:05 +12:00
<!-- tcp server interface -->
< span v-else-if = "iface.type === 'TCPServerInterface'" >
{{ iface.type }} • {{ iface.listen_ip }}:{{ iface.listen_port }}
< / span >
2024-07-12 22:45:38 +12:00
<!-- udp interface -->
< span v-else-if = "iface.type === 'UDPInterface'" >
{{ iface.type }} • {{ iface.listen_ip }}:{{ iface.listen_port }} • {{ iface.forward_ip }}:{{ iface.forward_port }}
< / span >
2024-05-29 23:17:40 +12:00
<!-- rnode interface details -->
< span v-else-if = "iface.type === 'RNodeInterface'" >
{{ iface.type }} • {{ iface.port }} • freq={{ iface.frequency }} • bw={{ iface.bandwidth }} • power={{ iface.txpower }}dBm • sf={{ iface.spreadingfactor }} • cr={{ iface.codingrate }}
< / span >
<!-- unknown interface types -->
< span v-else >
{{ iface.type ?? 'Unknown Interface Type' }}
< / span >
< / div >
2024-05-29 21:20:35 +12:00
< / div >
2024-05-29 23:17:40 +12:00
<!-- enabled state badge -->
2024-05-29 21:20:35 +12:00
< div class = "ml-auto my-auto mr-2" >
2024-07-12 11:30:05 +12:00
< span v-if = "isInterfaceEnabled(iface)" class = "inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20" > Enabled< / span >
2024-05-29 22:32:38 +12:00
< span v-else class = "inline-flex items-center rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-600/20" > Disabled< / span >
< / div >
2024-05-29 23:17:40 +12:00
<!-- enable/disable interface button -->
2024-05-30 01:16:46 +12:00
< div class = "my-auto mr-1" >
2024-05-31 11:47:21 +12:00
< button v-if = "isInterfaceEnabled(iface)" @ click = "disableInterface(iface._name)" type = "button" class = "cursor-pointer" >
2024-05-29 21:37:03 +12:00
< span class = "flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" class = "size-5" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" / >
< / svg >
< / span >
< / button >
2024-05-31 11:47:21 +12:00
< button v-else @ click = "enableInterface(iface._name)" type = "button" class = "cursor-pointer" >
2024-05-29 21:37:03 +12:00
< span class = "flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" class = "size-5" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" / >
< / svg >
< / span >
< / button >
< / div >
2024-05-30 01:16:46 +12:00
<!-- edit interface button -->
< div class = "my-auto mr-1" >
2024-05-31 11:47:21 +12:00
< button @ click = "editInterface(iface._name)" type = "button" class = "cursor-pointer" >
2024-05-30 01:16:46 +12:00
< span class = "flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" class = "size-5" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" / >
< / svg >
< / span >
< / button >
< / div >
2024-05-29 23:17:40 +12:00
<!-- delete interface button -->
2024-05-29 21:37:03 +12:00
< div class = "my-auto mr-2" >
2024-05-31 11:47:21 +12:00
< button @ click = "deleteInterface(iface._name)" type = "button" class = "cursor-pointer" >
2024-05-29 21:20:35 +12:00
< span class = "flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< 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 = "m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" / >
< / svg >
< / span >
< / button >
< / div >
< / div >
2024-05-30 02:41:22 +12:00
< div class = "flex bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-t" >
2024-05-29 22:32:38 +12:00
<!-- status -->
2024-05-31 11:47:21 +12:00
< div v-if = "iface._stats?.status === true" class = "text-sm text-green-500" > Connected< / div >
2024-05-29 22:32:38 +12:00
< div v-else class = "text-sm text-red-500" > Disconnected< / div >
<!-- stats -->
2024-05-31 11:47:21 +12:00
< div > • Bitrate: {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}< / div >
< div > • TX: {{ formatBytes(iface._stats?.txb ?? 0) }}< / div >
< div > • RX: {{ formatBytes(iface._stats?.rxb ?? 0) }}< / div >
2024-05-29 21:20:35 +12:00
< / div >
< / div >
< / div >
2024-05-30 00:03:13 +12:00
<!-- add interface tab -->
< div v-if = "tab === 'interfaces.add'" class = "overflow-y-auto p-2 space-y-2" >
2024-07-28 22:30:22 +12:00
<!-- community interfaces -->
< div v-if = "!isEditingInterface && config.show_suggested_community_interfaces" class = "bg-white rounded shadow divide-y divide-gray-200" >
< div class = "flex p-2" >
< div class = "my-auto mr-auto" >
< div class = "font-bold" > Community Interfaces< / div >
< div class = "text-sm" > These TCP interfaces serve as a quick way to test Reticulum. We suggest running your own as these may not always be available.< / div >
< / div >
< div class = "my-auto ml-2" >
< button @ click = "updateConfig({'show_suggested_community_interfaces': false})" type = "button" class = "text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< 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 >
< / button >
< / div >
< / div >
< div class = "divide-y divide-gray-200" >
< div class = "flex px-2 py-1" >
< div class = "my-auto mr-auto" >
< div > RNS Testnet Amsterdam< / div >
< div class = "text-xs" > amsterdam.connect.reticulum.network:4965< / div >
< / div >
< div class = "ml-2 my-auto" >
< button @ click = "newInterfaceName='RNS Testnet Amsterdam';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='amsterdam.connect.reticulum.network';newInterfaceTargetPort='4965'" type = "button" class = "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" >
< span > Use Interface< / span >
< / button >
< / div >
< / div >
< div class = "flex px-2 py-1" >
< div class = "my-auto mr-auto" >
< div > RNS Testnet BetweenTheBorders< / div >
< div class = "text-xs" > betweentheborders.com:4242< / div >
< / div >
< div class = "ml-2 my-auto" >
< button @ click = "newInterfaceName='RNS Testnet BetweenTheBorders';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='betweentheborders.com';newInterfaceTargetPort='4242'" type = "button" class = "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" >
< span > Use Interface< / span >
< / button >
< / div >
< / div >
< / div >
< / div >
2024-05-30 00:03:13 +12:00
<!-- add interface form -->
< div class = "bg-white rounded shadow divide-y divide-gray-200" >
2024-05-30 01:16:46 +12:00
< div class = "p-2 font-bold" >
< span v-if = "isEditingInterface" > Edit Interface< / span >
< span v-else > Add Interface< / span >
< / div >
2024-05-30 00:03:13 +12:00
< div class = "p-2 space-y-3" >
<!-- interface name -->
< div >
< label class = "block mb-2 text-sm font-medium text-gray-900" > Name< / label >
2024-07-18 22:17:40 +12:00
< input type = "text" :disabled = "isEditingInterface" placeholder = "New Interface Name" v-model = "newInterfaceName" class = "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" :class = "[ isEditingInterface ? 'cursor-not-allowed bg-gray-200' : 'bg-gray-50' ]" >
2024-05-30 00:53:42 +12:00
< div class = "text-xs text-gray-600" > Interface names must be unique.< / div >
2024-05-30 00:03:13 +12:00
< / div >
<!-- interface type -->
< div class = "mb-2" >
< label class = "block mb-2 text-sm font-medium text-gray-900" > Type< / label >
< select v-model = "newInterfaceType" 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" >
2024-05-30 00:48:25 +12:00
< option disabled selected > --< / option >
2024-05-30 00:03:13 +12:00
< option value = "AutoInterface" > AutoInterface< / option >
2024-05-30 00:41:03 +12:00
< option value = "RNodeInterface" > RNodeInterface< / option >
2024-05-30 00:03:13 +12:00
< option value = "TCPClientInterface" > TCPClientInterface< / option >
2024-07-12 11:41:28 +12:00
< option value = "TCPServerInterface" > TCPServerInterface< / option >
2024-07-12 22:45:38 +12:00
< option value = "UDPInterface" > UDPInterface< / option >
2024-05-30 00:03:13 +12:00
< / select >
2024-05-30 00:53:42 +12:00
< div class = "text-xs text-gray-600" >
2024-07-28 22:30:22 +12:00
Need help? < a class = "text-blue-500 underline" href = "https://reticulum.network/manual/interfaces.html" target = "_blank" > Reticulum Docs: Configuring Interfaces< / a >
2024-05-30 00:53:42 +12:00
< / div >
2024-05-30 00:03:13 +12:00
< / div >
<!-- interface target host -->
< div v-if = "newInterfaceType === 'TCPClientInterface'" class = "mb-2" >
< label class = "block mb-2 text-sm font-medium text-gray-900" > Target Host< / label >
< input type = "text" placeholder = "example.com" v-model = "newInterfaceTargetHost" 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 >
<!-- interface target port -->
< div v-if = "newInterfaceType === 'TCPClientInterface'" class = "mb-2" >
< label class = "block mb-2 text-sm font-medium text-gray-900" > Target Port< / label >
< input type = "text" placeholder = "1234" v-model = "newInterfaceTargetPort" 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 >
2024-07-12 11:41:28 +12:00
<!-- interface listen ip -->
2024-07-12 22:45:38 +12:00
< div v-if = "newInterfaceType === 'TCPServerInterface' || newInterfaceType === 'UDPInterface'" class = "mb-2" >
2024-07-12 11:41:28 +12:00
< label class = "block mb-2 text-sm font-medium text-gray-900" > Listen IP< / label >
< input type = "text" placeholder = "0.0.0.0" v-model = "newInterfaceListenIp" 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 >
<!-- interface listen port -->
2024-07-12 22:45:38 +12:00
< div v-if = "newInterfaceType === 'TCPServerInterface' || newInterfaceType === 'UDPInterface'" class = "mb-2" >
2024-07-12 11:41:28 +12:00
< label class = "block mb-2 text-sm font-medium text-gray-900" > Listen Port< / label >
< input type = "text" placeholder = "1234" v-model = "newInterfaceListenPort" 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 >
2024-07-12 22:45:38 +12:00
<!-- interface forward ip -->
< div v-if = "newInterfaceType === 'UDPInterface'" class = "mb-2" >
< label class = "block mb-2 text-sm font-medium text-gray-900" > Forward IP< / label >
< input type = "text" placeholder = "255.255.255.255" v-model = "newInterfaceForwardIp" 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 >
<!-- interface listen port -->
< div v-if = "newInterfaceType === 'UDPInterface'" class = "mb-2" >
< label class = "block mb-2 text-sm font-medium text-gray-900" > Forward Port< / label >
< input type = "text" placeholder = "1234" v-model = "newInterfaceForwardPort" 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 >
2024-05-30 00:41:03 +12:00
<!-- interface port -->
< div v-if = "newInterfaceType === 'RNodeInterface'" class = "mb-2" >
< label class = "block mb-2 text-sm font-medium text-gray-900" > Port< / label >
< select v-model = "newInterfacePort" 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" >
< option v-for = "comport of comports" :value = "comport.device" > {{ comport.device }} (Product: {{ comport.product ?? '?' }}, Serial: {{ comport.serial ?? '?' }})< / option >
< / select >
< / div >
<!-- interface frequency -->
< div v-if = "newInterfaceType === 'RNodeInterface'" class = "mb-2" >
2024-06-04 12:10:05 +12:00
< label class = "block mb-2 text-sm font-medium text-gray-900" > Frequency (Hz)< / label >
2024-06-04 12:58:42 +12:00
< input type = "number" v-model = "newInterfaceFrequency" 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" >
2024-06-04 12:10:05 +12:00
< div class = "text-xs text-gray-600" > {{ formatFrequency(newInterfaceFrequency) }}< / div >
2024-05-30 00:41:03 +12:00
< / div >
<!-- interface bandwidth -->
< div v-if = "newInterfaceType === 'RNodeInterface'" class = "mb-2" >
< label class = "block mb-2 text-sm font-medium text-gray-900" > Bandwidth< / label >
2024-06-03 14:43:03 -04:00
< select v-model = "newInterfaceBandwidth" 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" >
2024-06-04 12:28:09 +12:00
< option v-for = "bandwidth in RNodeInterfaceDefaults.bandwidths" :value = "bandwidth" > {{ bandwidth / 1000 }} KHz< / option >
2024-06-03 14:43:03 -04:00
< / select >
2024-05-30 00:41:03 +12:00
< / div >
<!-- interface txpower -->
< div v-if = "newInterfaceType === 'RNodeInterface'" class = "mb-2" >
2024-06-04 12:14:25 +12:00
< label class = "block mb-2 text-sm font-medium text-gray-900" > Transmit Power (dBm)< / label >
< input v-model = "newInterfaceTxpower" type = "number" 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" >
2024-05-30 00:41:03 +12:00
< / div >
<!-- interface spreading factor -->
< div v-if = "newInterfaceType === 'RNodeInterface'" class = "mb-2" >
< label class = "block mb-2 text-sm font-medium text-gray-900" > Spreading Factor< / label >
2024-06-03 14:43:03 -04:00
< select v-model = "newInterfaceSpreadingFactor" 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" >
2024-06-04 12:28:09 +12:00
< option v-for = "spreadingfactor in RNodeInterfaceDefaults.spreadingfactors" :value = "spreadingfactor" > {{ spreadingfactor }}< / option >
2024-06-03 14:43:03 -04:00
< / select >
2024-05-30 00:41:03 +12:00
< / div >
<!-- interface coding rate -->
< div v-if = "newInterfaceType === 'RNodeInterface'" class = "mb-2" >
< label class = "block mb-2 text-sm font-medium text-gray-900" > Coding Rate< / label >
2024-06-03 14:43:03 -04:00
< select v-model = "newInterfaceCodingRate" 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" >
2024-06-04 12:28:09 +12:00
< option v-for = "codingrate in RNodeInterfaceDefaults.codingrates" :value = "codingrate" > 4:{{ codingrate }}< / option >
2024-06-03 14:43:03 -04:00
< / select >
2024-05-30 00:41:03 +12:00
< / div >
2024-05-30 00:03:13 +12:00
<!-- add button -->
< button @ click = "addInterface" type = "button" class = "bg-green-500 hover:bg-green-400 focus-visible:outline-green-500 my-auto inline-flex items-center gap-x-1 rounded-md p-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" >
2024-05-30 01:16:46 +12:00
< span v-if = "isEditingInterface" > Save Interface< / span >
< span v-else > Add Interface< / span >
2024-05-30 00:03:13 +12:00
< / button >
< / div >
< / div >
< / div >
2024-07-28 15:36:37 +12:00
<!-- settings tab -->
< div v-if = "tab === 'settings'" class = "overflow-y-auto space-y-2 p-2" >
2024-07-28 22:30:22 +12:00
<!-- failed messages -->
2024-07-28 15:36:37 +12:00
< div class = "bg-white rounded shadow" >
2024-07-28 15:39:00 +12:00
< div class = "flex border-b border-gray-300 text-gray-700 p-2 font-semibold" > Failed Messages< / div >
2024-07-28 15:36:37 +12:00
< div class = "divide-y text-gray-900" >
< div class = "p-2" >
< div class = "flex items-start" >
< div class = "flex items-center h-5" >
< input v-model = "config.auto_resend_failed_messages_when_announce_received" @ change = "onAutoResendFailedMessagesWhenAnnounceReceivedChange" type = "checkbox" class = "w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" >
< / div >
2024-07-28 15:39:00 +12:00
< label class = "ml-2 text-sm font-medium text-gray-900" > Auto resend< / label >
2024-07-28 15:36:37 +12:00
< / div >
< div class = "text-sm text-gray-700" > When enabled, failed messages will auto resend when an announce is received from the intended destination.< / div >
< / div >
< div class = "p-2" >
< div class = "flex items-start" >
< div class = "flex items-center h-5" >
< input v-model = "config.allow_auto_resending_failed_messages_with_attachments" @ change = "onAllowAutoResendingFailedMessagesWithAttachmentsChange" type = "checkbox" class = "w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" >
< / div >
2024-07-28 15:39:00 +12:00
< label class = "ml-2 text-sm font-medium text-gray-900" > Allow resending with attachments< / label >
2024-07-28 15:36:37 +12:00
< / div >
< div class = "text-sm text-gray-700" > When enabled, failed messages that have attachments are allowed to auto resend.< / div >
< / div >
< / div >
< / div >
2024-07-28 22:30:22 +12:00
<!-- interfaces -->
< div class = "bg-white rounded shadow" >
< div class = "flex border-b border-gray-300 text-gray-700 p-2 font-semibold" > Interfaces< / div >
< div class = "divide-y text-gray-900" >
< div class = "p-2" >
< div class = "flex items-start" >
< div class = "flex items-center h-5" >
< input v-model = "config.show_suggested_community_interfaces" @ change = "onShowSuggestedCommunityInterfacesChange" type = "checkbox" class = "w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" >
< / div >
< label class = "ml-2 text-sm font-medium text-gray-900" > Show Community Interfaces< / label >
< / div >
< div class = "text-sm text-gray-700" > When enabled, community interfaces will be shown on the Add Interface page.< / div >
< / div >
< / div >
< / div >
2024-07-28 15:36:37 +12:00
< / div >
2024-06-02 13:56:04 +12:00
<!-- about tab -->
< div v-if = "tab === 'about'" class = "overflow-y-auto space-y-2 p-2" >
<!-- app info -->
< div v-if = "appInfo" class = "bg-white rounded shadow" >
< div class = "flex border-b border-gray-300 text-gray-700 p-2 font-semibold" > App Info< / div >
< div class = "divide-y text-gray-900" >
2024-07-30 23:07:26 +12:00
< div class = "flex p-1" >
< div class = "mr-auto" >
< div > Version< / div >
< div class = "text-sm text-gray-700" > v{{ appInfo.version }}< / div >
< / div >
< div class = "mx-2 my-auto" >
< a target = "_blank" href = "https://github.com/liamcottle/reticulum-meshchat/releases" 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" >
Check for Updates
< / a >
< / div >
2024-06-02 13:56:04 +12:00
< / div >
2024-07-30 22:56:53 +12:00
< div class = "flex p-1" >
< div class = "mr-auto" >
< div > Reticulum Config Path< / div >
< div class = "text-sm text-gray-700" > {{ appInfo.reticulum_config_path }}< / div >
< / div >
< div v-if = "isElectron" class = "mx-2 my-auto" >
< button @ click = "showReticulumConfigFile" 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" >
Show in Folder
< / button >
< / div >
2024-06-02 13:56:04 +12:00
< / div >
2024-07-30 22:56:53 +12:00
< div class = "flex p-1" >
< div class = "mr-auto" >
< div > Database Path< / div >
< div class = "text-sm text-gray-700" > {{ appInfo.database_path }}< / div >
< / div >
< div v-if = "isElectron" class = "mx-2 my-auto" >
< button @ click = "showDatabaseFile" 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" >
Show in Folder
< / button >
< / div >
2024-06-02 13:56:04 +12:00
< / div >
< div class = "p-1" >
< div > Database File Size< / div >
< div class = "text-sm text-gray-700" > {{ formatBytes(appInfo.database_file_size) }}< / div >
< / div >
< / div >
< / div >
<!-- my addresses -->
< div v-if = "config" class = "bg-white rounded shadow" >
< div class = "flex border-b border-gray-300 text-gray-700 p-2 font-semibold" > My Addresses< / div >
< div class = "divide-y text-gray-900" >
< 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< / div >
< div class = "text-sm text-gray-700" > {{ config.lxmf_address_hash }}< / div >
< / div >
< div class = "p-1" >
< div > Audio Call Address< / div >
< div class = "text-sm text-gray-700" > {{ config.audio_call_address_hash }}< / div >
< / div >
< / div >
< / div >
< / div >
2024-04-29 01:09:06 +12:00
< / div >
2024-04-29 22:06:06 +12:00
2024-04-29 01:09:06 +12:00
< / div >
2024-05-05 00:49:09 +12:00
<!-- hidden file input for selecting files -->
2024-05-05 02:11:47 +12:00
< input ref = "image-input" @ change = "onImageInputChange" type = "file" accept = "image/*" style = "display:none" / >
2024-05-05 00:49:09 +12:00
< input ref = "file-input" @ change = "onFileInputChange" type = "file" multiple style = "display:none" / >
2024-04-29 01:09:06 +12:00
< / div >
< script >
Vue.createApp({
data() {
return {
isWebsocketConnected: false,
2024-04-29 04:08:17 +12:00
autoReconnectWebsocket: true,
2024-04-29 01:09:06 +12:00
2024-07-30 22:04:59 +12:00
isShowingMyIdentitySection: true,
isShowingAnnounceSection: true,
isShowingCallsSection: true,
2024-04-29 01:09:06 +12:00
newMessageText: "",
2024-05-05 02:11:47 +12:00
newMessageImage: null,
newMessageImageUrl: null,
2024-06-03 02:51:39 +12:00
newMessageAudio: null,
2024-05-05 00:49:09 +12:00
newMessageFiles: [],
2024-04-29 01:09:06 +12:00
isSendingMessage: false,
autoScrollOnNewMessage: true,
2024-04-29 23:16:54 +12:00
displayName: "Anonymous Peer",
2024-04-29 22:28:34 +12:00
config: null,
2024-06-02 13:56:04 +12:00
appInfo: null,
2024-04-29 22:28:34 +12:00
2024-05-23 23:24:26 +12:00
audioCalls: [],
2024-05-05 23:51:15 +12:00
lxmfDeliveryAnnounces: [],
2024-05-30 01:37:47 +12:00
tab: "messages",
2024-04-29 20:41:50 +12:00
peers: {},
2024-04-30 23:55:07 +12:00
peersSearchTerm: "",
2024-04-29 20:41:50 +12:00
selectedPeer: null,
2024-05-18 21:19:22 +12:00
selectedPeerPath: null,
2024-05-04 03:57:57 +12:00
2024-05-06 00:10:48 +12:00
nodes: {},
nodesSearchTerm: "",
selectedNode: null,
2024-05-18 21:19:22 +12:00
selectedNodePath: null,
2024-05-06 00:10:48 +12:00
2024-06-01 19:07:11 +12:00
conversations: [],
conversationsSearchTerm: "",
messagesTab: "conversations",
2024-05-04 03:57:57 +12:00
lxmfMessagesRequestSequence: 0,
2024-04-29 02:34:03 +12:00
chatItems: [],
2024-04-29 01:09:06 +12:00
2024-05-08 01:20:03 +12:00
isLoadingNodePage: false,
2024-05-06 01:01:21 +12:00
nodePageRequestSequence: 0,
nodePagePath: null,
2024-05-26 21:21:17 +12:00
nodePagePathUrlInput: null,
2024-05-06 00:43:25 +12:00
nodePageContent: null,
2024-05-08 01:20:03 +12:00
nodePageProgress: 0,
2024-05-26 14:25:38 +12:00
nodePagePathHistory: [],
2024-05-26 14:53:19 +12:00
nodePageCache: {},
2024-05-06 00:43:25 +12:00
2024-05-10 23:04:39 +12:00
isDownloadingNodeFile: false,
nodeFilePath: null,
nodeFileProgress: 0,
2024-05-01 23:54:34 +12:00
nomadnetPageDownloadCallbacks: {},
nomadnetFileDownloadCallbacks: {},
2024-05-29 21:20:35 +12:00
interfaces: {},
2024-05-29 22:32:38 +12:00
interfaceStats: {},
2024-05-29 21:20:35 +12:00
2024-05-30 00:03:13 +12:00
newInterfaceName: null,
2024-05-30 00:48:25 +12:00
newInterfaceType: null,
2024-07-12 11:41:28 +12:00
2024-05-30 00:03:13 +12:00
newInterfaceTargetHost: null,
newInterfaceTargetPort: null,
2024-07-12 11:41:28 +12:00
newInterfaceListenIp: null,
newInterfaceListenPort: null,
2024-07-12 22:45:38 +12:00
newInterfaceForwardIp: null,
newInterfaceForwardPort: null,
2024-05-30 00:41:03 +12:00
newInterfacePort: null,
newInterfaceFrequency: null,
newInterfaceBandwidth: null,
newInterfaceTxpower: null,
newInterfaceSpreadingFactor: null,
newInterfaceCodingRate: null,
2024-06-04 12:22:23 +12:00
RNodeInterfaceDefaults: {
// bandwidth in hz
2024-06-04 12:28:09 +12:00
bandwidths: [
2024-06-04 12:22:23 +12:00
7800, // 7.8 kHz
10400, // 10.4 kHz
15600, // 15.6 kHz
20800, // 20.8 kHz
31250, // 31.25 kHz
41700, // 41.7 kHz
62500, // 62.5 kHz
125000, // 125 kHz
250000, // 250 kHz
500000, // 500 kHz
],
2024-06-04 12:28:09 +12:00
codingrates: [
2024-06-04 12:22:23 +12:00
5, // 4:5
6, // 4:6
7, // 4:7
8, // 4:8
],
2024-06-04 12:28:09 +12:00
spreadingfactors: [
2024-06-04 12:22:23 +12:00
7,
8,
9,
10,
11,
12,
],
},
2024-05-30 00:41:03 +12:00
comports: [],
2024-06-03 02:51:39 +12:00
isRecordingAudioAttachment: false,
audioAttachmentMicrophoneRecorder: null,
audioAttachmentRecordingStartedAt: null,
audioAttachmentRecordingDuration: null,
audioAttachmentRecordingTimer: null,
2024-06-03 01:17:05 +12:00
lxmfMessageAudioAttachmentCache: {},
lxmfAudioModeToCodec2ModeMap: {
// https://github.com/markqvist/LXMF/blob/master/LXMF/LXMF.py#L21
0x01: "450PWB", // AM_CODEC2_450PWB
0x02: "450", // AM_CODEC2_450
0x03: "700C", // AM_CODEC2_700C
0x04: "1200", // AM_CODEC2_1200
0x05: "1300", // AM_CODEC2_1300
0x06: "1400", // AM_CODEC2_1400
0x07: "1600", // AM_CODEC2_1600
0x08: "2400", // AM_CODEC2_2400
0x09: "3200", // AM_CODEC2_3200
},
2024-04-29 01:09:06 +12:00
};
},
mounted: function() {
2024-05-23 23:24:26 +12:00
2024-06-02 13:56:04 +12:00
this.getAppInfo();
2024-04-29 01:09:06 +12:00
this.connectWebsocket();
2024-05-05 23:51:15 +12:00
this.getLxmfDeliveryAnnounces();
2024-05-06 00:10:48 +12:00
this.getNomadnetworkNodeAnnounces();
2024-06-01 19:07:11 +12:00
this.getConversations();
2024-05-29 21:20:35 +12:00
this.loadInterfaces();
2024-05-29 22:32:38 +12:00
this.updateInterfaceStats();
2024-05-29 21:20:35 +12:00
2024-05-30 00:41:03 +12:00
this.loadComports();
2024-05-08 01:20:03 +12:00
window.onNodePageUrlClick = (url) => {
this.onNodePageUrlClick(url);
};
2024-05-23 23:24:26 +12:00
// update calls list
this.updateCallsList();
2024-06-01 19:09:37 +12:00
// update info every few seconds
2024-05-23 23:24:26 +12:00
setInterval(() => {
this.updateCallsList();
2024-05-29 22:32:38 +12:00
this.updateInterfaceStats();
2024-06-01 19:09:28 +12:00
this.getConversations();
2024-05-23 23:24:26 +12:00
}, 3000);
2024-04-29 01:09:06 +12:00
},
methods: {
connectWebsocket: function() {
// connect to websocket
2024-04-29 14:51:47 +12:00
this.ws = new WebSocket(location.origin.replace(/^http/, 'ws') + "/ws");
2024-04-29 01:09:06 +12:00
this.ws.addEventListener('open', () => {
this.isWebsocketConnected = true;
});
this.ws.addEventListener('close', () => {
this.isWebsocketConnected = false;
2024-04-29 04:08:17 +12:00
if(this.autoReconnectWebsocket){
setTimeout(() => {
this.connectWebsocket();
}, 1000);
}
2024-04-29 01:09:06 +12:00
});
// handle data from reticulum
this.ws.onmessage = (message) => {
const json = JSON.parse(message.data);
switch(json.type){
2024-04-29 22:28:34 +12:00
case 'config': {
this.config = json.config;
2024-04-29 23:16:54 +12:00
this.displayName = json.config.display_name;
2024-04-29 22:28:34 +12:00
break;
}
2024-04-29 20:41:50 +12:00
case 'announce': {
2024-05-05 23:51:15 +12:00
const aspect = json.announce.aspect;
if(aspect === "lxmf.delivery"){
this.updatePeerFromAnnounce(json.announce);
2024-05-06 00:10:48 +12:00
} else if(aspect === "nomadnetwork.node"){
this.updateNodeFromAnnounce(json.announce);
2024-04-30 23:20:45 +12:00
}
2024-04-29 20:41:50 +12:00
break;
}
2024-06-02 12:07:59 +12:00
case 'announced': {
// we just announced, update config so we can show the new last updated at
this.getConfig();
break;
}
2024-07-31 15:56:10 +12:00
case 'incoming_audio_call': {
Notification.requestPermission().then((result) => {
if(result === "granted"){
new window.Notification("Incoming Call", {
body: "Someone is calling you.",
tag: "new_audio_call", // only ever show one notification at a time
});
}
});
break;
}
2024-04-29 01:09:06 +12:00
case 'lxmf.delivery': {
2024-04-30 03:15:42 +12:00
// add inbound message to ui
2024-04-29 02:34:03 +12:00
this.chatItems.push({
2024-04-30 03:15:42 +12:00
"type": "lxmf_message",
"lxmf_message": json.lxmf_message,
});
2024-06-01 19:55:23 +12:00
// if inbound message is for a conversation we are currently looking at, mark it as read
if(this.tab === "messages"
2024-07-07 22:26:54 +12:00
& & json.lxmf_message.source_hash === this.selectedPeer?.destination_hash){
2024-06-01 19:55:23 +12:00
// find conversation
const conversation = this.findConversation(this.selectedPeer.destination_hash);
if(conversation){
this.markConversationAsRead(conversation);
}
}
2024-07-07 22:14:00 +12:00
// show notification for new messages if window is not focussed
if(!document.hasFocus()){
Notification.requestPermission().then((result) => {
if(result === "granted"){
new window.Notification("New Message", {
body: "Someone sent you a message.",
tag: "new_message", // only ever show one notification at a time
});
}
});
}
2024-07-07 21:10:26 +12:00
2024-04-30 03:15:42 +12:00
// auto scroll to bottom if we want to
2024-04-29 01:09:06 +12:00
if(this.autoScrollOnNewMessage){
this.scrollMessagesToBottom();
}
2024-04-30 03:15:42 +12:00
2024-04-29 01:09:06 +12:00
break;
2024-04-30 03:15:42 +12:00
2024-07-28 14:28:11 +12:00
}
case 'lxmf_message_created': {
// add new outbound lxmf message from server
if(!this.isLxmfMessageInUi(json.lxmf_message.hash)){
this.chatItems.push({
"type": "lxmf_message",
"lxmf_message": json.lxmf_message,
"is_outbound": true,
});
}
break;
2024-04-30 03:15:42 +12:00
}
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;
2024-07-28 14:28:11 +12:00
}
case 'lxmf_message_deleted': {
// remove existing chat item by lxmf message hash
const lxmfMessageHash = json.hash;
if(lxmfMessageHash){
this.chatItems = this.chatItems.filter((item) => {
return item.lxmf_message?.hash !== lxmfMessageHash;
});
}
break;
2024-04-29 01:09:06 +12:00
}
2024-05-01 23:54:34 +12:00
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);
2024-05-02 00:14:13 +12:00
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
2024-05-01 23:54:34 +12:00
return;
}
// handle failure
if(nomadnetPageDownload.status === "failure" & & nomadnetPageDownloadCallback.onFailureCallback){
nomadnetPageDownloadCallback.onFailureCallback(nomadnetPageDownload.failure_reason);
2024-05-02 00:14:13 +12:00
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
2024-05-01 23:54:34 +12:00
return;
}
2024-05-02 00:14:13 +12:00
// handle progress
if(nomadnetPageDownload.status === "progress" & & nomadnetPageDownloadCallback.onProgressCallback){
nomadnetPageDownloadCallback.onProgressCallback(nomadnetPageDownload.progress);
return;
}
2024-05-01 23:54:34 +12:00
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);
2024-05-02 00:14:13 +12:00
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
2024-05-01 23:54:34 +12:00
return;
}
// handle failure
if(nomadnetFileDownload.status === "failure" & & nomadnetFileDownloadCallback.onFailureCallback){
nomadnetFileDownloadCallback.onFailureCallback(nomadnetFileDownload.failure_reason);
2024-05-02 00:14:13 +12:00
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
2024-05-01 23:54:34 +12:00
return;
}
2024-05-02 00:14:13 +12:00
// handle progress
if(nomadnetFileDownload.status === "progress" & & nomadnetFileDownloadCallback.onProgressCallback){
nomadnetFileDownloadCallback.onProgressCallback(nomadnetFileDownload.progress);
return;
}
2024-05-01 23:54:34 +12:00
break;
}
2024-04-29 01:09:06 +12:00
}
};
},
disconnectWebsocket: function() {
if(this.ws){
this.ws.close();
}
},
scrollMessagesToBottom: function() {
Vue.nextTick(() => {
const container = document.getElementById("messages");
2024-07-07 20:43:37 +12:00
if(container){
container.scrollTop = container.scrollHeight;
}
2024-04-29 01:09:06 +12:00
});
},
2024-07-28 14:28:11 +12:00
isLxmfMessageInUi: function(hash) {
return this.chatItems.findIndex((chatItem) => chatItem.lxmf_message?.hash === hash) !== -1;
},
2024-06-02 13:56:04 +12:00
async getAppInfo() {
try {
const response = await window.axios.get(`/api/v1/app/info`);
this.appInfo = response.data.app_info;
} catch(e) {
// do nothing if failed to load app info
console.log(e);
}
},
2024-06-02 11:50:58 +12:00
async getConfig() {
try {
const response = await window.axios.get(`/api/v1/config`);
this.config = response.data.config;
} catch(e) {
// do nothing if failed to load config
console.log(e);
}
},
2024-04-29 01:09:06 +12:00
async sendAnnounce() {
try {
2024-07-09 00:45:27 +12:00
await window.axios.get(`/api/v1/announce`);
2024-04-29 01:09:06 +12:00
} catch(e) {
2024-07-09 00:45:27 +12:00
this.alert("failed to announce");
console.log(e);
2024-04-29 01:09:06 +12:00
}
2024-06-02 11:50:58 +12:00
// fetch config so it updates last announced timestamp
await this.getConfig();
2024-04-29 01:09:06 +12:00
},
async sendMessage() {
2024-05-05 11:28:16 +12:00
// do nothing if can't send message
if(!this.canSendMessage){
2024-04-29 01:09:06 +12:00
return;
}
2024-04-29 20:41:50 +12:00
// do nothing if no peer selected
if(!this.selectedPeer){
return;
}
2024-04-29 01:09:06 +12:00
this.isSendingMessage = true;
try {
2024-05-05 00:49:09 +12:00
// build fields
const fields = {};
// add file attachments
if(this.newMessageFiles.length > 0){
const fileAttachments = [];
var fileAttachmentsTotalSize = 0;
for(const file of this.newMessageFiles){
fileAttachmentsTotalSize += file.size;
fileAttachments.push({
"file_name": file.name,
"file_bytes": this.arrayBufferToBase64(await file.arrayBuffer()),
});
}
fields["file_attachments"] = fileAttachments;
}
2024-05-05 02:11:47 +12:00
// add image attachment
if(this.newMessageImage){
fields["image"] = {
2024-05-05 11:23:44 +12:00
// Reticulum sends image type as "jpg" or "png" and not "image/jpg" or "image/png"
"image_type": this.newMessageImage.type.replace("image/", ""),
2024-05-05 02:11:47 +12:00
"image_bytes": this.arrayBufferToBase64(await this.newMessageImage.arrayBuffer()),
};
}
2024-06-03 02:51:39 +12:00
// add audio attachment
if(this.newMessageAudio){
fields["audio"] = {
"audio_mode": this.newMessageAudio.audio_mode,
"audio_bytes": this.arrayBufferToBase64(await this.newMessageAudio.audio_blob.arrayBuffer()),
};
}
2024-07-08 23:54:51 +12:00
// send message to reticulum
const response = await window.axios.post(`/api/v1/lxmf-messages/send`, {
2024-05-05 00:49:09 +12:00
"lxmf_message": {
"destination_hash": this.selectedPeer.destination_hash,
2024-05-05 11:31:35 +12:00
"content": this.newMessageText,
2024-05-05 00:49:09 +12:00
"fields": fields,
},
2024-07-08 23:54:51 +12:00
});
// add outbound message to ui
2024-07-28 14:28:11 +12:00
if(!this.isLxmfMessageInUi(response.data.lxmf_message.hash)){
this.chatItems.push({
"type": "lxmf_message",
"lxmf_message": response.data.lxmf_message,
"is_outbound": true,
});
}
2024-07-08 23:54:51 +12:00
// always scroll to bottom since we just sent a message
this.scrollMessagesToBottom();
2024-04-29 01:09:06 +12:00
2024-05-05 00:49:09 +12:00
// clear message inputs
2024-04-29 01:09:06 +12:00
this.newMessageText = "";
2024-05-05 02:11:47 +12:00
this.newMessageImage = null;
this.newMessageImageUrl = null;
2024-06-03 02:51:39 +12:00
this.newMessageAudio = null;
2024-05-05 00:49:09 +12:00
this.newMessageFiles = [];
2024-05-05 02:11:47 +12:00
this.clearImageInput();
2024-05-05 00:49:09 +12:00
this.clearFileInput();
2024-04-29 01:09:06 +12:00
} catch(e) {
2024-07-08 23:54:51 +12:00
// show error
const message = e.response?.data?.message ?? "failed to send message";
this.alert(message);
console.log(e);
2024-04-29 01:09:06 +12:00
} finally {
this.isSendingMessage = false;
}
2024-07-08 22:04:22 +12:00
},
async retrySendingMessage(chatItem) {
// force delete existing message
await this.deleteChatItem(chatItem, false);
try {
2024-07-08 23:54:51 +12:00
// send message to reticulum
const response = await window.axios.post(`/api/v1/lxmf-messages/send`, {
"lxmf_message": {
"destination_hash": chatItem.lxmf_message.destination_hash,
"content": chatItem.lxmf_message.content,
"fields": chatItem.lxmf_message.fields,
},
});
// add outbound message to ui
2024-07-28 14:28:11 +12:00
if(!this.isLxmfMessageInUi(response.data.lxmf_message.hash)){
this.chatItems.push({
"type": "lxmf_message",
"lxmf_message": response.data.lxmf_message,
"is_outbound": true,
});
}
2024-07-08 23:54:51 +12:00
// always scroll to bottom since we just sent a message
this.scrollMessagesToBottom();
2024-07-08 22:04:22 +12:00
} catch(e) {
2024-07-08 23:54:51 +12:00
// show error
const message = e.response?.data?.message ?? "failed to send message";
this.alert(message);
console.log(e);
2024-07-08 22:04:22 +12:00
}
2024-04-29 01:09:06 +12:00
},
2024-04-29 23:16:54 +12:00
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);
}
},
2024-06-02 11:50:58 +12:00
async saveIdentitySettings() {
2024-04-29 23:16:54 +12:00
await this.updateConfig({
"display_name": this.displayName,
2024-06-02 11:58:04 +12:00
});
},
async onAnnounceIntervalSecondsChange() {
await this.updateConfig({
2024-06-02 11:50:58 +12:00
"auto_announce_interval_seconds": this.config.auto_announce_interval_seconds,
2024-04-29 23:16:54 +12:00
});
},
2024-07-28 15:36:37 +12:00
async onAutoResendFailedMessagesWhenAnnounceReceivedChange() {
await this.updateConfig({
"auto_resend_failed_messages_when_announce_received": this.config.auto_resend_failed_messages_when_announce_received,
});
},
async onAllowAutoResendingFailedMessagesWithAttachmentsChange() {
await this.updateConfig({
"allow_auto_resending_failed_messages_with_attachments": this.config.allow_auto_resending_failed_messages_with_attachments,
});
},
2024-07-28 22:30:22 +12:00
async onShowSuggestedCommunityInterfacesChange() {
await this.updateConfig({
"show_suggested_community_interfaces": this.config.show_suggested_community_interfaces,
});
},
2024-05-28 21:14:41 +12:00
async startNewLXMFConversation() {
// ask for destination address
const destinationHash = await this.prompt("Enter LXMF Address");
if(!destinationHash){
return;
}
2024-05-29 04:01:52 +12:00
this.openLXMFConversation(destinationHash);
},
openLXMFConversation(destinationHash) {
2024-05-28 21:14:41 +12:00
// attempt to find existing peer so we can show their name
const existingPeer = this.peers[destinationHash];
if(existingPeer){
this.onPeerClick(existingPeer);
return;
}
// simple attempt to prevent garbage input
if(destinationHash.length !== 32){
2024-07-08 18:05:29 +12:00
this.alert("Invalid Address");
2024-05-28 21:14:41 +12:00
return;
}
// we didn't find an existing peer, so just use an unknown name
this.onPeerClick({
name: "Unknown Peer",
destination_hash: destinationHash,
});
},
2024-05-06 00:43:25 +12:00
downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
2024-05-01 23:54:34 +12:00
// do nothing if not connected to websocket
if(!this.isWebsocketConnected){
2024-07-08 18:05:29 +12:00
this.alert("Not connected to WebSocket!");
2024-05-01 23:54:34 +12:00
return;
}
try {
// set callbacks for nomadnet filePath download
this.nomadnetFileDownloadCallbacks[this.getNomadnetFileDownloadCallbackKey(destinationHash, filePath)] = {
onSuccessCallback: onSuccessCallback,
onFailureCallback: onFailureCallback,
2024-05-02 00:14:13 +12:00
onProgressCallback: onProgressCallback,
2024-05-01 23:54:34 +12:00
};
// 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);
}
},
2024-05-06 00:43:25 +12:00
downloadNomadNetPage(destinationHash, pagePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
2024-05-01 23:54:34 +12:00
// do nothing if not connected to websocket
if(!this.isWebsocketConnected){
2024-07-08 18:05:29 +12:00
this.alert("Not connected to WebSocket!");
2024-05-01 23:54:34 +12:00
return;
}
try {
// set callbacks for nomadnet page download
this.nomadnetPageDownloadCallbacks[this.getNomadnetPageDownloadCallbackKey(destinationHash, pagePath)] = {
onSuccessCallback: onSuccessCallback,
onFailureCallback: onFailureCallback,
2024-05-02 00:14:13 +12:00
onProgressCallback: onProgressCallback,
2024-05-01 23:54:34 +12:00
};
// 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);
}
},
2024-05-05 23:51:15 +12:00
async getLxmfDeliveryAnnounces() {
try {
// fetch announces for "lxmf.delivery" aspect
const response = await window.axios.get(`/api/v1/announces`, {
params: {
aspect: "lxmf.delivery",
},
});
// update ui
const lxmfDeliveryAnnounces = response.data.announces;
for(const lxmfDeliveryAnnounce of lxmfDeliveryAnnounces){
this.updatePeerFromAnnounce(lxmfDeliveryAnnounce);
}
} catch(e) {
// do nothing if failed to load announces
2024-05-06 00:10:48 +12:00
console.log(e);
}
},
async getNomadnetworkNodeAnnounces() {
try {
// fetch announces for "nomadnetwork.node" aspect
const response = await window.axios.get(`/api/v1/announces`, {
params: {
aspect: "nomadnetwork.node",
},
});
// update ui
const nodeAnnounces = response.data.announces;
for(const nodeAnnounce of nodeAnnounces){
this.updateNodeFromAnnounce(nodeAnnounce);
}
} catch(e) {
// do nothing if failed to load announces
console.log(e);
2024-05-05 23:51:15 +12:00
}
},
2024-06-01 19:07:11 +12:00
async getConversations() {
try {
const response = await window.axios.get(`/api/v1/lxmf/conversations`);
this.conversations = response.data.conversations;
} catch(e) {
// do nothing if failed to load conversations
console.log(e);
}
},
2024-05-18 21:19:22 +12:00
async getPeerPath(destinationHash) {
// clear previous known path
this.selectedPeerPath = null;
try {
// get path to destination
const response = await window.axios.get(`/api/v1/destination/${destinationHash}/path`);
// update ui
this.selectedPeerPath = response.data.path;
} catch(e) {
console.log(e);
}
},
async getNodePath(destinationHash) {
// clear previous known path
this.selectedNodePath = null;
try {
// get path to destination
const response = await window.axios.get(`/api/v1/destination/${destinationHash}/path`);
// update ui
this.selectedNodePath = response.data.path;
} catch(e) {
console.log(e);
}
},
2024-06-02 12:29:30 +12:00
decodeBase64ToUtf8String: function(base64) {
// support for decoding base64 as a utf8 string to support emojis and cyrillic characters etc
return decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
},
2024-05-05 23:51:15 +12:00
getPeerNameFromAppData: function(appData) {
try {
// app data should be peer name, and our server provides it base64 encoded
2024-06-02 12:29:30 +12:00
return this.decodeBase64ToUtf8String(appData);
2024-05-05 23:51:15 +12:00
} catch(e){
return "Anonymous Peer";
}
},
2024-05-06 00:10:48 +12:00
getNodeNameFromAppData: function(appData) {
try {
// app data should be node name, and our server provides it base64 encoded
2024-06-02 12:29:30 +12:00
return this.decodeBase64ToUtf8String(appData);
2024-05-06 00:10:48 +12:00
} catch(e){
return "Anonymous Node";
}
},
2024-05-05 23:51:15 +12:00
updatePeerFromAnnounce: function(announce) {
this.peers[announce.destination_hash] = {
...announce,
// helper property for easily grabbing peer name from app data
name: this.getPeerNameFromAppData(announce.app_data),
};
},
2024-05-06 00:10:48 +12:00
updateNodeFromAnnounce: function(announce) {
this.nodes[announce.destination_hash] = {
...announce,
// helper property for easily grabbing node name from app data
name: this.getNodeNameFromAppData(announce.app_data),
};
},
2024-05-04 03:57:57 +12:00
async loadLxmfMessages(destinationHash) {
const seq = ++this.lxmfMessagesRequestSequence;
try {
// fetch lxmf messages from "us to destination" and from "destination to us"
2024-05-05 20:23:48 +12:00
const response = await window.axios.get(`/api/v1/lxmf-messages/conversation/${destinationHash}`);
2024-05-04 03:57:57 +12:00
// do nothing if response is for a previous request
if(seq !== this.lxmfMessagesRequestSequence){
console.log("ignoring response for previous lxmf messages request")
return;
}
// convert lxmf messages to chat items
const chatItems = [];
const lxmfMessages = response.data.lxmf_messages;
for(const lxmfMessage of lxmfMessages){
chatItems.push({
"type": "lxmf_message",
"is_outbound": this.config.lxmf_address_hash === lxmfMessage.source_hash,
"lxmf_message": lxmfMessage,
});
}
// update ui
this.chatItems = chatItems;
// scroll to bottom
this.scrollMessagesToBottom();
} catch(e) {
// do nothing if failed to load messages
}
},
2024-05-29 21:20:35 +12:00
async loadInterfaces() {
try {
// fetch interfaces
const response = await window.axios.get(`/api/v1/reticulum/interfaces`);
// update ui
this.interfaces = response.data.interfaces;
} catch(e) {
// do nothing if failed to load interfaces
}
},
2024-05-30 00:41:03 +12:00
async loadComports() {
try {
const response = await window.axios.get(`/api/v1/comports`);
this.comports = response.data.comports;
} catch(e) {
// do nothing if failed to load interfaces
}
},
2024-05-29 22:32:38 +12:00
async updateInterfaceStats() {
try {
// fetch interface stats
const response = await window.axios.get(`/api/v1/interface-stats`);
// update data
const interfaces = response.data.interface_stats?.interfaces ?? [];
for(const iface of interfaces){
this.interfaceStats[iface.name] = iface;
}
} catch(e) {
// do nothing if failed to load interfaces
}
},
2024-05-29 22:58:34 +12:00
getInterfaceDescription(interfaceName) {
// the interface-stats api returns interface names like the following;
//
// "AutoInterface[Default Interface]"
// "RNodeInterface[RNode LoRa Interface Fast]"
// "TCPInterface[RNS Testnet Amsterdam/amsterdam.connect.reticulum.network:4965]"
//
// however, the interfaces api just returns;
// "Default Interface"
// "RNode LoRa Interface Fast"
// "RNS Testnet Amsterdam"
//
// so we need to map the basic interface name to the former, so we can lookup stats for the interface
2024-05-29 22:32:38 +12:00
const iface = this.interfaces[interfaceName];
if(iface){
2024-05-29 22:58:34 +12:00
switch(iface.type){
case "TCPClientInterface": {
// yes, this is meant to be passed as TCPInterface, even though the interface type includes client...
// example: "TCPInterface[RNS Testnet Amsterdam/amsterdam.connect.reticulum.network:4965]";
return `TCPInterface[${interfaceName}/${iface.target_host}:${iface.target_port}]`;
}
2024-07-12 11:30:05 +12:00
case "TCPServerInterface": {
// example: "TCPServerInterface[TCP Server Interface/0.0.0.0:4242]";
return `TCPServerInterface[${interfaceName}/${iface.listen_ip}:${iface.listen_port}]`;
}
2024-07-12 22:45:38 +12:00
case "UDPInterface": {
// example: "UDPInterface[UDP Interface/0.0.0.0:1234]";
return `UDPInterface[${interfaceName}/${iface.listen_ip}:${iface.listen_port}]`;
}
2024-05-29 22:58:34 +12:00
default: {
// example: "RNodeInterface[RNode LoRa Interface Fast]",
return `${iface.type}[${interfaceName}]`;
}
}
2024-05-29 22:32:38 +12:00
}
return null;
},
2024-05-29 22:58:34 +12:00
findInterfaceStats(interfaceName) {
const interfaceDescription = this.getInterfaceDescription(interfaceName);
return this.interfaceStats[interfaceDescription];
},
2024-05-26 14:53:19 +12:00
async loadNodePage(destinationHash, pagePath, addToHistory = true, loadFromCache = true) {
2024-05-06 01:01:21 +12:00
// get new sequence for this page load
const seq = ++this.nodePageRequestSequence;
2024-05-26 14:25:38 +12:00
// get previous page path
const previousNodePagePath = this.nodePagePath;
2024-05-06 01:01:21 +12:00
// update ui
2024-05-08 01:20:03 +12:00
this.isLoadingNodePage = true;
this.nodePagePath = `${destinationHash}:${pagePath}`;
this.nodePageContent = null;
this.nodePageProgress = 0;
2024-05-06 01:01:21 +12:00
2024-05-26 21:21:17 +12:00
// update url bar
this.nodePagePathUrlInput = this.nodePagePath;
2024-07-30 22:14:22 +12:00
// update node path
this.getNodePath(destinationHash);
2024-05-26 14:25:38 +12:00
// add to previous page to history if we are not loading that previous page
2024-05-26 21:21:17 +12:00
if(addToHistory & & previousNodePagePath != null & & previousNodePagePath !== this.nodePagePath){
2024-05-26 14:25:38 +12:00
this.nodePagePathHistory.push(previousNodePagePath);
}
2024-05-26 14:53:19 +12:00
// check if we can load this page from the cache
if(loadFromCache){
// load from cache
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
const cachedNodePageContent = this.nodePageCache[nodePagePathCacheKey];
// if page is cache, we can just return it now
if(cachedNodePageContent != null){
this.nodePageContent = cachedNodePageContent;
this.isLoadingNodePage = false;
return;
}
}
2024-05-06 01:01:21 +12:00
this.downloadNomadNetPage(destinationHash, pagePath, (pageContent) => {
// do nothing if callback is for a previous request
if(seq !== this.nodePageRequestSequence){
console.log("ignoring page content callback for previous page request")
return;
}
2024-05-08 01:20:03 +12:00
// convert micron to html if page ends with .mu extension
// otherwise, we will just serve the content as is
if(pagePath.endsWith(".mu")){
this.nodePageContent = MicronParser.convertMicronToHtml(pageContent);
} else {
this.nodePageContent = pageContent;
}
2024-05-26 14:53:19 +12:00
// update cache
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
this.nodePageCache[nodePagePathCacheKey] = this.nodePageContent;
2024-05-06 01:01:21 +12:00
// update page content
2024-05-08 01:20:03 +12:00
this.isLoadingNodePage = false;
2024-05-06 01:01:21 +12:00
2024-07-30 22:14:22 +12:00
// update node path
this.getNodePath(destinationHash);
2024-05-06 00:43:25 +12:00
}, (failureReason) => {
2024-05-06 01:01:21 +12:00
// do nothing if callback is for a previous request
if(seq !== this.nodePageRequestSequence){
console.log("ignoring failure callback for previous page request")
return;
}
// update page content
2024-05-06 00:43:25 +12:00
this.nodePageContent = `Failed loading page: ${failureReason}`;
2024-05-08 01:20:03 +12:00
this.isLoadingNodePage = false;
2024-05-06 01:01:21 +12:00
2024-07-30 22:26:01 +12:00
// update node path
this.getNodePath(destinationHash);
2024-05-06 00:43:25 +12:00
}, (progress) => {
2024-05-06 01:01:21 +12:00
// do nothing if callback is for a previous request
if(seq !== this.nodePageRequestSequence){
console.log("ignoring progress callback for previous page request")
return;
}
// update page content
2024-05-08 01:20:03 +12:00
this.nodePageProgress = Math.round(progress * 100);
2024-05-06 01:01:21 +12:00
2024-05-06 00:43:25 +12:00
});
},
2024-05-26 14:32:10 +12:00
async reloadNodePage() {
2024-05-26 14:53:19 +12:00
// reload current node page without adding to history and without using cache
this.onNodePageUrlClick(this.nodePagePath, false, false);
2024-05-26 14:32:10 +12:00
},
2024-05-26 14:25:38 +12:00
async loadPreviousNodePage() {
// get the previous path from history, or do nothing
const previousNodePagePath = this.nodePagePathHistory.pop();
if(!previousNodePagePath){
return;
}
// load the page
this.onNodePageUrlClick(previousNodePagePath, false);
},
2024-05-08 01:20:03 +12:00
parseNomadnetworkUrl: function(url) {
// parse relative urls
if(url.startsWith(":")){
return {
destination_hash: null, // node hash was not in provided url
path: url.substring(1), // remove leading ":"
};
}
// parse absolute urls such as 00000000000000000000000000000000:/page/index.mu
if(url.includes(":")){
// parse destination hash and url
const [destinationHash, relativeUrl] = url.split(":");
// ensure destination is expected length
if(destinationHash.length === 32){
return {
destination_hash: destinationHash,
path: relativeUrl,
};
}
}
// parse node id only
if(url.length === 32){
return {
destination_hash: url,
path: "/page/index.mu",
};
}
// unsupported url
return null;
},
2024-05-26 14:53:19 +12:00
onNodePageUrlClick: function(url, addToHistory = true, useCache = true) {
2024-05-08 01:20:03 +12:00
// open http urls in new tab
if(url.startsWith("http://") || url.startsWith("https://")){
window.open(url, "_blank");
return;
}
2024-05-29 04:01:52 +12:00
// lxmf urls should open the conversation
if(url.startsWith("lxmf@")){
const destinationHash = url.replace("lxmf@", "");
if(destinationHash.length === 32){
this.openLXMFConversation(destinationHash);
return;
}
}
2024-05-08 01:20:03 +12:00
// attempt to parse url
const parsedUrl = this.parseNomadnetworkUrl(url);
if(parsedUrl != null){
2024-05-10 23:04:39 +12:00
// use parsed destination hash, or fallback to selected node destination hash
const destinationHash = parsedUrl.destination_hash || this.selectedNode.destination_hash;
// download file
2024-05-08 01:20:03 +12:00
if(parsedUrl.path.startsWith("/file/")){
2024-05-10 23:04:39 +12:00
// prevent simultaneous downloads
if(this.isDownloadingNodeFile){
2024-07-08 18:05:29 +12:00
this.alert("An existing download is in progress. Please wait for it to finish beforing starting another download.");
2024-05-10 23:04:39 +12:00
return;
}
// update ui
this.isDownloadingNodeFile = true;
this.nodeFilePath = parsedUrl.path.split("/").pop();
this.nodeFileProgress = 0;
// start file download
this.downloadNomadNetFile(destinationHash, parsedUrl.path, (fileName, fileBytesBase64) => {
// no longer downloading
this.isDownloadingNodeFile = false;
// download file to browser
this.downloadFileFromBase64(fileName, fileBytesBase64);
}, (failureReason) => {
// no longer downloading
this.isDownloadingNodeFile = false;
// show error message
2024-07-08 18:05:29 +12:00
this.alert(`Failed to download file: ${failureReason}`);
2024-05-10 23:04:39 +12:00
}, (progress) => {
this.nodeFileProgress = Math.round(progress * 100);
});
2024-05-08 01:20:03 +12:00
return;
2024-05-10 23:04:39 +12:00
}
2024-05-08 01:20:03 +12:00
// update selected node, so relative urls work correctly when returned by the new node
this.selectedNode = this.nodes[destinationHash] || {
name: "Unknown Node",
destination_hash: destinationHash,
};
// navigate to node page
2024-05-26 14:53:19 +12:00
this.loadNodePage(destinationHash, parsedUrl.path, addToHistory, useCache);
2024-05-08 01:20:03 +12:00
return;
}
// unsupported url
2024-07-08 18:05:29 +12:00
this.alert("unsupported url: " + url);
2024-05-08 01:20:03 +12:00
},
2024-07-08 22:04:22 +12:00
async deleteChatItem(chatItem, shouldConfirm = true) {
2024-05-05 01:46:20 +12:00
try {
2024-05-05 02:44:07 +12:00
// ask user to confirm deleting message
2024-07-08 22:04:22 +12:00
if(shouldConfirm & & !confirm("Are you sure you want to delete this message? This can not be undone!")){
2024-05-05 02:44:07 +12:00
return;
}
2024-05-05 01:46:20 +12:00
// make sure it's an lxmf message
if(chatItem.type !== "lxmf_message"){
return;
}
// delete lxmf message from server
2024-07-08 23:10:21 +12:00
await window.axios.delete(`/api/v1/lxmf-messages/${chatItem.lxmf_message.hash}`);
2024-05-05 01:46:20 +12:00
2024-07-08 22:17:23 +12:00
// remove lxmf message from chat items using hash, as other pending items might not have an id yet
2024-05-05 01:46:20 +12:00
this.chatItems = this.chatItems.filter((item) => {
2024-07-08 22:17:23 +12:00
return item.lxmf_message?.hash !== chatItem.lxmf_message.hash;
2024-05-05 01:46:20 +12:00
});
} catch(e) {
// do nothing if failed to delete message
}
},
2024-04-29 01:09:06 +12:00
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();
},
2024-04-29 02:34:03 +12:00
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);
2024-05-10 23:04:39 +12:00
},
downloadFileFromBase64: async function(fileName, fileBytesBase64) {
// create blob from base64 encoded file bytes
const byteCharacters = atob(fileBytesBase64);
const byteNumbers = new Array(byteCharacters.length);
for(let i = 0; i < byteCharacters.length ; i + + ) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray]);
// create object url for blob
const objectUrl = URL.createObjectURL(blob);
// create link element to download blob
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
link.style.display = "none";
document.body.append(link);
// click link to download file in browser
link.click();
// link element is no longer needed
link.remove();
// revoke object url to clear memory
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
2024-04-29 02:34:03 +12:00
},
2024-04-29 20:41:50 +12:00
onPeerClick: function(peer) {
this.selectedPeer = peer;
2024-05-29 15:28:23 +12:00
this.tab = "messages";
2024-05-04 03:57:57 +12:00
this.chatItems = [];
2024-05-18 21:19:22 +12:00
this.getPeerPath(peer.destination_hash);
2024-05-04 03:57:57 +12:00
this.loadLxmfMessages(peer.destination_hash);
2024-04-29 20:41:50 +12:00
},
2024-05-06 00:10:48 +12:00
onNodeClick: function(node) {
this.selectedNode = node;
2024-05-29 15:28:23 +12:00
this.tab = "nomadnetwork";
2024-05-06 01:01:21 +12:00
this.loadNodePage(node.destination_hash, "/page/index.mu");
2024-05-06 00:10:48 +12:00
},
2024-06-01 19:07:11 +12:00
onConversationClick: function(conversation) {
2024-06-01 19:21:42 +12:00
2024-06-01 19:07:11 +12:00
// object must stay compatible with format of peers
this.onPeerClick(conversation);
2024-06-01 19:21:42 +12:00
// mark conversation as read
2024-06-01 19:45:19 +12:00
this.markConversationAsRead(conversation);
2024-06-01 19:21:42 +12:00
2024-06-01 19:07:11 +12:00
},
2024-04-30 23:33:02 +12:00
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,
};
},
2024-05-05 23:51:15 +12:00
formatTimeAgo: function(datetimeString) {
const millisecondsAgo = Date.now() - new Date(datetimeString).getTime();
const secondsAgo = Math.round(millisecondsAgo / 1000);
2024-06-02 12:15:53 +12:00
return this.formatSeconds(secondsAgo);
2024-06-02 11:50:58 +12:00
},
formatSecondsAgo: function(seconds) {
const secondsAgo = Math.round((Date.now() / 1000) - seconds);
return this.formatSeconds(secondsAgo);
},
formatSeconds: function(seconds) {
const parsedSeconds = this.parseSeconds(seconds);
2024-04-30 23:33:02 +12:00
if(parsedSeconds.days > 0){
2024-07-28 17:56:23 +12:00
if(parsedSeconds.days === 1){
return "1 day ago";
} else {
return parsedSeconds.days + " days ago";
}
2024-04-30 23:33:02 +12:00
}
if(parsedSeconds.hours > 0){
2024-07-28 17:56:23 +12:00
if(parsedSeconds.hours === 1){
return "1 hour ago";
} else {
return parsedSeconds.hours + " hours ago";
}
2024-04-30 23:33:02 +12:00
}
if(parsedSeconds.minutes > 0){
2024-07-28 17:56:23 +12:00
if(parsedSeconds.minutes === 1){
return "a minute ago";
} else {
return parsedSeconds.minutes + " minutes ago";
}
2024-04-30 23:33:02 +12:00
}
2024-07-28 17:56:23 +12:00
if(parsedSeconds.seconds < = 1){
return "a second ago";
} else {
return parsedSeconds.seconds + " seconds ago";
}
2024-04-30 23:33:02 +12:00
},
2024-06-03 02:51:39 +12:00
formatMinutesSeconds: function(seconds) {
const parsedSeconds = this.parseSeconds(seconds);
const paddedMinutes = parsedSeconds.minutes.toString().padStart(2, "0");
const paddedSeconds = parsedSeconds.seconds.toString().padStart(2, "0");
return `${paddedMinutes}:${paddedSeconds}`;
},
2024-05-01 23:54:34 +12:00
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
return `${destinationHash}:${pagePath}`;
},
getNomadnetFileDownloadCallbackKey: function(destinationHash, filePath) {
return `${destinationHash}:${filePath}`;
},
2024-05-05 00:49:09 +12:00
addFilesToMessage: function() {
this.$refs["file-input"].click();
},
onFileInputChange: function(event) {
for(const file of event.target.files){
this.newMessageFiles.push(file);
}
},
clearFileInput: function() {
this.$refs["file-input"].value = null;
},
removeFileAttachment: function(file) {
this.newMessageFiles = this.newMessageFiles.filter((newMessageFile) => {
return newMessageFile !== file;
});
},
2024-05-05 02:11:47 +12:00
addImageToMessage: function() {
this.$refs["image-input"].click();
},
onImageInputChange: function(event) {
if(event.target.files.length > 0){
// update selected file
this.newMessageImage = event.target.files[0];
// update image url when file is read
const fileReader = new FileReader();
fileReader.onload = (event) => {
this.newMessageImageUrl = event.target.result
}
// convert image to data url
fileReader.readAsDataURL(this.newMessageImage);
// clear image input to allow selecting the same file after user removed it
this.clearImageInput();
}
},
clearImageInput: function() {
this.$refs["image-input"].value = null;
},
removeImageAttachment: function() {
this.newMessageImage = null;
this.newMessageImageUrl = null;
},
2024-06-03 02:51:39 +12:00
async startRecordingAudioAttachment() {
// do nothing if already recording
if(this.isRecordingAudioAttachment){
return;
}
2024-06-03 02:56:48 +12:00
// ask user to confirm recording new audio attachment, if an existing audio attachment exists
if(this.newMessageAudio & & !confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
return;
}
2024-06-03 02:51:39 +12:00
// start recording microphone
this.audioAttachmentMicrophoneRecorder = new Codec2MicrophoneRecorder();
this.audioAttachmentRecordingStartedAt = Date.now();
this.isRecordingAudioAttachment = await this.audioAttachmentMicrophoneRecorder.start();
// update recording time in ui every second
this.audioAttachmentRecordingDuration = this.formatMinutesSeconds(0);
this.audioAttachmentRecordingTimer = setInterval(() => {
const recordingDurationMillis = Date.now() - this.audioAttachmentRecordingStartedAt;
const recordingDurationSeconds = recordingDurationMillis / 1000;
this.audioAttachmentRecordingDuration = this.formatMinutesSeconds(recordingDurationSeconds);
}, 1000);
// alert if failed to start recording
if(!this.isRecordingAudioAttachment){
2024-07-08 18:05:29 +12:00
this.alert("failed to start recording");
2024-06-03 02:51:39 +12:00
}
},
2024-06-03 14:24:07 +12:00
async stopRecordingAudioAttachment() {
2024-06-03 02:51:39 +12:00
// clear audio recording timer
clearInterval(this.audioAttachmentRecordingTimer);
// do nothing if not recording
if(!this.isRecordingAudioAttachment){
return;
}
// stop recording microphone and get audio
this.isRecordingAudioAttachment = false;
2024-06-05 12:36:53 +12:00
const audio = await this.audioAttachmentMicrophoneRecorder.stop();
2024-06-03 02:51:39 +12:00
// do nothing if no audio was provided
if(audio.length === 0){
return;
}
2024-06-03 14:24:07 +12:00
// decode codec2 audio back to wav so we can show a preview audio player before user sends it
const decoded = await Codec2Lib.runDecode("1200", new Uint8Array(audio));
// convert decoded codec2 to wav audio and create a blob
const wavAudio = await Codec2Lib.rawToWav(decoded);
const wavBlob = new Blob([wavAudio], {
type: "audio/wav",
});
2024-06-03 02:51:39 +12:00
// update message audio attachment
this.newMessageAudio = {
audio_mode: 0x04, // hardcoded to LXMF.AM_CODEC2_1200 for now
audio_blob: new Blob([audio]),
2024-06-03 14:24:07 +12:00
audio_wav_url: URL.createObjectURL(wavBlob),
2024-06-03 02:51:39 +12:00
};
},
removeAudioAttachment: function() {
// ask user to confirm removing audio attachment
if(!confirm("Are you sure you want to remove this audio attachment?")){
return;
}
// remove audio
this.newMessageAudio = null;
},
2024-05-05 00:49:09 +12:00
formatBytes: function(bytes) {
if(bytes === 0){
return '0 Bytes';
}
const k = 1024;
const decimals = 0;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
},
2024-05-29 22:32:38 +12:00
formatBitsPerSecond: function(bits) {
if (bits === 0) {
return '0 bps';
}
const k = 1000; // Use 1000 instead of 1024 for network speeds
const decimals = 0;
const sizes = ['bps', 'kbps', 'Mbps', 'Gbps', 'Tbps', 'Pbps', 'Ebps', 'Zbps', 'Ybps'];
const i = Math.floor(Math.log(bits) / Math.log(k));
return parseFloat((bits / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
},
2024-06-04 12:10:05 +12:00
formatFrequency: function(hz) {
if(hz === 0 || hz == null){
return '0 Hz';
}
const k = 1000;
const sizes = ['Hz', 'kHz', 'MHz', 'GHz', 'THz', 'PHz', 'EHz', 'ZHz', 'YHz'];
const i = Math.floor(Math.log(hz) / Math.log(k));
return parseFloat((hz / Math.pow(k, i))) + ' ' + sizes[i];
},
2024-05-05 00:49:09 +12:00
arrayBufferToBase64: function(arrayBuffer) {
var binary = '';
var bytes = new Uint8Array(arrayBuffer);
var len = bytes.byteLength;
for(var i = 0; i < len ; i + + ) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
2024-05-05 02:40:03 +12:00
},
2024-06-03 01:17:05 +12:00
base64ToArrayBuffer: function(base64) {
return Uint8Array.from(atob(base64), c => c.charCodeAt(0));
},
2024-05-05 02:40:03 +12:00
async deleteConversation() {
// do nothing if no peer selected
if(!this.selectedPeer){
return;
}
// ask user to confirm deleting conversation history
if(!confirm("Are you sure you want to delete all messages from this conversation? This can not be undone!")){
return;
}
// delete all lxmf messages from "us to destination" and from "destination to us"
try {
await window.axios.delete(`/api/v1/lxmf-messages/conversation/${this.selectedPeer.destination_hash}`);
} catch(e) {
2024-07-08 18:05:29 +12:00
this.alert("failed to delete conversation");
2024-05-05 02:40:03 +12:00
console.log(e);
}
// reload conversation
await this.loadLxmfMessages(this.selectedPeer.destination_hash);
2024-06-01 20:10:15 +12:00
// reload conversations
await this.getConversations();
2024-06-01 19:21:42 +12:00
},
2024-06-01 19:45:19 +12:00
async markConversationAsRead(conversation) {
2024-06-01 19:21:42 +12:00
2024-06-01 19:45:19 +12:00
// manually mark conversation read in memory to avoid delay updating ui
conversation.is_unread = false;
// mark conversation as read on server
2024-06-01 19:21:42 +12:00
try {
2024-06-01 19:45:19 +12:00
await window.axios.get(`/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`);
2024-06-01 19:21:42 +12:00
} catch(e) {
// do nothing if failed to mark as read
console.log(e);
}
// reload conversations
await this.getConversations();
2024-05-29 21:37:03 +12:00
},
2024-07-28 15:36:37 +12:00
showSettingsTab() {
this.tab = "settings";
this.getConfig();
},
2024-06-02 13:56:04 +12:00
showAboutTab() {
this.tab = "about";
this.getAppInfo();
},
2024-05-29 21:37:03 +12:00
async enableInterface(interfaceName) {
// enable interface
try {
await window.axios.post(`/api/v1/reticulum/interfaces/enable`, {
name: interfaceName,
});
} catch(e) {
2024-07-08 18:05:29 +12:00
this.alert("failed to enable interface");
2024-05-29 21:37:03 +12:00
console.log(e);
}
// reload interfaces
await this.loadInterfaces();
},
async disableInterface(interfaceName) {
// disable interface
try {
await window.axios.post(`/api/v1/reticulum/interfaces/disable`, {
name: interfaceName,
});
} catch(e) {
2024-07-08 18:05:29 +12:00
this.alert("failed to disable interface");
2024-05-29 21:37:03 +12:00
console.log(e);
}
// reload interfaces
await this.loadInterfaces();
2024-05-30 01:16:46 +12:00
},
showAddInterfaceForm() {
this.resetAddInterfaceForm();
this.loadComports();
this.isEditingInterface = false;
this.tab = "interfaces.add";
},
showEditInterfaceForm() {
this.resetAddInterfaceForm();
this.loadComports();
this.isEditingInterface = true;
this.tab = "interfaces.add";
},
resetAddInterfaceForm() {
// clear add interface form
this.newInterfaceName = null;
this.newInterfaceType = null;
// tcp client interface
this.newInterfaceTargetHost = null;
this.newInterfaceTargetPort = null;
2024-07-12 22:45:38 +12:00
// tcp server interface & udp interface
2024-07-12 11:41:28 +12:00
this.newInterfaceListenIp = null;
this.newInterfaceListenPort = null;
2024-07-12 22:45:38 +12:00
// udp interface
this.newInterfaceForwardIp = null;
this.newInterfaceForwardPort = null;
2024-05-30 01:16:46 +12:00
// rnode interface
this.newInterfacePort = null;
this.newInterfaceFrequency = null;
this.newInterfaceBandwidth = null;
this.newInterfaceTxpower = null;
this.newInterfaceSpreadingFactor = null;
this.newInterfaceCodingRate = null;
},
async editInterface(interfaceName) {
// find interface
const iface = this.interfaces[interfaceName];
if(!iface){
return;
}
// go to edit interface tab
this.showEditInterfaceForm();
// set form values
this.newInterfaceName = interfaceName;
this.newInterfaceType = iface.type;
// tcp client interface
this.newInterfaceTargetHost = iface.target_host;
this.newInterfaceTargetPort = iface.target_port;
2024-07-12 22:45:38 +12:00
// tcp server interface & udp interface
2024-07-12 11:41:28 +12:00
this.newInterfaceListenIp = iface.listen_ip;
this.newInterfaceListenPort = iface.listen_port;
2024-07-12 22:45:38 +12:00
// udp interface
this.newInterfaceForwardIp = iface.forward_ip;
this.newInterfaceForwardPort = iface.forward_port;
2024-05-30 01:16:46 +12:00
// rnode interface
this.newInterfacePort = iface.port;
this.newInterfaceFrequency = iface.frequency;
this.newInterfaceBandwidth = iface.bandwidth;
this.newInterfaceTxpower = iface.txpower;
this.newInterfaceSpreadingFactor = iface.spreadingfactor;
this.newInterfaceCodingRate = iface.codingrate;
2024-05-29 21:20:35 +12:00
},
async deleteInterface(interfaceName) {
// ask user to confirm deleting conversation history
if(!confirm("Are you sure you want to delete this interface? This can not be undone!")){
return;
}
// delete interface
try {
await window.axios.post(`/api/v1/reticulum/interfaces/delete`, {
name: interfaceName,
});
} catch(e) {
2024-07-08 18:05:29 +12:00
this.alert("failed to delete interface");
2024-05-29 21:20:35 +12:00
console.log(e);
}
// reload interfaces
2024-05-30 00:03:13 +12:00
await this.loadInterfaces();
},
async addInterface() {
try {
// add interface
const response = await window.axios.post(`/api/v1/reticulum/interfaces/add`, {
2024-05-30 01:16:46 +12:00
allow_overwriting_interface: this.isEditingInterface,
2024-05-30 00:41:03 +12:00
// required values
2024-05-30 00:03:13 +12:00
name: this.newInterfaceName,
type: this.newInterfaceType,
2024-05-30 00:41:03 +12:00
// tcp client interface
2024-05-30 00:03:13 +12:00
target_host: this.newInterfaceTargetHost,
target_port: this.newInterfaceTargetPort,
2024-07-12 22:45:38 +12:00
// tcp server interface & udp interface
2024-07-12 11:41:28 +12:00
listen_ip: this.newInterfaceListenIp,
listen_port: this.newInterfaceListenPort,
2024-07-12 22:45:38 +12:00
// udp interface
forward_ip: this.newInterfaceForwardIp,
forward_port: this.newInterfaceForwardPort,
2024-05-30 00:41:03 +12:00
// rnode interface
port: this.newInterfacePort,
frequency: this.newInterfaceFrequency,
bandwidth: this.newInterfaceBandwidth,
txpower: this.newInterfaceTxpower,
spreadingfactor: this.newInterfaceSpreadingFactor,
codingrate: this.newInterfaceCodingRate,
2024-05-30 00:03:13 +12:00
});
2024-05-30 01:16:46 +12:00
// reset add interface form
this.resetAddInterfaceForm();
2024-05-30 00:03:13 +12:00
// go to interfaces tab
this.tab = "interfaces";
// show success message
if(response.data.message){
2024-07-08 18:05:29 +12:00
this.alert(response.data.message);
2024-05-30 00:03:13 +12:00
}
} catch(e) {
const message = e.response?.data?.message ?? "failed to add interface";
2024-07-08 18:05:29 +12:00
this.alert(message);
2024-05-30 00:03:13 +12:00
console.log(e);
}
// reload interfaces
2024-05-29 21:20:35 +12:00
await this.loadInterfaces();
2024-05-05 02:40:03 +12:00
},
2024-05-05 20:06:04 +12:00
onChatItemClick: function(chatItem) {
if(!chatItem.is_actions_expanded){
chatItem.is_actions_expanded = true;
} else {
chatItem.is_actions_expanded = false;
}
},
2024-05-18 21:19:22 +12:00
onDestinationPathClick: function(path) {
2024-07-08 18:05:29 +12:00
this.alert(`${path.hops} ${ path.hops === 1 ? 'hop' : 'hops' } away via ${path.next_hop_interface}`);
2024-05-18 21:19:22 +12:00
},
2024-05-23 23:24:26 +12:00
async updateCallsList() {
try {
// fetch calls
const response = await axios.get("/api/v1/calls");
// update ui
this.audioCalls = response.data.audio_calls;
} catch(e) {
// do nothing on error
}
},
async hangupAllCalls() {
// confirm user wants to hang up calls
if(!confirm("Are you sure you want to hang up all incoming and outgoing calls?")){
return;
}
try {
// hangup all calls
await axios.get(`/api/v1/calls/hangup-all`);
// reload calls list
await this.updateCallsList();
} catch(e) {
// ignore error hanging up call
}
},
2024-07-08 18:05:29 +12:00
alert(message) {
if(window.electron){
// running inside electron, use ipc alert
window.electron.alert(message);
} else {
// running inside normal browser, use browser alert
window.alert(message);
}
},
2024-05-28 21:14:41 +12:00
async prompt(message) {
if(window.electron){
// running inside electron, use ipc prompt
return await window.electron.prompt(message);
} else {
// running inside normal browser, use browser prompt
return window.prompt(message);
}
},
2024-07-09 17:24:23 +12:00
relaunch() {
if(window.electron){
window.electron.relaunch();
}
},
2024-07-30 22:56:53 +12:00
showReticulumConfigFile() {
if(window.electron & & this.appInfo.reticulum_config_path){
window.electron.showPathInFolder(this.appInfo.reticulum_config_path);
}
},
showDatabaseFile() {
if(window.electron & & this.appInfo.database_path){
window.electron.showPathInFolder(this.appInfo.database_path);
}
},
2024-05-29 21:20:35 +12:00
isInterfaceEnabled: function(iface) {
const rawValue = iface.enabled ?? iface.interface_enabled;
2024-05-29 21:37:03 +12:00
const value = rawValue?.toLowerCase();
2024-05-29 21:20:35 +12:00
return value === "on" || value === "yes" || value === "true";
},
2024-05-31 11:17:37 +12:00
onIFACSignatureClick: function(ifacSignature) {
2024-07-08 18:05:29 +12:00
this.alert(ifacSignature);
2024-05-31 11:17:37 +12:00
},
2024-06-01 19:55:23 +12:00
findConversation: function(destinationHash) {
return this.conversations.find((conversation) => {
return conversation.destination_hash === destinationHash;
});
},
2024-06-03 01:17:05 +12:00
async processAudioForSelectedPeerChatItems() {
for(const chatItem of this.selectedPeerChatItems){
// skip if no audio
if(!chatItem.lxmf_message?.fields?.audio){
continue;
}
// skip if audio already cached
if(this.lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]){
continue;
}
// decode audio to blob url
const objectUrl = await this.decodeLxmfAudioFieldToBlobUrl(chatItem.lxmf_message.fields.audio);
if(!objectUrl){
continue;
}
// update audio cache
this.lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash] = objectUrl;
}
},
async decodeLxmfAudioFieldToBlobUrl(audioField) {
try {
// get audio mode and audio bytes from audio field
const audioMode = audioField.audio_mode;
const audioBytes = audioField.audio_bytes;
2024-06-05 12:24:20 +12:00
// handle opus: AM_OPUS_OGG
if(audioMode === 0x10){
return this.decodeOpusAudioToBlobUrl(audioField.audio_bytes);
}
2024-06-03 01:17:05 +12:00
// determine codec2 mode, or skip if unknown
const codecMode = this.lxmfAudioModeToCodec2ModeMap[audioMode];
if(!codecMode){
console.log("unsupported audio mode: " + audioMode)
return null;
}
// convert base64 to uint8 array
const encoded = this.base64ToArrayBuffer(audioBytes);
// decode codec2 audio
const decoded = await Codec2Lib.runDecode(codecMode, new Uint8Array(encoded));
// convert decoded codec2 to wav audio
const wavAudio = await Codec2Lib.rawToWav(decoded);
// create blob from wav audio
const blob = new Blob([wavAudio], {
type: "audio/wav",
});
// create object url for blob
return URL.createObjectURL(blob);
} catch(e) {
// failed to decode lxmf audio field
console.log(e);
return null;
}
},
2024-06-05 12:24:20 +12:00
async decodeOpusAudioToBlobUrl(audioBytes) {
try {
// convert base64 to uint8 array
const opusAudioBytes = this.base64ToArrayBuffer(audioBytes);
// create blob from opus audio
const blob = new Blob([opusAudioBytes], {
type: "audio/opus",
});
// create object url for blob
return URL.createObjectURL(blob);
} catch(e) {
// failed to decode opus audio
console.log(e);
return null;
}
},
2024-04-29 01:09:06 +12:00
},
computed: {
2024-07-09 17:24:23 +12:00
isElectron() {
return window.electron != null;
},
2024-04-29 01:09:06 +12:00
isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
},
2024-04-30 23:36:54 +12:00
peersCount() {
return Object.keys(this.peers).length;
},
2024-04-30 23:35:40 +12:00
peersOrderedByLatestAnnounce() {
const peers = Object.values(this.peers);
return peers.sort(function(peerA, peerB) {
2024-05-05 23:51:15 +12:00
// order by updated_at desc
const peerAUpdatedAt = new Date(peerA.updated_at).getTime();
const peerBUpdatedAt = new Date(peerB.updated_at).getTime();
return peerBUpdatedAt - peerAUpdatedAt;
2024-04-30 23:35:40 +12:00
});
},
2024-04-30 23:55:07 +12:00
searchedPeers() {
return this.peersOrderedByLatestAnnounce.filter((peer) => {
const search = this.peersSearchTerm.toLowerCase();
2024-05-05 23:51:15 +12:00
const matchesAppData = peer.name.toLowerCase().includes(search);
const matchesDestinationHash = peer.destination_hash.toLowerCase().includes(search);
2024-05-05 02:41:54 +12:00
return matchesAppData || matchesDestinationHash;
2024-04-30 23:55:07 +12:00
});
},
2024-04-30 00:22:23 +12:00
selectedPeerChatItems() {
// get all chat items related to the selected peer
if(this.selectedPeer){
return this.chatItems.filter((chatItem) => {
2024-04-30 03:15:42 +12:00
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;
2024-04-30 00:22:23 +12:00
});
}
// no peer, so no chat items!
return [];
2024-05-05 11:28:16 +12:00
},
2024-05-06 00:10:48 +12:00
nodesCount() {
return Object.keys(this.nodes).length;
},
nodesOrderedByLatestAnnounce() {
const nodes = Object.values(this.nodes);
return nodes.sort(function(nodeA, nodeB) {
// order by updated_at desc
const nodeAUpdatedAt = new Date(nodeA.updated_at).getTime();
const nodeBUpdatedAt = new Date(nodeB.updated_at).getTime();
return nodeBUpdatedAt - nodeAUpdatedAt;
});
},
searchedNodes() {
return this.nodesOrderedByLatestAnnounce.filter((node) => {
const search = this.nodesSearchTerm.toLowerCase();
const matchesAppData = node.name.toLowerCase().includes(search);
const matchesDestinationHash = node.destination_hash.toLowerCase().includes(search);
return matchesAppData || matchesDestinationHash;
});
},
2024-06-01 19:07:11 +12:00
searchedConversations() {
return this.conversations.filter((conversation) => {
const search = this.conversationsSearchTerm.toLowerCase();
const matchesName = conversation.name.toLowerCase().includes(search);
const matchesDestinationHash = conversation.destination_hash.toLowerCase().includes(search);
return matchesName || matchesDestinationHash;
});
},
2024-06-01 19:45:19 +12:00
unreadConversationsCount() {
return this.conversations.filter((conversation) => {
return conversation.is_unread;
}).length;
},
2024-05-05 11:28:16 +12:00
canSendMessage() {
// can't send if empty message
const messageText = this.newMessageText.trim();
if(messageText == null || messageText === ""){
return false;
}
2024-07-12 20:54:07 +12:00
// can't send if already sending
if(this.isSendingMessage){
return false;
}
2024-05-05 11:28:16 +12:00
return true;
},
2024-05-23 23:24:26 +12:00
activeAudioCalls() {
return this.audioCalls.filter(function(audioCall) {
return audioCall.is_active;
});
},
activeInboundAudioCalls() {
return this.activeAudioCalls.filter(function(audioCall) {
return !audioCall.is_outbound;
});
},
activeOutboundAudioCalls() {
return this.activeAudioCalls.filter(function(audioCall) {
return audioCall.is_outbound;
});
},
2024-05-31 11:47:21 +12:00
interfacesWithStats() {
const results = [];
for(const [interfaceName, iface] of Object.entries(this.interfaces)){
iface._name = interfaceName;
iface._stats = this.findInterfaceStats(interfaceName);
results.push(iface);
}
return results;
},
2024-04-29 01:09:06 +12:00
},
2024-06-03 01:17:05 +12:00
watch: {
async selectedPeerChatItems() {
// chat items for selected peer changed, so lets process any available audio
await this.processAudioForSelectedPeerChatItems();
},
},
2024-04-29 01:09:06 +12:00
}).mount('#app');
< / script >
< / body >
< / html >