Rough out fetching DMs
This commit is contained in:
@@ -6,3 +6,4 @@ A discord-like nostr client. WIP.
|
|||||||
|
|
||||||
- [ ] Delete events when leaving a space
|
- [ ] Delete events when leaving a space
|
||||||
- [ ] Add topic and event tags to compose
|
- [ ] Add topic and event tags to compose
|
||||||
|
- [ ] If the user isn't following anyone, show warning/fallback on people/notes pages
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {nip19} from 'nostr-tools'
|
||||||
|
import {ctx} from "@welshman/lib"
|
||||||
import {displayPubkey} from "@welshman/util"
|
import {displayPubkey} from "@welshman/util"
|
||||||
import {deriveProfile, deriveProfileDisplay, formatTimestamp} from "@welshman/app"
|
import {deriveProfile, deriveProfileDisplay, formatTimestamp} from "@welshman/app"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
import Avatar from "@lib/components/Avatar.svelte"
|
||||||
import Profile from "@app/components/Profile.svelte"
|
import Profile from "@app/components/Profile.svelte"
|
||||||
|
import {entityLink} from '@app/state'
|
||||||
|
|
||||||
export let event
|
export let event
|
||||||
|
|
||||||
|
const relays = ctx.app.router.Event(event).getUrls()
|
||||||
|
const nevent = nip19.neventEncode({id: event.id, relays})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 {$$props.class}">
|
<div class="flex flex-col gap-2 {$$props.class}">
|
||||||
<div class="flex justify-between gap-2">
|
<div class="flex justify-between gap-2">
|
||||||
<Profile pubkey={event.pubkey} />
|
<Profile pubkey={event.pubkey} />
|
||||||
<span class="text-sm opacity-75">{formatTimestamp(event.created_at)}</span>
|
<Link external href={entityLink(nevent)} class="text-sm opacity-75">
|
||||||
|
{formatTimestamp(event.created_at)}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,12 +10,13 @@
|
|||||||
import {tweened} from "svelte/motion"
|
import {tweened} from "svelte/motion"
|
||||||
import {quintOut} from "svelte/easing"
|
import {quintOut} from "svelte/easing"
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
|
import {userProfile} from "@welshman/app"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
import Avatar from "@lib/components/Avatar.svelte"
|
||||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||||
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||||
import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
|
import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
|
||||||
import {userProfile, userMembership} from "@app/state"
|
import {userMembership} from "@app/state"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {makeSpacePath, getPrimaryNavItemIndex} from "@app/routes"
|
import {makeSpacePath, getPrimaryNavItemIndex} from "@app/routes"
|
||||||
|
|
||||||
|
|||||||
+62
-16
@@ -1,19 +1,22 @@
|
|||||||
import {nip19} from "nostr-tools"
|
import {nip19} from "nostr-tools"
|
||||||
import {get, derived} from "svelte/store"
|
import {get, derived} from "svelte/store"
|
||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
import {setContext, partition, nth, max, pushToMapKey, nthEq} from "@welshman/lib"
|
import {setContext, sort, uniq, partition, nth, max, pushToMapKey, nthEq} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
getIdFilters,
|
getIdFilters,
|
||||||
NOTE,
|
NOTE,
|
||||||
|
WRAP,
|
||||||
RELAYS,
|
RELAYS,
|
||||||
REACTION,
|
REACTION,
|
||||||
ZAP_RESPONSE,
|
ZAP_RESPONSE,
|
||||||
|
DIRECT_MESSAGE,
|
||||||
EVENT_DATE,
|
EVENT_DATE,
|
||||||
EVENT_TIME,
|
EVENT_TIME,
|
||||||
getRelayTagValues,
|
getRelayTagValues,
|
||||||
isShareableRelayUrl,
|
isShareableRelayUrl,
|
||||||
getAncestorTags,
|
getAncestorTags,
|
||||||
getAncestorTagValues,
|
getAncestorTagValues,
|
||||||
|
getPubkeyTagValues,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
@@ -152,12 +155,12 @@ export const {
|
|||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
|
|
||||||
export type Message = {
|
export type ChatMessage = {
|
||||||
room: string
|
room: string
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
export const readMessage = (event: TrustedEvent): Maybe<Message> => {
|
export const readMessage = (event: TrustedEvent): Maybe<ChatMessage> => {
|
||||||
const rooms = event.tags.filter(nthEq(0, ROOM)).map(nth(1))
|
const rooms = event.tags.filter(nthEq(0, ROOM)).map(nth(1))
|
||||||
|
|
||||||
if (rooms.length > 1) return undefined
|
if (rooms.length > 1) return undefined
|
||||||
@@ -165,7 +168,7 @@ export const readMessage = (event: TrustedEvent): Maybe<Message> => {
|
|||||||
return {room: rooms[0] || "", event}
|
return {room: rooms[0] || "", event}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const messages = deriveEventsMapped<Message>(repository, {
|
export const chatMessages = deriveEventsMapped<ChatMessage>(repository, {
|
||||||
filters: [{kinds: [MESSAGE, REPLY]}],
|
filters: [{kinds: [MESSAGE, REPLY]}],
|
||||||
eventToItem: readMessage,
|
eventToItem: readMessage,
|
||||||
itemToEvent: item => item.event,
|
itemToEvent: item => item.event,
|
||||||
@@ -177,17 +180,17 @@ export type Chat = {
|
|||||||
id: string
|
id: string
|
||||||
url: string
|
url: string
|
||||||
room: string
|
room: string
|
||||||
messages: Message[]
|
messages: ChatMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeChatId = (url: string, room: string) => `${url}'${room}`
|
export const makeChatId = (url: string, room: string) => `${url}'${room}`
|
||||||
|
|
||||||
export const splitChatId = (id: string) => id.split("'")
|
export const splitChatId = (id: string) => id.split("'")
|
||||||
|
|
||||||
export const chats = derived([trackerStore, messages], ([$tracker, $messages]) => {
|
export const chats = derived([trackerStore, chatMessages], ([$tracker, $chatMessages]) => {
|
||||||
const messagesByChatId = new Map<string, Message[]>()
|
const messagesByChatId = new Map<string, ChatMessage[]>()
|
||||||
|
|
||||||
for (const message of $messages) {
|
for (const message of $chatMessages) {
|
||||||
for (const url of $tracker.getRelays(message.event.id)) {
|
for (const url of $tracker.getRelays(message.event.id)) {
|
||||||
const chatId = makeChatId(url, message.room)
|
const chatId = makeChatId(url, message.room)
|
||||||
|
|
||||||
@@ -220,6 +223,57 @@ export const {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Channels
|
||||||
|
|
||||||
|
export const channelMessages = deriveEvents(repository, {filters: [{kinds: [DIRECT_MESSAGE]}]})
|
||||||
|
|
||||||
|
export type Channel = {
|
||||||
|
id: string
|
||||||
|
pubkeys: string[]
|
||||||
|
messages: TrustedEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeChannelId = (pubkeys: string[]) => sort(uniq(pubkeys)).join(",")
|
||||||
|
|
||||||
|
export const splitChannelId = (id: string) => id.split(",")
|
||||||
|
|
||||||
|
export const channels = derived(channelMessages, $messages => {
|
||||||
|
const messagesByChannelId = new Map<string, TrustedEvent[]>()
|
||||||
|
|
||||||
|
for (const message of $messages) {
|
||||||
|
const channelId = makeChannelId(getPubkeyTagValues(message.tags))
|
||||||
|
|
||||||
|
pushToMapKey(messagesByChannelId, channelId, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(messagesByChannelId.entries()).map(([id, messages]): Channel => {
|
||||||
|
const pubkeys = splitChannelId(id)
|
||||||
|
|
||||||
|
return {id, pubkeys, messages}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const {
|
||||||
|
indexStore: channelsById,
|
||||||
|
deriveItem: deriveChannel,
|
||||||
|
loadItem: loadChannel,
|
||||||
|
} = collection({
|
||||||
|
name: "channels",
|
||||||
|
store: channels,
|
||||||
|
getKey: channel => channel.id,
|
||||||
|
load: async (id: string, request: Partial<SubscribeRequestWithHandlers> = {}) => {
|
||||||
|
const $pubkey = pubkey.get()
|
||||||
|
const [url, room] = splitChannelId(id)
|
||||||
|
const channel = get(channelsById).get(id)
|
||||||
|
const timestamps = channel?.messages.map(e => e.created_at) || []
|
||||||
|
const since = Math.max(0, max(timestamps) - 3600)
|
||||||
|
|
||||||
|
if ($pubkey) {
|
||||||
|
await load({...request, filters: [{kinds: [WRAP], '#p': [$pubkey], since}]})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// Calendar events
|
// Calendar events
|
||||||
|
|
||||||
export const events = deriveEvents(repository, {filters: [{kinds: [EVENT_DATE, EVENT_TIME]}]})
|
export const events = deriveEvents(repository, {filters: [{kinds: [EVENT_DATE, EVENT_TIME]}]})
|
||||||
@@ -289,14 +343,6 @@ export const roomsByUrl = derived(chats, $chats => {
|
|||||||
|
|
||||||
// User stuff
|
// User stuff
|
||||||
|
|
||||||
export const userProfile = derived([pubkey, profilesByPubkey], ([$pubkey, $profilesByPubkey]) => {
|
|
||||||
if (!$pubkey) return null
|
|
||||||
|
|
||||||
loadProfile($pubkey)
|
|
||||||
|
|
||||||
return $profilesByPubkey.get($pubkey)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const userMembership = withGetter(
|
export const userMembership = withGetter(
|
||||||
derived([pubkey, membershipByPubkey], ([$pubkey, $membershipByPubkey]) => {
|
derived([pubkey, membershipByPubkey], ([$pubkey, $membershipByPubkey]) => {
|
||||||
if (!$pubkey) return null
|
if (!$pubkey) return null
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||||
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
|
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
|
||||||
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
|
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
|
||||||
|
import {channels} from '@app/state'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SecondaryNav>
|
<SecondaryNav>
|
||||||
@@ -21,6 +22,11 @@
|
|||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
</div>
|
</div>
|
||||||
<div in:fly={{delay: 100}}>
|
<div in:fly={{delay: 100}}>
|
||||||
|
<SecondaryNavItem href="/home/notes">
|
||||||
|
<Icon icon="notes-minimalistic" /> Notes
|
||||||
|
</SecondaryNavItem>
|
||||||
|
</div>
|
||||||
|
<div in:fly={{delay: 150}}>
|
||||||
<SecondaryNavHeader>
|
<SecondaryNavHeader>
|
||||||
Chats
|
Chats
|
||||||
<div class="cursor-pointer">
|
<div class="cursor-pointer">
|
||||||
@@ -28,6 +34,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</SecondaryNavHeader>
|
</SecondaryNavHeader>
|
||||||
</div>
|
</div>
|
||||||
|
{#each $channels as {id, pubkeys}, i (id)}
|
||||||
|
<div in:fly={{delay: 200 + i * 50}}>
|
||||||
|
<SecondaryNavItem href="/home/{id}">
|
||||||
|
{id}
|
||||||
|
</SecondaryNavItem>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</SecondaryNavSection>
|
</SecondaryNavSection>
|
||||||
</SecondaryNav>
|
</SecondaryNav>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
const createSpace = () => pushModal(SpaceCreateExternal)
|
const createSpace = () => pushModal(SpaceCreateExternal)
|
||||||
|
|
||||||
const browseSpaces = () => goto("/discover")
|
const browseSpaces = () => goto("/discover")
|
||||||
|
|
||||||
|
const leaveFeedback = () => goto("/home/97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322")
|
||||||
|
|
||||||
|
const donate = () => window.open('https://geyser.fund/project/flotilla')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="hero min-h-screen bg-base-200">
|
<div class="hero min-h-screen bg-base-200">
|
||||||
@@ -21,10 +25,10 @@
|
|||||||
<CardButton icon="compass" title="Discover spaces" class="h-24" on:click={browseSpaces}>
|
<CardButton icon="compass" title="Discover spaces" class="h-24" on:click={browseSpaces}>
|
||||||
Find a community based on your hobbies or interests.
|
Find a community based on your hobbies or interests.
|
||||||
</CardButton>
|
</CardButton>
|
||||||
<CardButton icon="plain" title="Leave feedback" class="h-24">
|
<CardButton icon="plain" title="Leave feedback" class="h-24" on:click={leaveFeedback}>
|
||||||
Let us know how we can improve by giving us feedback.
|
Let us know how we can improve by giving us feedback.
|
||||||
</CardButton>
|
</CardButton>
|
||||||
<CardButton icon="hand-pills" title="Donate to Flotilla" class="h-24">
|
<CardButton icon="hand-pills" title="Donate to Flotilla" class="h-24" on:click={donate}>
|
||||||
Support the project by donating to the developer.
|
Support the project by donating to the developer.
|
||||||
</CardButton>
|
</CardButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte'
|
||||||
|
import {derived} from 'svelte/store'
|
||||||
|
import {createScroller} from '@lib/html'
|
||||||
|
import {shuffle, sortBy, sleep, ago, DAY, HOUR, pushToMapKey} from '@welshman/lib'
|
||||||
|
import {getListValues, getAncestorTagValues, NOTE, REACTION} from '@welshman/util'
|
||||||
|
import type {TrustedEvent} from '@welshman/util'
|
||||||
|
import {deriveEvents} from '@welshman/store'
|
||||||
|
import {profileSearch, repository, userFollows, load} from '@welshman/app'
|
||||||
|
import Spinner from '@lib/components/Spinner.svelte'
|
||||||
|
import NoteCard from '@app/components/NoteCard.svelte'
|
||||||
|
import Content from '@app/components/Content.svelte'
|
||||||
|
|
||||||
|
let element: Element
|
||||||
|
let loading = sleep(3000)
|
||||||
|
let events: TrustedEvent[] = []
|
||||||
|
|
||||||
|
const since = ago(DAY)
|
||||||
|
const authors = getListValues("p", $userFollows)
|
||||||
|
const notesFilter = {kinds: [NOTE], authors, since}
|
||||||
|
const notes = deriveEvents(repository, {filters: [notesFilter]})
|
||||||
|
const reactionsFilter = {kinds: [REACTION], '#p': authors, since}
|
||||||
|
const reactions = deriveEvents(repository, {filters: [reactionsFilter]})
|
||||||
|
const reactionsByParent = derived(
|
||||||
|
reactions,
|
||||||
|
$reactions => {
|
||||||
|
const $reactionsByParent = new Map<string, TrustedEvent[]>()
|
||||||
|
|
||||||
|
for (const event of $reactions) {
|
||||||
|
const [parentId] = getAncestorTagValues(event.tags).replies
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
pushToMapKey($reactionsByParent, parentId, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reactionsByParent
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const isLike = (e: TrustedEvent) =>
|
||||||
|
e.kind === REACTION && ["+", ""].includes(e.content)
|
||||||
|
|
||||||
|
const isReplyOf = (e: TrustedEvent, p: TrustedEvent) =>
|
||||||
|
getAncestorTagValues(e.tags).replies.includes(e.id)
|
||||||
|
|
||||||
|
const scoreEvent = (e: TrustedEvent) => {
|
||||||
|
const thisReactions = $reactionsByParent.get(e.id) || []
|
||||||
|
const thisLikes = thisReactions.filter(r => isLike(r))
|
||||||
|
const recency = Math.max(1, e.created_at - since) / HOUR
|
||||||
|
const score = Math.max(1, thisReactions.length) * Math.max(1, thisLikes.length) * recency
|
||||||
|
|
||||||
|
return -score
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
load({filters: [notesFilter, reactionsFilter]})
|
||||||
|
|
||||||
|
const scroller = createScroller({
|
||||||
|
element: element.closest('.max-h-screen')!,
|
||||||
|
onScroll: () => {
|
||||||
|
const seen = new Set(events.map(e => e.id))
|
||||||
|
const eligible = sortBy(
|
||||||
|
scoreEvent,
|
||||||
|
$notes.filter(e => !seen.has(e.id) && getAncestorTagValues(e.tags).replies.length === 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
events = [...events, ...eligible.slice(0, 10)]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => scroller.stop()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="content column gap-4" bind:this={element}>
|
||||||
|
{#await loading}
|
||||||
|
<div class="center my-20">
|
||||||
|
<Spinner loading>Loading notes...</Spinner>
|
||||||
|
</div>
|
||||||
|
{:then}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each events as event (event.id)}
|
||||||
|
<NoteCard {event} class="card2 w-full">
|
||||||
|
<div class="ml-12">
|
||||||
|
<Content {event} />
|
||||||
|
</div>
|
||||||
|
</NoteCard>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user