mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-29 00:10:52 +00:00
Things are basically working, profile update done
This commit is contained in:
parent
2b48a59c85
commit
c2eee552b4
BIN
package-lock.json
generated
BIN
package-lock.json
generated
Binary file not shown.
@ -24,10 +24,14 @@
|
||||
"@noble/secp256k1": "^1.7.0",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "^2.3.2",
|
||||
"compressorjs": "^1.1.1",
|
||||
"dexie": "^3.2.2",
|
||||
"fuse.js": "^6.6.2",
|
||||
"hurdak": "github:ConsignCloud/hurdak",
|
||||
"nostr-tools": "^0.24.1",
|
||||
"ramda": "^0.28.0",
|
||||
"svelte-routing": "^1.6.0"
|
||||
"svelte-routing": "^1.6.0",
|
||||
"throttle-debounce": "^5.0.0",
|
||||
"vite-plugin-node-polyfills": "^0.5.0"
|
||||
}
|
||||
}
|
||||
|
@ -5,13 +5,14 @@
|
||||
import {onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import {fly} from "svelte/transition"
|
||||
import {Router, Route, links} from "svelte-routing"
|
||||
import {Router, Route, links, navigate} from "svelte-routing"
|
||||
import {store as toast} from "src/state/toast"
|
||||
import {user} from 'src/state/user'
|
||||
import Feed from "src/routes/Feed.svelte"
|
||||
import Login from "src/routes/Login.svelte"
|
||||
import Profile from "src/routes/Profile.svelte"
|
||||
import RelayList from "src/routes/RelayList.svelte"
|
||||
import RelayDetail from "src/routes/RelayDetail.svelte"
|
||||
import UserDetail from "src/routes/UserDetail.svelte"
|
||||
import Explore from "src/routes/Explore.svelte"
|
||||
import Messages from "src/routes/Messages.svelte"
|
||||
|
||||
@ -26,12 +27,20 @@
|
||||
export let url = ""
|
||||
|
||||
onMount(() => {
|
||||
document.querySelector("body").addEventListener("click", e => {
|
||||
document.querySelector("html").addEventListener("click", e => {
|
||||
if (e.target !== menuIcon) {
|
||||
menuIsOpen.set(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Give the animation a moment to finish
|
||||
const logout = () => {
|
||||
setTimeout(() => {
|
||||
user.set(null)
|
||||
navigate("/login")
|
||||
}, 200)
|
||||
}
|
||||
</script>
|
||||
|
||||
<Router {url}>
|
||||
@ -39,28 +48,49 @@
|
||||
<div class="py-20 p-4 text-white">
|
||||
<Route path="/" component={Feed} />
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/profile" component={Profile} />
|
||||
<Route path="/relays" component={RelayList} />
|
||||
<Route path="/relays/:id" component={RelayDetail} />
|
||||
<Route path="/user/:pubkey" component={UserDetail} />
|
||||
<Route path="/explore" component={Explore} />
|
||||
<Route path="/messages" component={Messages} />
|
||||
<Route path="/settings/profile" component={Profile} />
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="py-20 p-4 w-48 bg-dark fixed top-0 bottom-0 left-0 transition-all shadow-xl
|
||||
class="py-20 w-48 bg-dark fixed top-0 bottom-0 left-0 transition-all shadow-xl
|
||||
border-r border-light text-white"
|
||||
class:-ml-48={!$menuIsOpen}
|
||||
>
|
||||
<li class="cursor-pointer py-2">
|
||||
<a href="/profile">
|
||||
{#if $user}
|
||||
<li class="flex gap-2 px-4 py-2 pb-8 items-center">
|
||||
<div
|
||||
class="overflow-hidden w-6 h-6 rounded-full bg-cover bg-center shrink-0"
|
||||
style="background-image: url({$user.picture})" />
|
||||
<span class="text-lg font-bold">{$user.name}</span>
|
||||
</li>
|
||||
<li class="cursor-pointer">
|
||||
<a class="block pl-6 px-4 py-2 hover:bg-accent transition-all" href="/user/{$user.pubkey}">
|
||||
<i class="fa-solid fa-user-astronaut mr-2" /> Profile
|
||||
</a>
|
||||
</li>
|
||||
<li class="cursor-pointer py-2">
|
||||
<a href="/relays">
|
||||
{:else}
|
||||
<li class="cursor-pointer">
|
||||
<a class="block pl-6 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}
|
||||
<li class="cursor-pointer">
|
||||
<a class="block pl-6 px-4 py-2 hover:bg-accent transition-all" href="/relays">
|
||||
<i class="fa-solid fa-server mr-2" /> Relays
|
||||
</a>
|
||||
</li>
|
||||
{#if $user}
|
||||
<li class="cursor-pointer">
|
||||
<a class="block pl-6 px-4 py-2 hover:bg-accent transition-all" on:click={logout}>
|
||||
<i class="fa-solid fa-right-from-bracket mr-2" /> Logout
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
|
||||
<div
|
||||
@ -68,7 +98,7 @@
|
||||
border-b border-light"
|
||||
>
|
||||
<i class="fa-solid fa-bars fa-2xl cursor-pointer" bind:this={menuIcon} on:click={toggleMenu} />
|
||||
<h1 class="staatliches text-3xl">Blazepoint</h1>
|
||||
<h1 class="staatliches text-3xl">Coracle</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
@ -1,35 +0,0 @@
|
||||
import {identity} from 'ramda'
|
||||
import {defmulti} from "hurdak/lib/hurdak"
|
||||
import keys from "src/util/keys"
|
||||
import {db} from "src/adapters/mock/db"
|
||||
import {user} from "src/adapters/mock/user"
|
||||
import {broadcastNewEvent} from "src/adapters/mock/events"
|
||||
|
||||
// Commands are processed in two layers:
|
||||
// - App-oriented commands are created via dispatch
|
||||
// - processEvent, which can be used to populate the database
|
||||
// whether commands are coming from our instance or a remote instance.
|
||||
|
||||
export const dispatch = defmulti("commands/dispatch", identity)
|
||||
|
||||
dispatch.addMethod("account/init", (topic, privkey) => {
|
||||
const pubkey = keys.getPublicKey(privkey)
|
||||
|
||||
user.set({
|
||||
pubkey,
|
||||
privkey,
|
||||
name: pubkey.slice(0, 8),
|
||||
picture: null,
|
||||
about: null,
|
||||
})
|
||||
})
|
||||
|
||||
dispatch.addMethod("server/join", (topic, s) => {
|
||||
db.relays.put(s)
|
||||
})
|
||||
|
||||
dispatch.addMethod("server/leave", (topic, s) => {
|
||||
db.relays.where("url").equals(s.url).delete()
|
||||
})
|
||||
|
||||
dispatch.addMethod("post/create", broadcastNewEvent)
|
@ -1,17 +0,0 @@
|
||||
import {Dexie} from "dexie"
|
||||
|
||||
export const db = new Dexie("coracle/db", {})
|
||||
|
||||
db.version(2).stores({
|
||||
relays: "url",
|
||||
events: "id, pubkey",
|
||||
})
|
||||
|
||||
db
|
||||
.open()
|
||||
.then(async db => {
|
||||
console.log("Database ready")
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
})
|
@ -1,117 +0,0 @@
|
||||
import {get} from "svelte/store"
|
||||
import {Buffer} from "buffer"
|
||||
import {pickValues, switcherFn} from "hurdak/lib/hurdak"
|
||||
import keys from "src/util/keys"
|
||||
import {db} from "src/adapters/mock/db"
|
||||
import {user} from "src/adapters/mock/user"
|
||||
|
||||
export const broadcastNewEvent = async (topic, payload) => {
|
||||
const event = await initEvent(topic, payload)
|
||||
|
||||
try {
|
||||
await broadcast(event)
|
||||
} catch (e) {
|
||||
console.error("Failed to broadcast new event", e)
|
||||
}
|
||||
}
|
||||
|
||||
const initEvent = async (topic, payload) => {
|
||||
const {pubKey, instance} = get(user)
|
||||
const ord = await db.events.where({pubKey}).count()
|
||||
|
||||
return {
|
||||
topic,
|
||||
pubKey,
|
||||
instance,
|
||||
path: [],
|
||||
ordinal: ord,
|
||||
payload: JSON.stringify(payload),
|
||||
synchronized: [],
|
||||
gid: [pubKey, instance, ord].join("."),
|
||||
}
|
||||
}
|
||||
|
||||
export const broadcast = async event => {
|
||||
const signedEvent = await signEvent(event)
|
||||
const servers = await db.servers.toArray()
|
||||
|
||||
if (event.pubKey === get(user).pubKey) {
|
||||
await db.events.put(signedEvent)
|
||||
}
|
||||
|
||||
await processEvent(signedEvent)
|
||||
|
||||
await Promise.all(servers.map(({url}) => req("POST", url, "/publish", {events: [signedEvent]})))
|
||||
}
|
||||
|
||||
export const signEvent = async event => {
|
||||
const {privKey, pubKey} = get(user)
|
||||
|
||||
return {
|
||||
...event,
|
||||
path: event.path.concat({
|
||||
pubKey,
|
||||
timestamp: new Date().toISOString(),
|
||||
signature: await keys.sign(await hashEvent(event), privKey),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export const hashEvent = async event => {
|
||||
let payload = pickValues(["topic", "payload", "instance", "ordinal"], event).join("")
|
||||
|
||||
for (let part of event["path"]) {
|
||||
payload += pickValues(["pubKey", "timestamp", "signature"], part).join("")
|
||||
}
|
||||
|
||||
const hash = await crypto.subtle.digest("SHA-256", _encoder.encode(payload))
|
||||
|
||||
return Buffer.from(hash).toString("hex")
|
||||
}
|
||||
|
||||
export const processEvent = async event => {
|
||||
const payload = JSON.parse(event.payload)
|
||||
|
||||
console.log("processing event", {...event, payload})
|
||||
|
||||
event.path.forEach(async ({signature, pubKey}, i) => {
|
||||
const hash = await hashEvent({...event, path: event.path.slice(0, i)})
|
||||
|
||||
if (!(await keys.verify(signature, hash, pubKey))) {
|
||||
console.warn(`Event failed to validate: ${event.gid} at signature #${i}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (event.pubKey === get(user).pubKey) {
|
||||
await switcherFn(event.topic, {
|
||||
"post/create": () => db.posts.put({...payload, gid: event.gid}),
|
||||
default: () => console.warn(`Unrecognized event topic: ${event.topic}`),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const req = async (method, url, path, {json, body} = {}) => {
|
||||
const $user = get(user)
|
||||
const headers = {}
|
||||
|
||||
if (json) {
|
||||
headers["Content-Type"] = "application/json"
|
||||
body = JSON.stringify(json)
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
const {privKey, pubKey} = $user
|
||||
const prep = x => btoa(JSON.stringify(x))
|
||||
const header = prep({typ: "JWT", alg: "secp256k1"})
|
||||
const meta = prep({iss: pubKey, exp: new Date().valueOf() + 1000, aud: url})
|
||||
const payload = Buffer.from(_encoder.encode([header, meta].join(".")))
|
||||
const signature = btoa(await keys.sign(payload.toString("hex"), privKey))
|
||||
const jwt = [header, meta, signature].join(".")
|
||||
|
||||
headers["Authorization"] = `Bearer ${jwt}`
|
||||
}
|
||||
|
||||
return fetch(`${url}${path}`, {method, body, headers})
|
||||
}
|
||||
|
||||
const _encoder = new TextEncoder()
|
@ -1,3 +0,0 @@
|
||||
export {dispatch} from "src/adapters/mock/commands"
|
||||
export {user} from "src/adapters/mock/user"
|
||||
export {db} from "src/adapters/mock/db"
|
@ -1,6 +0,0 @@
|
||||
import {writable} from "svelte/store"
|
||||
import {getLocalJson, setLocalJson} from "src/util/misc"
|
||||
|
||||
export const user = writable(getLocalJson("coracle/user"))
|
||||
|
||||
user.subscribe($user => setLocalJson("coracle/user", $user))
|
@ -2,7 +2,7 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
font-face {
|
||||
@font-face {
|
||||
font-family: "Montserrat";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
|
@ -7,9 +7,10 @@
|
||||
|
||||
const className = cx(
|
||||
$$props.class,
|
||||
"cursor-pointer",
|
||||
switcher(type, {
|
||||
anchor: "underline",
|
||||
button: "py-2 px-4 rounded bg-white text-accent cursor-pointer",
|
||||
button: "py-2 px-4 rounded bg-white text-accent",
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
@ -2,7 +2,8 @@
|
||||
import {liveQuery} from "dexie"
|
||||
import {navigate} from "svelte-routing"
|
||||
import Anchor from "src/partials/Anchor.svelte"
|
||||
import {db, user} from "src/adapters/mock"
|
||||
import {user} from "src/state/user"
|
||||
import {db} from "src/state/db"
|
||||
|
||||
const relays = liveQuery(() => db.relays.toArray())
|
||||
|
||||
@ -28,7 +29,7 @@
|
||||
</div>
|
||||
{:else if $relays}
|
||||
<div class="flex w-full justify-center items-center py-16">
|
||||
<div class="text-center max-w-sm">
|
||||
<div class="text-center max-w-2xl">
|
||||
You aren't yet connected to any relays. Please click <Anchor href="/relays"
|
||||
>here</Anchor
|
||||
> to get started.
|
||||
|
@ -1 +1,61 @@
|
||||
Login
|
||||
<script>
|
||||
import {navigate} from 'svelte-routing'
|
||||
import {generatePrivateKey} from 'nostr-tools'
|
||||
import {copyToClipboard} from "src/util/html"
|
||||
import Anchor from "src/partials/Anchor.svelte"
|
||||
import Input from "src/partials/Input.svelte"
|
||||
import toast from "src/state/toast"
|
||||
import {user} from "src/state/user"
|
||||
import {dispatch} from "src/state/dispatch"
|
||||
|
||||
let privKey = ''
|
||||
|
||||
const copyKey = () => {
|
||||
copyToClipboard(privKey)
|
||||
toast.show("info", "Your private key has been copied to the clipboard.")
|
||||
}
|
||||
|
||||
const generateKey = () => {
|
||||
privKey = generatePrivateKey()
|
||||
toast.show("info", "Your private key has been re-generated.")
|
||||
}
|
||||
|
||||
const logIn = async () => {
|
||||
if (!privKey.match(/[a-z0-9]{64}/)) {
|
||||
toast.show("error", "Sorry, but that's an invalid private key.")
|
||||
} else {
|
||||
const {found} = await dispatch("account/init", privKey)
|
||||
|
||||
await navigate(found ? `/` : '/settings/profile')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center pt-12">
|
||||
<div class="flex flex-col gap-4 max-w-2xl">
|
||||
<div class="flex justify-center items-center flex-col mb-4">
|
||||
<h1 class="staatliches text-6xl">Welcome!</h1>
|
||||
<i>To the Dogwood Social Network</i>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<small>
|
||||
To log in to existing account, simply enter your private key. To create a new account, just
|
||||
let us generate one for you.
|
||||
</small>
|
||||
<div class="flex flex-col gap-1">
|
||||
<strong>Private Key</strong>
|
||||
<Input type="password" bind:value={privKey} placeholder="Enter your private key">
|
||||
<i slot="after" class="fa-solid fa-copy" on:click={copyKey} />
|
||||
</Input>
|
||||
<Anchor on:click={generateKey} class="text-right">
|
||||
<small>Generate new key</small>
|
||||
</Anchor>
|
||||
</div>
|
||||
<small>
|
||||
Your private key is a string of random letters and numbers that allow you to prove
|
||||
you own your account. Write it down and keep it secret!
|
||||
</small>
|
||||
</div>
|
||||
<Anchor class="text-center" type="button" on:click={logIn}>Log In</Anchor>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1 +1,96 @@
|
||||
Profile
|
||||
<script>
|
||||
import {onMount} from "svelte"
|
||||
import {fly} from 'svelte/transition'
|
||||
import {navigate} from "svelte-routing"
|
||||
import pick from "ramda/src/pick"
|
||||
import {stripExifData} from "src/util/html"
|
||||
import Input from "src/partials/Input.svelte"
|
||||
import Select from "src/partials/Select.svelte"
|
||||
import Textarea from "src/partials/Textarea.svelte"
|
||||
import Anchor from "src/partials/Anchor.svelte"
|
||||
import Button from "src/partials/Button.svelte"
|
||||
import {user} from "src/state/user"
|
||||
import {dispatch} from "src/state/dispatch"
|
||||
import toast from "src/state/toast"
|
||||
|
||||
let values = {picture: null, about: null, name: null}
|
||||
|
||||
onMount(async () => {
|
||||
if (!$user) {
|
||||
return navigate("/login")
|
||||
}
|
||||
|
||||
values = pick(Object.keys(values), $user)
|
||||
|
||||
document.querySelector('[name=picture]').addEventListener('change', async e => {
|
||||
const [file] = e.target.files
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => values.picture = reader.result
|
||||
reader.onerror = e => console.error(e)
|
||||
reader.readAsDataURL(await stripExifData(file))
|
||||
} else {
|
||||
values.picture = null
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const submit = async event => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!values.name.match(/^\w[\w\-]+\w$/)) {
|
||||
toast.show("error", "Names must be comprised of letters, numbers, and dashes only.")
|
||||
} else {
|
||||
await dispatch("account/update", values)
|
||||
|
||||
toast.show("info", "Your profile has been updated!")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form on:submit={submit} class="flex justify-center py-12" in:fly={{y: 20}}>
|
||||
<div class="flex flex-col gap-4 max-w-2xl">
|
||||
<div class="flex justify-center items-center flex-col mb-4">
|
||||
<h1 class="staatliches text-6xl">About You</h1>
|
||||
<p>
|
||||
Give people a friendly way to recognize you. We recommend you do not use your real name or
|
||||
share your personal information. The future of the internet is
|
||||
<Anchor
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href="https://www.coindesk.com/markets/2020/06/29/many-bitcoin-developers-are-choosing-to-use-pseudonyms-for-good-reason/"
|
||||
>
|
||||
pseudonymous
|
||||
</Anchor>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-8 w-full">
|
||||
<div class="flex flex-col gap-1">
|
||||
<strong>Username</strong>
|
||||
<Input type="text" name="name" wrapperClass="flex-grow" bind:value={values.name}>
|
||||
<i slot="before" class="fa-solid fa-user-astronaut" />
|
||||
</Input>
|
||||
<p class="text-sm text-light">
|
||||
Your username can be changed at any time. To prevent spoofing, a few characters of your
|
||||
public key will also be displayed next to your posts.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<strong>About you</strong>
|
||||
<Textarea name="about" bind:value={values.about} />
|
||||
<p class="text-sm text-light">
|
||||
Tell the world about yourself. This will be shown on your profile page.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<strong>Profile Image</strong>
|
||||
<input type="file" name="picture" />
|
||||
<p class="text-sm text-light">
|
||||
Your profile image will have all metadata removed before being published.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" class="text-center">Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -1 +0,0 @@
|
||||
RelayDetail
|
@ -1 +1,53 @@
|
||||
Relays
|
||||
<script>
|
||||
import {liveQuery} from "dexie"
|
||||
import {propEq} from "ramda"
|
||||
import {fly} from 'svelte/transition'
|
||||
import {fuzzy, hash} from "src/util/misc"
|
||||
import Input from "src/partials/Input.svelte"
|
||||
import Anchor from "src/partials/Anchor.svelte"
|
||||
import {db} from "src/state/db"
|
||||
import {dispatch} from "src/state/dispatch"
|
||||
import {nostr, relays} from "src/state/nostr"
|
||||
|
||||
let q = ""
|
||||
let search = () => []
|
||||
|
||||
const knownRelays = liveQuery(() => db.relays.toArray())
|
||||
|
||||
$: search = fuzzy($knownRelays || [], {keys: ["name", "description", "url"]})
|
||||
|
||||
const toggle = (url, value) => {
|
||||
dispatch(value ? "relay/join" : "relay/leave", url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center py-12" in:fly={{y: 20}}>
|
||||
<div class="flex flex-col gap-8 max-w-2xl w-full">
|
||||
<div class="flex justify-center items-center flex-col mb-4">
|
||||
<h1 class="staatliches text-6xl">Get Connected</h1>
|
||||
<p>
|
||||
Relays are hubs for your content and connections. At least one is required to
|
||||
interact with the network, but you can join as many as you like.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<Input bind:value={q} type="text" wrapperClass="flex-grow" placeholder="Type to search">
|
||||
<i slot="before" class="fa-solid fa-search" />
|
||||
</Input>
|
||||
<Anchor type="button" href="/">Done</Anchor>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6 overflow-auto flex-grow -mx-6 px-6">
|
||||
{#each search(q) as relay}
|
||||
<div class="flex gap-2 justify-between cursor-pointer">
|
||||
<div>
|
||||
<strong>{relay.name || relay.url}</strong>
|
||||
<p class="text-light">{relay.description || ''}</p>
|
||||
</div>
|
||||
<a class="underline" on:click={() => toggle(relay.url, !$relays.includes(relay.url))}>
|
||||
{$relays.includes(relay.url) ? "Leave" : "Join"}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
74
src/routes/UserDetail.svelte
Normal file
74
src/routes/UserDetail.svelte
Normal file
@ -0,0 +1,74 @@
|
||||
<script>
|
||||
import {onMount} from 'svelte'
|
||||
import {reverse} from 'ramda'
|
||||
import {fly} from 'svelte/transition'
|
||||
import {uniqBy, prop} from 'ramda'
|
||||
import {switcherFn} from 'hurdak/src/core'
|
||||
import {nostr} from 'src/state/nostr'
|
||||
import {user} from 'src/state/user'
|
||||
|
||||
export let pubkey
|
||||
|
||||
let userData
|
||||
let notes = []
|
||||
|
||||
onMount(() => {
|
||||
const sub = nostr.sub({
|
||||
filter: {authors: [pubkey]},
|
||||
cb: e => {
|
||||
switcherFn(e.kind, {
|
||||
[0]: () => {
|
||||
userData = JSON.parse(e.content)
|
||||
},
|
||||
[1]: () => {
|
||||
notes = uniqBy(prop('id'), notes.concat(e))
|
||||
},
|
||||
default: () => null,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return () => sub.unsub()
|
||||
})
|
||||
|
||||
const formatTimestamp = ts => {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
return formatter.format(new Date(ts * 1000))
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if userData}
|
||||
<div class="max-w-2xl m-auto flex flex-col gap-4 py-4">
|
||||
<div class="flex flex-col gap-4" in:fly={{y: 20}}>
|
||||
<div class="flex gap-4">
|
||||
<div
|
||||
class="overflow-hidden w-12 h-12 rounded-full bg-cover bg-center shrink-0"
|
||||
style="background-image: url({userData.picture})" />
|
||||
<div class="flex-grow">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl">{userData.name}</h1>
|
||||
{#if $user?.pubkey === pubkey}
|
||||
<a href="/settings/profile" class="cursor-pointer text-sm">
|
||||
<i class="fa-solid fa-edit" /> Edit
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<p>{userData.about}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-px bg-light" in:fly={{y: 20, delay: 200}} />
|
||||
<div class="flex flex-col gap-4" in:fly={{y: 20, delay: 400}}>
|
||||
{#each reverse(notes) as note}
|
||||
<div>
|
||||
<small class="text-light">{formatTimestamp(note.created_at)}</small>
|
||||
<p>{note.content}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
38
src/state/db.js
Normal file
38
src/state/db.js
Normal file
@ -0,0 +1,38 @@
|
||||
import {Dexie} from "dexie"
|
||||
|
||||
export const db = new Dexie("coracle/db", {})
|
||||
|
||||
db.version(2).stores({
|
||||
relays: "url",
|
||||
events: "id, pubkey",
|
||||
})
|
||||
|
||||
db
|
||||
.open()
|
||||
.then(async db => {
|
||||
console.log("Database ready")
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
export const registerRelay = async url => {
|
||||
let json
|
||||
try {
|
||||
const res = await fetch(url.replace(/^ws/, 'http'), {
|
||||
headers: {
|
||||
Accept: 'application/nostr_json',
|
||||
},
|
||||
})
|
||||
|
||||
json = await res.json()
|
||||
} catch (e) {
|
||||
json = {}
|
||||
}
|
||||
|
||||
db.relays.put({...json, url})
|
||||
}
|
||||
|
||||
registerRelay('wss://nostr-pub.wellorder.net')
|
||||
registerRelay('wss://nostr-relay.wlvs.space')
|
||||
registerRelay('ws://localhost:7000')
|
44
src/state/dispatch.js
Normal file
44
src/state/dispatch.js
Normal file
@ -0,0 +1,44 @@
|
||||
import {identity, without} from 'ramda'
|
||||
import {getPublicKey} from 'nostr-tools'
|
||||
import {get} from 'svelte/store'
|
||||
import {defmulti} from "hurdak/lib/hurdak"
|
||||
import {db} from "src/state/db"
|
||||
import {user} from "src/state/user"
|
||||
import {nostr, relays} from 'src/state/nostr'
|
||||
|
||||
// Commands are processed in two layers:
|
||||
// - App-oriented commands are created via dispatch
|
||||
// - processEvent, which can be used to populate the database
|
||||
// whether commands are coming from our instance or a remote instance.
|
||||
|
||||
export const dispatch = defmulti("dispatch", identity)
|
||||
|
||||
dispatch.addMethod("account/init", async (topic, privkey) => {
|
||||
// Generate a public key
|
||||
const pubkey = getPublicKey(privkey)
|
||||
|
||||
// Set what we know about the user to our store
|
||||
user.set({name: pubkey.slice(0, 8), privkey, pubkey})
|
||||
|
||||
// Attempt to refresh user data from the network
|
||||
const found = Boolean(await user.refresh())
|
||||
|
||||
// Tell the caller whether this user was found
|
||||
return {found}
|
||||
})
|
||||
|
||||
dispatch.addMethod("account/update", async (topic, updates) => {
|
||||
// Update our local copy
|
||||
user.set({...get(user), ...updates})
|
||||
|
||||
// Tell the network
|
||||
await nostr.publish(nostr.event(0, JSON.stringify(updates)))
|
||||
})
|
||||
|
||||
dispatch.addMethod("relay/join", (topic, url) => {
|
||||
relays.update(r => r.concat(url))
|
||||
})
|
||||
|
||||
dispatch.addMethod("relay/leave", (topic, url) => {
|
||||
relays.update(r => without([url], r))
|
||||
})
|
77
src/state/nostr.js
Normal file
77
src/state/nostr.js
Normal file
@ -0,0 +1,77 @@
|
||||
import {writable} from 'svelte/store'
|
||||
import {relayPool, getPublicKey} from 'nostr-tools'
|
||||
import {getLocalJson, setLocalJson} from "src/util/misc"
|
||||
|
||||
export const nostr = relayPool()
|
||||
|
||||
// Augment nostr with some extra methods
|
||||
|
||||
nostr.login = privkey => {
|
||||
nostr.setPrivateKey(privkey)
|
||||
nostr._privkey = privkey
|
||||
}
|
||||
|
||||
nostr.event = (kind, content = '', tags = []) => {
|
||||
const pubkey = getPublicKey(nostr._privkey)
|
||||
const createdAt = Math.round(new Date().valueOf() / 1000)
|
||||
|
||||
return {kind, content, tags, pubkey, created_at: createdAt}
|
||||
}
|
||||
|
||||
nostr.find = (filter, timeout = 300) => {
|
||||
return new Promise(resolve => {
|
||||
const sub = nostr.sub({
|
||||
filter,
|
||||
cb: e => {
|
||||
resolve(e)
|
||||
|
||||
sub.unsub()
|
||||
},
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
resolve(null)
|
||||
|
||||
sub.unsub()
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
nostr.findLast = (filter, timeout = 300) => {
|
||||
return new Promise(resolve => {
|
||||
let result = null
|
||||
|
||||
const sub = nostr.sub({
|
||||
filter,
|
||||
cb: e => {
|
||||
result = e
|
||||
},
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
resolve(result)
|
||||
|
||||
sub.unsub()
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
// Create writable store for relays so we can observe changes in the app
|
||||
|
||||
export const relays = writable(getLocalJson("coracle/relays") || [])
|
||||
|
||||
relays.subscribe($relays => {
|
||||
Object.keys(nostr.relays).forEach(url => {
|
||||
if (!$relays.includes(url)) {
|
||||
nostr.removeRelay(url)
|
||||
}
|
||||
})
|
||||
|
||||
$relays.forEach(url => {
|
||||
if (!nostr.relays[url]) {
|
||||
nostr.addRelay(url)
|
||||
}
|
||||
})
|
||||
|
||||
setLocalJson("coracle/relays", $relays)
|
||||
})
|
31
src/state/user.js
Normal file
31
src/state/user.js
Normal file
@ -0,0 +1,31 @@
|
||||
import {writable, get} from "svelte/store"
|
||||
import {getLocalJson, setLocalJson} from "src/util/misc"
|
||||
import {nostr} from 'src/state/nostr'
|
||||
|
||||
export const user = writable(getLocalJson("coracle/user"))
|
||||
|
||||
user.subscribe($user => {
|
||||
setLocalJson("coracle/user", $user)
|
||||
|
||||
// Keep nostr in sync
|
||||
nostr.login($user?.privkey)
|
||||
})
|
||||
|
||||
user.refresh = async () => {
|
||||
const $user = get(user)
|
||||
|
||||
if ($user) {
|
||||
const data = await nostr.findLast({authors: [$user.pubkey], kinds: [0]})
|
||||
|
||||
if (data) {
|
||||
user.update($user => ({...$user, ...JSON.parse(data.content)}))
|
||||
}
|
||||
|
||||
return Boolean(data)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for new data every so often
|
||||
setTimeout(() => user.refresh(), 60 * 1000)
|
@ -13,3 +13,34 @@ export const copyToClipboard = text => {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const stripExifData = async file => {
|
||||
if (window.DataTransferItem && file instanceof DataTransferItem) {
|
||||
file = file.getAsFile()
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
return file
|
||||
}
|
||||
|
||||
const {default: Compressor} = await import("compressorjs")
|
||||
|
||||
/* eslint no-new: 0 */
|
||||
|
||||
return new Promise((resolve, _reject) => {
|
||||
new Compressor(file, {
|
||||
maxWidth: 50,
|
||||
maxHeight: 50,
|
||||
convertSize: 1024,
|
||||
success: resolve,
|
||||
error: e => {
|
||||
// Non-images break compressor
|
||||
if (e.toString().includes("File or Blob")) {
|
||||
return resolve(file)
|
||||
}
|
||||
|
||||
_reject(e)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -1,11 +0,0 @@
|
||||
import {Buffer} from "buffer"
|
||||
import * as secp from "@noble/secp256k1"
|
||||
|
||||
const toHex = buffer => Buffer.from(buffer).toString("hex")
|
||||
|
||||
export default {
|
||||
generatePrivateKey: () => toHex(secp.utils.randomPrivateKey()),
|
||||
getPublicKey: privKey => toHex(secp.schnorr.getPublicKey(privKey)),
|
||||
sign: async (hex, privKey) => toHex(await secp.schnorr.sign(hex, privKey)),
|
||||
verify: (sig, payload, pubKey) => secp.schnorr.verify(sig, payload, pubKey),
|
||||
}
|
@ -1,18 +1,27 @@
|
||||
import * as path from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: [
|
||||
{find: 'src', replacement: path.resolve(__dirname, 'src')},
|
||||
],
|
||||
define: {
|
||||
global: {},
|
||||
},
|
||||
plugins: [svelte({
|
||||
resolve: {
|
||||
alias: {
|
||||
src: path.resolve(__dirname, 'src'),
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
nodePolyfills({
|
||||
protocolImports: true,
|
||||
}),
|
||||
svelte({
|
||||
onwarn: (warning, handler) => {
|
||||
if (warning.code.startsWith("a11y-")) return
|
||||
handler(warning)
|
||||
},
|
||||
})],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user