{#each $reports as event (event.id)}
-
+
{:else}
No reports found.
{/each}
diff --git a/src/app/components/SpaceSearch.svelte b/src/app/components/SpaceSearch.svelte
new file mode 100644
index 00000000..c5557bcb
--- /dev/null
+++ b/src/app/components/SpaceSearch.svelte
@@ -0,0 +1,167 @@
+
+
+
+
+ {#if show}
+
+
+
+
+ Search
+
+
+
+
+ {#if !term}
+
+ {h ? "Search for messages in this room." : "Search for messages across this space."}
+
+ {:else if eventsByAge.size === 0}
+
No results found.
+ {:else}
+
+ {#each eventsByAge as [key, events] (key)}
+
+
+ {#if key === "day"}
+ Last 24 Hours
+ {:else if key === "week"}
+ Last 7 Days
+ {:else}
+ Older
+ {/if}
+
+
+ {#each events as event (event.id)}
+
+ {/each}
+
+
+ {/each}
+
+ {/if}
+
+
+
+ {/if}
+
diff --git a/src/app/components/ThunkStatusDetail.svelte b/src/app/components/ThunkStatusDetail.svelte
index cc8b2653..3da57bb4 100644
--- a/src/app/components/ThunkStatusDetail.svelte
+++ b/src/app/components/ThunkStatusDetail.svelte
@@ -2,6 +2,7 @@
import {PublishStatus} from "@welshman/net"
import {displayRelayUrl} from "@welshman/util"
import Button from "@lib/components/Button.svelte"
+ import {addPeriod} from "@lib/util"
interface Props {
url: string
@@ -25,7 +26,7 @@
- Failed to publish to {displayRelayUrl(url)}: {message}.
+ Failed to publish to {displayRelayUrl(url)}: {addPeriod(message)}
diff --git a/src/app/core/requests.ts b/src/app/core/requests.ts
index 448a8fe6..abb26063 100644
--- a/src/app/core/requests.ts
+++ b/src/app/core/requests.ts
@@ -1,5 +1,6 @@
import {get, writable} from "svelte/store"
import {
+ call,
uniq,
int,
YEAR,
@@ -8,6 +9,7 @@ import {
sortBy,
now,
on,
+ between,
isDefined,
filterVals,
fromPairs,
@@ -22,9 +24,8 @@ import {
getRelaysFromList,
} from "@welshman/util"
import type {TrustedEvent, Filter, List} from "@welshman/util"
-import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds"
import {load, request} from "@welshman/net"
-import {repository, makeFeedController, loadRelay, tracker} from "@welshman/app"
+import {repository, loadRelay, tracker} from "@welshman/app"
import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util"
import {getEventsForUrl} from "@app/core/state"
@@ -35,82 +36,131 @@ export const makeFeed = ({
url,
filters,
element,
- onExhausted,
+ onBackwardExhausted,
+ onForwardExhausted,
+ at = now(),
}: {
url: string
filters: Filter[]
element: HTMLElement
- onExhausted?: () => void
+ onBackwardExhausted?: () => void
+ onForwardExhausted?: () => void
+ at?: number
}) => {
- const seen = new Set
()
+ const interval = int(DAY)
const controller = new AbortController()
- const buffer = writable([])
const events = writable([])
+ let buffer: TrustedEvent[] = []
+ let backwardWindow = [at - interval, at]
+ let forwardWindow = [at, at + interval]
+
const insertEvent = (event: TrustedEvent) => {
let handled = false
- if (seen.has(event.id)) {
- return
- }
+ if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) {
+ const $events = get(events)
- events.update($events => {
for (let i = 0; i < $events.length; i++) {
- if ($events[i].id === event.id) return $events
- if ($events[i].created_at < event.created_at) {
+ if ($events[i].created_at > event.created_at) {
+ events.set(insertAt(i, event, $events))
handled = true
- return insertAt(i, event, $events)
+ break
}
}
- return $events
- })
-
- if (!handled) {
- buffer.update($buffer => {
- for (let i = 0; i < $buffer.length; i++) {
- if ($buffer[i].id === event.id) return $buffer
- if ($buffer[i].created_at < event.created_at) return insertAt(i, event, $buffer)
+ if (!handled) {
+ events.set([...$events, event])
+ }
+ } else {
+ for (let i = 0; i < buffer.length; i++) {
+ if (buffer[i].created_at > event.created_at) {
+ buffer.splice(i, 0, event)
+ handled = true
+ break
}
+ }
- return [...$buffer, event]
- })
+ if (!handled) {
+ buffer.push(event)
+ }
}
-
- seen.add(event.id)
}
- const unsubscribe = on(repository, "update", ({added, removed}) => {
- if (removed.size > 0) {
- buffer.update($buffer => $buffer.filter(e => !removed.has(e.id)))
- events.update($events => $events.filter(e => !removed.has(e.id)))
- }
-
- for (const event of added) {
- if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) {
- insertEvent(event)
+ const unsubscribers = [
+ on(repository, "update", ({added, removed}) => {
+ if (removed.size > 0) {
+ buffer = buffer.filter(e => !removed.has(e.id))
+ events.update($events => $events.filter(e => !removed.has(e.id)))
}
- }
- })
- const ctrl = makeFeedController({
- useWindowing: true,
- signal: controller.signal,
- feed: makeIntersectionFeed(makeRelayFeed(url), feedFromFilters(filters)),
- onExhausted,
- })
+ for (const event of added) {
+ if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) {
+ insertEvent(event)
+ }
+ }
+ }),
+ on(tracker, "add", (id: string, trackerUrl: string) => {
+ if (trackerUrl === url) {
+ const event = repository.getEvent(id)
- const scroller = createScroller({
+ if (event && matchFilters(filters, event)) {
+ insertEvent(event)
+ }
+ }
+ }),
+ ]
+
+ const loadTimeframe = (since: number, until: number) => {
+ request({
+ relays: [url],
+ autoClose: true,
+ signal: controller.signal,
+ filters: filters.map(filter => ({...filter, since, until})),
+ })
+ }
+
+ const backwardScroller = createScroller({
element,
delay: 300,
- threshold: 10_000,
- onScroll: async () => {
- const $buffer = get(buffer)
+ threshold: 5000,
+ onScroll: () => {
+ const [since, until] = backwardWindow
- events.update($events => [...$events, ...$buffer.splice(0, 30)])
+ backwardWindow = [since - interval, since]
- if ($buffer.length < 100) {
- ctrl.load(100)
+ for (const event of buffer.splice(0)) {
+ insertEvent(event)
+ }
+
+ if (until > now() - int(2, YEAR)) {
+ loadTimeframe(since, until)
+ } else if (!buffer.some(e => e.created_at < at)) {
+ backwardScroller.stop()
+ onBackwardExhausted?.()
+ }
+ },
+ })
+
+ const forwardScroller = createScroller({
+ element,
+ reverse: true,
+ delay: 300,
+ threshold: 5000,
+ onScroll: () => {
+ const [since, until] = forwardWindow
+
+ forwardWindow = [until, until + interval]
+
+ for (const event of buffer.splice(0)) {
+ insertEvent(event)
+ }
+
+ if (until < now()) {
+ loadTimeframe(since, until)
+ } else if (!buffer.some(e => e.created_at > at)) {
+ forwardScroller.stop()
+ onForwardExhausted?.()
}
},
})
@@ -122,9 +172,10 @@ export const makeFeed = ({
return {
events,
cleanup: () => {
- unsubscribe()
- scroller.stop()
controller.abort()
+ forwardScroller.stop()
+ backwardScroller.stop()
+ unsubscribers.forEach(call)
},
}
}
@@ -169,17 +220,28 @@ export const makeCalendarFeed = ({
})
}
- const unsubscribe = on(repository, "update", ({added, removed}) => {
- if (removed.size > 0) {
- events.update($events => $events.filter(e => !removed.has(e.id)))
- }
-
- for (const event of added) {
- if (matchFilters(filters, event)) {
- insertEvent(event)
+ const unsubscribers = [
+ on(repository, "update", ({added, removed}) => {
+ if (removed.size > 0) {
+ events.update($events => $events.filter(e => !removed.has(e.id)))
}
- }
- })
+
+ for (const event of added) {
+ if (matchFilters(filters, event)) {
+ insertEvent(event)
+ }
+ }
+ }),
+ on(tracker, "add", (id: string, trackerUrl: string) => {
+ if (trackerUrl === url) {
+ const event = repository.getEvent(id)
+
+ if (event && matchFilters(filters, event)) {
+ insertEvent(event)
+ }
+ }
+ }),
+ ]
const loadTimeframe = (since: number, until: number) => {
const hashes = daysBetween(since, until).map(String)
@@ -234,10 +296,10 @@ export const makeCalendarFeed = ({
return {
events,
cleanup: () => {
- backwardScroller.stop()
- forwardScroller.stop()
controller.abort()
- unsubscribe()
+ forwardScroller.stop()
+ backwardScroller.stop()
+ unsubscribers.forEach(call)
},
}
}
diff --git a/src/app/core/state.ts b/src/app/core/state.ts
index 1005790f..c5a4dcc2 100644
--- a/src/app/core/state.ts
+++ b/src/app/core/state.ts
@@ -50,9 +50,12 @@ import {
makeDeriveItem,
deriveItemsByKey,
deriveDeduplicated,
+ deriveEventsById,
deriveEventsByIdByUrl,
deriveEventsByIdForUrl,
getEventsByIdForUrl,
+ deriveEventsAsc,
+ deriveEventsDesc,
} from "@welshman/store"
import {
APP_DATA,
@@ -126,6 +129,10 @@ export const ROOM = "h"
export const PROTECTED = ["-"]
+export const IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
+
+export const VIDEO_CONTENT_TYPES = ["video/quicktime", "video/webm", "video/mp4"]
+
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios"
export const PUSH_SERVER = import.meta.env.VITE_PUSH_SERVER
@@ -229,12 +236,21 @@ export const deriveEvent = makeDeriveEvent({
onDerive: (filters: Filter[], relays: string[]) => load({filters, relays}),
})
+export const deriveEvents = (filters: Filter[] = [{}]) =>
+ deriveEventsDesc(deriveEventsById({repository, filters}))
+
export const getEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
getEventsByIdForUrl({url, tracker, repository, filters}).values()
export const deriveEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
deriveArray(deriveEventsByIdForUrl({url, tracker, repository, filters}))
+export const deriveEventsForUrlAsc = (url: string, filters: Filter[] = [{}]) =>
+ deriveEventsAsc(deriveEventsByIdForUrl({url, tracker, repository, filters}))
+
+export const deriveEventsForUrlDesc = (url: string, filters: Filter[] = [{}]) =>
+ deriveEventsDesc(deriveEventsByIdForUrl({url, tracker, repository, filters}))
+
export const deriveLatestEventForUrl = (url: string, filters: Filter[] = [{}]) =>
deriveDeduplicated(deriveEventsByIdForUrl({url, tracker, repository, filters}), $eventsById =>
first(sortEventsDesc($eventsById.values())),
@@ -434,7 +450,10 @@ export const chatsById = call(() => {
const pubkeys = getChatPubkeysFromEvent(event)
const id = makeChatId(pubkeys)
const chat = chatsById.get(id)
- const messages = sortBy(e => -e.created_at, append(event, chat?.messages || []))
+ const messages = sortBy(
+ e => -e.created_at,
+ uniqBy(e => e.id, append(event, chat?.messages || [])),
+ )
const last_activity = Math.max(chat?.last_activity || 0, event.created_at)
const updatedChat = addSearchText({id, pubkeys, messages, last_activity})
@@ -463,7 +482,7 @@ export const chatsById = call(() => {
}
}
- addEvents(repository.query([{kinds: [DIRECT_MESSAGE, PROFILE]}]))
+ addEvents(repository.query([{kinds: [...DM_KINDS, PROFILE]}]))
const unsubscribers = [
on(repository, "update", ({added}: RepositoryUpdate) => addEvents(added)),
diff --git a/src/app/editor/index.ts b/src/app/editor/index.ts
index 2184f317..8ea78606 100644
--- a/src/app/editor/index.ts
+++ b/src/app/editor/index.ts
@@ -15,6 +15,7 @@ import {
} from "@welshman/app"
import type {FileAttributes} from "@welshman/editor"
import {Editor, MentionSuggestion, WelshmanExtension, editorProps} from "@welshman/editor"
+import {escapeHtml} from "@lib/html"
import {makeMentionNodeView} from "@app/editor/MentionNodeView"
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
import {uploadFile} from "@app/core/commands"
@@ -82,7 +83,7 @@ export const makeEditor = async ({
)
return new Editor({
- content,
+ content: escapeHtml(content),
autofocus,
editorProps,
element: document.createElement("div"),
diff --git a/src/app/util/routes.ts b/src/app/util/routes.ts
index 51f9268d..001310d3 100644
--- a/src/app/util/routes.ts
+++ b/src/app/util/routes.ts
@@ -2,16 +2,14 @@ import type {Page} from "@sveltejs/kit"
import {get} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
-import {nthEq, sleep} from "@welshman/lib"
+import {page} from "$app/stores"
+import {nthEq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getAddress} from "@welshman/util"
import {tracker, loadRelay} from "@welshman/app"
-import {scrollToEvent} from "@lib/html"
import {identity} from "@welshman/lib"
import {
getTagValue,
- DIRECT_MESSAGE,
- DIRECT_MESSAGE_FILE,
MESSAGE,
THREAD,
CLASSIFIED,
@@ -26,6 +24,7 @@ import {
encodeRelay,
userSpaceUrls,
hasNip29,
+ DM_KINDS,
ROOM,
} from "@app/core/state"
import {lastPageBySpaceUrl} from "@app/util/history"
@@ -63,6 +62,14 @@ export const makeRoomPath = (url: string, h: string) => `/spaces/${encodeRelay(u
export const makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat")
+export const makeMessagePath = (url: string, event: TrustedEvent) => {
+ const h = getTagValue(ROOM, event.tags)
+ const path = h ? makeRoomPath(url, h) : makeSpaceChatPath(url)
+ const qp = new URLSearchParams({at: String(event.created_at)})
+
+ return path + "?" + qp.toString()
+}
+
export const makeGoalPath = (url: string, id?: string) => makeSpacePath(url, "goals", id)
export const makeThreadPath = (url: string, id?: string) => makeSpacePath(url, "threads", id)
@@ -93,27 +100,43 @@ export const getPrimaryNavItemIndex = ($page: Page) => {
}
}
-export const goToEvent = async (event: TrustedEvent, options: Record = {}) => {
+export const scrollToEvent = (id: string) => {
+ const element = document.querySelector(`[data-event="${id}"]`) as any
+
+ if (element) {
+ element.scrollIntoView({behavior: "smooth", block: "center"})
+ element.style = "filter: brightness(1.5); transition-property: all; transition-duration: 400ms;"
+
+ setTimeout(() => {
+ element.style = "transition-property: all; transition-duration: 300ms;"
+ }, 800)
+
+ setTimeout(() => {
+ element.style = ""
+ }, 800 + 400)
+ }
+
+ return Boolean(element)
+}
+
+export const goToEvent = (event: TrustedEvent, options: Record = {}) => {
const urls = Array.from(tracker.getRelays(event.id))
- const path = await getEventPath(event, urls)
+ const path = getEventPath(event, urls)
if (path.includes("://")) {
window.open(path)
- } else {
- goto(path, options)
+ } else if (!scrollToEvent(event.id)) {
+ const replaceState = path.replace(/\?.*$/, "") === get(page).url.pathname
- await sleep(300)
- await scrollToEvent(event.id)
+ goto(path, {replaceState, ...options})
}
}
-export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
- if (event.kind === DIRECT_MESSAGE || event.kind === DIRECT_MESSAGE_FILE) {
+export const getEventPath = (event: TrustedEvent, urls: string[]) => {
+ if (DM_KINDS.includes(event.kind)) {
return makeChatPath([event.pubkey, ...getPubkeyTagValues(event.tags)])
}
- const h = getTagValue(ROOM, event.tags)
-
if (urls.length > 0) {
const url = urls[0]
@@ -134,7 +157,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
}
if (event.kind === MESSAGE) {
- return h ? makeRoomPath(url, h) : makeSpacePath(url, "chat")
+ return makeMessagePath(url, event)
}
const address = event.tags.find(nthEq(0, "A"))?.[1]
@@ -151,7 +174,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
}
if (parseInt(kind) === MESSAGE) {
- return h ? makeRoomPath(url, h) : makeSpacePath(url, "chat")
+ return makeMessagePath(url, event)
}
}
diff --git a/src/lib/components/PageContent.svelte b/src/lib/components/PageContent.svelte
index 0c9df337..84483d6d 100644
--- a/src/lib/components/PageContent.svelte
+++ b/src/lib/components/PageContent.svelte
@@ -1,4 +1,5 @@
-