Compare commits

...

10 Commits

Author SHA1 Message Date
Jon Staab c5baef7673 Fix scroll to event behavior 2026-02-16 15:31:22 -08:00
Jon Staab 4f1384042e Pin scroll position to at'd event until user scrolls 2026-02-16 15:01:50 -08:00
Jon Staab 781102628e Simplify goToEvent 2026-02-16 14:45:54 -08:00
Jon Staab 66c576a9ac Add forward scrolling to makeMakeFeed 2026-02-16 14:29:56 -08:00
Jon Staab 1e9a25e434 Make createScroller honor reverse param 2026-02-16 12:48:42 -08:00
Jon Staab 88e44f5111 Fix duplicate ids in chat 2026-02-16 12:39:21 -08:00
Jon Staab b42d3bf8d8 Use compressorjs-next 2026-02-16 11:34:46 -08:00
Jon Staab 0bd14a9401 Refactor SpaceSearch into its own component 2026-02-16 11:23:20 -08:00
Ben 7914c07b58 Space search 2026-02-16 08:45:30 -08:00
Jon Staab c74f17d931 Fix editing messages with html tags 2026-02-16 08:16:03 -08:00
23 changed files with 579 additions and 272 deletions
+2 -1
View File
@@ -157,7 +157,7 @@ src/
- Derive all other data inside the component from identifiers
- Example: Don't pass `members` prop, derive it from `h` inside component
**Code Style:**
**CRITICAL Code Style Guidelines:**
- **No `null`** - only use `undefined`
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
@@ -168,6 +168,7 @@ src/
- When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax.
- When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
## Common Tasks
+1 -1
View File
@@ -20,7 +20,7 @@ If you're deploying a custom version of flotilla, be sure to remove the `plausib
## Development
See [./CONTRIBUTING.md](CONTRIBUTING.md).
See [CONTRIBUTING.md](AGENTS.md).
## Deployment
+1 -1
View File
@@ -70,7 +70,7 @@
"@welshman/signer": "^0.8.4",
"@welshman/store": "^0.8.4",
"@welshman/util": "^0.8.4",
"compressorjs": "^1.2.1",
"compressorjs-next": "^1.1.2",
"daisyui": "^4.12.24",
"date-picker-svelte": "^2.17.0",
"dotenv": "^16.6.1",
+6 -20
View File
@@ -110,9 +110,9 @@ importers:
'@welshman/util':
specifier: ^0.8.4
version: 0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3))
compressorjs:
specifier: ^1.2.1
version: 1.2.1
compressorjs-next:
specifier: ^1.1.2
version: 1.1.2
daisyui:
specifier: ^4.12.24
version: 4.12.24(postcss@8.5.6)
@@ -2048,9 +2048,6 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
blueimp-canvas-to-blob@3.29.0:
resolution: {integrity: sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
@@ -2211,8 +2208,8 @@ packages:
compare-func@2.0.0:
resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==}
compressorjs@1.2.1:
resolution: {integrity: sha512-+geIjeRnPhQ+LLvvA7wxBQE5ddeLU7pJ3FsKFWirDw6veY3s9iLxAQEw7lXGHnhCJvBujEQWuNnGzZcvCvdkLQ==}
compressorjs-next@1.1.2:
resolution: {integrity: sha512-5nwrVCR3+kSd4cwIzQEB72W4d+uHQ9so8U2C+WBr74DFoG34FM9CXoNZMsCnCTUDhmDKJ/3aI4Di1+QKF8LFow==}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -3001,10 +2998,6 @@ packages:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
is-blob@2.1.0:
resolution: {integrity: sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw==}
engines: {node: '>=6'}
is-boolean-object@1.2.2:
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
engines: {node: '>= 0.4'}
@@ -6909,8 +6902,6 @@ snapshots:
binary-extensions@2.3.0: {}
blueimp-canvas-to-blob@3.29.0: {}
boolbase@1.0.0: {}
bplist-creator@0.1.0:
@@ -7073,10 +7064,7 @@ snapshots:
array-ify: 1.0.0
dot-prop: 5.3.0
compressorjs@1.2.1:
dependencies:
blueimp-canvas-to-blob: 3.29.0
is-blob: 2.1.0
compressorjs-next@1.1.2: {}
concat-map@0.0.1: {}
@@ -8004,8 +7992,6 @@ snapshots:
dependencies:
binary-extensions: 2.3.0
is-blob@2.1.0: {}
is-boolean-object@1.2.2:
dependencies:
call-bound: 1.0.4
+4
View File
@@ -402,6 +402,10 @@ progress[value]::-webkit-progress-value {
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
}
.ct {
@apply top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)];
}
/* Keyboard open state adjustments */
body.keyboard-open .cb {
+2 -2
View File
@@ -140,7 +140,7 @@
data-tip={tooltip}
class={cx(
reactionClass,
"flex-inline btn btn-outline btn-neutral btn-xs flex items-center gap-1 rounded-full text-xs font-normal",
"flex-inline btn btn-outline btn-neutral btn-xs flex items-center gap-1 rounded-full text-xs font-normal bg-alt",
{
tooltip: !noTooltip && !isMobile,
"border-neutral-content/20": !isOwn,
@@ -162,7 +162,7 @@
data-tip={tooltip}
class={cx(
reactionClass,
"flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full font-normal",
"flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full font-normal bg-alt",
{
tooltip: !noTooltip && !isMobile,
"border-neutral-content/20": !isOwn,
+1 -1
View File
@@ -13,7 +13,7 @@
const openMenu = () => pushDrawer(SpaceMenu, {url})
</script>
<Button onclick={openMenu} class="btn btn-neutral btn-sm relative md:hidden">
<Button onclick={openMenu} class="btn btn-neutral btn-sm relative md:hidden btn-square">
<Icon icon={MenuDots} />
{#if $status.theme !== "success"}
<div class="absolute right-0 top-0 -mr-1 -mt-1 h-2 w-2 rounded-full bg-{$status.theme}"></div>
+167
View File
@@ -0,0 +1,167 @@
<script lang="ts">
import {tick} from "svelte"
import {createSearch} from "@welshman/app"
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {MESSAGE} from "@welshman/util"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import {fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {deriveEventsForUrl} from "@app/core/state"
import {goToEvent} from "@app/util/routes"
type Props = {
url: string
h?: string
}
const {url, h}: Props = $props()
const spaceMessages = deriveEventsForUrl(
url,
h ? [{kinds: [MESSAGE], "#h": [h]}] : [{kinds: [MESSAGE]}],
)
let term = $state("")
let show = $state(false)
let input: HTMLInputElement | undefined = $state()
const open = () => {
show = true
tick().then(() => input?.focus())
}
const close = () => {
show = false
}
const clear = () => {
term = ""
show = false
}
const onInput = () => {
show = true
}
const searchIndex = $derived.by(() =>
createSearch($spaceMessages, {
getValue: event => event.id,
fuseOptions: {keys: ["content"]},
}),
)
const results = $derived(term ? searchIndex.searchOptions(term) : [])
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
const getAgeSection = (createdAt: number) => {
const age = now() - createdAt
if (age <= DAY) {
return "day"
}
if (age <= WEEK) {
return "week"
}
return "older"
}
const getAgeLabel = (createdAt: number) => {
const age = now() - createdAt
if (age < MINUTE) {
return "Just now"
}
if (age < HOUR) {
return `${Math.floor(age / MINUTE)}m ago`
}
if (age < DAY) {
return `${Math.floor(age / HOUR)}h ago`
}
return `${Math.floor(age / DAY)}d ago`
}
const onRoomSearchResultClick = (event: TrustedEvent) => {
close()
goToEvent(event, {keepFocus: true})
}
</script>
<div>
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
<Icon size={4} icon={Magnifier} />
</button>
{#if show}
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
<div class="fixed cw top-0 right-0 z-feature p-2">
<div
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
transition:fly={{y: -40, duration: 150}}>
<div class="flex justify-between">
<strong>Search</strong>
<Button onclick={clear}>
<Icon icon={CloseCircle} />
</Button>
</div>
<label class="input input-sm input-bordered flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:this={input}
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder={h ? "Search this room..." : "Search this space..."}
oninput={onInput} />
</label>
<div class="max-h-[65vh] overflow-y-auto">
{#if !term}
<p class="text-sm opacity-70">
{h ? "Search for messages in this room." : "Search for messages across this space."}
</p>
{:else if eventsByAge.size === 0}
<p class="text-sm opacity-70">No results found.</p>
{:else}
<div class="col-2">
{#each eventsByAge as [key, events] (key)}
<div class="col-2">
<p class="text-xs uppercase tracking-wide opacity-60">
{#if key === "day"}
Last 24 Hours
{:else if key === "week"}
Last 7 Days
{:else}
Older
{/if}
</p>
<div class="col-2">
{#each events as event (event.id)}
<button
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
onclick={() => onRoomSearchResultClick(event)}>
<p class="line-clamp-2 text-sm">
{event.content.trim() || "(No text content)"}
</p>
<div class="row-2 text-xs opacity-70">
<span>{getAgeLabel(event.created_at)}</span>
<span>{formatTimestampAsDate(event.created_at)}</span>
</div>
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
+2 -1
View File
@@ -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 @@
<div class="card2 bg-alt col-2 shadow-lg">
<p>
Failed to publish to {displayRelayUrl(url)}: {message}.
Failed to publish to {displayRelayUrl(url)}: {addPeriod(message)}
</p>
<Button class="link" onclick={retry}>Retry</Button>
</div>
+79 -40
View File
@@ -9,6 +9,7 @@ import {
sortBy,
now,
on,
between,
isDefined,
filterVals,
fromPairs,
@@ -23,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"
@@ -36,55 +36,61 @@ 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<string>()
const interval = int(DAY)
const controller = new AbortController()
const buffer = writable<TrustedEvent[]>([])
const events = writable<TrustedEvent[]>([])
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 unsubscribers = [
on(repository, "update", ({added, removed}) => {
if (removed.size > 0) {
buffer.update($buffer => $buffer.filter(e => !removed.has(e.id)))
buffer = buffer.filter(e => !removed.has(e.id))
events.update($events => $events.filter(e => !removed.has(e.id)))
}
@@ -105,24 +111,56 @@ export const makeFeed = ({
}),
]
const ctrl = makeFeedController({
useWindowing: true,
signal: controller.signal,
feed: makeIntersectionFeed(makeRelayFeed(url), feedFromFilters(filters)),
onExhausted,
})
const loadTimeframe = (since: number, until: number) => {
request({
relays: [url],
autoClose: true,
signal: controller.signal,
filters: filters.map(filter => ({...filter, since, until})),
})
}
const scroller = createScroller({
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?.()
}
},
})
@@ -134,8 +172,9 @@ export const makeFeed = ({
return {
events,
cleanup: () => {
scroller.stop()
controller.abort()
forwardScroller.stop()
backwardScroller.stop()
unsubscribers.forEach(call)
},
}
+4 -1
View File
@@ -450,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})
+2 -1
View File
@@ -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"),
+37 -13
View File
@@ -2,11 +2,11 @@ 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,
@@ -62,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)
@@ -92,27 +100,43 @@ export const getPrimaryNavItemIndex = ($page: Page) => {
}
}
export const goToEvent = async (event: TrustedEvent, options: Record<string, any> = {}) => {
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<string, any> = {}) => {
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[]) => {
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]
@@ -133,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]
@@ -150,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)
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
const className = cx(
props.class,
"scroll-container cw cb fixed top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)] z-feature overflow-y-auto overflow-x-hidden",
"scroll-container cw cb ct fixed z-feature overflow-y-auto overflow-x-hidden",
)
</script>
+16 -41
View File
@@ -1,4 +1,4 @@
import {sleep, last, randomId} from "@welshman/lib"
import {sleep, randomId} from "@welshman/lib"
export {preventDefault, stopPropagation} from "svelte/legacy"
export const copyToClipboard = (text: string) => {
@@ -47,9 +47,12 @@ export const createScroller = ({
if (container) {
// While we have empty space, fill it
const {scrollY, innerHeight} = window
const {scrollHeight, scrollTop} = container
const {scrollHeight, scrollTop, clientHeight} = container
const viewHeight = clientHeight || innerHeight
const offset = Math.abs(scrollTop || scrollY)
const shouldLoad = offset + innerHeight + threshold > scrollHeight
const shouldLoad = reverse
? offset < threshold
: offset + viewHeight + threshold > scrollHeight
// Only trigger loading the first time we reach the threshold
if (shouldLoad) {
@@ -100,53 +103,17 @@ export const isIntersecting = async (element: Element) =>
observer.observe(element)
})
export const scrollToEvent = async (id: string, attempts = 3): Promise<boolean> => {
const element = document.querySelector(`[data-event="${id}"]`) as any
const elements = Array.from(document.querySelectorAll("[data-event]"))
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 true
} else if (elements.length > 0) {
const lastElement = last(elements)
if (lastElement && !isIntersecting(lastElement)) {
lastElement.scrollIntoView({behavior: "smooth", block: "center"})
}
await sleep(300)
if (attempts > 0) {
return scrollToEvent(id, attempts - 1)
} else {
return false
}
}
return false
}
export const compressFile = async (
file: File | Blob,
options: Record<string, any> = {},
): Promise<File> => {
const {default: Compressor} = await import("compressorjs")
const {default: Compressor} = await import("compressorjs-next")
return new Promise<File>((resolve, _reject) => {
new Compressor(file, {
maxWidth: 2048,
maxHeight: 2048,
convertSize: 10 * 1024 * 1024,
convertTypes: ["image/png"],
...options,
success: result => resolve(result as File),
error: e => {
@@ -164,3 +131,11 @@ export const compressFile = async (
})
})
}
export const escapeHtml = (html: string) => {
const element = document.createElement("div")
element.innerText = html
return element.innerHTML
}
+2
View File
@@ -26,3 +26,5 @@ export const buildUrl = (base: string | URL, ...pathname: string[]) => {
return url.toString()
}
export const addPeriod = (s: string) => (s + ".").replace(/\.+$/, ".")
+5 -7
View File
@@ -46,13 +46,11 @@
<Icon icon={Bell} /> Alerts
</SecondaryNavItem>
</div>
{#if Capacitor.getPlatform() !== "ios"}
<div in:fly|local={{delay: 100}}>
<SecondaryNavItem href="/settings/wallet">
<Icon icon={Wallet} /> Wallet
</SecondaryNavItem>
</div>
{/if}
<div in:fly|local={{delay: 100}} class:hidden={Capacitor.getPlatform() === "ios"}>
<SecondaryNavItem href="/settings/wallet">
<Icon icon={Wallet} /> Wallet
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 150}}>
<SecondaryNavItem href="/settings/relays">
<Icon icon={Server} /> Relays
@@ -0,0 +1,7 @@
<script>
import {page} from "$app/stores"
</script>
{#key $page.url.searchParams.get("at")}
<slot />
{/key}
+125 -79
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import {onMount, tick} from "svelte"
import {readable} from "svelte/store"
import {onMount, onDestroy} from "svelte"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import type {Readable} from "svelte/store"
import type {MakeNonOptional} from "@welshman/lib"
import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app"
import {now, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {
makeEvent,
@@ -13,44 +15,44 @@
ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER,
} from "@welshman/util"
import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app"
import {slide, fade, fly} from "@lib/transition"
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
import {slide, fade, fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Divider from "@lib/components/Divider.svelte"
import Icon from "@lib/components/Icon.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import RoomName from "@app/components/RoomName.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
import RoomDetail from "@app/components/RoomDetail.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomName from "@app/components/RoomName.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
import {canEnforceNip70, prependParent, publishDelete} from "@app/core/commands"
import {
decodeRelay,
deriveUserRoomMembershipStatus,
deriveRoom,
deriveUserRoomMembershipStatus,
MESSAGE_KINDS,
MembershipStatus,
PROTECTED,
MESSAGE_KINDS,
userSettingsValues,
} from "@app/core/state"
import {checked} from "@app/util/notifications"
import {canEnforceNip70, prependParent, publishDelete} from "@app/core/commands"
import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit"
import {pushToast} from "@app/util/toast"
import {checked} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
const {h, relay} = $page.params as MakeNonOptional<typeof $page.params>
const mounted = now()
@@ -59,6 +61,7 @@
const room = deriveRoom(url, h)
const shouldProtect = canEnforceNip70(url)
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
const at = $derived(parseInt($page.url.searchParams.get("at") || String(now())))
const showRoomDetail = () => pushModal(RoomDetail, {url, h})
@@ -110,55 +113,62 @@
}
const onSubmit = async ({content, tags}: EventContent) => {
tags.push(["h", h])
try {
tags.push(["h", h])
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (await shouldProtect) {
tags.push(PROTECTED)
}
let template: EventContent & {created_at?: number} = {content, tags}
let template: EventContent & {created_at?: number} = {content, tags}
if (eventToEdit) {
// Delete previous message, to be republished with same timestamp
template.created_at = eventToEdit.created_at
publishDelete({
if (eventToEdit) {
// Don't do anything if message hasn't changed
if (eventToEdit.content === content) {
return
}
// Delete previous message, to be republished with same timestamp
template.created_at = eventToEdit.created_at
publishDelete({
relays: [url],
event: $state.snapshot(eventToEdit),
protect: await shouldProtect,
})
}
if (share) {
template = prependParent(share, template, url)
}
if (parent) {
template = prependParent(parent, template, url)
}
const thunk = publishThunk({
relays: [url],
event: $state.snapshot(eventToEdit),
protect: await shouldProtect,
event: makeEvent(MESSAGE, template),
delay: $userSettingsValues.send_delay,
})
if ($userSettingsValues.send_delay) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
})
}
} finally {
clearParent()
clearShare()
clearEventToEdit()
}
if (share) {
template = prependParent(share, template, url)
}
if (parent) {
template = prependParent(parent, template, url)
}
const thunk = publishThunk({
relays: [url],
event: makeEvent(MESSAGE, template),
delay: $userSettingsValues.send_delay,
})
if ($userSettingsValues.send_delay) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
})
}
clearParent()
clearShare()
clearEventToEdit()
}
const onScroll = () => {
showScrollButton = Math.abs(element?.scrollTop || 0) > 1500
const manageScrollPosition = () => {
showScrollButton = Boolean(at) || Math.abs(element?.scrollTop || 0) > 1500
const newMessages = document.getElementById("new-messages")
@@ -173,16 +183,47 @@
showFixedNewMessages = y < 0
}
}
if (!userHasScrolled && $page.url.searchParams.get("at")) {
const targetEvent = $events.find(event => event.created_at >= at)
if (targetEvent) {
const target = element?.querySelector(`[data-event="${targetEvent.id}"]`)
if (target instanceof HTMLElement) {
isProgrammaticScroll = true
target.scrollIntoView({block: "center"})
}
}
}
}
const onScroll = () => {
if (!isProgrammaticScroll) {
userHasScrolled = true
manageScrollPosition()
}
isProgrammaticScroll = false
}
const scrollToNewMessages = () =>
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
const scrollToBottom = () => {
if ($page.url.searchParams.get("at")) {
goto($page.url.pathname, {replaceState: true})
} else {
element?.scrollTo({top: 0, behavior: "smooth"})
}
}
let joining = $state(false)
let leaving = $state(false)
let loadingEvents = $state(true)
let userHasScrolled = $state(false)
let isProgrammaticScroll = $state(false)
let loadingBackward = $state(true)
let loadingForward = $state(true)
let share = $state(popKey<TrustedEvent | undefined>("share"))
let parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state()
@@ -213,7 +254,7 @@
const adjustedLastChecked =
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
for (const event of $events.toReversed()) {
for (const event of $events) {
if (seen.has(event.id)) {
continue
}
@@ -255,7 +296,7 @@
elements.reverse()
setTimeout(onScroll, 100)
tick().then(manageScrollPosition)
return elements
})
@@ -264,11 +305,15 @@
cleanup?.()
const feed = makeFeed({
at,
url,
element: element!,
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}],
onExhausted: () => {
loadingEvents = false
onBackwardExhausted: () => {
loadingBackward = false
},
onForwardExhausted: () => {
loadingForward = false
},
})
@@ -308,17 +353,15 @@
observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
start()
return () => {
cleanup()
observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
}
})
onDestroy(() => {
cleanup?.()
})
</script>
<PageBar>
@@ -329,11 +372,9 @@
<RoomName {url} {h} />
{/snippet}
{#snippet action()}
<div class="row-2">
<Button
class="btn btn-neutral btn-sm tooltip tooltip-left"
data-tip="Room information"
onclick={showRoomDetail}>
<div class="row-2 items-center">
<SpaceSearch {url} {h} />
<Button class="btn btn-neutral btn-sm btn-square" onclick={showRoomDetail}>
<Icon size={4} icon={InfoCircle} />
</Button>
<SpaceMenuButton {url} />
@@ -367,6 +408,11 @@
</div>
</div>
{:else}
{#if loadingForward}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
@@ -399,8 +445,8 @@
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingEvents}
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
{#if loadingBackward}
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
+112 -59
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {onMount, tick} from "svelte"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import type {Readable} from "svelte/store"
import {readable} from "svelte/store"
import {now, int, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib"
@@ -17,6 +18,7 @@
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
@@ -26,7 +28,7 @@
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
import {userSettingsValues, decodeRelay, PROTECTED, MESSAGE_KINDS} from "@app/core/state"
import {prependParent, canEnforceNip70, publishDelete} from "@app/core/commands"
import {setChecked, checked} from "@app/util/notifications"
import {checked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit"
@@ -35,6 +37,7 @@
const lastChecked = $checked[$page.url.pathname]
const url = decodeRelay($page.params.relay!)
const shouldProtect = canEnforceNip70(url)
const at = $derived(parseInt($page.url.searchParams.get("at") || String(now())))
const replyTo = (event: TrustedEvent) => {
parent = event
@@ -54,49 +57,56 @@
}
const onSubmit = async ({content, tags}: EventContent) => {
let template: EventContent & {created_at?: number} = {content, tags}
try {
let template: EventContent & {created_at?: number} = {content, tags}
if (eventToEdit) {
// Delete previous message, to be republished with same timestamp
template.created_at = eventToEdit.created_at
publishDelete({relays: [url], event: eventToEdit, protect: await shouldProtect})
}
if (eventToEdit) {
// Don't do anything if message hasn't changed
if (eventToEdit.content === content) {
return
}
if (await shouldProtect) {
tags.push(PROTECTED)
}
// Delete previous message, to be republished with same timestamp
template.created_at = eventToEdit.created_at
publishDelete({relays: [url], event: eventToEdit, protect: await shouldProtect})
}
if (share) {
template = prependParent(share, template, url)
}
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (parent) {
template = prependParent(parent, template, url)
}
if (share) {
template = prependParent(share, template, url)
}
const thunk = publishThunk({
relays: [url],
event: makeEvent(MESSAGE, template),
delay: $userSettingsValues.send_delay,
})
if (parent) {
template = prependParent(parent, template, url)
}
if ($userSettingsValues.send_delay) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
const thunk = publishThunk({
relays: [url],
event: makeEvent(MESSAGE, template),
delay: $userSettingsValues.send_delay,
})
}
clearParent()
clearShare()
clearEventToEdit()
if ($userSettingsValues.send_delay) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
})
}
} finally {
clearParent()
clearShare()
clearEventToEdit()
}
}
const onScroll = () => {
showScrollButton = Math.abs(element?.scrollTop || 0) > 1500
const manageScrollPosition = () => {
showScrollButton = Boolean(at) || Math.abs(element?.scrollTop || 0) > 1500
const newMessages = document.getElementById("new-messages")
@@ -111,14 +121,45 @@
showFixedNewMessages = y < 0
}
}
if (!userHasScrolled && $page.url.searchParams.get("at")) {
const targetEvent = $events.find(event => event.created_at >= at)
if (targetEvent) {
const target = element?.querySelector(`[data-event="${targetEvent.id}"]`)
if (target instanceof HTMLElement) {
isProgrammaticScroll = true
target.scrollIntoView({block: "center"})
}
}
}
}
const onScroll = () => {
if (!isProgrammaticScroll) {
userHasScrolled = true
manageScrollPosition()
}
isProgrammaticScroll = false
}
const scrollToNewMessages = () =>
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
const scrollToBottom = () => {
if ($page.url.searchParams.get("at")) {
goto($page.url.pathname, {replaceState: true})
} else {
element?.scrollTo({top: 0, behavior: "smooth"})
}
}
let loadingEvents = $state(true)
let loadingBackward = $state(true)
let loadingForward = $state(true)
let userHasScrolled = $state(false)
let isProgrammaticScroll = $state(false)
let share = $state(popKey<TrustedEvent | undefined>("share"))
let parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state()
@@ -149,7 +190,7 @@
const adjustedLastChecked =
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
for (const event of $events.toReversed()) {
for (const event of $events) {
if (seen.has(event.id)) {
continue
}
@@ -191,11 +232,31 @@
elements.reverse()
setTimeout(onScroll, 100)
tick().then(manageScrollPosition)
return elements
})
const start = () => {
cleanup?.()
const feed = makeFeed({
at,
url,
element: element!,
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
onBackwardExhausted: () => {
loadingBackward = false
},
onForwardExhausted: () => {
loadingForward = false
},
})
events = feed.events
cleanup = feed.cleanup
}
const onEscape = () => {
clearParent()
clearShare()
@@ -230,29 +291,13 @@
observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
const feed = makeFeed({
url,
element: element!,
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
onExhausted: () => {
loadingEvents = false
},
})
events = feed.events
cleanup = feed.cleanup
start()
return () => {
cleanup()
controller.abort()
observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
// Sveltekit calls onDestroy at the beginning of the page load for some reason
setTimeout(() => {
setChecked($page.url.pathname)
}, 800)
}
})
</script>
@@ -267,12 +312,20 @@
<strong>Chat</strong>
{/snippet}
{#snippet action()}
<SpaceMenuButton {url} />
<div class="row-2 items-center">
<SpaceSearch {url} />
<SpaceMenuButton {url} />
</div>
{/snippet}
</PageBar>
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
<div bind:this={dynamicPadding}></div>
{#if loadingForward}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
@@ -305,8 +358,8 @@
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingEvents}
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
{#if loadingBackward}
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
@@ -49,7 +49,7 @@
url,
element: element!,
filters: [{kinds: [CLASSIFIED]}, makeCommentFilter([CLASSIFIED])],
onExhausted: () => {
onBackwardExhausted: () => {
loading = false
},
})
+1 -1
View File
@@ -48,7 +48,7 @@
url,
element: element!,
filters: [{kinds: [ZAP_GOAL]}, makeCommentFilter([ZAP_GOAL])],
onExhausted: () => {
onBackwardExhausted: () => {
loading = false
},
})
@@ -49,7 +49,7 @@
url,
element: element!,
filters: [{kinds: [THREAD]}, makeCommentFilter([THREAD])],
onExhausted: () => {
onBackwardExhausted: () => {
loading = false
},
})