mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-30 00:41:12 +00:00
Add chat
This commit is contained in:
parent
45d1f37686
commit
1a88bfa629
@ -4,8 +4,6 @@ Coracle is a web client for the Nostr protocol. While Nostr is useful for many t
|
||||
|
||||
[Dufflepud](https://github.com/staab/dufflepud) is a companion server which you can self-host. It helps Coracle with things like link previews and image uploads.
|
||||
|
||||
Coracle is currently in _alpha_ - expect bugs, slow loading times, and rough edges.
|
||||
|
||||
If you like Coracle and want to support its development, you can donate sats via [Geyser](https://geyser.fund/project/coracle).
|
||||
|
||||
# Features
|
||||
@ -50,6 +48,9 @@ If you like Coracle and want to support its development, you can donate sats via
|
||||
- [ ] Figure out migrations from previous version
|
||||
- [ ] Fix search
|
||||
- [ ] Deploy coracle relay, set better defaults
|
||||
- [ ] Chat
|
||||
- [ ] Figure out which relays to use
|
||||
- [ ] Add petnames for channels - join/leave from list page?
|
||||
|
||||
## 0.2.7
|
||||
|
||||
|
@ -23,6 +23,7 @@
|
||||
import NoteDetail from "src/views/NoteDetail.svelte"
|
||||
import PersonSettings from "src/views/PersonSettings.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"
|
||||
@ -36,6 +37,8 @@
|
||||
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"
|
||||
|
||||
export let url = ""
|
||||
|
||||
@ -126,6 +129,12 @@
|
||||
<Person {...params} />
|
||||
{/key}
|
||||
</Route>
|
||||
<Route path="/chat" component={Chat} />
|
||||
<Route path="/chat/:roomId" let:params>
|
||||
{#key params.roomId}
|
||||
<ChatRoom {...params} />
|
||||
{/key}
|
||||
</Route>
|
||||
<Route path="/keys" component={Keys} />
|
||||
<Route path="/relays" component={RelayList} />
|
||||
<Route path="/profile" component={Profile} />
|
||||
@ -174,6 +183,11 @@
|
||||
</a>
|
||||
</li>
|
||||
{#if $user}
|
||||
<li class="cursor-pointer">
|
||||
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/chat">
|
||||
<i class="fa-solid fa-message mr-2" /> Chat
|
||||
</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">
|
||||
@ -246,6 +260,8 @@
|
||||
<AddRelay />
|
||||
{:else if $modal.form === 'signUp'}
|
||||
<SignUp />
|
||||
{:else if $modal.form === 'room/edit'}
|
||||
<ChatEdit {...$modal} />
|
||||
{:else if $modal.form === 'privkeyLogin'}
|
||||
<PrivKeyLogin />
|
||||
{:else if $modal.form === 'pubkeyLogin'}
|
||||
|
@ -1,16 +1,18 @@
|
||||
import Dexie from 'dexie'
|
||||
import {pick} from 'ramda'
|
||||
import {nip05} from 'nostr-tools'
|
||||
import {writable} from 'svelte/store'
|
||||
import {noop, ensurePlural, createMap, switcherFn} from 'hurdak/lib/hurdak'
|
||||
import {now} from 'src/util/misc'
|
||||
import {personKinds} from 'src/util/nostr'
|
||||
import {personKinds, Tags, roomAttrs} from 'src/util/nostr'
|
||||
|
||||
export const db = new Dexie('agent/data/db')
|
||||
|
||||
db.version(9).stores({
|
||||
db.version(11).stores({
|
||||
relays: '++url, name',
|
||||
alerts: '++id, created_at',
|
||||
people: '++pubkey',
|
||||
rooms: '++id, joined',
|
||||
})
|
||||
|
||||
// A flag for hiding things that rely on people being loaded initially
|
||||
@ -46,6 +48,13 @@ export const updatePeople = async updates => {
|
||||
// Hooks
|
||||
|
||||
export const processEvents = async events => {
|
||||
await Promise.all([
|
||||
processProfileEvents(events),
|
||||
processRoomEvents(events),
|
||||
])
|
||||
}
|
||||
|
||||
const processProfileEvents = async events => {
|
||||
const profileEvents = ensurePlural(events)
|
||||
.filter(e => personKinds.includes(e.kind))
|
||||
|
||||
@ -100,11 +109,56 @@ export const processEvents = async events => {
|
||||
await updatePeople(updates)
|
||||
}
|
||||
|
||||
const processRoomEvents = async events => {
|
||||
const roomEvents = ensurePlural(events)
|
||||
.filter(e => [40, 41].includes(e.kind))
|
||||
|
||||
const updates = {}
|
||||
for (const e of roomEvents) {
|
||||
let content
|
||||
try {
|
||||
content = pick(roomAttrs, JSON.parse(e.content))
|
||||
} catch (e) {
|
||||
continue
|
||||
}
|
||||
|
||||
const roomId = e.kind === 40 ? e.id : Tags.from(e).type("e").values().first()
|
||||
|
||||
if (!roomId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const room = await db.rooms.get(roomId)
|
||||
|
||||
// Merge edits but don't let old ones override new ones
|
||||
if (room?.edited_at > e.created_at) {
|
||||
continue
|
||||
}
|
||||
|
||||
// There are some non-standard rooms out there, ignore them
|
||||
// if they don't have a name
|
||||
if (content.name) {
|
||||
updates[roomId] = {
|
||||
joined: 0,
|
||||
...room,
|
||||
...updates[roomId],
|
||||
...content,
|
||||
id: roomId,
|
||||
pubkey: e.pubkey,
|
||||
edited_at: e.created_at,
|
||||
updated_at: now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.rooms.bulkPut(Object.values(updates))
|
||||
}
|
||||
|
||||
// Utils
|
||||
|
||||
const verifyNip05 = (pubkey, as) =>
|
||||
nip05.queryProfile(as).then(result => {
|
||||
if (result.pubkey === pubkey) {
|
||||
if (result?.pubkey === pubkey) {
|
||||
const person = getPerson(pubkey, true)
|
||||
|
||||
updatePeople({[pubkey]: {...person, verified_as: as}})
|
||||
|
@ -107,6 +107,7 @@ const subscribe = async (relays, filters) => {
|
||||
relays = uniqBy(prop('url'), relays.filter(r => isRelay(r.url)))
|
||||
filters = ensurePlural(filters)
|
||||
|
||||
|
||||
// Create a human readable subscription id for debugging
|
||||
const id = [
|
||||
Math.random().toString().slice(2, 6),
|
||||
@ -128,7 +129,7 @@ const subscribe = async (relays, filters) => {
|
||||
sub.conn.stats.activeCount += 1
|
||||
|
||||
if (sub.conn.stats.activeCount > 10) {
|
||||
console.warn(`Relay ${sub.url} has >10 active subscriptions`)
|
||||
console.warn(`Relay ${sub.conn.url} has >10 active subscriptions`)
|
||||
}
|
||||
|
||||
return sub
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {prop, join, uniqBy, last} from 'ramda'
|
||||
import {prop, pick, join, uniqBy, last} from 'ramda'
|
||||
import {get} from 'svelte/store'
|
||||
import {first} from "hurdak/lib/hurdak"
|
||||
import {Tags, isRelay} from 'src/util/nostr'
|
||||
import {Tags, isRelay, roomAttrs} from 'src/util/nostr'
|
||||
import {keys, publish, getRelays} from 'src/agent'
|
||||
|
||||
const updateUser = (relays, updates) =>
|
||||
@ -17,13 +17,13 @@ const muffle = (relays, muffle) =>
|
||||
publishEvent(relays, 12165, {tags: muffle})
|
||||
|
||||
const createRoom = (relays, room) =>
|
||||
publishEvent(relays, 40, {content: JSON.stringify(room)})
|
||||
publishEvent(relays, 40, {content: JSON.stringify(pick(roomAttrs, room))})
|
||||
|
||||
const updateRoom = (relays, {id, ...room}) =>
|
||||
publishEvent(relays, 41, {content: JSON.stringify(room), tags: [["e", id]]})
|
||||
publishEvent(relays, 41, {content: JSON.stringify(pick(roomAttrs, room)), tags: [["e", id]]})
|
||||
|
||||
const createMessage = (relays, roomId, content) =>
|
||||
publishEvent(relays, 42, {content, tags: [["e", roomId, first(relays), "root"]]})
|
||||
publishEvent(relays, 42, {content, tags: [["e", roomId, prop('url', first(relays)), "root"]]})
|
||||
|
||||
const createNote = (relays, content, mentions = [], topics = []) => {
|
||||
mentions = mentions.map(p => ["p", p, prop('url', first(getRelays(p)))])
|
||||
@ -59,8 +59,6 @@ const createReply = (relays, note, content, mentions = [], topics = []) => {
|
||||
.concat(mentions.concat(topics))
|
||||
)
|
||||
|
||||
console.log(relays)
|
||||
|
||||
return publishEvent(relays, 1, {content, tags})
|
||||
}
|
||||
|
||||
|
40
src/partials/Room.svelte
Normal file
40
src/partials/Room.svelte
Normal file
@ -0,0 +1,40 @@
|
||||
<script>
|
||||
import {fly} from 'svelte/transition'
|
||||
import Anchor from 'src/partials/Anchor.svelte'
|
||||
|
||||
export let joined = false
|
||||
export let room
|
||||
export let setRoom
|
||||
export let joinRoom
|
||||
export let leaveRoom
|
||||
</script>
|
||||
|
||||
<li
|
||||
class="flex gap-4 px-4 py-6 cursor-pointer hover:bg-medium transition-all rounded border border-solid border-medium bg-dark"
|
||||
on:click={() => setRoom(room.id)}
|
||||
in:fly={{y: 20}}>
|
||||
<div
|
||||
class="overflow-hidden w-14 h-14 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
|
||||
style="background-image: url({room.picture})" />
|
||||
<div class="flex flex-grow flex-col justify-start gap-2">
|
||||
<div class="flex flex-grow items-center justify-between gap-2">
|
||||
<h2 class="text-lg">{room.name}</h2>
|
||||
{#if joined}
|
||||
<Anchor type="button" class="flex items-center gap-2" on:click={e => { e.stopPropagation(); leaveRoom(room.id) }}>
|
||||
<i class="fa fa-right-from-bracket" />
|
||||
<span>Leave</span>
|
||||
</Anchor>
|
||||
{:else}
|
||||
<Anchor type="button" class="flex items-center gap-2" on:click={e => { e.stopPropagation(); joinRoom(room.id) }}>
|
||||
<i class="fa fa-right-to-bracket" />
|
||||
<span>Join</span>
|
||||
</Anchor>
|
||||
{/if}
|
||||
</div>
|
||||
{#if room.about}
|
||||
<p class="text-light whitespace-nowrap text-ellipsis overflow-hidden">
|
||||
{room.about}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
20
src/partials/RoomBadge.svelte
Normal file
20
src/partials/RoomBadge.svelte
Normal file
@ -0,0 +1,20 @@
|
||||
<script>
|
||||
export let room
|
||||
export let setRoom
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex gap-4 px-2 py-4 cursor-pointer hover:bg-dark transition-all"
|
||||
on:click={() => setRoom(room.id)}>
|
||||
<div
|
||||
class="overflow-hidden w-20 h-20 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
|
||||
style="background-image: url({room.picture})" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="text-lg">{room.name}</h2>
|
||||
{#if room.about}
|
||||
<p class="text-light whitespace-nowrap text-ellipsis overflow-hidden">
|
||||
{room.about}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
@ -3,6 +3,7 @@
|
||||
import {Circle2} from 'svelte-loading-spinners'
|
||||
</script>
|
||||
|
||||
<div class="py-20 flex justify-center" in:fade={{delay: 1000}}>
|
||||
<div class="py-20 flex flex-col gap-4 items-center justify-center" in:fade={{delay: 1000}}>
|
||||
<slot />
|
||||
<Circle2 colorOuter="#CCC5B9" colorInner="#403D39" colorCenter="#EB5E28" />
|
||||
</div>
|
||||
|
87
src/routes/Chat.svelte
Normal file
87
src/routes/Chat.svelte
Normal file
@ -0,0 +1,87 @@
|
||||
<script>
|
||||
import {reject} from 'ramda'
|
||||
import {onMount} from "svelte"
|
||||
import {navigate} from "svelte-routing"
|
||||
import {liveQuery} from 'dexie'
|
||||
import {fuzzy} from "src/util/misc"
|
||||
import {getRelays, listen, db} from 'src/agent'
|
||||
import {modal} from 'src/app'
|
||||
import Room from "src/partials/Room.svelte"
|
||||
import Input from "src/partials/Input.svelte"
|
||||
import Content from "src/partials/Content.svelte"
|
||||
import Spinner from "src/partials/Spinner.svelte"
|
||||
import Anchor from "src/partials/Anchor.svelte"
|
||||
|
||||
let q = ""
|
||||
let roomsCount = 0
|
||||
|
||||
const joined = liveQuery(() => db.rooms.where('joined').equals(1).toArray())
|
||||
|
||||
const search = liveQuery(async () => {
|
||||
const rooms = await db.rooms.where('joined').equals(0).toArray()
|
||||
const nonTestRooms = reject(r => r.name.toLowerCase().includes('test'), rooms)
|
||||
|
||||
roomsCount = rooms.length
|
||||
|
||||
return fuzzy(rooms, {keys: ["name", "about"]})
|
||||
})
|
||||
|
||||
const createRoom = () => navigate(`/chat/new`)
|
||||
|
||||
const setRoom = id => navigate(`/chat/${id}`)
|
||||
|
||||
const joinRoom = id => {
|
||||
db.rooms.where('id').equals(id).modify({joined: 1})
|
||||
}
|
||||
|
||||
const leaveRoom = id => {
|
||||
db.rooms.where('id').equals(id).modify({joined: 0})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const sub = listen(getRelays(), {kinds: [40, 41]})
|
||||
|
||||
return () => {
|
||||
sub.then(s => {
|
||||
s.unsub()
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if $search}
|
||||
<Content>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex gap-2 items-center">
|
||||
<i class="fa fa-server fa-lg" />
|
||||
<h2 class="staatliches text-2xl">Your rooms</h2>
|
||||
</div>
|
||||
<Anchor type="button-accent" on:click={() => modal.set({form: 'room/edit'})}>
|
||||
<i class="fa-solid fa-plus" /> Create Room
|
||||
</Anchor>
|
||||
</div>
|
||||
{#each ($joined || []) as room (room.id)}
|
||||
<Room joined {room} {setRoom} {joinRoom} {leaveRoom} />
|
||||
{:else}
|
||||
<p class="text-center py-8">You haven't yet joined any rooms.</p>
|
||||
{/each}
|
||||
<div class="pt-2 mb-2 border-b border-solid border-medium" />
|
||||
<div class="flex gap-2 items-center">
|
||||
<i class="fa fa-globe fa-lg" />
|
||||
<h2 class="staatliches text-2xl">Other rooms</h2>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<Input bind:value={q} type="text" placeholder="Search rooms">
|
||||
<i slot="before" class="fa-solid fa-search" />
|
||||
</Input>
|
||||
</div>
|
||||
{#each $search ? $search(q).slice(0, 50) : [] as room (room.id)}
|
||||
<Room {room} {setRoom} {joinRoom} {leaveRoom} />
|
||||
{:else}
|
||||
<Spinner />
|
||||
{/each}
|
||||
<small class="text-center">
|
||||
Showing {Math.min(50, roomsCount)} of {roomsCount} known rooms
|
||||
</small>
|
||||
</Content>
|
||||
{/if}
|
193
src/routes/ChatRoom.svelte
Normal file
193
src/routes/ChatRoom.svelte
Normal file
@ -0,0 +1,193 @@
|
||||
<script>
|
||||
import {onMount} from 'svelte'
|
||||
import {liveQuery} from 'dexie'
|
||||
import {fly} from 'svelte/transition'
|
||||
import {navigate} from 'svelte-routing'
|
||||
import {prop, path as getPath, pluck, reverse, uniqBy, sortBy, last} from 'ramda'
|
||||
import {formatTimestamp, now, createScroller, batch, Cursor} from 'src/util/misc'
|
||||
import Badge from 'src/partials/Badge.svelte'
|
||||
import Spinner from 'src/partials/Spinner.svelte'
|
||||
import {user, getPerson, getRelays, db, listen, load} from 'src/agent'
|
||||
import {render, modal} from 'src/app'
|
||||
import loaders from 'src/app/loaders'
|
||||
import cmd from 'src/app/cmd'
|
||||
|
||||
export let roomId
|
||||
|
||||
let textarea
|
||||
let messages = []
|
||||
let annotatedMessages = []
|
||||
let showNewMessages = false
|
||||
let room = liveQuery(() => db.rooms.where('id').equals(roomId).first())
|
||||
let cursor = new Cursor()
|
||||
|
||||
$: {
|
||||
// Group messages so we're only showing the person once per chunk
|
||||
annotatedMessages = reverse(sortBy(prop('created_at'), uniqBy(prop('id'), messages)).reduce(
|
||||
(mx, m) => {
|
||||
const person = getPerson(m.pubkey, true)
|
||||
const showPerson = person.pubkey !== getPath(['person', 'pubkey'], last(mx))
|
||||
|
||||
return mx.concat({...m, person, showPerson})
|
||||
},
|
||||
[]
|
||||
))
|
||||
}
|
||||
|
||||
// flex-col means the first is the last
|
||||
const getLastListItem = () => document.querySelector('ul[name=messages] li')
|
||||
|
||||
const stickToBottom = async (behavior, cb) => {
|
||||
const shouldStick = window.scrollY + window.innerHeight > document.body.scrollHeight - 200
|
||||
|
||||
await cb()
|
||||
|
||||
if (shouldStick) {
|
||||
const $li = getLastListItem()
|
||||
|
||||
if ($li) {
|
||||
$li.scrollIntoView({behavior})
|
||||
}
|
||||
} else {
|
||||
showNewMessages = true
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!$user) {
|
||||
return navigate('/login')
|
||||
}
|
||||
|
||||
const sub = listen(
|
||||
getRelays(),
|
||||
// Listen for updates to the room in case we didn't get them before
|
||||
[{kinds: [40, 41], ids: [roomId]},
|
||||
{kinds: [42], '#e': [roomId], since: now()}],
|
||||
batch(300, events => {
|
||||
const newMessages = events.filter(e => e.kind === 42)
|
||||
|
||||
loaders.loadPeople(getRelays(), pluck('pubkey', events))
|
||||
|
||||
stickToBottom('smooth', () => {
|
||||
messages = messages.concat(newMessages)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const scroller = createScroller(
|
||||
async () => {
|
||||
const {until, limit} = cursor
|
||||
const events = await load(getRelays(), {kinds: [42], '#e': [roomId], until, limit})
|
||||
|
||||
if (events.length) {
|
||||
cursor.onChunk(events)
|
||||
|
||||
await loaders.loadPeople(getRelays(), pluck('pubkey', events))
|
||||
|
||||
stickToBottom('auto', () => {
|
||||
messages = events.concat(messages)
|
||||
})
|
||||
}
|
||||
},
|
||||
{reverse: true}
|
||||
)
|
||||
|
||||
return async () => {
|
||||
const {unsub} = await sub
|
||||
|
||||
scroller.stop()
|
||||
unsub()
|
||||
}
|
||||
})
|
||||
|
||||
const edit = () => {
|
||||
modal.set({form: 'room/edit', room: $room})
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
const content = textarea.value.trim()
|
||||
|
||||
if (content) {
|
||||
textarea.value = ''
|
||||
|
||||
const event = await cmd.createMessage(getRelays(), roomId, content)
|
||||
|
||||
stickToBottom('smooth', () => {
|
||||
messages = [event].concat(messages)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyPress = e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:scroll={() => { showNewMessages = false}} />
|
||||
|
||||
<div class="flex gap-4 h-full">
|
||||
<div class="relative w-full">
|
||||
<div class="flex flex-col py-32">
|
||||
<ul class="p-4 max-h-full flex-grow flex flex-col-reverse" name="messages">
|
||||
{#each annotatedMessages as m (m.id)}
|
||||
<li in:fly={{y: 20}} class="py-1 flex flex-col gap-2">
|
||||
{#if m.showPerson}
|
||||
<div class="flex gap-4 items-center justify-between">
|
||||
<Badge person={m.person} />
|
||||
<p class="text-sm text-light">{formatTimestamp(m.created_at)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="ml-6 overflow-hidden text-ellipsis">
|
||||
{@html render(m, {showEntire: true})}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
<Spinner>Looking for messages...</Spinner>
|
||||
<div class="h-64" />
|
||||
</ul>
|
||||
</div>
|
||||
<div class="fixed z-10 top-0 pt-20 w-full 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({$room?.picture})" />
|
||||
<div class="w-full">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="text-lg font-bold">{$room?.name || ''}</div>
|
||||
{#if $room?.pubkey === $user?.pubkey}
|
||||
<small class="cursor-pointer" on:click={edit}>
|
||||
<i class="fa-solid fa-edit" /> Edit
|
||||
</small>
|
||||
{/if}
|
||||
</div>
|
||||
<div>{$room?.about || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed z-10 bottom-0 w-full flex bg-medium 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-white bg-medium
|
||||
placeholder:text-light 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-white ">
|
||||
<i class="fa-solid fa-paper-plane fa-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if showNewMessages}
|
||||
<div class="fixed w-full flex justify-center bottom-32" transition:fly|local={{y: 20}}>
|
||||
<div class="rounded-full bg-accent text-white py-2 px-4">
|
||||
New messages found
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -91,52 +91,53 @@
|
||||
url('{person.banner}')" />
|
||||
|
||||
<Content>
|
||||
<div class="flex flex-col gap-4" in:fly={{y: 20}}>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="overflow-hidden w-32 h-32 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
|
||||
style="background-image: url({person.picture})" />
|
||||
<div class="flex-grow flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl">{displayPerson(person)}</h1>
|
||||
</div>
|
||||
{#if person.verified_as}
|
||||
<div class="flex gap-1 text-sm">
|
||||
<i class="fa fa-user-check text-accent" />
|
||||
<span class="text-light">{last(person.verified_as.split('@'))}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<p>{@html renderContent(person.about || '')}</p>
|
||||
</div>
|
||||
<div class="whitespace-nowrap flex gap-4 items-center">
|
||||
{#if $user?.pubkey === pubkey && $canSign}
|
||||
<a href="/profile" class="cursor-pointer text-sm">
|
||||
<i class="fa-solid fa-edit" /> Edit
|
||||
</a>
|
||||
{/if}
|
||||
{#if $user && $user.pubkey !== pubkey}
|
||||
<i class="fa-solid fa-sliders cursor-pointer" on:click={openAdvanced} />
|
||||
{/if}
|
||||
{#if $user?.petnames && $canSign}
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
{#if following}
|
||||
<Button on:click={unfollow}>Unfollow</Button>
|
||||
{:else}
|
||||
<Button on:click={follow}>Follow</Button>
|
||||
<div class="flex gap-4" in:fly={{y: 20}}>
|
||||
<div
|
||||
class="overflow-hidden w-32 h-32 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
|
||||
style="background-image: url({person.picture})" />
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-grow flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl">{displayPerson(person)}</h1>
|
||||
</div>
|
||||
{#if person.verified_as}
|
||||
<div class="flex gap-1 text-sm">
|
||||
<i class="fa fa-user-check text-accent" />
|
||||
<span class="text-light">{last(person.verified_as.split('@'))}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="whitespace-nowrap flex gap-4 items-center">
|
||||
{#if $user?.pubkey === pubkey && $canSign}
|
||||
<a href="/profile" class="cursor-pointer text-sm">
|
||||
<i class="fa-solid fa-edit" /> Edit
|
||||
</a>
|
||||
{/if}
|
||||
{#if $user && $user.pubkey !== pubkey}
|
||||
<i class="fa-solid fa-sliders cursor-pointer" on:click={openAdvanced} />
|
||||
{/if}
|
||||
{#if $user?.petnames && $canSign}
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
{#if following}
|
||||
<Button on:click={unfollow}>Unfollow</Button>
|
||||
{:else}
|
||||
<Button on:click={follow}>Follow</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p>{@html renderContent(person.about || '')}</p>
|
||||
{#if person?.petnames}
|
||||
<div class="flex gap-8">
|
||||
<div><strong>{person.petnames.length}</strong> following</div>
|
||||
<div><strong>{followersCount}</strong> followers</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if person?.petnames}
|
||||
<div class="flex gap-8 ml-16">
|
||||
<div><strong>{person.petnames.length}</strong> following</div>
|
||||
<div><strong>{followersCount}</strong> followers</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Tabs tabs={['notes', 'likes', 'network']} {activeTab} {setActiveTab} />
|
||||
|
||||
{#if activeTab === 'notes'}
|
||||
|
@ -94,7 +94,7 @@ export const poll = (t, cb) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const createScroller = loadMore => {
|
||||
export const createScroller = (loadMore, {reverse = false} = {}) => {
|
||||
// NOTE TO FUTURE SELF
|
||||
// If the scroller is saturating request channels on a slow relay, the
|
||||
// loadMore function is not properly awaiting all the work necessary.
|
||||
@ -105,9 +105,11 @@ export const createScroller = loadMore => {
|
||||
// While we have empty space, fill it
|
||||
const {scrollY, innerHeight} = window
|
||||
const {scrollHeight} = document.body
|
||||
const shouldLoad = scrollY + innerHeight + 800 > scrollHeight
|
||||
const shouldLoad = reverse
|
||||
? scrollY < 800
|
||||
: scrollY + innerHeight + 800 > scrollHeight
|
||||
|
||||
// Only trigger loading the first time we reach the threshhold
|
||||
// Only trigger loading the first time we reach the threshold
|
||||
if (shouldLoad) {
|
||||
clearTimeout(timeout)
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {last, identity, objOf, prop, flatten, uniq} from 'ramda'
|
||||
import {nip19} from 'nostr-tools'
|
||||
import {ensurePlural, first} from 'hurdak/lib/hurdak'
|
||||
import {ensurePlural, ellipsize, first} from 'hurdak/lib/hurdak'
|
||||
|
||||
export const personKinds = [0, 2, 3, 10001, 12165]
|
||||
|
||||
@ -50,11 +50,11 @@ export const findRootId = e => Tags.wrap([findRoot(e)]).values().first()
|
||||
|
||||
export const displayPerson = p => {
|
||||
if (p.display_name) {
|
||||
return p.display_name
|
||||
return ellipsize(p.display_name, 60)
|
||||
}
|
||||
|
||||
if (p.name) {
|
||||
return p.name
|
||||
return ellipsize(p.name, 60)
|
||||
}
|
||||
|
||||
return nip19.npubEncode(p.pubkey).slice(4, 12)
|
||||
@ -77,3 +77,5 @@ export const isAlert = (e, pubkey) => {
|
||||
}
|
||||
|
||||
export const isRelay = url => typeof url === 'string' && url.match(/^wss?:\/\/.+/)
|
||||
|
||||
export const roomAttrs = ['name', 'about', 'picture']
|
||||
|
84
src/views/ChatEdit.svelte
Normal file
84
src/views/ChatEdit.svelte
Normal file
@ -0,0 +1,84 @@
|
||||
<script>
|
||||
import {onMount} from "svelte"
|
||||
import {fly} from 'svelte/transition'
|
||||
import {navigate} from "svelte-routing"
|
||||
import {stripExifData} from "src/util/html"
|
||||
import Input from "src/partials/Input.svelte"
|
||||
import Content from "src/partials/Content.svelte"
|
||||
import Textarea from "src/partials/Textarea.svelte"
|
||||
import Button from "src/partials/Button.svelte"
|
||||
import {getRelays, load} from 'src/agent'
|
||||
import {toast} from "src/app"
|
||||
import cmd from "src/app/cmd"
|
||||
|
||||
export let room = {}
|
||||
|
||||
onMount(async () => {
|
||||
document.querySelector('[name=picture]').addEventListener('change', async e => {
|
||||
const [file] = e.target.files
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => room.picture = reader.result
|
||||
reader.onerror = e => console.error(e)
|
||||
reader.readAsDataURL(await stripExifData(file))
|
||||
} else {
|
||||
room.picture = null
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const submit = async e => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!room.name) {
|
||||
toast.show("error", "Please enter a name for your room.")
|
||||
} else {
|
||||
const event = room.id
|
||||
? await cmd.updateRoom(getRelays(), room)
|
||||
: await cmd.createRoom(getRelays(), room)
|
||||
|
||||
console.log('here')
|
||||
await db.rooms.where('id').equals(room.id).modify({joined: 1})
|
||||
|
||||
toast.show("info", `Your room has been ${room.id ? 'updated' : 'created'}!`)
|
||||
|
||||
navigate(`/chat/${room.id || event.id}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form on:submit={submit} class="flex justify-center py-12" in:fly={{y: 20}}>
|
||||
<Content>
|
||||
<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={room.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={room.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>
|
||||
</Content>
|
||||
</form>
|
||||
|
Loading…
Reference in New Issue
Block a user