forked from coracle/flotilla
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 445ed27eb8 | |||
| 21f3970ca8 |
@@ -6,7 +6,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: coracle-social/flotilla
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-image:
|
build-and-push-image:
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ src/
|
|||||||
- Derive all other data inside the component from identifiers
|
- Derive all other data inside the component from identifiers
|
||||||
- Example: Don't pass `members` prop, derive it from `h` inside component
|
- Example: Don't pass `members` prop, derive it from `h` inside component
|
||||||
|
|
||||||
**CRITICAL Code Style Guidelines:**
|
**Code Style:**
|
||||||
|
|
||||||
- **No `null`** - only use `undefined`
|
- **No `null`** - only use `undefined`
|
||||||
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
|
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
|
||||||
@@ -168,16 +168,6 @@ src/
|
|||||||
- When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax.
|
- 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
|
- 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
|
- 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.
|
|
||||||
|
|
||||||
**Human-First Simplicity (Jon Staab Style):**
|
|
||||||
|
|
||||||
- Prefer direct, readable code over layered abstractions.
|
|
||||||
- Do not add indirection (extra helpers, wrappers, stores, or derived state) unless it removes real repeated complexity.
|
|
||||||
- Reuse existing Welshman and Flotilla primitives before introducing new utilities or dependencies.
|
|
||||||
- Favor linear control flow and explicit naming over clever patterns.
|
|
||||||
- Remove defensive checks that do not apply in this runtime model.
|
|
||||||
- When two approaches work, pick the one that feels more human and easier to maintain.
|
|
||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,5 +20,5 @@ ENV NODE_OPTIONS=--max_old_space_size=16384
|
|||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# Default to serving the build directory
|
# Default to serving the build directory
|
||||||
CMD ["npx", "serve", "build"]
|
CMD ["npx", "serve", "-s", "build"]
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ If you're deploying a custom version of flotilla, be sure to remove the `plausib
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
See [CONTRIBUTING.md](AGENTS.md).
|
See [./CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -358,7 +358,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 31;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||||
@@ -385,7 +385,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 31;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@
|
|||||||
"@welshman/signer": "^0.8.4",
|
"@welshman/signer": "^0.8.4",
|
||||||
"@welshman/store": "^0.8.4",
|
"@welshman/store": "^0.8.4",
|
||||||
"@welshman/util": "^0.8.4",
|
"@welshman/util": "^0.8.4",
|
||||||
"compressorjs-next": "^1.1.2",
|
"compressorjs": "^1.2.1",
|
||||||
"daisyui": "^4.12.24",
|
"daisyui": "^4.12.24",
|
||||||
"date-picker-svelte": "^2.17.0",
|
"date-picker-svelte": "^2.17.0",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
|
|||||||
Generated
+20
-6
@@ -110,9 +110,9 @@ importers:
|
|||||||
'@welshman/util':
|
'@welshman/util':
|
||||||
specifier: ^0.8.4
|
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))
|
version: 0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3))
|
||||||
compressorjs-next:
|
compressorjs:
|
||||||
specifier: ^1.1.2
|
specifier: ^1.2.1
|
||||||
version: 1.1.2
|
version: 1.2.1
|
||||||
daisyui:
|
daisyui:
|
||||||
specifier: ^4.12.24
|
specifier: ^4.12.24
|
||||||
version: 4.12.24(postcss@8.5.6)
|
version: 4.12.24(postcss@8.5.6)
|
||||||
@@ -2048,6 +2048,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
blueimp-canvas-to-blob@3.29.0:
|
||||||
|
resolution: {integrity: sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==}
|
||||||
|
|
||||||
boolbase@1.0.0:
|
boolbase@1.0.0:
|
||||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||||
|
|
||||||
@@ -2208,8 +2211,8 @@ packages:
|
|||||||
compare-func@2.0.0:
|
compare-func@2.0.0:
|
||||||
resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==}
|
resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==}
|
||||||
|
|
||||||
compressorjs-next@1.1.2:
|
compressorjs@1.2.1:
|
||||||
resolution: {integrity: sha512-5nwrVCR3+kSd4cwIzQEB72W4d+uHQ9so8U2C+WBr74DFoG34FM9CXoNZMsCnCTUDhmDKJ/3aI4Di1+QKF8LFow==}
|
resolution: {integrity: sha512-+geIjeRnPhQ+LLvvA7wxBQE5ddeLU7pJ3FsKFWirDw6veY3s9iLxAQEw7lXGHnhCJvBujEQWuNnGzZcvCvdkLQ==}
|
||||||
|
|
||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||||
@@ -2998,6 +3001,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
is-blob@2.1.0:
|
||||||
|
resolution: {integrity: sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
is-boolean-object@1.2.2:
|
is-boolean-object@1.2.2:
|
||||||
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -6902,6 +6909,8 @@ snapshots:
|
|||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
|
blueimp-canvas-to-blob@3.29.0: {}
|
||||||
|
|
||||||
boolbase@1.0.0: {}
|
boolbase@1.0.0: {}
|
||||||
|
|
||||||
bplist-creator@0.1.0:
|
bplist-creator@0.1.0:
|
||||||
@@ -7064,7 +7073,10 @@ snapshots:
|
|||||||
array-ify: 1.0.0
|
array-ify: 1.0.0
|
||||||
dot-prop: 5.3.0
|
dot-prop: 5.3.0
|
||||||
|
|
||||||
compressorjs-next@1.1.2: {}
|
compressorjs@1.2.1:
|
||||||
|
dependencies:
|
||||||
|
blueimp-canvas-to-blob: 3.29.0
|
||||||
|
is-blob: 2.1.0
|
||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
|
|
||||||
@@ -7992,6 +8004,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
binary-extensions: 2.3.0
|
binary-extensions: 2.3.0
|
||||||
|
|
||||||
|
is-blob@2.1.0: {}
|
||||||
|
|
||||||
is-boolean-object@1.2.2:
|
is-boolean-object@1.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
|
|||||||
+1
-5
@@ -402,10 +402,6 @@ progress[value]::-webkit-progress-value {
|
|||||||
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
|
@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 */
|
/* Keyboard open state adjustments */
|
||||||
|
|
||||||
body.keyboard-open .cb {
|
body.keyboard-open .cb {
|
||||||
@@ -423,5 +419,5 @@ body.keyboard-open .hide-on-keyboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat__scroll-down {
|
.chat__scroll-down {
|
||||||
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
|
@apply fixed bottom-28 right-4 z-feature md:bottom-16;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
|
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
|
||||||
import {isRelayUrl, getTagValue} from "@welshman/util"
|
import {isRelayUrl} from "@welshman/util"
|
||||||
import {preventDefault, stopPropagation} from "@lib/html"
|
import {preventDefault, stopPropagation} from "@lib/html"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
||||||
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {dufflepud, PLATFORM_URL, IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/core/state"
|
import {dufflepud, PLATFORM_URL} from "@app/core/state"
|
||||||
import {makeSpacePath} from "@app/util/routes"
|
import {makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
const {value, event} = $props()
|
const {value, event} = $props()
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
let hideImage = $state(false)
|
let hideImage = $state(false)
|
||||||
|
|
||||||
const url = value.url.toString()
|
const url = value.url.toString()
|
||||||
const fileType = getTagValue("file-type", event.tags) || ""
|
|
||||||
const [href, external] = call(() => {
|
const [href, external] = call(() => {
|
||||||
if (isRelayUrl(url)) return [makeSpacePath(url), false]
|
if (isRelayUrl(url)) return [makeSpacePath(url), false]
|
||||||
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
||||||
@@ -41,11 +40,11 @@
|
|||||||
|
|
||||||
<Link {external} {href} class="my-2 block">
|
<Link {external} {href} class="my-2 block">
|
||||||
<div class="overflow-hidden rounded-box">
|
<div class="overflow-hidden rounded-box">
|
||||||
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
|
{#if url.match(/\.(mov|webm|mp4)$/)}
|
||||||
<video controls src={url} class="max-h-96 rounded-box object-contain object-center">
|
<video controls src={url} class="max-h-96 rounded-box object-contain object-center">
|
||||||
<track kind="captions" />
|
<track kind="captions" />
|
||||||
</video>
|
</video>
|
||||||
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
|
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
|
||||||
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
|
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
|
||||||
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
|
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {call, displayUrl} from "@welshman/lib"
|
import {call, displayUrl} from "@welshman/lib"
|
||||||
import {isRelayUrl, getTagValue} from "@welshman/util"
|
import {isRelayUrl} from "@welshman/util"
|
||||||
import {preventDefault, stopPropagation} from "@lib/html"
|
import {preventDefault, stopPropagation} from "@lib/html"
|
||||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {PLATFORM_URL, IMAGE_CONTENT_TYPES} from "@app/core/state"
|
import {PLATFORM_URL} from "@app/core/state"
|
||||||
import {makeSpacePath} from "@app/util/routes"
|
import {makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
const {value, event} = $props()
|
const {value, event} = $props()
|
||||||
|
|
||||||
const url = value.url.toString()
|
const url = value.url.toString()
|
||||||
const fileType = getTagValue("file-type", event.tags) || ""
|
|
||||||
const [href, external] = call(() => {
|
const [href, external] = call(() => {
|
||||||
if (isRelayUrl(url)) return [makeSpacePath(url), false]
|
if (isRelayUrl(url)) return [makeSpacePath(url), false]
|
||||||
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
|
{#if url.match(/\.(jpe?g|png|gif|webp)$/)}
|
||||||
<!-- Use a real link so people can copy the href -->
|
<!-- Use a real link so people can copy the href -->
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {Capacitor} from "@capacitor/core"
|
|
||||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||||
import Server from "@assets/icons/server.svg?dataurl"
|
import Server from "@assets/icons/server.svg?dataurl"
|
||||||
import Moon from "@assets/icons/moon.svg?dataurl"
|
import Moon from "@assets/icons/moon.svg?dataurl"
|
||||||
@@ -53,21 +52,19 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</CardButton>
|
</CardButton>
|
||||||
</Link>
|
</Link>
|
||||||
{#if Capacitor.getPlatform() !== "ios"}
|
<Link replaceState href="/settings/wallet">
|
||||||
<Link replaceState href="/settings/wallet">
|
<CardButton class="btn-neutral">
|
||||||
<CardButton class="btn-neutral">
|
{#snippet icon()}
|
||||||
{#snippet icon()}
|
<div><Icon icon={Wallet} size={7} /></div>
|
||||||
<div><Icon icon={Wallet} size={7} /></div>
|
{/snippet}
|
||||||
{/snippet}
|
{#snippet title()}
|
||||||
{#snippet title()}
|
<div>Wallet</div>
|
||||||
<div>Wallet</div>
|
{/snippet}
|
||||||
{/snippet}
|
{#snippet info()}
|
||||||
{#snippet info()}
|
<div>Connect a bitcoin wallet for sending social tips</div>
|
||||||
<div>Connect a bitcoin wallet for sending social tips</div>
|
{/snippet}
|
||||||
{/snippet}
|
</CardButton>
|
||||||
</CardButton>
|
</Link>
|
||||||
</Link>
|
|
||||||
{/if}
|
|
||||||
<Link replaceState href="/settings/relays">
|
<Link replaceState href="/settings/relays">
|
||||||
<CardButton class="btn-neutral">
|
<CardButton class="btn-neutral">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {Router} from "@welshman/router"
|
|
||||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||||
@@ -12,28 +11,26 @@
|
|||||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
url: string
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
children?: Snippet
|
children?: Snippet
|
||||||
url?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, event, children}: Props = $props()
|
const {url, event, children}: Props = $props()
|
||||||
|
|
||||||
const relays = url ? [url] : Router.get().Event(event).getUrls()
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
const shouldProtect = url ? canEnforceNip70(url) : Promise.resolve(false)
|
|
||||||
|
|
||||||
const deleteReaction = async (event: TrustedEvent) =>
|
const deleteReaction = async (event: TrustedEvent) =>
|
||||||
publishDelete({relays, event, protect: await shouldProtect})
|
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||||
|
|
||||||
const createReaction = async (template: EventContent) =>
|
const createReaction = async (template: EventContent) =>
|
||||||
publishReaction({...template, event, relays, protect: await shouldProtect})
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
|
|
||||||
const onEmoji = async (emoji: NativeEmoji) =>
|
const onEmoji = async (emoji: NativeEmoji) =>
|
||||||
publishReaction({
|
publishReaction({
|
||||||
event,
|
event,
|
||||||
relays,
|
|
||||||
content: emoji.unicode,
|
content: emoji.unicode,
|
||||||
|
relays: [url],
|
||||||
protect: await shouldProtect,
|
protect: await shouldProtect,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
||||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||||
import MinusCircle from "@assets/icons/minus-circle.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 {fly} from "@lib/transition"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||||
@@ -31,7 +30,7 @@
|
|||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||||
import ChatEnable from "@app/components/ChatEnable.svelte"
|
import ChatEnable from "@app/components/ChatEnable.svelte"
|
||||||
import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state"
|
import {pubkeyLink, deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {makeChatPath} from "@app/util/routes"
|
import {makeChatPath} from "@app/util/routes"
|
||||||
@@ -47,10 +46,6 @@
|
|||||||
|
|
||||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
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 back = () => history.back()
|
||||||
|
|
||||||
const chatPath = makeChatPath([pubkey])
|
const chatPath = makeChatPath([pubkey])
|
||||||
@@ -86,20 +81,6 @@
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
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)
|
let showMenu = $state(false)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -131,21 +112,12 @@
|
|||||||
</li>
|
</li>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $userIsAdmin}
|
{#if $userIsAdmin}
|
||||||
{#if isBanned}
|
<li>
|
||||||
<li>
|
<Button class="text-error" onclick={banMember}>
|
||||||
<Button onclick={restoreMember}>
|
<Icon icon={MinusCircle} />
|
||||||
<Icon icon={Restart} />
|
Ban User
|
||||||
Restore User
|
</Button>
|
||||||
</Button>
|
</li>
|
||||||
</li>
|
|
||||||
{:else}
|
|
||||||
<li>
|
|
||||||
<Button class="text-error" onclick={banMember}>
|
|
||||||
<Icon icon={MinusCircle} />
|
|
||||||
Ban User
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
data-tip={tooltip}
|
data-tip={tooltip}
|
||||||
class={cx(
|
class={cx(
|
||||||
reactionClass,
|
reactionClass,
|
||||||
"flex-inline btn btn-outline btn-neutral btn-xs flex items-center gap-1 rounded-full text-xs font-normal bg-alt",
|
"flex-inline btn btn-outline btn-neutral btn-xs flex items-center gap-1 rounded-full text-xs font-normal",
|
||||||
{
|
{
|
||||||
tooltip: !noTooltip && !isMobile,
|
tooltip: !noTooltip && !isMobile,
|
||||||
"border-neutral-content/20": !isOwn,
|
"border-neutral-content/20": !isOwn,
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
data-tip={tooltip}
|
data-tip={tooltip}
|
||||||
class={cx(
|
class={cx(
|
||||||
reactionClass,
|
reactionClass,
|
||||||
"flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full font-normal bg-alt",
|
"flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full font-normal",
|
||||||
{
|
{
|
||||||
tooltip: !noTooltip && !isMobile,
|
tooltip: !noTooltip && !isMobile,
|
||||||
"border-neutral-content/20": !isOwn,
|
"border-neutral-content/20": !isOwn,
|
||||||
|
|||||||
@@ -40,7 +40,9 @@
|
|||||||
<ModalSubtitle>All reports for this event are shown below.</ModalSubtitle>
|
<ModalSubtitle>All reports for this event are shown below.</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
{#each $reports.values() as report (report.id)}
|
{#each $reports.values() as report (report.id)}
|
||||||
<ReportItem {url} event={report} {onDelete} />
|
<div class="card2 card2-sm bg-alt">
|
||||||
|
<ReportItem {url} event={report} {onDelete} />
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
import {getTag, getIdFilters} from "@welshman/util"
|
import {getTag, getIdFilters} from "@welshman/util"
|
||||||
import {load, LOCAL_RELAY_URL} from "@welshman/net"
|
import {load, LOCAL_RELAY_URL} from "@welshman/net"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {pubkey} from "@welshman/app"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Profile from "@app/components/Profile.svelte"
|
import Profile from "@app/components/Profile.svelte"
|
||||||
import ProfileName from "@app/components/ProfileName.svelte"
|
import ProfileName from "@app/components/ProfileName.svelte"
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import NoteContent from "@app/components/NoteContent.svelte"
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
import ReportMenu from "@app/components/ReportMenu.svelte"
|
import ReportMenu from "@app/components/ReportMenu.svelte"
|
||||||
|
import {publishDelete, canEnforceNip70} from "@app/core/commands"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {goToEvent} from "@app/util/routes"
|
import {goToEvent} from "@app/util/routes"
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
const etag = getTag("e", event.tags)
|
const etag = getTag("e", event.tags)
|
||||||
const ptag = getTag("p", event.tags)
|
const ptag = getTag("p", event.tags)
|
||||||
const reason = etag?.[2] || ptag?.[2]
|
const reason = etag?.[2] || ptag?.[2]
|
||||||
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
const onClick = (e: Event, event: TrustedEvent) => {
|
const onClick = (e: Event, event: TrustedEvent) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -32,12 +35,17 @@
|
|||||||
goToEvent(event)
|
goToEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteReport = async () => {
|
||||||
|
publishDelete({event, relays: [url], protect: await shouldProtect})
|
||||||
|
onDelete?.()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="column gap-4 card2 card2-sm bg-alt">
|
<div class="column gap-4">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<div>
|
<div>
|
||||||
<ProfileName pubkey={event.pubkey} {url} />
|
<Profile pubkey={event.pubkey} {url} avatarSize={5} />
|
||||||
<span>
|
<span>
|
||||||
Reported this event
|
Reported this event
|
||||||
{#if reason}
|
{#if reason}
|
||||||
@@ -45,7 +53,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ReportMenu {url} {event} {onDelete} />
|
{#if event.pubkey === $pubkey}
|
||||||
|
<Button class="btn-default btn" onclick={deleteReport}>Delete Report</Button>
|
||||||
|
{:else}
|
||||||
|
<ReportMenu {url} {event} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if event.content}
|
{#if event.content}
|
||||||
<div class="border-l-2 border-primary pl-3">
|
<div class="border-l-2 border-primary pl-3">
|
||||||
|
|||||||
@@ -1,32 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {getTag, ManagementMethod} from "@welshman/util"
|
import {getTag, ManagementMethod} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {pubkey, manageRelay, repository, displayProfileByPubkey} from "@welshman/app"
|
import {manageRelay, repository, displayProfileByPubkey} from "@welshman/app"
|
||||||
import InboxOut from "@assets/icons/inbox-out.svg?dataurl"
|
import InboxOut from "@assets/icons/inbox-out.svg?dataurl"
|
||||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||||
import Close from "@assets/icons/close.svg?dataurl"
|
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Popover from "@lib/components/Popover.svelte"
|
import Popover from "@lib/components/Popover.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
import {deriveUserIsSpaceAdmin} from "@app/core/state"
|
|
||||||
import {publishDelete, canEnforceNip70} from "@app/core/commands"
|
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
onDelete?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, event, onDelete}: Props = $props()
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
|
||||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
|
||||||
const etag = getTag("e", event.tags)
|
const etag = getTag("e", event.tags)
|
||||||
const ptag = getTag("p", event.tags)
|
const ptag = getTag("p", event.tags)
|
||||||
|
|
||||||
@@ -38,11 +32,6 @@
|
|||||||
isOpen = false
|
isOpen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteReport = async () => {
|
|
||||||
publishDelete({event, relays: [url], protect: await shouldProtect})
|
|
||||||
onDelete?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const dismissReport = async () => {
|
const dismissReport = async () => {
|
||||||
const {error} = await manageRelay(url, {
|
const {error} = await manageRelay(url, {
|
||||||
method: ManagementMethod.BanEvent,
|
method: ManagementMethod.BanEvent,
|
||||||
@@ -54,7 +43,7 @@
|
|||||||
} else {
|
} else {
|
||||||
pushToast({message: "Content has successfully been deleted!"})
|
pushToast({message: "Content has successfully been deleted!"})
|
||||||
repository.removeEvent(event.id)
|
repository.removeEvent(event.id)
|
||||||
onDelete?.()
|
history.back()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +51,7 @@
|
|||||||
const [_, id, reason = ""] = etag!
|
const [_, id, reason = ""] = etag!
|
||||||
|
|
||||||
pushModal(Confirm, {
|
pushModal(Confirm, {
|
||||||
title: `Remove Content`,
|
title: `Delete Content`,
|
||||||
message: `Are you sure you want to delete this content from the space?`,
|
message: `Are you sure you want to delete this content from the space?`,
|
||||||
confirm: async () => {
|
confirm: async () => {
|
||||||
const {error} = await manageRelay(url, {
|
const {error} = await manageRelay(url, {
|
||||||
@@ -74,17 +63,15 @@
|
|||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
} else {
|
} else {
|
||||||
pushToast({message: "Content has successfully been deleted!"})
|
pushToast({message: "Content has successfully been deleted!"})
|
||||||
repository.removeEvent(event.id)
|
|
||||||
repository.removeEvent(id)
|
repository.removeEvent(id)
|
||||||
history.back()
|
history.back()
|
||||||
setTimeout(() => onDelete?.())
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const banMember = () => {
|
const banMember = () => {
|
||||||
const [_, pubkey, reason = ""] = ptag!
|
const [pubkey, reason = ""] = ptag!
|
||||||
|
|
||||||
pushModal(Confirm, {
|
pushModal(Confirm, {
|
||||||
title: "Ban User",
|
title: "Ban User",
|
||||||
@@ -99,9 +86,7 @@
|
|||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
} else {
|
} else {
|
||||||
pushToast({message: "User has successfully been banned!"})
|
pushToast({message: "User has successfully been banned!"})
|
||||||
repository.removeEvent(event.id)
|
|
||||||
history.back()
|
history.back()
|
||||||
setTimeout(() => onDelete?.())
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -119,37 +104,27 @@
|
|||||||
<ul
|
<ul
|
||||||
transition:fly
|
transition:fly
|
||||||
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
||||||
{#if event.pubkey === $pubkey}
|
<li>
|
||||||
|
<Button onclick={dismissReport}>
|
||||||
|
<Icon icon={InboxOut} />
|
||||||
|
Dismiss Report
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{#if etag}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={deleteReport}>
|
<Button class="text-error" onclick={banContent}>
|
||||||
<Icon icon={Close} />
|
<Icon icon={TrashBin2} />
|
||||||
Delete Report
|
Remove Content
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $userIsAdmin}
|
{#if ptag}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={dismissReport}>
|
<Button class="text-error" onclick={banMember}>
|
||||||
<Icon icon={InboxOut} />
|
<Icon icon={MinusCircle} />
|
||||||
Dismiss Report
|
Ban User
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
{#if etag}
|
|
||||||
<li>
|
|
||||||
<Button class="text-error" onclick={banContent}>
|
|
||||||
<Icon icon={TrashBin2} />
|
|
||||||
Remove Content
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{#if ptag}
|
|
||||||
<li>
|
|
||||||
<Button class="text-error" onclick={banMember}>
|
|
||||||
<Icon icon={MinusCircle} />
|
|
||||||
Ban User
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import EyeClosed from "@assets/icons/eye-closed.svg?dataurl"
|
import EyeClosed from "@assets/icons/eye-closed.svg?dataurl"
|
||||||
import Eye from "@assets/icons/eye.svg?dataurl"
|
|
||||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||||
import Lock from "@assets/icons/lock.svg?dataurl"
|
import Lock from "@assets/icons/lock.svg?dataurl"
|
||||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
||||||
@@ -199,9 +198,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if $room?.about}
|
|
||||||
<p>{$room.about}</p>
|
|
||||||
{/if}
|
|
||||||
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
|
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
|
||||||
<strong class="text-lg">Room Permissions</strong>
|
<strong class="text-lg">Room Permissions</strong>
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
@@ -237,11 +233,14 @@
|
|||||||
<Button
|
<Button
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
||||||
data-tip="This room has no additional access controls.">
|
data-tip="This room has no additional access controls.">
|
||||||
<Icon size={4} icon={Eye} /> Public
|
<Icon size={4} icon={MinusCircle} /> Public
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if $room?.about}
|
||||||
|
<p>{$room.about}</p>
|
||||||
|
{/if}
|
||||||
{#if $members.length > 0}
|
{#if $members.length > 0}
|
||||||
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-4">
|
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-4">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
if (popover) {
|
if (popover) {
|
||||||
const {x, y, width, height} = popover.popper.getBoundingClientRect()
|
const {x, y, width, height} = popover.popper.getBoundingClientRect()
|
||||||
|
|
||||||
if (!between([x, x + width], clientX) || !between([y - 50, y + height + 50], clientY)) {
|
if (!between([x, x + width], clientX) || !between([y, y + height + 30], clientY)) {
|
||||||
popover.hide()
|
popover.hide()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,12 +172,12 @@
|
|||||||
</FieldInline>
|
</FieldInline>
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
<p class="flex flex-col items-start h-full">Description</p>
|
<p>Description</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<textarea
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
bind:value={values.description}
|
<input bind:value={values.description} class="grow" type="text" />
|
||||||
class="min-h-24 textarea textarea-bordered flex w-full"></textarea>
|
</label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
const openMenu = () => pushDrawer(SpaceMenu, {url})
|
const openMenu = () => pushDrawer(SpaceMenu, {url})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button onclick={openMenu} class="btn btn-neutral btn-sm relative md:hidden btn-square">
|
<Button onclick={openMenu} class="btn btn-neutral btn-sm relative md:hidden">
|
||||||
<Icon icon={MenuDots} />
|
<Icon icon={MenuDots} />
|
||||||
{#if $status.theme !== "success"}
|
{#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>
|
<div class="absolute right-0 top-0 -mr-1 -mt-1 h-2 w-2 rounded-full bg-{$status.theme}"></div>
|
||||||
|
|||||||
@@ -22,12 +22,6 @@
|
|||||||
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}])
|
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}])
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const onDelete = () => {
|
|
||||||
if ($reports.length === 0) {
|
|
||||||
back()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal>
|
<Modal>
|
||||||
@@ -38,7 +32,7 @@
|
|||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{#each $reports as event (event.id)}
|
{#each $reports as event (event.id)}
|
||||||
<ReportItem {url} {event} {onDelete} />
|
<ReportItem {url} {event} />
|
||||||
{:else}
|
{:else}
|
||||||
<p class="py-12 text-center">No reports found.</p>
|
<p class="py-12 text-center">No reports found.</p>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
<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,7 +2,6 @@
|
|||||||
import {PublishStatus} from "@welshman/net"
|
import {PublishStatus} from "@welshman/net"
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import {addPeriod} from "@lib/util"
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string
|
url: string
|
||||||
@@ -26,7 +25,7 @@
|
|||||||
|
|
||||||
<div class="card2 bg-alt col-2 shadow-lg">
|
<div class="card2 bg-alt col-2 shadow-lg">
|
||||||
<p>
|
<p>
|
||||||
Failed to publish to {displayRelayUrl(url)}: {addPeriod(message)}
|
Failed to publish to {displayRelayUrl(url)}: {message}.
|
||||||
</p>
|
</p>
|
||||||
<Button class="link" onclick={retry}>Retry</Button>
|
<Button class="link" onclick={retry}>Retry</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+66
-128
@@ -1,6 +1,5 @@
|
|||||||
import {get, writable} from "svelte/store"
|
import {get, writable} from "svelte/store"
|
||||||
import {
|
import {
|
||||||
call,
|
|
||||||
uniq,
|
uniq,
|
||||||
int,
|
int,
|
||||||
YEAR,
|
YEAR,
|
||||||
@@ -9,7 +8,6 @@ import {
|
|||||||
sortBy,
|
sortBy,
|
||||||
now,
|
now,
|
||||||
on,
|
on,
|
||||||
between,
|
|
||||||
isDefined,
|
isDefined,
|
||||||
filterVals,
|
filterVals,
|
||||||
fromPairs,
|
fromPairs,
|
||||||
@@ -24,8 +22,9 @@ import {
|
|||||||
getRelaysFromList,
|
getRelaysFromList,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {TrustedEvent, Filter, List} 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 {load, request} from "@welshman/net"
|
||||||
import {repository, loadRelay, tracker} from "@welshman/app"
|
import {repository, makeFeedController, loadRelay, tracker} from "@welshman/app"
|
||||||
import {createScroller} from "@lib/html"
|
import {createScroller} from "@lib/html"
|
||||||
import {daysBetween} from "@lib/util"
|
import {daysBetween} from "@lib/util"
|
||||||
import {getEventsForUrl} from "@app/core/state"
|
import {getEventsForUrl} from "@app/core/state"
|
||||||
@@ -36,131 +35,82 @@ export const makeFeed = ({
|
|||||||
url,
|
url,
|
||||||
filters,
|
filters,
|
||||||
element,
|
element,
|
||||||
onBackwardExhausted,
|
onExhausted,
|
||||||
onForwardExhausted,
|
|
||||||
at = now(),
|
|
||||||
}: {
|
}: {
|
||||||
url: string
|
url: string
|
||||||
filters: Filter[]
|
filters: Filter[]
|
||||||
element: HTMLElement
|
element: HTMLElement
|
||||||
onBackwardExhausted?: () => void
|
onExhausted?: () => void
|
||||||
onForwardExhausted?: () => void
|
|
||||||
at?: number
|
|
||||||
}) => {
|
}) => {
|
||||||
const interval = int(DAY)
|
const seen = new Set<string>()
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
const buffer = writable<TrustedEvent[]>([])
|
||||||
const events = writable<TrustedEvent[]>([])
|
const events = writable<TrustedEvent[]>([])
|
||||||
|
|
||||||
let buffer: TrustedEvent[] = []
|
|
||||||
let backwardWindow = [at - interval, at]
|
|
||||||
let forwardWindow = [at, at + interval]
|
|
||||||
|
|
||||||
const insertEvent = (event: TrustedEvent) => {
|
const insertEvent = (event: TrustedEvent) => {
|
||||||
let handled = false
|
let handled = false
|
||||||
|
|
||||||
if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) {
|
if (seen.has(event.id)) {
|
||||||
const $events = get(events)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
events.update($events => {
|
||||||
for (let i = 0; i < $events.length; i++) {
|
for (let i = 0; i < $events.length; i++) {
|
||||||
if ($events[i].created_at > event.created_at) {
|
if ($events[i].id === event.id) return $events
|
||||||
events.set(insertAt(i, event, $events))
|
if ($events[i].created_at < event.created_at) {
|
||||||
handled = true
|
handled = true
|
||||||
break
|
return insertAt(i, event, $events)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!handled) {
|
return $events
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!handled) {
|
if (!handled) {
|
||||||
buffer.push(event)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...$buffer, 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)))
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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: 5000,
|
|
||||||
onScroll: () => {
|
|
||||||
const [since, until] = backwardWindow
|
|
||||||
|
|
||||||
backwardWindow = [since - interval, since]
|
|
||||||
|
|
||||||
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({
|
const ctrl = makeFeedController({
|
||||||
|
useWindowing: true,
|
||||||
|
signal: controller.signal,
|
||||||
|
feed: makeIntersectionFeed(makeRelayFeed(url), feedFromFilters(filters)),
|
||||||
|
onExhausted,
|
||||||
|
})
|
||||||
|
|
||||||
|
const scroller = createScroller({
|
||||||
element,
|
element,
|
||||||
reverse: true,
|
|
||||||
delay: 300,
|
delay: 300,
|
||||||
threshold: 5000,
|
threshold: 10_000,
|
||||||
onScroll: () => {
|
onScroll: async () => {
|
||||||
const [since, until] = forwardWindow
|
const $buffer = get(buffer)
|
||||||
|
|
||||||
forwardWindow = [until, until + interval]
|
events.update($events => [...$events, ...$buffer.splice(0, 30)])
|
||||||
|
|
||||||
for (const event of buffer.splice(0)) {
|
if ($buffer.length < 100) {
|
||||||
insertEvent(event)
|
ctrl.load(100)
|
||||||
}
|
|
||||||
|
|
||||||
if (until < now()) {
|
|
||||||
loadTimeframe(since, until)
|
|
||||||
} else if (!buffer.some(e => e.created_at > at)) {
|
|
||||||
forwardScroller.stop()
|
|
||||||
onForwardExhausted?.()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -172,10 +122,9 @@ export const makeFeed = ({
|
|||||||
return {
|
return {
|
||||||
events,
|
events,
|
||||||
cleanup: () => {
|
cleanup: () => {
|
||||||
|
unsubscribe()
|
||||||
|
scroller.stop()
|
||||||
controller.abort()
|
controller.abort()
|
||||||
forwardScroller.stop()
|
|
||||||
backwardScroller.stop()
|
|
||||||
unsubscribers.forEach(call)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,28 +169,17 @@ export const makeCalendarFeed = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribers = [
|
const unsubscribe = on(repository, "update", ({added, removed}) => {
|
||||||
on(repository, "update", ({added, removed}) => {
|
if (removed.size > 0) {
|
||||||
if (removed.size > 0) {
|
events.update($events => $events.filter(e => !removed.has(e.id)))
|
||||||
events.update($events => $events.filter(e => !removed.has(e.id)))
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const event of added) {
|
for (const event of added) {
|
||||||
if (matchFilters(filters, event)) {
|
if (matchFilters(filters, event)) {
|
||||||
insertEvent(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 loadTimeframe = (since: number, until: number) => {
|
||||||
const hashes = daysBetween(since, until).map(String)
|
const hashes = daysBetween(since, until).map(String)
|
||||||
@@ -296,10 +234,10 @@ export const makeCalendarFeed = ({
|
|||||||
return {
|
return {
|
||||||
events,
|
events,
|
||||||
cleanup: () => {
|
cleanup: () => {
|
||||||
controller.abort()
|
|
||||||
forwardScroller.stop()
|
|
||||||
backwardScroller.stop()
|
backwardScroller.stop()
|
||||||
unsubscribers.forEach(call)
|
forwardScroller.stop()
|
||||||
|
controller.abort()
|
||||||
|
unsubscribe()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-21
@@ -50,12 +50,9 @@ import {
|
|||||||
makeDeriveItem,
|
makeDeriveItem,
|
||||||
deriveItemsByKey,
|
deriveItemsByKey,
|
||||||
deriveDeduplicated,
|
deriveDeduplicated,
|
||||||
deriveEventsById,
|
|
||||||
deriveEventsByIdByUrl,
|
deriveEventsByIdByUrl,
|
||||||
deriveEventsByIdForUrl,
|
deriveEventsByIdForUrl,
|
||||||
getEventsByIdForUrl,
|
getEventsByIdForUrl,
|
||||||
deriveEventsAsc,
|
|
||||||
deriveEventsDesc,
|
|
||||||
} from "@welshman/store"
|
} from "@welshman/store"
|
||||||
import {
|
import {
|
||||||
APP_DATA,
|
APP_DATA,
|
||||||
@@ -129,10 +126,6 @@ export const ROOM = "h"
|
|||||||
|
|
||||||
export const PROTECTED = ["-"]
|
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 ENABLE_ZAPS = Capacitor.getPlatform() != "ios"
|
||||||
|
|
||||||
export const PUSH_SERVER = import.meta.env.VITE_PUSH_SERVER
|
export const PUSH_SERVER = import.meta.env.VITE_PUSH_SERVER
|
||||||
@@ -236,21 +229,12 @@ export const deriveEvent = makeDeriveEvent({
|
|||||||
onDerive: (filters: Filter[], relays: string[]) => load({filters, relays}),
|
onDerive: (filters: Filter[], relays: string[]) => load({filters, relays}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const deriveEvents = (filters: Filter[] = [{}]) =>
|
|
||||||
deriveEventsDesc(deriveEventsById({repository, filters}))
|
|
||||||
|
|
||||||
export const getEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
|
export const getEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
|
||||||
getEventsByIdForUrl({url, tracker, repository, filters}).values()
|
getEventsByIdForUrl({url, tracker, repository, filters}).values()
|
||||||
|
|
||||||
export const deriveEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
|
export const deriveEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
|
||||||
deriveArray(deriveEventsByIdForUrl({url, tracker, repository, filters}))
|
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[] = [{}]) =>
|
export const deriveLatestEventForUrl = (url: string, filters: Filter[] = [{}]) =>
|
||||||
deriveDeduplicated(deriveEventsByIdForUrl({url, tracker, repository, filters}), $eventsById =>
|
deriveDeduplicated(deriveEventsByIdForUrl({url, tracker, repository, filters}), $eventsById =>
|
||||||
first(sortEventsDesc($eventsById.values())),
|
first(sortEventsDesc($eventsById.values())),
|
||||||
@@ -450,10 +434,7 @@ export const chatsById = call(() => {
|
|||||||
const pubkeys = getChatPubkeysFromEvent(event)
|
const pubkeys = getChatPubkeysFromEvent(event)
|
||||||
const id = makeChatId(pubkeys)
|
const id = makeChatId(pubkeys)
|
||||||
const chat = chatsById.get(id)
|
const chat = chatsById.get(id)
|
||||||
const messages = sortBy(
|
const messages = sortBy(e => -e.created_at, append(event, chat?.messages || []))
|
||||||
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 last_activity = Math.max(chat?.last_activity || 0, event.created_at)
|
||||||
const updatedChat = addSearchText({id, pubkeys, messages, last_activity})
|
const updatedChat = addSearchText({id, pubkeys, messages, last_activity})
|
||||||
|
|
||||||
@@ -482,7 +463,7 @@ export const chatsById = call(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addEvents(repository.query([{kinds: [...DM_KINDS, PROFILE]}]))
|
addEvents(repository.query([{kinds: [DIRECT_MESSAGE, PROFILE]}]))
|
||||||
|
|
||||||
const unsubscribers = [
|
const unsubscribers = [
|
||||||
on(repository, "update", ({added}: RepositoryUpdate) => addEvents(added)),
|
on(repository, "update", ({added}: RepositoryUpdate) => addEvents(added)),
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
import type {FileAttributes} from "@welshman/editor"
|
import type {FileAttributes} from "@welshman/editor"
|
||||||
import {Editor, MentionSuggestion, WelshmanExtension, editorProps} from "@welshman/editor"
|
import {Editor, MentionSuggestion, WelshmanExtension, editorProps} from "@welshman/editor"
|
||||||
import {escapeHtml} from "@lib/html"
|
|
||||||
import {makeMentionNodeView} from "@app/editor/MentionNodeView"
|
import {makeMentionNodeView} from "@app/editor/MentionNodeView"
|
||||||
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
|
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
|
||||||
import {uploadFile} from "@app/core/commands"
|
import {uploadFile} from "@app/core/commands"
|
||||||
@@ -83,7 +82,7 @@ export const makeEditor = async ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return new Editor({
|
return new Editor({
|
||||||
content: escapeHtml(content),
|
content,
|
||||||
autofocus,
|
autofocus,
|
||||||
editorProps,
|
editorProps,
|
||||||
element: document.createElement("div"),
|
element: document.createElement("div"),
|
||||||
|
|||||||
+16
-39
@@ -2,14 +2,16 @@ import type {Page} from "@sveltejs/kit"
|
|||||||
import {get} from "svelte/store"
|
import {get} from "svelte/store"
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
import {page} from "$app/stores"
|
import {nthEq, sleep} from "@welshman/lib"
|
||||||
import {nthEq} from "@welshman/lib"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getAddress} from "@welshman/util"
|
import {getAddress} from "@welshman/util"
|
||||||
import {tracker, loadRelay} from "@welshman/app"
|
import {tracker, loadRelay} from "@welshman/app"
|
||||||
|
import {scrollToEvent} from "@lib/html"
|
||||||
import {identity} from "@welshman/lib"
|
import {identity} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
getTagValue,
|
getTagValue,
|
||||||
|
DIRECT_MESSAGE,
|
||||||
|
DIRECT_MESSAGE_FILE,
|
||||||
MESSAGE,
|
MESSAGE,
|
||||||
THREAD,
|
THREAD,
|
||||||
CLASSIFIED,
|
CLASSIFIED,
|
||||||
@@ -24,7 +26,6 @@ import {
|
|||||||
encodeRelay,
|
encodeRelay,
|
||||||
userSpaceUrls,
|
userSpaceUrls,
|
||||||
hasNip29,
|
hasNip29,
|
||||||
DM_KINDS,
|
|
||||||
ROOM,
|
ROOM,
|
||||||
} from "@app/core/state"
|
} from "@app/core/state"
|
||||||
import {lastPageBySpaceUrl} from "@app/util/history"
|
import {lastPageBySpaceUrl} from "@app/util/history"
|
||||||
@@ -62,14 +63,6 @@ export const makeRoomPath = (url: string, h: string) => `/spaces/${encodeRelay(u
|
|||||||
|
|
||||||
export const makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat")
|
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 makeGoalPath = (url: string, id?: string) => makeSpacePath(url, "goals", id)
|
||||||
|
|
||||||
export const makeThreadPath = (url: string, id?: string) => makeSpacePath(url, "threads", id)
|
export const makeThreadPath = (url: string, id?: string) => makeSpacePath(url, "threads", id)
|
||||||
@@ -100,43 +93,27 @@ export const getPrimaryNavItemIndex = ($page: Page) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const scrollToEvent = (id: string) => {
|
export const goToEvent = async (event: TrustedEvent, options: Record<string, any> = {}) => {
|
||||||
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 urls = Array.from(tracker.getRelays(event.id))
|
||||||
const path = getEventPath(event, urls)
|
const path = await getEventPath(event, urls)
|
||||||
|
|
||||||
if (path.includes("://")) {
|
if (path.includes("://")) {
|
||||||
window.open(path)
|
window.open(path)
|
||||||
} else if (!scrollToEvent(event.id)) {
|
} else {
|
||||||
const replaceState = path.replace(/\?.*$/, "") === get(page).url.pathname
|
goto(path, options)
|
||||||
|
|
||||||
goto(path, {replaceState, ...options})
|
await sleep(300)
|
||||||
|
await scrollToEvent(event.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getEventPath = (event: TrustedEvent, urls: string[]) => {
|
export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
|
||||||
if (DM_KINDS.includes(event.kind)) {
|
if (event.kind === DIRECT_MESSAGE || event.kind === DIRECT_MESSAGE_FILE) {
|
||||||
return makeChatPath([event.pubkey, ...getPubkeyTagValues(event.tags)])
|
return makeChatPath([event.pubkey, ...getPubkeyTagValues(event.tags)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const h = getTagValue(ROOM, event.tags)
|
||||||
|
|
||||||
if (urls.length > 0) {
|
if (urls.length > 0) {
|
||||||
const url = urls[0]
|
const url = urls[0]
|
||||||
|
|
||||||
@@ -157,7 +134,7 @@ export const getEventPath = (event: TrustedEvent, urls: string[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind === MESSAGE) {
|
if (event.kind === MESSAGE) {
|
||||||
return makeMessagePath(url, event)
|
return h ? makeRoomPath(url, h) : makeSpacePath(url, "chat")
|
||||||
}
|
}
|
||||||
|
|
||||||
const address = event.tags.find(nthEq(0, "A"))?.[1]
|
const address = event.tags.find(nthEq(0, "A"))?.[1]
|
||||||
@@ -174,7 +151,7 @@ export const getEventPath = (event: TrustedEvent, urls: string[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parseInt(kind) === MESSAGE) {
|
if (parseInt(kind) === MESSAGE) {
|
||||||
return makeMessagePath(url, event)
|
return h ? makeRoomPath(url, h) : makeSpacePath(url, "chat")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import {append, identity, uniq} from "@welshman/lib"
|
|
||||||
import {repository} from "@welshman/app"
|
|
||||||
import {displayPubkey, getTagValue} from "@welshman/util"
|
|
||||||
import {PLATFORM_NAME, decodeRelay, getRoom, makeRoomId, splitChatId} from "@app/core/state"
|
|
||||||
|
|
||||||
const FALLBACK_APP_NAME = "Flotilla"
|
|
||||||
|
|
||||||
const staticTitles = new Map<string, string>([
|
|
||||||
["/", "Redirecting"],
|
|
||||||
["/home", "Home"],
|
|
||||||
["/discover", "Discover Spaces"],
|
|
||||||
["/spaces", "Your Spaces"],
|
|
||||||
["/spaces/create", "Create a Space"],
|
|
||||||
["/spaces/[relay]", "Space"],
|
|
||||||
["/spaces/[relay]/chat", "Space Chat"],
|
|
||||||
["/spaces/[relay]/recent", "Recent Activity"],
|
|
||||||
["/spaces/[relay]/threads", "Threads"],
|
|
||||||
["/spaces/[relay]/classifieds", "Classifieds"],
|
|
||||||
["/spaces/[relay]/calendar", "Calendar"],
|
|
||||||
["/spaces/[relay]/goals", "Goals"],
|
|
||||||
["/chat", "Messages"],
|
|
||||||
["/join", "Join Space"],
|
|
||||||
["/people", "Find People"],
|
|
||||||
["/settings/about", "About"],
|
|
||||||
["/settings/profile", "Profile Settings"],
|
|
||||||
["/settings/content", "Content Settings"],
|
|
||||||
["/settings/privacy", "Privacy Settings"],
|
|
||||||
["/settings/relays", "Relay Settings"],
|
|
||||||
["/settings/alerts", "Alert Settings"],
|
|
||||||
["/settings/wallet", "Wallet Settings"],
|
|
||||||
["/[bech32]", "Opening Link"],
|
|
||||||
])
|
|
||||||
|
|
||||||
const eventRoutes = new Set([
|
|
||||||
"/spaces/[relay]/threads/[id]",
|
|
||||||
"/spaces/[relay]/goals/[id]",
|
|
||||||
"/spaces/[relay]/calendar/[address]",
|
|
||||||
"/spaces/[relay]/classifieds/[address]",
|
|
||||||
])
|
|
||||||
|
|
||||||
type RouteParams = Record<string, string | undefined>
|
|
||||||
|
|
||||||
type TitlePage = {
|
|
||||||
route: {id: string | null}
|
|
||||||
params: RouteParams
|
|
||||||
}
|
|
||||||
|
|
||||||
type PageTitleContext = {
|
|
||||||
page: TitlePage
|
|
||||||
pubkey: string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRoomTitle = (params: RouteParams) => {
|
|
||||||
const relay = params.relay
|
|
||||||
const h = params.h
|
|
||||||
|
|
||||||
if (!relay || !h) {
|
|
||||||
return "Room"
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = decodeRelay(relay)
|
|
||||||
|
|
||||||
return getRoom(makeRoomId(url, h))?.name || "Room"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getEventForTitle = (routeId: string, params: RouteParams) => {
|
|
||||||
if (!eventRoutes.has(routeId)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventId = params.id || params.address
|
|
||||||
|
|
||||||
if (!eventId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return repository.getEvent(eventId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getChatTitle = (chatId: string | undefined, pubkey: string | undefined) => {
|
|
||||||
if (!chatId) {
|
|
||||||
return "Chat"
|
|
||||||
}
|
|
||||||
|
|
||||||
const chatPeers = pubkey ? uniq(append(pubkey, splitChatId(chatId))) : splitChatId(chatId)
|
|
||||||
const others = pubkey ? chatPeers.filter(pk => pk !== pubkey) : chatPeers
|
|
||||||
|
|
||||||
if (others.length === 1) {
|
|
||||||
return `Chat with ${displayPubkey(others[0])}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (others.length > 1) {
|
|
||||||
return `Group chat (${others.length})`
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Chat"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeTitle = (...parts: Array<string | undefined>) =>
|
|
||||||
parts
|
|
||||||
.map(part => part?.trim() || "")
|
|
||||||
.filter(identity)
|
|
||||||
.join(" · ") ||
|
|
||||||
PLATFORM_NAME ||
|
|
||||||
FALLBACK_APP_NAME
|
|
||||||
|
|
||||||
export const getPageTitle = ({page, pubkey}: PageTitleContext) => {
|
|
||||||
const routeId = page.route.id || ""
|
|
||||||
const staticTitle = staticTitles.get(routeId)
|
|
||||||
|
|
||||||
if (staticTitle) {
|
|
||||||
return makeTitle(staticTitle)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (routeId === "/chat/[chat]") {
|
|
||||||
return makeTitle(getChatTitle(page.params.chat, pubkey))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (routeId === "/spaces/[relay]/[h]") {
|
|
||||||
return makeTitle(getRoomTitle(page.params))
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = getEventForTitle(routeId, page.params)
|
|
||||||
|
|
||||||
if (routeId === "/spaces/[relay]/threads/[id]") {
|
|
||||||
return makeTitle(getTagValue("title", event?.tags || []) || "Thread")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (routeId === "/spaces/[relay]/calendar/[address]") {
|
|
||||||
return makeTitle(getTagValue("title", event?.tags || []) || "Event")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (routeId === "/spaces/[relay]/classifieds/[address]") {
|
|
||||||
return makeTitle(getTagValue("title", event?.tags || []) || "Listing")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (routeId === "/spaces/[relay]/goals/[id]") {
|
|
||||||
return makeTitle(event?.content || getTagValue("summary", event?.tags || []) || "Goal")
|
|
||||||
}
|
|
||||||
|
|
||||||
return makeTitle()
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,13 +8,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {children, element = $bindable(), ...props}: Props = $props()
|
let {children, element = $bindable(), ...props}: Props = $props()
|
||||||
|
|
||||||
const className = cx(
|
|
||||||
props.class,
|
|
||||||
"scroll-container cw cb ct fixed z-feature overflow-y-auto overflow-x-hidden",
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {...props} bind:this={element} data-component="PageContent" class={className}>
|
<div
|
||||||
|
{...props}
|
||||||
|
bind:this={element}
|
||||||
|
data-component="PageContent"
|
||||||
|
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 {props.class}">
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+41
-16
@@ -1,4 +1,4 @@
|
|||||||
import {sleep, randomId} from "@welshman/lib"
|
import {sleep, last, randomId} from "@welshman/lib"
|
||||||
export {preventDefault, stopPropagation} from "svelte/legacy"
|
export {preventDefault, stopPropagation} from "svelte/legacy"
|
||||||
|
|
||||||
export const copyToClipboard = (text: string) => {
|
export const copyToClipboard = (text: string) => {
|
||||||
@@ -47,12 +47,9 @@ export const createScroller = ({
|
|||||||
if (container) {
|
if (container) {
|
||||||
// While we have empty space, fill it
|
// While we have empty space, fill it
|
||||||
const {scrollY, innerHeight} = window
|
const {scrollY, innerHeight} = window
|
||||||
const {scrollHeight, scrollTop, clientHeight} = container
|
const {scrollHeight, scrollTop} = container
|
||||||
const viewHeight = clientHeight || innerHeight
|
|
||||||
const offset = Math.abs(scrollTop || scrollY)
|
const offset = Math.abs(scrollTop || scrollY)
|
||||||
const shouldLoad = reverse
|
const shouldLoad = offset + innerHeight + threshold > scrollHeight
|
||||||
? offset < threshold
|
|
||||||
: offset + viewHeight + threshold > scrollHeight
|
|
||||||
|
|
||||||
// Only trigger loading the first time we reach the threshold
|
// Only trigger loading the first time we reach the threshold
|
||||||
if (shouldLoad) {
|
if (shouldLoad) {
|
||||||
@@ -103,17 +100,53 @@ export const isIntersecting = async (element: Element) =>
|
|||||||
observer.observe(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 (
|
export const compressFile = async (
|
||||||
file: File | Blob,
|
file: File | Blob,
|
||||||
options: Record<string, any> = {},
|
options: Record<string, any> = {},
|
||||||
): Promise<File> => {
|
): Promise<File> => {
|
||||||
const {default: Compressor} = await import("compressorjs-next")
|
const {default: Compressor} = await import("compressorjs")
|
||||||
|
|
||||||
return new Promise<File>((resolve, _reject) => {
|
return new Promise<File>((resolve, _reject) => {
|
||||||
new Compressor(file, {
|
new Compressor(file, {
|
||||||
maxWidth: 2048,
|
maxWidth: 2048,
|
||||||
maxHeight: 2048,
|
maxHeight: 2048,
|
||||||
convertTypes: ["image/png"],
|
convertSize: 10 * 1024 * 1024,
|
||||||
...options,
|
...options,
|
||||||
success: result => resolve(result as File),
|
success: result => resolve(result as File),
|
||||||
error: e => {
|
error: e => {
|
||||||
@@ -131,11 +164,3 @@ export const compressFile = async (
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const escapeHtml = (html: string) => {
|
|
||||||
const element = document.createElement("div")
|
|
||||||
|
|
||||||
element.innerText = html
|
|
||||||
|
|
||||||
return element.innerHTML
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,5 +26,3 @@ export const buildUrl = (base: string | URL, ...pathname: string[]) => {
|
|||||||
|
|
||||||
return url.toString()
|
return url.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addPeriod = (s: string) => (s + ".").replace(/\.+$/, ".")
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import {App, type URLOpenListenerEvent} from "@capacitor/app"
|
import {App, type URLOpenListenerEvent} from "@capacitor/app"
|
||||||
import {dev} from "$app/environment"
|
import {dev} from "$app/environment"
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
import {page} from "$app/stores"
|
|
||||||
import {sync, throttled} from "@welshman/store"
|
import {sync, throttled} from "@welshman/store"
|
||||||
import {call} from "@welshman/lib"
|
import {call} from "@welshman/lib"
|
||||||
import {defaultSocketPolicies} from "@welshman/net"
|
import {defaultSocketPolicies} from "@welshman/net"
|
||||||
@@ -43,7 +42,6 @@
|
|||||||
import * as notifications from "@app/util/notifications"
|
import * as notifications from "@app/util/notifications"
|
||||||
import * as storage from "@app/util/storage"
|
import * as storage from "@app/util/storage"
|
||||||
import {syncKeyboard} from "@app/util/keyboard"
|
import {syncKeyboard} from "@app/util/keyboard"
|
||||||
import {getPageTitle} from "@app/util/title"
|
|
||||||
import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte"
|
import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte"
|
||||||
|
|
||||||
const {children} = $props()
|
const {children} = $props()
|
||||||
@@ -201,10 +199,6 @@
|
|||||||
App.removeAllListeners()
|
App.removeAllListeners()
|
||||||
unsubscribe.then(call)
|
unsubscribe.then(call)
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
document.title = getPageTitle({page: $page, pubkey: $pubkey})
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
+66
-193
@@ -1,204 +1,77 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {derived, writable} from "svelte/store"
|
import {goto} from "$app/navigation"
|
||||||
import {batch, call, sortBy, uniqBy} from "@welshman/lib"
|
import {shouldUnwrap} from "@welshman/app"
|
||||||
import {
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
NOTE,
|
import Compass from "@assets/icons/compass.svg?dataurl"
|
||||||
MESSAGE,
|
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||||
THREAD,
|
|
||||||
CLASSIFIED,
|
|
||||||
ZAP_GOAL,
|
|
||||||
EVENT_TIME,
|
|
||||||
COMMENT,
|
|
||||||
getTagValue,
|
|
||||||
getTagValues,
|
|
||||||
getIdAndAddress,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {
|
|
||||||
makeKindFeed,
|
|
||||||
makeRelayFeed,
|
|
||||||
makeScopeFeed,
|
|
||||||
makeIntersectionFeed,
|
|
||||||
makeUnionFeed,
|
|
||||||
Scope,
|
|
||||||
} from "@welshman/feeds"
|
|
||||||
import {repository, tracker, makeFeedController, loadUserFollowList} from "@welshman/app"
|
|
||||||
import History from "@assets/icons/history.svg?dataurl"
|
|
||||||
import {createScroller} from "@lib/html"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import PageBar from "@lib/components/PageBar.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import NoteItem from "@app/components/NoteItem.svelte"
|
import CardButton from "@lib/components/CardButton.svelte"
|
||||||
import ThreadItem from "@app/components/ThreadItem.svelte"
|
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||||
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
|
import ChatEnable from "@app/components/ChatEnable.svelte"
|
||||||
import GoalItem from "@app/components/GoalItem.svelte"
|
import {pushModal} from "@app/util/modal"
|
||||||
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
import {goToSpace} from "@app/util/routes"
|
||||||
import RecentConversation from "@app/components/RecentConversation.svelte"
|
import {PLATFORM_NAME, PLATFORM_RELAYS} from "@app/core/state"
|
||||||
import {makeRoomId, userSpaceUrls, loadUserGroupList, CONTENT_KINDS} from "@app/core/state"
|
|
||||||
|
|
||||||
type Activity = {
|
const addSpace = () => pushModal(SpaceAdd)
|
||||||
type: "message" | "content"
|
|
||||||
event: TrustedEvent
|
|
||||||
timestamp: number
|
|
||||||
count: number
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController()
|
const openChat = () => ($shouldUnwrap ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
|
||||||
const events = writable<TrustedEvent[]>([])
|
|
||||||
const limit = writable(0)
|
|
||||||
|
|
||||||
const recentActivity = derived([events, limit], ([$events, $limit]) => {
|
onMount(async () => {
|
||||||
const activity: Activity[] = []
|
if (PLATFORM_RELAYS.length > 0) {
|
||||||
const activityByRoom = new Map<string, Activity>()
|
goToSpace(PLATFORM_RELAYS[0])
|
||||||
const latestActivityByKey = new Map<string, number>()
|
|
||||||
|
|
||||||
for (const event of $events.slice(0, $limit)) {
|
|
||||||
if (event.kind === MESSAGE) {
|
|
||||||
const h = getTagValue("h", event.tags)
|
|
||||||
|
|
||||||
if (!h) continue
|
|
||||||
|
|
||||||
for (const url of tracker.getRelays(event.id)) {
|
|
||||||
const id = makeRoomId(url, h)
|
|
||||||
|
|
||||||
const item = activityByRoom.get(id)
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
activityByRoom.set(id, {
|
|
||||||
type: "message",
|
|
||||||
event,
|
|
||||||
timestamp: event.created_at,
|
|
||||||
count: 1,
|
|
||||||
url,
|
|
||||||
})
|
|
||||||
} else if (item.timestamp < event.created_at) {
|
|
||||||
item.count++
|
|
||||||
item.event = event
|
|
||||||
item.timestamp = event.created_at
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.kind === COMMENT) {
|
|
||||||
for (const k of getTagValues(["E", "A"], event.tags)) {
|
|
||||||
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const k of getIdAndAddress(event)) {
|
|
||||||
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of activityByRoom.values()) {
|
|
||||||
activity.push(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [address, timestamp] of latestActivityByKey.entries()) {
|
|
||||||
const event = repository.getEvent(address)
|
|
||||||
|
|
||||||
if (event) {
|
|
||||||
for (const url of tracker.getRelays(event.id)) {
|
|
||||||
activity.push({type: "content", event, timestamp, url, count: 1})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortBy(
|
|
||||||
a => -a.timestamp,
|
|
||||||
uniqBy(a => a.event.id, activity),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
let loading = $state(true)
|
|
||||||
let element: Element | undefined = $state()
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const promise = call(async () => {
|
|
||||||
await Promise.all([loadUserGroupList(), loadUserFollowList()])
|
|
||||||
|
|
||||||
const ctrl = makeFeedController({
|
|
||||||
useWindowing: true,
|
|
||||||
signal: controller.signal,
|
|
||||||
feed: makeUnionFeed(
|
|
||||||
makeIntersectionFeed(
|
|
||||||
makeRelayFeed(...$userSpaceUrls),
|
|
||||||
makeKindFeed(COMMENT, ...CONTENT_KINDS),
|
|
||||||
),
|
|
||||||
makeIntersectionFeed(makeScopeFeed(Scope.Follows), makeKindFeed(NOTE)),
|
|
||||||
),
|
|
||||||
onEvent: batch(100, (evts: TrustedEvent[]) => {
|
|
||||||
events.update($events => [...$events, ...evts])
|
|
||||||
}),
|
|
||||||
onExhausted: () => {
|
|
||||||
loading = false
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const scroller = createScroller({
|
|
||||||
element: element!,
|
|
||||||
delay: 800,
|
|
||||||
threshold: 3000,
|
|
||||||
onScroll: async () => {
|
|
||||||
console.log("scroll")
|
|
||||||
limit.update($limit => {
|
|
||||||
if ($events.length - $limit < 50) {
|
|
||||||
ctrl.load(50)
|
|
||||||
}
|
|
||||||
|
|
||||||
return $limit + 10
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
scroller.stop()
|
|
||||||
controller.abort()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => promise.then(call)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageBar>
|
<div class="hero min-h-screen overflow-auto pb-8">
|
||||||
{#snippet icon()}
|
<div class="hero-content">
|
||||||
<div class="center">
|
<div class="column content gap-4">
|
||||||
<Icon icon={History} />
|
<h1 class="text-center text-5xl">Welcome to</h1>
|
||||||
</div>
|
<h1 class="mb-4 text-center text-5xl font-bold uppercase">{PLATFORM_NAME}</h1>
|
||||||
{/snippet}
|
<div class="col-3">
|
||||||
{#snippet title()}
|
<Button onclick={addSpace}>
|
||||||
<strong>Recent Activity</strong>
|
<CardButton class="btn-neutral">
|
||||||
{/snippet}
|
{#snippet icon()}
|
||||||
{#snippet action()}
|
<Icon icon={AddCircle} size={7} />
|
||||||
<div class="row-2"></div>
|
{/snippet}
|
||||||
{/snippet}
|
{#snippet title()}
|
||||||
</PageBar>
|
<div>Add a space</div>
|
||||||
|
{/snippet}
|
||||||
<PageContent class="flex flex-col gap-2 p-2 pt-4" bind:element>
|
{#snippet info()}
|
||||||
{#each $recentActivity as { type, event, url, count } (event.id)}
|
<div>Use an invite link, or create your own space.</div>
|
||||||
{#if type === "message"}
|
{/snippet}
|
||||||
<RecentConversation {url} {event} {count} />
|
</CardButton>
|
||||||
{:else if event.kind === THREAD}
|
</Button>
|
||||||
<ThreadItem {url} {event} />
|
<Link href="/discover">
|
||||||
{:else if event.kind === CLASSIFIED}
|
<CardButton class="btn-neutral">
|
||||||
<ClassifiedItem {url} {event} />
|
{#snippet icon()}
|
||||||
{:else if event.kind === ZAP_GOAL}
|
<Icon icon={Compass} size={7} />
|
||||||
<GoalItem {url} {event} />
|
{/snippet}
|
||||||
{:else if event.kind === EVENT_TIME}
|
{#snippet title()}
|
||||||
<CalendarEventItem {url} {event} />
|
<div>Browse the network</div>
|
||||||
{:else}
|
{/snippet}
|
||||||
<NoteItem {url} {event} />
|
{#snippet info()}
|
||||||
{/if}
|
<div>Find communities on the nostr network.</div>
|
||||||
{:else}
|
{/snippet}
|
||||||
{#if loading}
|
</CardButton>
|
||||||
<div class="flex justify-center items-center py-20">
|
</Link>
|
||||||
<span class="loading loading-spinner mr-3"></span>
|
<Button onclick={openChat}>
|
||||||
Loading recent activity...
|
<CardButton class="btn-neutral">
|
||||||
|
{#snippet icon()}
|
||||||
|
<Icon icon={ChatRound} size={7} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet title()}
|
||||||
|
<div>Start a conversation</div>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
<div>Use nostr's encrypted group chats to stay in touch.</div>
|
||||||
|
{/snippet}
|
||||||
|
</CardButton>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
</div>
|
||||||
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
{/each}
|
|
||||||
</PageContent>
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {Capacitor} from "@capacitor/core"
|
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||||
import Wallet from "@assets/icons/wallet.svg?dataurl"
|
import Wallet from "@assets/icons/wallet.svg?dataurl"
|
||||||
@@ -46,7 +45,7 @@
|
|||||||
<Icon icon={Bell} /> Alerts
|
<Icon icon={Bell} /> Alerts
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
</div>
|
</div>
|
||||||
<div in:fly|local={{delay: 100}} class:hidden={Capacitor.getPlatform() === "ios"}>
|
<div in:fly|local={{delay: 100}}>
|
||||||
<SecondaryNavItem href="/settings/wallet">
|
<SecondaryNavItem href="/settings/wallet">
|
||||||
<Icon icon={Wallet} /> Wallet
|
<Icon icon={Wallet} /> Wallet
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
|
|||||||
@@ -126,13 +126,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card2 bg-alt flex flex-col shadow-md gap-6">
|
<div
|
||||||
<strong>Lightning Address</strong>
|
class="card2 bg-alt flex flex-col shadow-md"
|
||||||
<div class="flex justify-between items-center gap-2">
|
class:gap-6={profileLightningAddress && walletLud16 && profile?.lud16 !== walletLud16}>
|
||||||
<span class={profileLightningAddress ? "" : "text-warning"}>
|
<div class="flex items-center justify-between">
|
||||||
{profileLightningAddress ? profileLightningAddress : "Not set"}
|
<strong>Lightning Address</strong>
|
||||||
</span>
|
<div class="flex items-center gap-2">
|
||||||
<Button class="btn btn-neutral btn-xs ml-3" onclick={updateReceivingAddress}>Update</Button>
|
<span class={profileLightningAddress ? "" : "text-warning"}>
|
||||||
|
{profileLightningAddress ? profileLightningAddress : "Not set"}
|
||||||
|
</span>
|
||||||
|
<Button class="btn btn-neutral btn-xs ml-3" onclick={updateReceivingAddress}>Update</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if profileLightningAddress && walletLud16 && profile?.lud16 !== walletLud16}
|
{#if profileLightningAddress && walletLud16 && profile?.lud16 !== walletLud16}
|
||||||
<div class="card2 bg-alt flex items-center gap-2 text-xs">
|
<div class="card2 bg-alt flex items-center gap-2 text-xs">
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
<script>
|
|
||||||
import {page} from "$app/stores"
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#key $page.url.searchParams.get("at")}
|
|
||||||
<slot />
|
|
||||||
{/key}
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount, tick} from "svelte"
|
|
||||||
import {readable} from "svelte/store"
|
import {readable} from "svelte/store"
|
||||||
|
import {onMount, onDestroy} from "svelte"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {goto} from "$app/navigation"
|
|
||||||
import type {Readable} from "svelte/store"
|
import type {Readable} from "svelte/store"
|
||||||
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 {MakeNonOptional} from "@welshman/lib"
|
||||||
|
import {now, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
makeEvent,
|
makeEvent,
|
||||||
@@ -15,44 +13,44 @@
|
|||||||
ROOM_ADD_MEMBER,
|
ROOM_ADD_MEMBER,
|
||||||
ROOM_REMOVE_MEMBER,
|
ROOM_REMOVE_MEMBER,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app"
|
||||||
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 {slide, fade, fly} from "@lib/transition"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
|
||||||
import Divider from "@lib/components/Divider.svelte"
|
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 Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import PageBar from "@lib/components/PageBar.svelte"
|
import PageBar from "@lib/components/PageBar.svelte"
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
import RoomCompose from "@app/components/RoomCompose.svelte"
|
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||||
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
|
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
|
||||||
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
import RoomImage from "@app/components/RoomImage.svelte"
|
import RoomImage from "@app/components/RoomImage.svelte"
|
||||||
import RoomDetail from "@app/components/RoomDetail.svelte"
|
import RoomDetail from "@app/components/RoomDetail.svelte"
|
||||||
import RoomItem from "@app/components/RoomItem.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 RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
|
||||||
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
|
|
||||||
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
|
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
|
||||||
import {canEnforceNip70, prependParent, publishDelete} from "@app/core/commands"
|
import RoomCompose from "@app/components/RoomCompose.svelte"
|
||||||
|
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
|
||||||
|
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
|
||||||
import {
|
import {
|
||||||
decodeRelay,
|
decodeRelay,
|
||||||
deriveRoom,
|
|
||||||
deriveUserRoomMembershipStatus,
|
deriveUserRoomMembershipStatus,
|
||||||
MESSAGE_KINDS,
|
deriveRoom,
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
PROTECTED,
|
PROTECTED,
|
||||||
|
MESSAGE_KINDS,
|
||||||
userSettingsValues,
|
userSettingsValues,
|
||||||
} from "@app/core/state"
|
} 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 {makeFeed} from "@app/core/requests"
|
||||||
import {popKey} from "@lib/implicit"
|
import {popKey} from "@lib/implicit"
|
||||||
import {checked} from "@app/util/notifications"
|
|
||||||
import {pushModal} from "@app/util/modal"
|
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
const {h, relay} = $page.params as MakeNonOptional<typeof $page.params>
|
const {h, relay} = $page.params as MakeNonOptional<typeof $page.params>
|
||||||
const mounted = now()
|
const mounted = now()
|
||||||
@@ -61,7 +59,6 @@
|
|||||||
const room = deriveRoom(url, h)
|
const room = deriveRoom(url, h)
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
|
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
|
||||||
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
|
||||||
|
|
||||||
const showRoomDetail = () => pushModal(RoomDetail, {url, h})
|
const showRoomDetail = () => pushModal(RoomDetail, {url, h})
|
||||||
|
|
||||||
@@ -113,62 +110,55 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = async ({content, tags}: EventContent) => {
|
const onSubmit = async ({content, tags}: EventContent) => {
|
||||||
try {
|
tags.push(["h", h])
|
||||||
tags.push(["h", h])
|
|
||||||
|
|
||||||
if (await shouldProtect) {
|
if (await shouldProtect) {
|
||||||
tags.push(PROTECTED)
|
tags.push(PROTECTED)
|
||||||
}
|
|
||||||
|
|
||||||
let template: EventContent & {created_at?: number} = {content, tags}
|
|
||||||
|
|
||||||
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: makeEvent(MESSAGE, template),
|
|
||||||
delay: $userSettingsValues.send_delay,
|
|
||||||
})
|
|
||||||
|
|
||||||
if ($userSettingsValues.send_delay) {
|
|
||||||
pushToast({
|
|
||||||
timeout: 30_000,
|
|
||||||
children: {
|
|
||||||
component: ThunkToast,
|
|
||||||
props: {thunk},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
clearParent()
|
|
||||||
clearShare()
|
|
||||||
clearEventToEdit()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: $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: makeEvent(MESSAGE, template),
|
||||||
|
delay: $userSettingsValues.send_delay,
|
||||||
|
})
|
||||||
|
|
||||||
|
if ($userSettingsValues.send_delay) {
|
||||||
|
pushToast({
|
||||||
|
timeout: 30_000,
|
||||||
|
children: {
|
||||||
|
component: ThunkToast,
|
||||||
|
props: {thunk},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
clearParent()
|
||||||
|
clearShare()
|
||||||
|
clearEventToEdit()
|
||||||
}
|
}
|
||||||
|
|
||||||
const manageScrollPosition = () => {
|
const onScroll = () => {
|
||||||
showScrollButton = !isNaN(at) || Math.abs(element?.scrollTop || 0) > 1500
|
showScrollButton = Math.abs(element?.scrollTop || 0) > 1500
|
||||||
|
|
||||||
const newMessages = document.getElementById("new-messages")
|
const newMessages = document.getElementById("new-messages")
|
||||||
|
|
||||||
@@ -183,47 +173,16 @@
|
|||||||
showFixedNewMessages = y < 0
|
showFixedNewMessages = y < 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userHasScrolled && !isNaN(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 = () =>
|
const scrollToNewMessages = () =>
|
||||||
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
|
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
|
||||||
if (!isNaN(at)) {
|
|
||||||
goto($page.url.pathname, {replaceState: true})
|
|
||||||
} else {
|
|
||||||
element?.scrollTo({top: 0, behavior: "smooth"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let joining = $state(false)
|
let joining = $state(false)
|
||||||
let leaving = $state(false)
|
let leaving = $state(false)
|
||||||
let userHasScrolled = $state(false)
|
let loadingEvents = $state(true)
|
||||||
let isProgrammaticScroll = $state(false)
|
|
||||||
let loadingBackward = $state(true)
|
|
||||||
let loadingForward = $state(true)
|
|
||||||
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
||||||
let parent: TrustedEvent | undefined = $state()
|
let parent: TrustedEvent | undefined = $state()
|
||||||
let element: HTMLElement | undefined = $state()
|
let element: HTMLElement | undefined = $state()
|
||||||
@@ -254,7 +213,7 @@
|
|||||||
const adjustedLastChecked =
|
const adjustedLastChecked =
|
||||||
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
|
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
|
||||||
|
|
||||||
for (const event of $events) {
|
for (const event of $events.toReversed()) {
|
||||||
if (seen.has(event.id)) {
|
if (seen.has(event.id)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -296,7 +255,7 @@
|
|||||||
|
|
||||||
elements.reverse()
|
elements.reverse()
|
||||||
|
|
||||||
tick().then(manageScrollPosition)
|
setTimeout(onScroll, 100)
|
||||||
|
|
||||||
return elements
|
return elements
|
||||||
})
|
})
|
||||||
@@ -306,14 +265,10 @@
|
|||||||
|
|
||||||
const feed = makeFeed({
|
const feed = makeFeed({
|
||||||
url,
|
url,
|
||||||
at: at || now(),
|
|
||||||
element: element!,
|
element: element!,
|
||||||
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}],
|
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}],
|
||||||
onBackwardExhausted: () => {
|
onExhausted: () => {
|
||||||
loadingBackward = false
|
loadingEvents = false
|
||||||
},
|
|
||||||
onForwardExhausted: () => {
|
|
||||||
loadingForward = false
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -353,15 +308,17 @@
|
|||||||
|
|
||||||
observer.observe(chatCompose!)
|
observer.observe(chatCompose!)
|
||||||
observer.observe(dynamicPadding!)
|
observer.observe(dynamicPadding!)
|
||||||
|
|
||||||
start()
|
start()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cleanup()
|
|
||||||
observer.unobserve(chatCompose!)
|
observer.unobserve(chatCompose!)
|
||||||
observer.unobserve(dynamicPadding!)
|
observer.unobserve(dynamicPadding!)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
cleanup?.()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageBar>
|
<PageBar>
|
||||||
@@ -372,9 +329,11 @@
|
|||||||
<RoomName {url} {h} />
|
<RoomName {url} {h} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet action()}
|
{#snippet action()}
|
||||||
<div class="row-2 items-center">
|
<div class="row-2">
|
||||||
<SpaceSearch {url} {h} />
|
<Button
|
||||||
<Button class="btn btn-neutral btn-sm btn-square" onclick={showRoomDetail}>
|
class="btn btn-neutral btn-sm tooltip tooltip-left"
|
||||||
|
data-tip="Room information"
|
||||||
|
onclick={showRoomDetail}>
|
||||||
<Icon size={4} icon={InfoCircle} />
|
<Icon size={4} icon={InfoCircle} />
|
||||||
</Button>
|
</Button>
|
||||||
<SpaceMenuButton {url} />
|
<SpaceMenuButton {url} />
|
||||||
@@ -408,11 +367,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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)}
|
{#each elements as { type, id, value, showPubkey } (id)}
|
||||||
{#if type === "new-messages"}
|
{#if type === "new-messages"}
|
||||||
<div
|
<div
|
||||||
@@ -445,8 +399,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<p class="flex h-10 items-center justify-center py-20">
|
<p class="flex h-10 items-center justify-center py-20">
|
||||||
{#if loadingBackward}
|
{#if loadingEvents}
|
||||||
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
|
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
|
||||||
{:else}
|
{:else}
|
||||||
<Spinner>End of message history</Spinner>
|
<Spinner>End of message history</Spinner>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount, tick} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {goto} from "$app/navigation"
|
|
||||||
import type {Readable} from "svelte/store"
|
import type {Readable} from "svelte/store"
|
||||||
import {readable} from "svelte/store"
|
import {readable} from "svelte/store"
|
||||||
import {now, int, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib"
|
import {now, int, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib"
|
||||||
@@ -18,7 +17,6 @@
|
|||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import Divider from "@lib/components/Divider.svelte"
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||||
import SpaceSearch from "@app/components/SpaceSearch.svelte"
|
|
||||||
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
|
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
|
||||||
import RoomItem from "@app/components/RoomItem.svelte"
|
import RoomItem from "@app/components/RoomItem.svelte"
|
||||||
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
|
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
|
||||||
@@ -28,7 +26,7 @@
|
|||||||
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
|
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
|
||||||
import {userSettingsValues, decodeRelay, PROTECTED, MESSAGE_KINDS} from "@app/core/state"
|
import {userSettingsValues, decodeRelay, PROTECTED, MESSAGE_KINDS} from "@app/core/state"
|
||||||
import {prependParent, canEnforceNip70, publishDelete} from "@app/core/commands"
|
import {prependParent, canEnforceNip70, publishDelete} from "@app/core/commands"
|
||||||
import {checked} from "@app/util/notifications"
|
import {setChecked, checked} from "@app/util/notifications"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {makeFeed} from "@app/core/requests"
|
import {makeFeed} from "@app/core/requests"
|
||||||
import {popKey} from "@lib/implicit"
|
import {popKey} from "@lib/implicit"
|
||||||
@@ -37,7 +35,6 @@
|
|||||||
const lastChecked = $checked[$page.url.pathname]
|
const lastChecked = $checked[$page.url.pathname]
|
||||||
const url = decodeRelay($page.params.relay!)
|
const url = decodeRelay($page.params.relay!)
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
|
||||||
|
|
||||||
const replyTo = (event: TrustedEvent) => {
|
const replyTo = (event: TrustedEvent) => {
|
||||||
parent = event
|
parent = event
|
||||||
@@ -57,56 +54,49 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = async ({content, tags}: EventContent) => {
|
const onSubmit = async ({content, tags}: EventContent) => {
|
||||||
try {
|
let template: EventContent & {created_at?: number} = {content, tags}
|
||||||
let template: EventContent & {created_at?: number} = {content, tags}
|
|
||||||
|
|
||||||
if (eventToEdit) {
|
if (eventToEdit) {
|
||||||
// Don't do anything if message hasn't changed
|
// Delete previous message, to be republished with same timestamp
|
||||||
if (eventToEdit.content === content) {
|
template.created_at = eventToEdit.created_at
|
||||||
return
|
publishDelete({relays: [url], event: eventToEdit, protect: await shouldProtect})
|
||||||
}
|
|
||||||
|
|
||||||
// Delete previous message, to be republished with same timestamp
|
|
||||||
template.created_at = eventToEdit.created_at
|
|
||||||
publishDelete({relays: [url], event: eventToEdit, protect: await shouldProtect})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await shouldProtect) {
|
|
||||||
tags.push(PROTECTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
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},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
clearParent()
|
|
||||||
clearShare()
|
|
||||||
clearEventToEdit()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await shouldProtect) {
|
||||||
|
tags.push(PROTECTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 manageScrollPosition = () => {
|
const onScroll = () => {
|
||||||
showScrollButton = !isNaN(at) || Math.abs(element?.scrollTop || 0) > 1500
|
showScrollButton = Math.abs(element?.scrollTop || 0) > 1500
|
||||||
|
|
||||||
const newMessages = document.getElementById("new-messages")
|
const newMessages = document.getElementById("new-messages")
|
||||||
|
|
||||||
@@ -121,45 +111,14 @@
|
|||||||
showFixedNewMessages = y < 0
|
showFixedNewMessages = y < 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userHasScrolled && !isNaN(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 = () =>
|
const scrollToNewMessages = () =>
|
||||||
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
|
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
|
||||||
if (!isNaN(at)) {
|
|
||||||
goto($page.url.pathname, {replaceState: true})
|
|
||||||
} else {
|
|
||||||
element?.scrollTo({top: 0, behavior: "smooth"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let loadingBackward = $state(true)
|
let loadingEvents = $state(true)
|
||||||
let loadingForward = $state(true)
|
|
||||||
let userHasScrolled = $state(false)
|
|
||||||
let isProgrammaticScroll = $state(false)
|
|
||||||
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
||||||
let parent: TrustedEvent | undefined = $state()
|
let parent: TrustedEvent | undefined = $state()
|
||||||
let element: HTMLElement | undefined = $state()
|
let element: HTMLElement | undefined = $state()
|
||||||
@@ -190,7 +149,7 @@
|
|||||||
const adjustedLastChecked =
|
const adjustedLastChecked =
|
||||||
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
|
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
|
||||||
|
|
||||||
for (const event of $events) {
|
for (const event of $events.toReversed()) {
|
||||||
if (seen.has(event.id)) {
|
if (seen.has(event.id)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -232,31 +191,11 @@
|
|||||||
|
|
||||||
elements.reverse()
|
elements.reverse()
|
||||||
|
|
||||||
tick().then(manageScrollPosition)
|
setTimeout(onScroll, 100)
|
||||||
|
|
||||||
return elements
|
return elements
|
||||||
})
|
})
|
||||||
|
|
||||||
const start = () => {
|
|
||||||
cleanup?.()
|
|
||||||
|
|
||||||
const feed = makeFeed({
|
|
||||||
url,
|
|
||||||
at: at || now(),
|
|
||||||
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 = () => {
|
const onEscape = () => {
|
||||||
clearParent()
|
clearParent()
|
||||||
clearShare()
|
clearShare()
|
||||||
@@ -291,13 +230,29 @@
|
|||||||
|
|
||||||
observer.observe(chatCompose!)
|
observer.observe(chatCompose!)
|
||||||
observer.observe(dynamicPadding!)
|
observer.observe(dynamicPadding!)
|
||||||
start()
|
|
||||||
|
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
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cleanup()
|
cleanup()
|
||||||
controller.abort()
|
controller.abort()
|
||||||
observer.unobserve(chatCompose!)
|
observer.unobserve(chatCompose!)
|
||||||
observer.unobserve(dynamicPadding!)
|
observer.unobserve(dynamicPadding!)
|
||||||
|
|
||||||
|
// Sveltekit calls onDestroy at the beginning of the page load for some reason
|
||||||
|
setTimeout(() => {
|
||||||
|
setChecked($page.url.pathname)
|
||||||
|
}, 800)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -312,20 +267,12 @@
|
|||||||
<strong>Chat</strong>
|
<strong>Chat</strong>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet action()}
|
{#snippet action()}
|
||||||
<div class="row-2 items-center">
|
<SpaceMenuButton {url} />
|
||||||
<SpaceSearch {url} />
|
|
||||||
<SpaceMenuButton {url} />
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PageBar>
|
</PageBar>
|
||||||
|
|
||||||
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
|
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
|
||||||
<div bind:this={dynamicPadding}></div>
|
<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)}
|
{#each elements as { type, id, value, showPubkey } (id)}
|
||||||
{#if type === "new-messages"}
|
{#if type === "new-messages"}
|
||||||
<div
|
<div
|
||||||
@@ -358,8 +305,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<p class="flex h-10 items-center justify-center py-20">
|
<p class="flex h-10 items-center justify-center py-20">
|
||||||
{#if loadingBackward}
|
{#if loadingEvents}
|
||||||
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
|
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
|
||||||
{:else}
|
{:else}
|
||||||
<Spinner>End of message history</Spinner>
|
<Spinner>End of message history</Spinner>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
url,
|
url,
|
||||||
element: element!,
|
element: element!,
|
||||||
filters: [{kinds: [CLASSIFIED]}, makeCommentFilter([CLASSIFIED])],
|
filters: [{kinds: [CLASSIFIED]}, makeCommentFilter([CLASSIFIED])],
|
||||||
onBackwardExhausted: () => {
|
onExhausted: () => {
|
||||||
loading = false
|
loading = false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
url,
|
url,
|
||||||
element: element!,
|
element: element!,
|
||||||
filters: [{kinds: [ZAP_GOAL]}, makeCommentFilter([ZAP_GOAL])],
|
filters: [{kinds: [ZAP_GOAL]}, makeCommentFilter([ZAP_GOAL])],
|
||||||
onBackwardExhausted: () => {
|
onExhausted: () => {
|
||||||
loading = false
|
loading = false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
url,
|
url,
|
||||||
element: element!,
|
element: element!,
|
||||||
filters: [{kinds: [THREAD]}, makeCommentFilter([THREAD])],
|
filters: [{kinds: [THREAD]}, makeCommentFilter([THREAD])],
|
||||||
onBackwardExhausted: () => {
|
onExhausted: () => {
|
||||||
loading = false
|
loading = false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user