Add basic notes

This commit is contained in:
Jonathan Staab 2022-11-24 13:12:24 -08:00
parent 586b7853ac
commit 20f8d52c78
10 changed files with 516 additions and 49 deletions

View File

@ -1,7 +1,10 @@
Bugs
- [ ] Remove dexie, or use it instead of localstorage for cached data
- [ ] Add redirect to /notes, ditch / route
- [ ] Remove hack for re-rendering rooms on url change
- [ ] Memoize room list, every time the user switches chat rooms it pulls the full list
- [ ] Fix toast, it gets in the way. Make it smaller and dismissable.
Features

View File

@ -13,8 +13,8 @@
import Profile from "src/routes/Profile.svelte"
import RelayList from "src/routes/RelayList.svelte"
import UserDetail from "src/routes/UserDetail.svelte"
import NoteCreate from "src/routes/NoteCreate.svelte"
import Chat from "src/routes/Chat.svelte"
import ChatCreate from "src/routes/ChatCreate.svelte"
import ChatRoom from "src/routes/ChatRoom.svelte"
import ChatEdit from "src/routes/ChatEdit.svelte"
@ -49,14 +49,16 @@
<div use:links class="h-full">
<div class="pt-16 text-white h-full">
<Route path="/" component={Feed} />
<Route path="/login" component={Login} />
<Route path="/relays" component={RelayList} />
<Route path="/notes" component={Feed} />
<Route path="/notes/new" component={NoteCreate} />
<Route path="/chat" component={Chat} />
<Route path="/chat/new" component={ChatCreate} />
<Route path="/chat/new" component={ChatEdit} />
<Route path="/chat/:room" component={ChatRoom} />
<Route path="/chat/:room/edit" component={ChatEdit} />
<Route path="/user/:pubkey" component={UserDetail} />
<Route path="/settings/relays" component={RelayList} />
<Route path="/settings/profile" component={Profile} />
<Route path="/login" component={Login} />
</div>
<ul
@ -71,20 +73,10 @@
style="background-image: url({$user.picture})" />
<span class="text-lg font-bold">{$user.name}</span>
</li>
<li class="cursor-pointer">
<a class="block 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>
{/if}
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/">
<i class="fa-solid fa-home mr-2" /> Home
</a>
</li>
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/relays">
<i class="fa-solid fa-server mr-2" /> Relays
<i class="fa-solid fa-tag mr-2" /> Notes
</a>
</li>
<li class="cursor-pointer">
@ -92,7 +84,18 @@
<i class="fa-solid fa-message mr-2" /> Chat
</a>
</li>
<li class="h-px mx-3 my-4 bg-medium" />
{#if $user}
<li class="cursor-pointer">
<a class="block 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">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/settings/relays">
<i class="fa-solid fa-server mr-2" /> Relays
</a>
</li>
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" on:click={logout}>
<i class="fa-solid fa-right-from-bracket mr-2" /> Logout

View File

@ -0,0 +1,67 @@
<script>
import {onMount} from 'svelte'
import {writable} from 'svelte/store'
import {navigate} from 'svelte-routing'
import {fuzzy} from "src/util/misc"
import {nostr} from 'src/state/nostr'
import {rooms} from 'src/state/app'
import Input from "src/partials/Input.svelte"
let q = ""
let search
$: search = fuzzy(Object.values($rooms), {keys: ["name", "about"]})
const createRoom = () => navigate(`/chat/new`)
// TODO hack: there should be a way to re-render a route when the url changes
const setRoom = id => {
navigate(`/chat`)
setTimeout(() => navigate(`/chat/${id}`))
}
onMount(() => {
const sub = nostr.sub({
filter: {kinds: [40, 41]},
cb: e => {
const id = e.kind === 40 ? e.id : e.tags[0][1]
$rooms[id] = {id, pubkey: e.pubkey, ...$rooms[id], ...JSON.parse(e.content)}
},
})
return () => sub.unsub()
})
</script>
<div class="hidden sm:flex flex-col bg-dark w-56 h-full fixed py-8 border-r border-solid border-r-medium">
<div class="m-4">
<Input bind:value={q} type="text" placeholder="Search rooms">
<i slot="before" class="fa-solid fa-search" />
</Input>
</div>
<ul>
{#each search(q).slice(0, 10) as r}
<li
class="flex flex-col gap-2 px-3 py-2 cursor-pointer hover:bg-accent transition-all"
on:click={() => setRoom(r.id)}>
<div class="flex gap-2 items-center">
<div
class="overflow-hidden w-4 h-4 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
style="background-image: url({r.picture})" />
<span class="font-bold">{r.name}</span>
</div>
{#if r.about}
<small class="pl-6 text-light whitespace-nowrap text-ellipsis overflow-hidden">
{r.about}
</small>
{/if}
</li>
{/each}
<li class="bg-medium m-3 h-px" />
<li class="cursor-pointer font-bold hover:bg-accent transition-all px-3 py-2" on:click={createRoom}>
<i class="fa-solid fa-plus" /> Create Room
</li>
</ul>
</div>

View File

@ -0,0 +1,90 @@
<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 RoomList from "src/partials/chat/RoomList.svelte"
import {user} from "src/state/user"
import {rooms} from "src/state/app"
import {dispatch} from "src/state/dispatch"
import toast from "src/state/toast"
export let room
let values = $rooms[room] || {}
onMount(async () => {
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 e => {
e.preventDefault()
if (!values.name.match(/^\w[\w\-]+\w$/)) {
toast.show("error", "Names must be comprised of letters, numbers, and dashes only.")
} else {
const event = await dispatch(values.id ? "room/update" : "room/create", values)
toast.show("info", `Your room has been ${values.id ? 'updated' : 'created'}!`)
navigate(`/chat/${room}`)
}
}
</script>
<div class="flex gap-4 h-full">
<div class="sm:ml-56 w-full">
<form on:submit={submit} class="flex justify-center py-8 px-4" 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">Name your room</h1>
</div>
<div class="flex flex-col gap-8 w-full">
<div class="flex flex-col gap-1">
<strong>Room name</strong>
<Input type="text" name="name" wrapperClass="flex-grow" bind:value={values.name}>
<i slot="before" class="fa-solid fa-tag" />
</Input>
<p class="text-sm text-light">
The room's name can be changed anytime.
</p>
</div>
<div class="flex flex-col gap-1">
<strong>Room information</strong>
<Textarea name="about" bind:value={values.about} />
<p class="text-sm text-light">
Give people an idea of what kind of conversations will be happening here.
</p>
</div>
<div class="flex flex-col gap-1">
<strong>Picture</strong>
<input type="file" name="picture" />
<p class="text-sm text-light">
A picture to help people remember your room.
</p>
</div>
<Button type="submit" class="text-center">Done</Button>
</div>
</div>
</form>
</div>
<RoomList />
</div>

169
src/routes/ChatRoom.svelte Normal file
View File

@ -0,0 +1,169 @@
<script>
import {onMount} from 'svelte'
import {fly} from 'svelte/transition'
import {navigate} from 'svelte-routing'
import {prop, last} from 'ramda'
import {switcherFn} from 'hurdak/src/core'
import {nostr} from 'src/state/nostr'
import {rooms, accounts} from 'src/state/app'
import {dispatch} from 'src/state/dispatch'
import {user} from 'src/state/user'
import RoomList from "src/partials/chat/RoomList.svelte"
export let room
let textarea
let messages = []
let annotatedMessages = []
$: {
// Group messages so we're only showing the account once per chunk
annotatedMessages = messages.reduce(
(mx, m) => {
const account = $accounts[m.pubkey]
// If we don't have an account yet, don't show the message
if (!account) {
return mx
}
return mx.concat({
...m,
account,
showAccount: account !== prop('account', last(mx)),
})
},
[]
)
}
onMount(() => {
const isVisible = $el => {
const bodyRect = document.body.getBoundingClientRect()
const {top, height} = $el.getBoundingClientRect()
return top + height < bodyRect.height
}
const sub = nostr.sub({
filter: {kinds: [42, 43, 44], '#e': [room]},
cb: e => {
switcherFn(e.kind, {
42: () => {
const $prevListItem = last(document.querySelectorAll('.chat-message'))
messages = messages.concat(e)
if (!$accounts[e.pubkey]) {
const accountSub = nostr.sub({
filter: {kinds: [0], authors: [e.pubkey]},
cb: e => {
$accounts[e.pubkey] = {
...$accounts[e.pubkey],
...JSON.parse(e.content),
}
accountSub.unsub()
},
})
}
if ($prevListItem && isVisible($prevListItem)) {
setTimeout(() => {
const $li = last(document.querySelectorAll('.chat-message'))
$li.scrollIntoView({behavior: "smooth"})
}, 100)
}
},
43: () => null,
44: () => null,
})
},
})
return () => sub.unsub()
})
const edit = () => {
navigate(`/chat/${room}/edit`)
}
const sendMessage = () => {
const content = textarea.value.trim()
if (content) {
textarea.value = ''
dispatch("message/create", room, content)
}
}
const onKeyPress = e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
</script>
<div class="flex gap-4 h-full">
<div class="sm:ml-56 w-full">
<div class="relative">
<div class="flex flex-col pt-20 pb-32">
<ul class="p-4 max-h-full flex-grow">
{#each annotatedMessages as m}
<li in:fly={{y: 20}} class="py-1 chat-message">
{#if m.showAccount}
<div class="flex gap-2 items-center mt-2">
<div
class="overflow-hidden w-4 h-4 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
style="background-image: url({m.account.picture})" />
<span class="text-lg font-bold">{m.account.name}</span>
</div>
{/if}
<div class="ml-6">{m.content}</div>
</li>
{/each}
</ul>
</div>
{#if $rooms[room]}
<div class="fixed top-0 pt-20 w-full -ml-56 pl-60 p-4 border-b border-solid border-medium bg-dark flex gap-4">
<div
class="overflow-hidden w-12 h-12 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
style="background-image: url({$rooms[room].picture})" />
<div class="w-full">
<div class="flex items-center justify-between w-full">
<div class="text-lg font-bold">{$rooms[room].name}</div>
{#if $rooms[room].pubkey === $user.pubkey}
<small class="cursor-pointer" on:click={edit}>
<i class="fa-solid fa-edit" /> Edit
</small>
{/if}
</div>
<div>{$rooms[room].about || ''}</div>
</div>
</div>
{/if}
<div class="fixed bottom-0 w-full -ml-56 pl-56 flex bg-light border-medium border-t border-solid border-dark">
<textarea
rows="4"
autofocus
placeholder="Type something..."
bind:this={textarea}
on:keypress={onKeyPress}
class="w-full p-2 text-black bg-light
placeholder:text-medium outline-0 resize-none" />
<div
on:click={sendMessage}
class="flex flex-col py-8 p-4 justify-center gap-2 border-l border-solid border-dark
hover:bg-accent transition-all cursor-pointer text-black ">
<i class="fa-solid fa-paper-plane fa-xl" />
</div>
</div>
</div>
</div>
<RoomList />
</div>

View File

@ -1,39 +1,101 @@
<script>
import {liveQuery} from "dexie"
import {onMount} from 'svelte'
import {fly} from 'svelte/transition'
import {navigate} from "svelte-routing"
import {prop, last} from 'ramda'
import Anchor from "src/partials/Anchor.svelte"
import {nostr, relays} from "src/state/nostr"
import {user} from "src/state/user"
import {accounts} from "src/state/app"
import {db} from "src/state/db"
const relays = liveQuery(() => db.relays.toArray())
let notes = []
let annotatedNotes = []
const createPost = () => {
if ($user) {
navigate("/post/new")
} else {
navigate("/login")
$: {
// Group notes so we're only showing the account once per chunk
annotatedNotes = notes.reduce(
(mx, m) => {
const account = $accounts[m.pubkey]
// If we don't have an account yet, don't show the message
if (!account) {
return mx
}
return mx.concat({
...m,
account,
showAccount: account !== prop('account', last(mx)),
})
},
[]
)
}
const createNote = () => {
navigate("/notes/new")
}
onMount(() => {
const sub = nostr.sub({
filter: {kinds: [1], since: new Date().valueOf() / 1000 - 7 * 24 * 60 * 60},
cb: e => {
notes = notes.concat(e)
if (!$accounts[e.pubkey]) {
const accountSub = nostr.sub({
filter: {kinds: [0], authors: [e.pubkey]},
cb: e => {
$accounts[e.pubkey] = {
...$accounts[e.pubkey],
...JSON.parse(e.content),
}
accountSub.unsub()
},
})
}
},
})
return () => sub.unsub()
})
</script>
{#if $relays && $relays.length > 0}
feed
<div class="fixed bottom-0 right-0 p-8">
<ul class="p-2">
{#each annotatedNotes as n}
<li in:fly={{y: 20}} class="py-1 chat-message">
{#if n.showAccount}
<div class="flex gap-2 items-center mt-2">
<div
class="overflow-hidden w-4 h-4 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
style="background-image: url({n.account.picture})" />
<span class="text-lg font-bold">{n.account.name}</span>
</div>
{/if}
<div class="ml-6">{n.content}</div>
</li>
{/each}
</ul>
{#if $relays.length > 0}
<div class="fixed bottom-0 right-0 p-8">
<div
class="rounded-full bg-accent color-white w-16 h-16 flex justify-center
items-center border border-dark shadow-2xl cursor-pointer"
on:click={createPost}
on:click={createNote}
>
<span class="fa-sold fa-plus fa-2xl" />
</div>
</div>
{:else if $relays}
<div class="flex w-full justify-center items-center py-16">
</div>
{:else}
<div class="flex w-full justify-center items-center py-16">
<div class="text-center max-w-md">
You aren't yet connected to any relays. Please click <Anchor href="/relays"
You aren't yet connected to any relays. Please click <Anchor href="/settings/relays"
>here</Anchor
> to get started.
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,39 @@
<script>
import {onMount} from "svelte"
import {fly} from 'svelte/transition'
import {navigate} from "svelte-routing"
import Textarea from "src/partials/Textarea.svelte"
import Button from "src/partials/Button.svelte"
import {dispatch} from "src/state/dispatch"
import toast from "src/state/toast"
let values = {}
const submit = async e => {
e.preventDefault()
const event = await dispatch("note/create", values.content)
toast.show("info", `Your note has been created!`)
navigate('/notes')
}
</script>
<div class="m-auto">
<form on:submit={submit} class="flex justify-center py-8 px-4" in:fly={{y: 20}}>
<div class="flex flex-col gap-4 max-w-lg w-full">
<div class="flex justify-center items-center flex-col mb-4">
<h1 class="staatliches text-6xl">Create a note</h1>
</div>
<div class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-1">
<strong>What do you want to say?</strong>
<Textarea rows="8" name="content" bind:value={values.content} />
</div>
<Button type="submit" class="text-center">Send</Button>
</div>
</div>
</form>
</div>

View File

@ -5,11 +5,12 @@
import {uniqBy, prop} from 'ramda'
import {switcherFn} from 'hurdak/src/core'
import {nostr} from 'src/state/nostr'
import {user} from 'src/state/user'
import {user as currentUser} from 'src/state/user'
import {accounts} from 'src/state/app'
export let pubkey
let userData
let user
let notes = []
onMount(() => {
@ -18,7 +19,11 @@
cb: e => {
switcherFn(e.kind, {
[0]: () => {
userData = JSON.parse(e.content)
user = JSON.parse(e.content)
// Take this opportunity to sync account data. TODO this is a hack,
// we should by syncing and caching everywhere we grab accounts
$accounts[pubkey] = user
},
[1]: () => {
notes = uniqBy(prop('id'), notes.concat(e))
@ -41,23 +46,23 @@
}
</script>
{#if userData}
{#if user}
<div class="max-w-2xl m-auto flex flex-col gap-4 py-8 px-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 border border-solid border-white"
style="background-image: url({userData.picture})" />
style="background-image: url({user.picture})" />
<div class="flex-grow">
<div class="flex justify-between items-center">
<h1 class="text-2xl">{userData.name}</h1>
{#if $user?.pubkey === pubkey}
<h1 class="text-2xl">{user.name}</h1>
{#if $currentUser?.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>
<p>{user.about || ''}</p>
</div>
</div>
</div>

21
src/state/app.js Normal file
View File

@ -0,0 +1,21 @@
import {writable} from 'svelte/store'
import {getLocalJson, setLocalJson} from "src/util/misc"
import {user} from 'src/state/user'
export const rooms = writable(getLocalJson("coracle/rooms") || {})
rooms.subscribe($rooms => {
setLocalJson("coracle/rooms", $rooms)
})
export const accounts = writable(getLocalJson("coracle/accounts") || {})
accounts.subscribe($accounts => {
setLocalJson("coracle/accounts", $accounts)
})
user.subscribe($user => {
if ($user) {
accounts.update($accounts => ({...$accounts, [$user.pubkey]: $user}))
}
})

View File

@ -60,7 +60,7 @@ dispatch.addMethod("room/update", async (topic, {id, ...room}) => {
return event
})
dispatch.addMethod("message/create", async (topic, roomId, content, type = "root") => {
dispatch.addMethod("message/create", async (topic, roomId, content) => {
const [relay] = get(relays)
const event = nostr.event(42, content, [["e", roomId, relay, type]])
@ -68,3 +68,11 @@ dispatch.addMethod("message/create", async (topic, roomId, content, type = "root
return event
})
dispatch.addMethod("note/create", async (topic, content) => {
const event = nostr.event(1, content)
await nostr.publish(event)
return event
})