coracle/src/App.svelte
2023-02-13 17:56:03 -06:00

414 lines
14 KiB
Svelte

<script lang="ts">
import "@fortawesome/fontawesome-free/css/fontawesome.css"
import "@fortawesome/fontawesome-free/css/solid.css"
import {find, is, identity, nthArg, pluck} from 'ramda'
import {onMount} from "svelte"
import {createMap} from 'hurdak/lib/hurdak'
import {writable, get} from "svelte/store"
import {fly, fade} from "svelte/transition"
import {cubicInOut} from "svelte/easing"
import {Router, Route, links, navigate} from "svelte-routing"
import {globalHistory} from "svelte-routing/src/history"
import {log, warn} from 'src/util/logger'
import {displayPerson, isLike} from 'src/util/nostr'
import {timedelta, shuffle, now, sleep} from 'src/util/misc'
import cmd from 'src/agent/cmd'
import {user, getUserRelays} from 'src/agent/helpers'
import database from 'src/agent/database'
import keys from 'src/agent/keys'
import network from 'src/agent/network'
import pool from 'src/agent/pool'
import sync from 'src/agent/sync'
import {modal, toast, settings, logUsage, alerts, messages, loadAppData} from "src/app"
import {routes} from "src/app/ui"
import Anchor from 'src/partials/Anchor.svelte'
import Content from 'src/partials/Content.svelte'
import Spinner from 'src/partials/Spinner.svelte'
import Modal from 'src/partials/Modal.svelte'
import SignUp from "src/views/SignUp.svelte"
import PersonList from "src/views/PersonList.svelte"
import PrivKeyLogin from "src/views/PrivKeyLogin.svelte"
import PubKeyLogin from "src/views/PubKeyLogin.svelte"
import NoteDetail from "src/views/NoteDetail.svelte"
import PersonSettings from "src/views/PersonSettings.svelte"
import PersonShare from "src/views/PersonShare.svelte"
import NoteCreate from "src/views/NoteCreate.svelte"
import ChatEdit from "src/views/ChatEdit.svelte"
import NotFound from "src/routes/NotFound.svelte"
import Search from "src/routes/Search.svelte"
import Alerts from "src/routes/Alerts.svelte"
import Notes from "src/routes/Notes.svelte"
import Debug from "src/routes/Debug.svelte"
import Login from "src/routes/Login.svelte"
import Logout from "src/routes/Logout.svelte"
import Profile from "src/routes/Profile.svelte"
import Settings from "src/routes/Settings.svelte"
import Keys from "src/routes/Keys.svelte"
import RelayList from "src/routes/RelayList.svelte"
import AddRelay from "src/routes/AddRelay.svelte"
import Person from "src/routes/Person.svelte"
import Bech32Entity from "src/routes/Bech32Entity.svelte"
import Chat from "src/routes/Chat.svelte"
import ChatRoom from "src/routes/ChatRoom.svelte"
import Messages from "src/routes/Messages.svelte"
Object.assign(window, {cmd, database, keys, network, pool, sync})
export let url = ""
const menuIsOpen = writable(false)
const toggleMenu = () => menuIsOpen.update(x => !x)
const searchIsOpen = writable(false)
const toggleSearch = () => searchIsOpen.update(x => !x)
const closeModal = async () => {
modal.clear()
menuIsOpen.set(false)
}
const {ready} = database
const {lastCheckedAlerts, mostRecentAlert} = alerts
const {lastCheckedByPubkey, mostRecentByPubkey} = messages
let menuIcon
let scrollY
let suspendedSubs = []
let slowConnections = []
let hasNewMessages = false
$: {
hasNewMessages = Boolean(find(
([k, t]) => {
return t > now() - timedelta(7, 'days') && ($lastCheckedByPubkey[k] || 0) < t
},
Object.entries($mostRecentByPubkey)
))
}
onMount(() => {
if ($user) {
loadAppData($user.pubkey)
}
// Background work
const interval = setInterval(() => {
alertSlowConnections()
retrieveRelayMeta()
}, 30_000)
const alertSlowConnections = () => {
// Only notify about relays the user is actually subscribed to
const relayUrls = pluck('url', getUserRelays('read'))
// Prune connections we haven't used in a while
pool.getConnections()
.filter(conn => conn.lastRequest < Date.now() - 60_000)
.forEach(conn => conn.disconnect())
// Log stats for debugging purposes
log(
'Connection stats',
pool.getConnections()
.map(({nostr: {url}, stats: s}) => `${url} ${s.timer / s.count}`)
)
// Alert the user to any heinously slow connections
slowConnections = pool.getConnections()
.filter(({nostr: {url}, stats: s}) => relayUrls.includes(url) && s.timer / s.count > 3000)
}
const retrieveRelayMeta = async () => {
const {dufflepudUrl} = $settings
if (!dufflepudUrl) {
return
}
// Find relays with old/missing metadata and refresh them. Only pick a
// few so we're not sending too many concurrent http requests
const staleRelays = shuffle(
await database.relays.all({
'refreshed_at:lt': now() - timedelta(7, 'days'),
})
).slice(0, 10)
const freshRelays = await Promise.all(
staleRelays.map(async ({url}) => {
try {
const res = await fetch(dufflepudUrl + '/relay/info', {
method: 'POST',
body: JSON.stringify({url}),
headers: {
'Content-Type': 'application/json',
},
})
return {...await res.json(), url, refreshed_at: now()}
} catch (e) {
if (!e.toString().includes('Failed to fetch')) {
warn(e)
}
return {url, refreshed_at: now()}
}
})
)
database.relays.bulkPatch(createMap('url', freshRelays.filter(identity)))
}
// Close menu on click outside
document.querySelector("html").addEventListener("click", e => {
if (e.target !== menuIcon) {
menuIsOpen.set(false)
}
})
const unsubHistory = globalHistory.listen(({location}) => {
if (!location.hash) {
// Remove identifying information, e.g. pubkeys, event ids, etc
const name = location.pathname.slice(1)
.replace(/(npub|nprofile|note|nevent)[^\/]+/g, (_, m) => `<${m}>`)
logUsage(btoa(['page', name].join(':')))
}
})
const unsubModal = modal.subscribe($modal => {
// Keep scroll position on body, but don't allow scrolling
if ($modal) {
logUsage(btoa(['modal', $modal.type].join(':')))
// This is not idempotent, so don't duplicate it
if (document.body.style.position !== 'fixed') {
scrollY = window.scrollY
document.body.style.top = `-${scrollY}px`
document.body.style.position = `fixed`
}
} else {
document.body.setAttribute('style', '')
window.scrollTo(0, scrollY)
}
})
return () => {
clearInterval(interval)
unsubHistory()
unsubModal()
}
})
</script>
<Router {url}>
<div use:links class="h-full">
{#if $ready}
<div class="pt-16 text-white h-full lg:ml-56">
<Route path="/alerts" component={Alerts} />
<Route path="/search/:activeTab" component={Search} />
<Route path="/notes/:activeTab" component={Notes} />
<Route path="/people/:npub/:activeTab" let:params>
{#key params.npub}
<Person npub={params.npub} activeTab={params.activeTab} />
{/key}
</Route>
<Route path="/chat" component={Chat} />
<Route path="/chat/:entity" let:params>
{#key params.entity}
<ChatRoom entity={params.entity} />
{/key}
</Route>
<Route path="/messages/:entity" let:params>
{#key params.entity}
<Messages entity={params.entity} />
{/key}
</Route>
<Route path="/keys" component={Keys} />
<Route path="/relays" component={RelayList} />
<Route path="/profile" component={Profile} />
<Route path="/settings" component={Settings} />
<Route path="/login" component={Login} />
<Route path="/logout" component={Logout} />
<Route path="/debug" component={Debug} />
<Route path="/:entity" let:params>
{#key params.entity}
<Bech32Entity entity={params.entity} />
{/key}
</Route>
<Route path="*" component={NotFound} />
</div>
{/if}
<ul
class="py-20 w-56 bg-dark fixed top-0 bottom-0 left-0 transition-all shadow-xl
border-r border-medium text-white overflow-hidden z-10 lg:ml-0"
class:-ml-56={!$menuIsOpen}
>
{#if $user}
<li>
<a href={routes.person($user.pubkey)} class="flex gap-2 px-4 py-2 pb-6 items-center">
<div
class="overflow-hidden w-6 h-6 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
style="background-image: url({$user.picture})" />
<span class="text-lg font-bold">{displayPerson($user)}</span>
</a>
</li>
<li class="cursor-pointer relative">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/alerts">
<i class="fa-solid fa-bell mr-2" /> Alerts
{#if $mostRecentAlert > $lastCheckedAlerts}
<div class="w-2 h-2 rounded bg-accent absolute top-3 left-6" />
{/if}
</a>
</li>
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/search/people">
<i class="fa-solid fa-search mr-2" /> Search
</a>
</li>
{/if}
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/notes/network">
<i class="fa-solid fa-tag mr-2" /> Notes
</a>
</li>
{#if $user}
<li class="cursor-pointer relative">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/chat">
<i class="fa-solid fa-message mr-2" /> Chat
{#if hasNewMessages}
<div class="w-2 h-2 rounded bg-accent absolute top-2 left-7" />
{/if}
</a>
</li>
<li class="h-px mx-3 my-4 bg-medium" />
<li class="cursor-pointer relative">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/relays">
<i class="fa-solid fa-server mr-2" /> Relays
{#if slowConnections.length > 0}
<div class="w-2 h-2 rounded bg-accent absolute top-2 left-8" />
{/if}
</a>
</li>
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/keys">
<i class="fa-solid fa-key mr-2" /> Keys
</a>
</li>
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/settings">
<i class="fa-solid fa-gear mr-2" /> Settings
</a>
</li>
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/logout">
<i class="fa-solid fa-right-from-bracket mr-2" /> Logout
</a>
</li>
{:else}
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/login">
<i class="fa-solid fa-right-to-bracket mr-2" /> Login
</a>
</li>
{/if}
{#if import.meta.env.VITE_SHOW_DEBUG_ROUTE === 'true'}
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/debug">
<i class="fa-solid fa-bug mr-2" /> Debug
</a>
</li>
{/if}
</ul>
<div
class="fixed top-0 bg-dark flex justify-between items-center text-white w-full p-4
border-b border-medium z-10"
>
<div class="lg:hidden">
<button class="fa-solid fa-bars fa-2xl cursor-pointer" bind:this={menuIcon} on:click={toggleMenu} />
</div>
<Anchor external type="unstyled" href="https://github.com/staab/coracle" class="flex items-center gap-2">
<img alt="Coracle Logo" src="/images/favicon.png" class="w-8" />
<h1 class="staatliches text-3xl">Coracle</h1>
</Anchor>
{#if $mostRecentAlert > $lastCheckedAlerts || hasNewMessages}
<div class="w-2 h-2 rounded bg-accent absolute top-4 left-12 lg:hidden" />
{/if}
</div>
{#if keys.canSign()}
<div class="fixed bottom-0 right-0 m-8">
<button
class="rounded-full bg-accent color-white w-16 h-16 flex justify-center
items-center border border-dark shadow-2xl"
on:click={() => modal.set({type: 'note/create'})}>
<span class="fa-sold fa-plus fa-2xl" />
</button>
</div>
{/if}
{#if $modal}
<Modal onEscape={closeModal}>
{#if $modal.type === 'note/detail'}
{#key $modal.note.id}
<NoteDetail {...$modal} />
{/key}
{:else if $modal.type === 'note/create'}
<NoteCreate />
{:else if $modal.type === 'relay/add'}
<AddRelay />
{:else if $modal.type === 'signUp'}
<SignUp />
{:else if $modal.type === 'room/edit'}
<ChatEdit {...$modal} />
{:else if $modal.type === 'login/privkey'}
<PrivKeyLogin />
{:else if $modal.type === 'login/pubkey'}
<PubKeyLogin />
{:else if $modal.type === 'person/settings'}
<PersonSettings />
{:else if $modal.type === 'person/share'}
<PersonShare />
{:else if $modal.type === 'person/list'}
<PersonList pubkeys={$modal.pubkeys} />
{:else if $modal.type === 'message'}
<Content size="lg">
<div class="text-center">{$modal.message}</div>
{#if $modal.spinner}
<Spinner delay={0} />
{/if}
</Content>
{/if}
</Modal>
{/if}
{#if $toast}
<div
class="fixed top-0 left-0 right-0 z-10"
transition:fly={{y: -50, duration: 300}}
>
<div
class="rounded bg-accent shadow-xl mx-24 sm:mx-32 mt-2 p-3 text-white text-center border border-dark"
>
{#if is(String, $toast.message)}
{$toast.message}
{:else}
<div>
{$toast.message.text}
{#if $toast.message.link}
<a class="ml-1 underline" href={$toast.message.link.href}>
{$toast.message.link.text}
</a>
{/if}
</div>
{/if}
</div>
</div>
{/if}
</div>
</Router>