diff --git a/AGENTS.md b/AGENTS.md index 74bbf0ec..f1aa7e3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 51731e26..29e880ec 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index cd4c47df..e00521c1 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -358,7 +358,7 @@ CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = S26U9DYW3A; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; @@ -385,7 +385,7 @@ CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = S26U9DYW3A; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; diff --git a/package.json b/package.json index ca4cf686..49243051 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d343e776..6658938e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app.css b/src/app.css index 7829d090..09fbae83 100644 --- a/src/app.css +++ b/src/app.css @@ -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 { @@ -419,5 +423,5 @@ body.keyboard-open .hide-on-keyboard { } .chat__scroll-down { - @apply fixed bottom-28 right-4 z-feature md:bottom-16; + @apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16; } diff --git a/src/app/components/ContentLinkBlock.svelte b/src/app/components/ContentLinkBlock.svelte index abe1105d..ec981015 100644 --- a/src/app/components/ContentLinkBlock.svelte +++ b/src/app/components/ContentLinkBlock.svelte @@ -1,12 +1,12 @@ -{#if url.match(/\.(jpe?g|png|gif|webp)$/)} +{#if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)} + import {Capacitor} from "@capacitor/core" import UserRounded from "@assets/icons/user-rounded.svg?dataurl" import Server from "@assets/icons/server.svg?dataurl" import Moon from "@assets/icons/moon.svg?dataurl" @@ -52,19 +53,21 @@ {/snippet} - - - {#snippet icon()} -
- {/snippet} - {#snippet title()} -
Wallet
- {/snippet} - {#snippet info()} -
Connect a bitcoin wallet for sending social tips
- {/snippet} -
- + {#if Capacitor.getPlatform() !== "ios"} + + + {#snippet icon()} +
+ {/snippet} + {#snippet title()} +
Wallet
+ {/snippet} + {#snippet info()} +
Connect a bitcoin wallet for sending social tips
+ {/snippet} +
+ + {/if} {#snippet icon()} diff --git a/src/app/components/NoteItem.svelte b/src/app/components/NoteItem.svelte index 81e12cf1..df8b742d 100644 --- a/src/app/components/NoteItem.svelte +++ b/src/app/components/NoteItem.svelte @@ -2,6 +2,7 @@ import type {Snippet} from "svelte" import type {NativeEmoji} from "emoji-picker-element/shared" import type {TrustedEvent, EventContent} from "@welshman/util" + import {Router} from "@welshman/router" import SmileCircle from "@assets/icons/smile-circle.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte" @@ -11,26 +12,28 @@ import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands" type Props = { - url: string event: TrustedEvent children?: Snippet + url?: string } const {url, event, children}: Props = $props() - const shouldProtect = canEnforceNip70(url) + const relays = url ? [url] : Router.get().Event(event).getUrls() + + const shouldProtect = url ? canEnforceNip70(url) : Promise.resolve(false) const deleteReaction = async (event: TrustedEvent) => - publishDelete({relays: [url], event, protect: await shouldProtect}) + publishDelete({relays, event, protect: await shouldProtect}) const createReaction = async (template: EventContent) => - publishReaction({...template, event, relays: [url], protect: await shouldProtect}) + publishReaction({...template, event, relays, protect: await shouldProtect}) const onEmoji = async (emoji: NativeEmoji) => publishReaction({ event, + relays, content: emoji.unicode, - relays: [url], protect: await shouldProtect, }) diff --git a/src/app/components/ProfileDetail.svelte b/src/app/components/ProfileDetail.svelte index 70727ee4..3dbf68c4 100644 --- a/src/app/components/ProfileDetail.svelte +++ b/src/app/components/ProfileDetail.svelte @@ -15,6 +15,7 @@ import Letter from "@assets/icons/letter-opened.svg?dataurl" import MenuDots from "@assets/icons/menu-dots.svg?dataurl" import MinusCircle from "@assets/icons/minus-circle.svg?dataurl" + import Restart from "@assets/icons/restart.svg?dataurl" import {fly} from "@lib/transition" import Icon from "@lib/components/Icon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte" @@ -30,7 +31,7 @@ import EventInfo from "@app/components/EventInfo.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte" import ChatEnable from "@app/components/ChatEnable.svelte" - import {pubkeyLink, deriveUserIsSpaceAdmin} from "@app/core/state" + import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state" import {pushModal} from "@app/util/modal" import {pushToast} from "@app/util/toast" import {makeChatPath} from "@app/util/routes" @@ -46,6 +47,10 @@ const userIsAdmin = deriveUserIsSpaceAdmin(url) + const bannedPubkeys = url ? deriveSpaceBannedPubkeyItems(url) : undefined + + const isBanned = $derived($bannedPubkeys?.some(item => item.pubkey === pubkey) ?? false) + const back = () => history.back() const chatPath = makeChatPath([pubkey]) @@ -81,6 +86,20 @@ }, }) + const restoreMember = async () => { + const {error} = await manageRelay(url!, { + method: ManagementMethod.AllowPubkey, + params: [pubkey], + }) + + if (error) { + pushToast({theme: "error", message: error}) + } else { + pushToast({message: "User has successfully been restored!"}) + back() + } + } + let showMenu = $state(false) onMount(() => { @@ -112,12 +131,21 @@ {/if} {#if $userIsAdmin} -
  • - -
  • + {#if isBanned} +
  • + +
  • + {:else} +
  • + +
  • + {/if} {/if} diff --git a/src/app/components/ReactionSummary.svelte b/src/app/components/ReactionSummary.svelte index 876a905c..fad73460 100644 --- a/src/app/components/ReactionSummary.svelte +++ b/src/app/components/ReactionSummary.svelte @@ -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, diff --git a/src/app/components/ReportDetails.svelte b/src/app/components/ReportDetails.svelte index 06cf399c..0eef7db1 100644 --- a/src/app/components/ReportDetails.svelte +++ b/src/app/components/ReportDetails.svelte @@ -40,9 +40,7 @@ All reports for this event are shown below. {#each $reports.values() as report (report.id)} -
    - -
    + {/each} diff --git a/src/app/components/ReportItem.svelte b/src/app/components/ReportItem.svelte index 8101797f..3faecb1e 100644 --- a/src/app/components/ReportItem.svelte +++ b/src/app/components/ReportItem.svelte @@ -3,14 +3,12 @@ import {getTag, getIdFilters} from "@welshman/util" import {load, LOCAL_RELAY_URL} from "@welshman/net" import type {TrustedEvent} from "@welshman/util" - import {pubkey} from "@welshman/app" import Button from "@lib/components/Button.svelte" import Profile from "@app/components/Profile.svelte" import ProfileName from "@app/components/ProfileName.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte" import NoteContent from "@app/components/NoteContent.svelte" import ReportMenu from "@app/components/ReportMenu.svelte" - import {publishDelete, canEnforceNip70} from "@app/core/commands" import {pushModal} from "@app/util/modal" import {goToEvent} from "@app/util/routes" @@ -25,7 +23,6 @@ const etag = getTag("e", event.tags) const ptag = getTag("p", event.tags) const reason = etag?.[2] || ptag?.[2] - const shouldProtect = canEnforceNip70(url) const onClick = (e: Event, event: TrustedEvent) => { // @ts-ignore @@ -35,17 +32,12 @@ goToEvent(event) } } - - const deleteReport = async () => { - publishDelete({event, relays: [url], protect: await shouldProtect}) - onDelete?.() - } -
    +
    - + Reported this event {#if reason} @@ -53,11 +45,7 @@ {/if}
    - {#if event.pubkey === $pubkey} - - {:else} - - {/if} +
    {#if event.content}
    diff --git a/src/app/components/ReportMenu.svelte b/src/app/components/ReportMenu.svelte index e3cc81df..48c08108 100644 --- a/src/app/components/ReportMenu.svelte +++ b/src/app/components/ReportMenu.svelte @@ -1,26 +1,32 @@ - + {#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 @@ -
    +
    {@render children?.()}
    diff --git a/src/lib/html.ts b/src/lib/html.ts index ec38e0d4..640cae70 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -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 => { - 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 = {}, ): Promise => { - const {default: Compressor} = await import("compressorjs") + const {default: Compressor} = await import("compressorjs-next") return new Promise((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 +} diff --git a/src/lib/util.ts b/src/lib/util.ts index a1ae8d5b..6a2248ec 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -26,3 +26,5 @@ export const buildUrl = (base: string | URL, ...pathname: string[]) => { return url.toString() } + +export const addPeriod = (s: string) => (s + ".").replace(/\.+$/, ".") diff --git a/src/routes/home/+page.svelte b/src/routes/home/+page.svelte index 1b221d46..daaa0eef 100644 --- a/src/routes/home/+page.svelte +++ b/src/routes/home/+page.svelte @@ -1,77 +1,204 @@ -
    -
    -
    -

    Welcome to

    -

    {PLATFORM_NAME}

    -
    - - - - {#snippet icon()} - - {/snippet} - {#snippet title()} -
    Browse the network
    - {/snippet} - {#snippet info()} -
    Find communities on the nostr network.
    - {/snippet} -
    - - -
    + + {#snippet icon()} +
    +
    -
    -
    + {/snippet} + {#snippet title()} + Recent Activity + {/snippet} + {#snippet action()} +
    + {/snippet} + + + + {#each $recentActivity as { type, event, url, count } (event.id)} + {#if type === "message"} + + {:else if event.kind === THREAD} + + {:else if event.kind === CLASSIFIED} + + {:else if event.kind === ZAP_GOAL} + + {:else if event.kind === EVENT_TIME} + + {:else} + + {/if} + {:else} + {#if loading} +
    + + Loading recent activity... +
    + {:else} +

    No recent activity found!

    + {/if} + {/each} +
    diff --git a/src/routes/settings/+layout.svelte b/src/routes/settings/+layout.svelte index 59b81b09..4e8f4b9b 100644 --- a/src/routes/settings/+layout.svelte +++ b/src/routes/settings/+layout.svelte @@ -1,5 +1,6 @@ + +{#key $page.url.searchParams.get("at")} + +{/key} diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index e93bf402..b5fca7ed 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -1,10 +1,12 @@ @@ -329,11 +372,9 @@ {/snippet} {#snippet action()} -
    - @@ -367,6 +408,11 @@
    {:else} + {#if loadingForward} +

    + Looking for messages... +

    + {/if} {#each elements as { type, id, value, showPubkey } (id)} {#if type === "new-messages"}
    - {#if loadingEvents} - Looking for messages... + {#if loadingBackward} + Looking for messages... {:else} End of message history {/if} diff --git a/src/routes/spaces/[relay]/chat/+page.svelte b/src/routes/spaces/[relay]/chat/+page.svelte index 3da1032e..e41ff886 100644 --- a/src/routes/spaces/[relay]/chat/+page.svelte +++ b/src/routes/spaces/[relay]/chat/+page.svelte @@ -1,6 +1,7 @@ @@ -267,12 +312,20 @@ Chat {/snippet} {#snippet action()} - +
    + + +
    {/snippet}
    + {#if loadingForward} +

    + Looking for messages... +

    + {/if} {#each elements as { type, id, value, showPubkey } (id)} {#if type === "new-messages"}
    - {#if loadingEvents} - Looking for messages... + {#if loadingBackward} + Looking for messages... {:else} End of message history {/if} diff --git a/src/routes/spaces/[relay]/classifieds/+page.svelte b/src/routes/spaces/[relay]/classifieds/+page.svelte index a274ddbe..bbca8fb0 100644 --- a/src/routes/spaces/[relay]/classifieds/+page.svelte +++ b/src/routes/spaces/[relay]/classifieds/+page.svelte @@ -49,7 +49,7 @@ url, element: element!, filters: [{kinds: [CLASSIFIED]}, makeCommentFilter([CLASSIFIED])], - onExhausted: () => { + onBackwardExhausted: () => { loading = false }, }) diff --git a/src/routes/spaces/[relay]/goals/+page.svelte b/src/routes/spaces/[relay]/goals/+page.svelte index bd9c6a44..d1475ce8 100644 --- a/src/routes/spaces/[relay]/goals/+page.svelte +++ b/src/routes/spaces/[relay]/goals/+page.svelte @@ -48,7 +48,7 @@ url, element: element!, filters: [{kinds: [ZAP_GOAL]}, makeCommentFilter([ZAP_GOAL])], - onExhausted: () => { + onBackwardExhausted: () => { loading = false }, }) diff --git a/src/routes/spaces/[relay]/threads/+page.svelte b/src/routes/spaces/[relay]/threads/+page.svelte index adddc5e0..a4079b1b 100644 --- a/src/routes/spaces/[relay]/threads/+page.svelte +++ b/src/routes/spaces/[relay]/threads/+page.svelte @@ -49,7 +49,7 @@ url, element: element!, filters: [{kinds: [THREAD]}, makeCommentFilter([THREAD])], - onExhausted: () => { + onBackwardExhausted: () => { loading = false }, })