Compare commits

...

13 Commits

Author SHA1 Message Date
Jon Staab e484c3cb00 Bump versions 2025-02-14 10:56:00 -08:00
Jon Staab 69d0e11ba4 Add nip 01 login flow to mobile 2025-02-14 10:53:36 -08:00
Jon Staab 27d9d4fff1 Update changelog 2025-02-13 16:32:34 -08:00
Jon Staab c089812363 Show spinner when joining room 2025-02-13 15:34:14 -08:00
Jon Staab 07dd1e97dc Fix long-running subscriptions clogging things up 2025-02-13 14:50:03 -08:00
Jon Staab 7f6a1bff34 Re-work threads page, fix some iphone bugs 2025-02-13 10:52:00 -08:00
Jon Staab 7d1310722a Factor out calendar event component, render calendar event notes better 2025-02-11 16:00:14 -08:00
Jon Staab cb57710654 Clean up quotes/depth 2025-02-11 15:37:55 -08:00
Jon Staab c74c116667 Fix page bar margin #112 2025-02-11 15:16:24 -08:00
Jon Staab 0ba55f2387 Attempt to fix new messages button #114 2025-02-11 11:49:17 -08:00
Jon Staab 622214713b replace state when navigating from space menu 2025-02-11 11:42:49 -08:00
Jon Staab d8cf48381b Build before other stuff 2025-02-06 12:52:42 -08:00
Jon Staab 7dc7b5abeb Bump android version 2025-02-06 12:43:18 -08:00
38 changed files with 674 additions and 314 deletions
+13
View File
@@ -1,5 +1,18 @@
# Changelog # Changelog
# 0.2.9
* Add NIP 01 signup flow on mobile
# 0.2.8
* Show spinner when joining a room
* Reduce self-rate limiting of REQs
* Fix disabled signers link
* Prepare for iOS release
* Improve threads and calendar pages
* Improve quote rendering and new messages button
# 0.2.7 # 0.2.7
* Add calendar events * Add calendar events
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 7 versionCode 10
versionName "0.2.7" versionName "0.2.9"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1 -1
View File
@@ -14,7 +14,7 @@ const config: CapacitorConfig = {
}, },
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload // Use this for live reload https://capacitorjs.com/docs/guides/live-reload
// server: { // server: {
// url: "http://192.168.1.251:1847", // url: "http://192.168.1.250:1847",
// cleartext: true // cleartext: true
// }, // },
}; };
+8 -4
View File
@@ -351,12 +351,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.2.9;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -374,12 +376,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.2.9;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
+5 -1
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Flotilla</string> <string>Flotilla</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -30,6 +30,8 @@
<array> <array>
<string>armv7</string> <string>armv7</string>
</array> </array>
<key>UIStatusBarStyle</key>
<string></string>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
@@ -45,5 +47,7 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict> </dict>
</plist> </plist>
+44 -30
View File
@@ -1,12 +1,12 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "0.2.7", "version": "0.2.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "flotilla", "name": "flotilla",
"version": "0.2.7", "version": "0.2.9",
"dependencies": { "dependencies": {
"@capacitor/android": "^7.0.0", "@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0", "@capacitor/app": "^7.0.0",
@@ -22,15 +22,15 @@
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6", "@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "~0.0.42", "@welshman/app": "~0.0.42",
"@welshman/content": "~0.0.17", "@welshman/content": "~0.0.18",
"@welshman/dvm": "~0.0.14", "@welshman/dvm": "~0.0.14",
"@welshman/editor": "~0.0.13", "@welshman/editor": "~0.0.15",
"@welshman/feeds": "~0.0.30", "@welshman/feeds": "~0.0.30",
"@welshman/lib": "~0.0.40", "@welshman/lib": "~0.0.41",
"@welshman/net": "~0.0.46", "@welshman/net": "~0.0.47",
"@welshman/signer": "~0.0.20", "@welshman/signer": "~0.0.20",
"@welshman/store": "~0.0.16", "@welshman/store": "~0.0.16",
"@welshman/util": "~0.0.60", "@welshman/util": "~0.0.61",
"daisyui": "^4.12.10", "daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0", "date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -4766,13 +4766,13 @@
} }
}, },
"node_modules/@welshman/content": { "node_modules/@welshman/content": {
"version": "0.0.17", "version": "0.0.18",
"resolved": "https://registry.npmjs.org/@welshman/content/-/content-0.0.17.tgz", "resolved": "https://registry.npmjs.org/@welshman/content/-/content-0.0.18.tgz",
"integrity": "sha512-xiBSL8BSzHrwRmGqKXkR/S6EK7a1wT1rG1qdlQN30lBX5ZS+NSkoI0aNuF8p313mElHNZWgrqxFaat+FML4yOw==", "integrity": "sha512-7LHs9xKStrkaet9VY1PWSEUWrdIaIThIo+ByN6lF3nRZwPTExrBy4rPXnEa5roVAAwgmlhXw3zTkfGP15V6joQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "^7.0.2", "@braintree/sanitize-url": "^7.0.2",
"@welshman/lib": "~0.0.37", "@welshman/lib": "~0.0.40",
"nostr-tools": "^2.7.2" "nostr-tools": "^2.7.2"
}, },
"engines": { "engines": {
@@ -4793,9 +4793,9 @@
} }
}, },
"node_modules/@welshman/editor": { "node_modules/@welshman/editor": {
"version": "0.0.13", "version": "0.0.15",
"resolved": "https://registry.npmjs.org/@welshman/editor/-/editor-0.0.13.tgz", "resolved": "https://registry.npmjs.org/@welshman/editor/-/editor-0.0.15.tgz",
"integrity": "sha512-860kn8iOXHKGBOnL3zalFQVw8eeILNU6YQ4V+xFtgqIxxCMk1c/9F5k0k0OyloUqRNjtSG6hvLdQLacBvhz2WQ==", "integrity": "sha512-Eg3alzv+cjCXtr6oEItRqoRSD4DTllt3c2JyJTxpF/KNiy8XHHMeUSpVFgph3+pAt5jwyl6b1feKPEwpShgqHw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tiptap/core": "^2.11.5", "@tiptap/core": "^2.11.5",
@@ -4813,11 +4813,25 @@
"@tiptap/suggestion": "^2.11.5", "@tiptap/suggestion": "^2.11.5",
"@welshman/lib": "~0.0.40", "@welshman/lib": "~0.0.40",
"@welshman/util": "^0.0.60", "@welshman/util": "^0.0.60",
"nostr-editor": "^0.0.4-pre.12", "nostr-editor": "^0.0.4-pre.13",
"nostr-tools": "^2.10.4", "nostr-tools": "^2.10.4",
"tippy.js": "^6.3.7" "tippy.js": "^6.3.7"
} }
}, },
"node_modules/@welshman/editor/node_modules/@welshman/util": {
"version": "0.0.60",
"resolved": "https://registry.npmjs.org/@welshman/util/-/util-0.0.60.tgz",
"integrity": "sha512-kqZgYnrwxKx0JTDZnTSaQYc2ev7E9ZjNDy5MclX36d5T/qPUspmwksAOodFJY9kJoJd49bf1omAmBTgnFJfeHw==",
"license": "MIT",
"dependencies": {
"@types/ws": "^8.5.13",
"@welshman/lib": "~0.0.37",
"nostr-tools": "^2.7.2"
},
"engines": {
"node": ">=10.4.0"
}
},
"node_modules/@welshman/feeds": { "node_modules/@welshman/feeds": {
"version": "0.0.30", "version": "0.0.30",
"resolved": "https://registry.npmjs.org/@welshman/feeds/-/feeds-0.0.30.tgz", "resolved": "https://registry.npmjs.org/@welshman/feeds/-/feeds-0.0.30.tgz",
@@ -4829,9 +4843,9 @@
} }
}, },
"node_modules/@welshman/lib": { "node_modules/@welshman/lib": {
"version": "0.0.40", "version": "0.0.41",
"resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.0.40.tgz", "resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.0.41.tgz",
"integrity": "sha512-6Qk5fJABv+7HPqhNC5eLM4VZxCLpcu22nShmrNMbamkMwr4eLj2Bl4dRmuzFsvMcsL/Jc148zqpfuq37CY2NCw==", "integrity": "sha512-FMJVoPZw8Vi1fd2/ulwqlBS1tvjkFAm9lg+Dz5SXItXxrNC06YMRTjGjInCBEkArrvNGPUjchzSFDNmbH0fxHQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@scure/base": "^1.1.6", "@scure/base": "^1.1.6",
@@ -4840,13 +4854,13 @@
} }
}, },
"node_modules/@welshman/net": { "node_modules/@welshman/net": {
"version": "0.0.46", "version": "0.0.47",
"resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.46.tgz", "resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.47.tgz",
"integrity": "sha512-ehH4grz0VHjuofyVUE3r5GoynHTh+cIT/XFH6ov6nOGRU/LZXCLGk/9CUPlqNYHRfc/zBtaIyfVu0AelLqV6lw==", "integrity": "sha512-/mIr+QyLH+RlD16rsPDTIW250lOm5eNaLO6dhZw8dMKznMhVtSWe/X/lJZOXmexzbB2z7WYZVN5x5TggZROyxA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@welshman/lib": "~0.0.37", "@welshman/lib": "~0.0.40",
"@welshman/util": "~0.0.58", "@welshman/util": "~0.0.59",
"isomorphic-ws": "^5.0.0", "isomorphic-ws": "^5.0.0",
"ws": "^8.16.0" "ws": "^8.16.0"
} }
@@ -4917,13 +4931,13 @@
} }
}, },
"node_modules/@welshman/util": { "node_modules/@welshman/util": {
"version": "0.0.60", "version": "0.0.61",
"resolved": "https://registry.npmjs.org/@welshman/util/-/util-0.0.60.tgz", "resolved": "https://registry.npmjs.org/@welshman/util/-/util-0.0.61.tgz",
"integrity": "sha512-kqZgYnrwxKx0JTDZnTSaQYc2ev7E9ZjNDy5MclX36d5T/qPUspmwksAOodFJY9kJoJd49bf1omAmBTgnFJfeHw==", "integrity": "sha512-+l4YX01msAtnyylzpIFIAYubvnBLyr9hGx3iRO5LS3OPv/yUDOeyYJseWDqorkIiN5BRT7PCgnWJdlQP71ZtAw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/ws": "^8.5.13", "@types/ws": "^8.5.13",
"@welshman/lib": "~0.0.37", "@welshman/lib": "~0.0.40",
"nostr-tools": "^2.7.2" "nostr-tools": "^2.7.2"
}, },
"engines": { "engines": {
@@ -10152,9 +10166,9 @@
} }
}, },
"node_modules/nostr-editor": { "node_modules/nostr-editor": {
"version": "0.0.4-pre.12", "version": "0.0.4-pre.13",
"resolved": "https://registry.npmjs.org/nostr-editor/-/nostr-editor-0.0.4-pre.12.tgz", "resolved": "https://registry.npmjs.org/nostr-editor/-/nostr-editor-0.0.4-pre.13.tgz",
"integrity": "sha512-vztmbEKxt2jnO1JEoprwVf3s4TN4D3B0fcsrhckOITR1KaDX88QhIG+qTee92xp+n96vYj4GQt0W06rSv3NXHA==", "integrity": "sha512-izIidrrIjQp41MAY2dNoticQSc0E5XOFKEe04tmZdTdF9Ry8CKxIdv6yvO3qh4gdhrOq+QPLTRii6X3X5iC/5Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"light-bolt11-decoder": "^3.1.1" "light-bolt11-decoder": "^3.1.1"
+8 -8
View File
@@ -1,12 +1,12 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "0.2.7", "version": "0.2.9",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "./build.sh", "build": "./build.sh",
"sourcemaps": "./sourcemaps.sh", "sourcemaps": "./build.sh && ./sourcemaps.sh",
"release:android": "cap build android --androidreleasetype APK --signing-type apksigner", "release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src && eslint src", "lint": "prettier --check src && eslint src",
@@ -51,15 +51,15 @@
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6", "@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "~0.0.42", "@welshman/app": "~0.0.42",
"@welshman/content": "~0.0.17", "@welshman/content": "~0.0.18",
"@welshman/dvm": "~0.0.14", "@welshman/dvm": "~0.0.14",
"@welshman/editor": "~0.0.13", "@welshman/editor": "~0.0.15",
"@welshman/feeds": "~0.0.30", "@welshman/feeds": "~0.0.30",
"@welshman/lib": "~0.0.40", "@welshman/lib": "~0.0.41",
"@welshman/net": "~0.0.46", "@welshman/net": "~0.0.47",
"@welshman/signer": "~0.0.20", "@welshman/signer": "~0.0.20",
"@welshman/store": "~0.0.16", "@welshman/store": "~0.0.16",
"@welshman/util": "~0.0.60", "@welshman/util": "~0.0.61",
"daisyui": "^4.12.10", "daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0", "date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -0,0 +1,19 @@
<script lang="ts">
import {fromPairs} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {LOCALE, secondsToDate} from "@welshman/app"
type Props = {
event: TrustedEvent
}
const {event}: Props = $props()
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
const startDate = $derived(secondsToDate(parseInt(meta.start)))
</script>
<div
class="flex h-14 w-14 flex-col items-center justify-center gap-1 rounded-box border border-solid border-base-content p-2 sm:h-24 sm:w-24">
<span class="sm:text-lg">{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</span>
<span class="sm:text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import {fromPairs} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/app"
type Props = {
event: TrustedEvent
}
const {event}: Props = $props()
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
const start = $derived(parseInt(meta.start))
const end = $derived(parseInt(meta.end))
const startDateDisplay = $derived(formatTimestampAsDate(start))
const endDateDisplay = $derived(formatTimestampAsDate(end))
const isSingleDay = $derived(startDateDisplay === endDateDisplay)
</script>
<p class="text-xl">{meta.title || meta.name}</p>
<div class="flex items-center gap-2 text-sm">
<Icon icon="clock-circle" size={4} />
{formatTimestampAsTime(start)}{isSingleDay ? formatTimestampAsTime(end) : formatTimestamp(end)}
</div>
+5 -21
View File
@@ -1,38 +1,22 @@
<script lang="ts"> <script lang="ts">
import {fromPairs} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import CalendarEventActions from "@app/components/CalendarEventActions.svelte" import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import {makeCalendarPath} from "@app/routes" import {makeCalendarPath} from "@app/routes"
const { type Props = {
url,
event,
}: {
url: string url: string
event: TrustedEvent event: TrustedEvent
} = $props() }
const meta = $derived(fromPairs(event.tags) as Record<string, string>) const {url, event}: Props = $props()
const end = $derived(parseInt(meta.end))
const start = $derived(parseInt(meta.start))
const startDateDisplay = $derived(formatTimestampAsDate(start))
const endDateDisplay = $derived(formatTimestampAsDate(end))
const isSingleDay = $derived(startDateDisplay === endDateDisplay)
</script> </script>
<Link class="col-3 card2 bg-alt w-full cursor-pointer" href={makeCalendarPath(url, event.id)}> <Link class="col-3 card2 bg-alt w-full cursor-pointer" href={makeCalendarPath(url, event.id)}>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<p class="text-xl">{meta.title || meta.name}</p> <CalendarEventHeader {event} />
<div class="flex items-center gap-2 text-sm">
<Icon icon="clock-circle" size={4} />
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div>
</div> </div>
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"> <div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75"> <span class="whitespace-nowrap py-1 text-sm opacity-75">
@@ -0,0 +1,24 @@
<script lang="ts">
import {fromPairs} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
type Props = {
event: TrustedEvent
}
const {event}: Props = $props()
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
</script>
<span>
Posted by <ProfileLink pubkey={event.pubkey} />
</span>
{#if meta.location}
<span></span>
<span class="flex items-center gap-1">
<Icon icon="map-point" size={4} />
{meta.location}
</span>
{/if}
@@ -4,7 +4,7 @@
import {slide} from "@lib/transition" import {slide} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte" import NoteContent from "@app/components/NoteContent.svelte"
const { const {
verb, verb,
@@ -22,7 +22,12 @@
transition:slide> transition:slide>
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p> <p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
{#key event.id} {#key event.id}
<Content {event} hideMedia minLength={100} maxLength={300} expandMode="disabled" /> <NoteContent
{event}
hideMediaAtDepth={0}
minLength={100}
maxLength={300}
expandMode="disabled" />
{/key} {/key}
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}> <Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Icon icon="close-circle" /> <Icon icon="close-circle" />
+1 -1
View File
@@ -89,7 +89,7 @@
</div> </div>
{/if} {/if}
<div class="text-sm"> <div class="text-sm">
<Content {event} quoteProps={{minimal: true, relays: [url]}} /> <Content {event} relays={[url]} />
{#if thunk} {#if thunk}
<ThunkStatus {thunk} class="mt-2" /> <ThunkStatus {thunk} class="mt-2" />
{/if} {/if}
+8 -14
View File
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {fromNostrURI} from "@welshman/util" import {fromNostrURI} from "@welshman/util"
import {nthEq} from "@welshman/lib" import {nthEq} from "@welshman/lib"
import { import {
@@ -22,7 +21,6 @@
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import ContentToken from "@app/components/ContentToken.svelte" import ContentToken from "@app/components/ContentToken.svelte"
import ContentCode from "@app/components/ContentCode.svelte" import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte" import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
@@ -38,9 +36,9 @@
minLength?: number minLength?: number
maxLength?: number maxLength?: number
showEntire?: boolean showEntire?: boolean
hideMedia?: boolean hideMediaAtDepth?: number
expandMode?: string expandMode?: string
quoteProps?: Record<string, any> relays?: string[]
depth?: number depth?: number
} }
@@ -49,9 +47,9 @@
minLength = 500, minLength = 500,
maxLength = 700, maxLength = 700,
showEntire = $bindable(false), showEntire = $bindable(false),
hideMedia = false, hideMediaAtDepth = 1,
expandMode = "block", expandMode = "block",
quoteProps = {}, relays = [],
depth = 0, depth = 0,
}: Props = $props() }: Props = $props()
@@ -64,13 +62,13 @@
const isBlock = (i: number) => { const isBlock = (i: number) => {
const parsed = fullContent[i] const parsed = fullContent[i]
if (!parsed || hideMedia) return false if (!parsed || hideMediaAtDepth <= depth) return false
if (isLink(parsed) && $userSettingValues.show_media && isStartOrEnd(i)) { if (isLink(parsed) && $userSettingValues.show_media && isStartOrEnd(i)) {
return true return true
} }
if ((isEvent(parsed) || isAddress(parsed)) && isStartOrEnd(i) && depth < 1) { if ((isEvent(parsed) || isAddress(parsed)) && isStartOrEnd(i)) {
return true return true
} }
@@ -108,7 +106,7 @@
: truncate(fullContent, { : truncate(fullContent, {
minLength, minLength,
maxLength, maxLength,
mediaLength: hideMedia ? 20 : 200, mediaLength: hideMediaAtDepth <= depth ? 20 : 200,
}), }),
) )
@@ -151,11 +149,7 @@
<ContentMention value={parsed.value} /> <ContentMention value={parsed.value} />
{:else if isEvent(parsed) || isAddress(parsed)} {:else if isEvent(parsed) || isAddress(parsed)}
{#if isBlock(i)} {#if isBlock(i)}
<ContentQuote {...quoteProps} value={parsed.value} {event}> <ContentQuote {depth} {relays} {hideMediaAtDepth} value={parsed.value} {event} />
{#snippet noteContent({event, minimal}: {event: TrustedEvent; minimal: boolean})}
<Content {quoteProps} hideMedia={minimal || hideMedia} {event} depth={depth + 1} />
{/snippet}
</ContentQuote>
{:else} {:else}
<Link <Link
external external
+8 -1
View File
@@ -8,6 +8,8 @@
const {value} = $props() const {value} = $props()
let hideImage = $state(false)
const url = value.url.toString() const url = value.url.toString()
const loadPreview = async () => { const loadPreview = async () => {
@@ -20,6 +22,10 @@
return json return json
} }
const onError = () => {
hideImage = true
}
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true}) const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
</script> </script>
@@ -40,9 +46,10 @@
</div> </div>
{:then preview} {:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal"> <div class="bg-alt flex max-w-xl flex-col leading-normal">
{#if preview.image} {#if preview.image && !hideImage}
<img <img
alt="Link preview" alt="Link preview"
onerror={onError}
src={imgproxy(preview.image)} src={imgproxy(preview.image)}
class="bg-alt max-h-72 object-contain object-center" /> class="bg-alt max-h-72 object-contain object-center" />
{/if} {/if}
+4 -3
View File
@@ -7,10 +7,11 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import {deriveEvent, entityLink, ROOM} from "@app/state" import {deriveEvent, entityLink, ROOM} from "@app/state"
import {makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes" import {makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes"
const {value, event, noteContent, relays = [], minimal = false} = $props() const {value, event, depth, hideMediaAtDepth, relays = []} = $props()
const {id, identifier, kind, pubkey, relays: relayHints = []} = value const {id, identifier, kind, pubkey, relays: relayHints = []} = value
const idOrAddress = id || new Address(kind, pubkey, identifier).toString() const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
@@ -103,8 +104,8 @@
<Button class="my-2 block max-w-full text-left" {onclick}> <Button class="my-2 block max-w-full text-left" {onclick}>
{#if $quote} {#if $quote}
<NoteCard {minimal} event={$quote} class="bg-alt rounded-box p-4"> <NoteCard event={$quote} class="bg-alt rounded-box p-4">
{@render noteContent({event: $quote, minimal})} <NoteContent {hideMediaAtDepth} {relays} event={$quote} depth={depth + 1} />
</NoteCard> </NoteCard>
{:else} {:else}
<div class="rounded-box p-4"> <div class="rounded-box p-4">
+11 -9
View File
@@ -16,6 +16,11 @@
import {loadUserData} from "@app/commands" import {loadUserData} from "@app/commands"
import {setChecked} from "@app/notifications" import {setChecked} from "@app/notifications"
let signers: any[] = $state([])
let loading: string | undefined = $state()
const disabled = $derived(loading ? true : undefined)
const signUp = () => pushModal(SignUp) const signUp = () => pushModal(SignUp)
const onSuccess = async (session: Session, relays: string[] = []) => { const onSuccess = async (session: Session, relays: string[] = []) => {
@@ -70,9 +75,6 @@
const loginWithBunker = () => pushModal(LogInBunker) const loginWithBunker = () => pushModal(LogInBunker)
let signers: any[] = $state([])
let loading: string | undefined = $state()
const hasSigner = $derived(getNip07() || signers.length > 0) const hasSigner = $derived(getNip07() || signers.length > 0)
onMount(async () => { onMount(async () => {
@@ -90,7 +92,7 @@
you to own your social identity. you to own your social identity.
</p> </p>
{#if getNip07()} {#if getNip07()}
<Button disabled={Boolean(loading)} onclick={loginWithNip07} class="btn btn-primary"> <Button {disabled} onclick={loginWithNip07} class="btn btn-primary">
{#if loading === "nip07"} {#if loading === "nip07"}
<span class="loading loading-spinner mr-3"></span> <span class="loading loading-spinner mr-3"></span>
{:else} {:else}
@@ -100,7 +102,7 @@
</Button> </Button>
{/if} {/if}
{#each signers as app} {#each signers as app}
<Button disabled={Boolean(loading)} class="btn btn-primary" onclick={() => loginWithNip55(app)}> <Button {disabled} class="btn btn-primary" onclick={() => loginWithNip55(app)}>
{#if loading === "nip55"} {#if loading === "nip55"}
<span class="loading loading-spinner mr-3"></span> <span class="loading loading-spinner mr-3"></span>
{:else} {:else}
@@ -110,7 +112,7 @@
</Button> </Button>
{/each} {/each}
{#if BURROW_URL && !hasSigner} {#if BURROW_URL && !hasSigner}
<Button disabled={Boolean(loading)} onclick={loginWithPassword} class="btn btn-primary"> <Button {disabled} onclick={loginWithPassword} class="btn btn-primary">
{#if loading === "password"} {#if loading === "password"}
<span class="loading loading-spinner mr-3"></span> <span class="loading loading-spinner mr-3"></span>
{:else} {:else}
@@ -121,13 +123,13 @@
{/if} {/if}
<Button <Button
onclick={loginWithBunker} onclick={loginWithBunker}
disabled={Boolean(loading)} {disabled}
class="btn {hasSigner || BURROW_URL ? 'btn-neutral' : 'btn-primary'}"> class="btn {hasSigner || BURROW_URL ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="cpu" /> <Icon icon="cpu" />
Log in with Remote Signer Log in with Remote Signer
</Button> </Button>
{#if BURROW_URL && hasSigner} {#if BURROW_URL && hasSigner}
<Button disabled={Boolean(loading)} onclick={loginWithPassword} class="btn"> <Button {disabled} onclick={loginWithPassword} class="btn">
{#if loading === "password"} {#if loading === "password"}
<span class="loading loading-spinner mr-3"></span> <span class="loading loading-spinner mr-3"></span>
{:else} {:else}
@@ -139,7 +141,7 @@
{#if !hasSigner || !BURROW_URL} {#if !hasSigner || !BURROW_URL}
<Link <Link
external external
disabled={Boolean(loading)} {disabled}
href="https://nostrapps.com#signers" href="https://nostrapps.com#signers"
class="btn {hasSigner || BURROW_URL ? '' : 'btn-neutral'}"> class="btn {hasSigner || BURROW_URL ? '' : 'btn-neutral'}">
<Icon icon="compass" /> <Icon icon="compass" />
+14 -8
View File
@@ -57,14 +57,14 @@
const addRoom = () => pushModal(RoomCreate, {url}, {replaceState}) const addRoom = () => pushModal(RoomCreate, {url}, {replaceState})
let showMenu = $state(false) let showMenu = $state(false)
let replaceState = false let replaceState = $state(false)
let element: Element | undefined = $state() let element: Element | undefined = $state()
const members = $derived( const members = $derived(
$memberships.filter(l => hasMembershipUrl(l, url)).map(l => l.event.pubkey), $memberships.filter(l => hasMembershipUrl(l, url)).map(l => l.event.pubkey),
) )
onMount(async () => { onMount(() => {
replaceState = Boolean(element?.closest(".drawer")) replaceState = Boolean(element?.closest(".drawer"))
pullConservatively({relays: [url], filters: [{kinds: [GROUP_META]}]}) pullConservatively({relays: [url], filters: [{kinds: [GROUP_META]}]})
}) })
@@ -112,19 +112,25 @@
{/if} {/if}
</div> </div>
<div class="flex min-h-0 flex-col gap-1 overflow-auto"> <div class="flex min-h-0 flex-col gap-1 overflow-auto">
<SecondaryNavItem href={makeSpacePath(url)}> <SecondaryNavItem {replaceState} href={makeSpacePath(url)}>
<Icon icon="home-smile" /> Home <Icon icon="home-smile" /> Home
</SecondaryNavItem> </SecondaryNavItem>
<SecondaryNavItem href={threadsPath} notification={$notifications.has(threadsPath)}> <SecondaryNavItem
{replaceState}
href={threadsPath}
notification={$notifications.has(threadsPath)}>
<Icon icon="notes-minimalistic" /> Threads <Icon icon="notes-minimalistic" /> Threads
</SecondaryNavItem> </SecondaryNavItem>
<SecondaryNavItem href={calendarPath} notification={$notifications.has(calendarPath)}> <SecondaryNavItem
{replaceState}
href={calendarPath}
notification={$notifications.has(calendarPath)}>
<Icon icon="calendar-minimalistic" /> Calendar <Icon icon="calendar-minimalistic" /> Calendar
</SecondaryNavItem> </SecondaryNavItem>
<div class="h-2"></div> <div class="h-2"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader> <SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{#each $userRooms as room, i (room)} {#each $userRooms as room, i (room)}
<MenuSpaceRoomItem notify {url} {room} /> <MenuSpaceRoomItem {replaceState} notify {url} {room} />
{/each} {/each}
{#if $otherRooms.length > 0} {#if $otherRooms.length > 0}
<div class="h-2"></div> <div class="h-2"></div>
@@ -137,9 +143,9 @@
</SecondaryNavHeader> </SecondaryNavHeader>
{/if} {/if}
{#each $otherRooms as room, i (room)} {#each $otherRooms as room, i (room)}
<MenuSpaceRoomItem {url} {room} /> <MenuSpaceRoomItem {replaceState} {url} {room} />
{/each} {/each}
<SecondaryNavItem onclick={addRoom}> <SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon="add-circle" /> <Icon icon="add-circle" />
Create room Create room
</SecondaryNavItem> </SecondaryNavItem>
+6 -2
View File
@@ -10,15 +10,19 @@
url: any url: any
room: any room: any
notify?: boolean notify?: boolean
replaceState?: boolean
} }
const {url, room, notify = false}: Props = $props() const {url, room, notify = false, replaceState = false}: Props = $props()
const path = makeRoomPath(url, room) const path = makeRoomPath(url, room)
const channel = deriveChannel(url, room) const channel = deriveChannel(url, room)
</script> </script>
<SecondaryNavItem href={path} notification={notify ? $notifications.has(path) : false}> <SecondaryNavItem
href={path}
{replaceState}
notification={notify ? $notifications.has(path) : false}>
{#if channelIsLocked($channel)} {#if channelIsLocked($channel)}
<Icon icon="lock" size={4} /> <Icon icon="lock" size={4} />
{:else} {:else}
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME} from "@welshman/util"
import Content from "@app/components/Content.svelte"
import CalendarEventDate from "@app/components/CalendarEventDate.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
const props: ComponentProps<typeof Content> = $props()
</script>
{#if props.event.kind === EVENT_TIME}
<div class="flex items-start gap-4">
<CalendarEventDate event={props.event} />
<div class="flex flex-grow flex-col">
<div class="flex flex-grow flex-wrap justify-between gap-2">
<CalendarEventHeader event={props.event} />
</div>
<div class="flex py-2 opacity-50">
<div class="h-px flex-grow bg-base-content opacity-25"></div>
</div>
<Content {...props} />
</div>
</div>
{:else}
<Content {...props} />
{/if}
+2 -2
View File
@@ -4,7 +4,7 @@
import {pubkey} from "@welshman/app" import {pubkey} from "@welshman/app"
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"
import Content from "@app/components/Content.svelte" import NoteContent from "@app/components/NoteContent.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import {publishDelete, publishReaction} from "@app/commands" import {publishDelete, publishReaction} from "@app/commands"
@@ -26,7 +26,7 @@
</script> </script>
<NoteCard {event} class="card2 bg-alt"> <NoteCard {event} class="card2 bg-alt">
<Content {event} expandMode="inline" /> <NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2"> <div class="flex w-full justify-between gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right"> <ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right">
<EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box"> <EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box">
+14 -58
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import {ctx} from "@welshman/lib" import {ctx} from "@welshman/lib"
import type {Profile} from "@welshman/util"
import { import {
createEvent, createEvent,
makeProfile, makeProfile,
@@ -8,76 +9,31 @@
isPublishedProfile, isPublishedProfile,
} from "@welshman/util" } from "@welshman/util"
import {pubkey, profilesByPubkey, publishThunk} from "@welshman/app" import {pubkey, profilesByPubkey, publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import InputProfilePicture from "@lib/components/InputProfilePicture.svelte" import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import InfoHandle from "@app/components/InfoHandle.svelte" import {clearModals} from "@app/modal"
import {pushModal, clearModals} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
const values = $state({...($profilesByPubkey.get($pubkey!) || makeProfile())}) const initialValues = {...($profilesByPubkey.get($pubkey!) || makeProfile())}
const back = () => history.back() const back = () => history.back()
const saveEdit = () => { const onsubmit = (profile: Profile) => {
const relays = ctx.app.router.FromUser().getUrls() const relays = ctx.app.router.FromUser().getUrls()
const template = isPublishedProfile(values) const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
? editProfile($state.snapshot(values))
: createProfile($state.snapshot(values))
const event = createEvent(template.kind, template) const event = createEvent(template.kind, template)
publishThunk({event, relays}) publishThunk({event, relays})
pushToast({message: "Your profile has been updated!"}) pushToast({message: "Your profile has been updated!"})
clearModals() clearModals()
} }
let file: File | undefined = $state()
</script> </script>
<form class="col-4" onsubmit={preventDefault(saveEdit)}> <ProfileEditForm {initialValues} {onsubmit}>
<div class="flex justify-center py-2"> {#snippet footer()}
<InputProfilePicture bind:file bind:url={values.picture} /> <div class="mt-4 flex flex-row items-center justify-between gap-4">
</div> <Button class="btn btn-neutral" onclick={back}>Discard Changes</Button>
<Field> <Button type="submit" class="btn btn-primary">Save Changes</Button>
{#snippet label()} </div>
<p>Username</p> {/snippet}
{/snippet} </ProfileEditForm>
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-circle" />
<input bind:value={values.name} class="grow" type="text" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>About You</p>
{/snippet}
{#snippet input()}
<textarea class="textarea textarea-bordered leading-4" rows="3" bind:value={values.about}>
</textarea>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Nostr Address</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<input bind:value={values.nip05} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
<p>
<Button class="link" onclick={() => pushModal(InfoHandle)}>What is a nostr address?</Button>
</p>
{/snippet}
</Field>
<div class="mt-4 flex flex-row items-center justify-between gap-4">
<Button class="btn btn-neutral" onclick={back}>Discard Changes</Button>
<Button type="submit" class="btn btn-primary">Save Changes</Button>
</div>
</form>
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {Profile} from "@welshman/util"
import {makeProfile} from "@welshman/util"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import InputProfilePicture from "@lib/components/InputProfilePicture.svelte"
import InfoHandle from "@app/components/InfoHandle.svelte"
import {pushModal} from "@app/modal"
type Props = {
initialValues?: Profile
onsubmit: (profile: Profile) => void
hideAddress?: boolean
footer: Snippet
}
const {initialValues = makeProfile(), hideAddress, onsubmit, footer}: Props = $props()
const values = $state(initialValues)
const submit = () => onsubmit($state.snapshot(values))
let file: File | undefined = $state()
</script>
<form class="col-4" onsubmit={preventDefault(submit)}>
<div class="flex justify-center py-2">
<InputProfilePicture bind:file bind:url={values.picture} />
</div>
<Field>
{#snippet label()}
<p>Username</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-circle" />
<input bind:value={values.name} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
What would you like people to call you?
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>About You</p>
{/snippet}
{#snippet input()}
<textarea class="textarea textarea-bordered leading-4" rows="3" bind:value={values.about}
></textarea>
{/snippet}
{#snippet info()}
Give a brief introduction to why you're here.
{/snippet}
</Field>
{#if !hideAddress}
<Field>
{#snippet label()}
<p>Nostr Address</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<input bind:value={values.nip05} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
<p>
<Button class="link" onclick={() => pushModal(InfoHandle)}
>What is a nostr address?</Button>
</p>
{/snippet}
</Field>
{/if}
{@render footer()}
</form>
+1 -1
View File
@@ -8,5 +8,5 @@
</script> </script>
{#if $profile} {#if $profile}
<Content event={{content: $profile.about, tags: []}} hideMedia /> <Content event={{content: $profile.about, tags: []}} />
{/if} {/if}
+21 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import {Capacitor} from "@capacitor/core"
import {postJson} from "@welshman/lib" import {postJson} from "@welshman/lib"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -8,6 +9,7 @@
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import LogIn from "@app/components/LogIn.svelte" import LogIn from "@app/components/LogIn.svelte"
import InfoNostr from "@app/components/InfoNostr.svelte" import InfoNostr from "@app/components/InfoNostr.svelte"
import SignUpKey from "@app/components/SignUpKey.svelte"
import SignUpSuccess from "@app/components/SignUpSuccess.svelte" import SignUpSuccess from "@app/components/SignUpSuccess.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {BURROW_URL, PLATFORM_NAME} from "@app/state" import {BURROW_URL, PLATFORM_NAME} from "@app/state"
@@ -37,18 +39,20 @@
} }
} }
const signup = () => { const usePassword = () => {
if (BURROW_URL) { if (BURROW_URL) {
signupPassword() signupPassword()
} }
} }
const useKey = () => pushModal(SignUpKey)
let email = $state("") let email = $state("")
let password = $state("") let password = $state("")
let loading = $state(false) let loading = $state(false)
</script> </script>
<form class="column gap-4" onsubmit={preventDefault(signup)}> <form class="column gap-4" onsubmit={preventDefault(usePassword)}>
<h1 class="heading">Sign up with Nostr</h1> <h1 class="heading">Sign up with Nostr</h1>
<p class="m-auto max-w-sm text-center"> <p class="m-auto max-w-sm text-center">
{PLATFORM_NAME} is built using the {PLATFORM_NAME} is built using the
@@ -89,10 +93,21 @@
</p> </p>
<Divider>Or</Divider> <Divider>Or</Divider>
{/if} {/if}
<a href={nstart} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}"> {#if Capacitor.isNativePlatform()}
<Icon icon="square-share-line" /> <Button onclick={useKey} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
Get going on nstart <Icon icon="key" />
</a> Generate a key
</Button>
<a href={nstart} class="btn">
<Icon icon="square-share-line" />
Create an account on Nstart
</a>
{:else}
<a href={nstart} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="square-share-line" />
Create an account on Nstart
</a>
{/if}
<div class="text-sm"> <div class="text-sm">
Already have an account? Already have an account?
<Button class="link" onclick={login}>Log in instead</Button> <Button class="link" onclick={login}>Log in instead</Button>
+84
View File
@@ -0,0 +1,84 @@
<script lang="ts">
import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@noble/hashes/utils"
import {makeSecret, getPubkey} from "@welshman/signer"
import {preventDefault, downloadText} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SignUpKeyConfirm from "@app/components/SignUpKeyConfirm.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
const secret = makeSecret()
const pubkey = getPubkey(secret)
const back = () => history.back()
const next = () => {
if (password.length < 12) {
return pushToast({
theme: "error",
message: "Passwords must be at least 12 characters long.",
})
}
const ncryptsec = encrypt(hexToBytes(secret), password)
downloadText("Nostr Secret Key.txt", ncryptsec)
pushModal(SignUpKeyConfirm, {secret, pubkey, ncryptsec})
}
let password = ""
</script>
<form class="column gap-4" onsubmit={preventDefault(next)}>
<ModalHeader>
{#snippet title()}
<div>Welcome to Nostr!</div>
{/snippet}
</ModalHeader>
<p>
<Link external href="https://nostr.com/" class="link">Nostr</Link> is way to build social apps that
talk to each other. Users own their social identity instead of renting it from a tech company, and
can take it with them.
</p>
<p>
This means that instead of using a password to log in, you generate a <strong
>secret key</strong>
which gives you full control over your account.
</p>
<p>
Keeping this key safe is very important, so we encourage you to download an encrypted copy. To
do this, go ahead and fill in the password you'd like to use to secure your key below.
</p>
<Field>
{#snippet label()}
Password*
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="key" />
<input bind:value={password} class="grow" type="password" />
</label>
{/snippet}
{#snippet info()}
<p>Passwords should be at least 12 characters long. Write this down!</p>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button class="btn btn-primary" type="submit">
Download my key
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
@@ -0,0 +1,66 @@
<script lang="ts">
import {preventDefault, copyToClipboard} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SignUpProfile from "@app/components/SignUpProfile.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
type Props = {
secret: string
pubkey: string
ncryptsec: string
}
const {secret, pubkey, ncryptsec}: Props = $props()
const back = () => history.back()
const copy = () => {
copyToClipboard(ncryptsec)
pushToast({message: "Your secret key has been copied to your clipboard!"})
}
const next = () => {
pushModal(SignUpProfile, {secret, pubkey})
}
</script>
<form class="column gap-4" onsubmit={preventDefault(next)}>
<ModalHeader>
{#snippet title()}
<div>Download your key</div>
{/snippet}
</ModalHeader>
<p>
Great! We've encrypted your secret key and saved it to your device. If that didn't work, or if
you'd rather save your key somewhere else, you can find the encrypted version below:
</p>
<Field>
{#snippet label()}
Encrypted Secret Key
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="key" />
<input value={ncryptsec} class="ellipsize grow" />
<Button onclick={copy} class="flex items-center">
<Icon icon="copy" />
</Button>
</label>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button class="btn btn-primary" type="submit">
Fill out your profile
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import type {Profile} from "@welshman/util"
import {PROFILE, createProfile, createEvent} from "@welshman/util"
import {addSession, publishThunk} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {INDEXER_RELAYS} from "@app/state"
type Props = {
secret: string
pubkey: string
}
const {secret, pubkey}: Props = $props()
const onsubmit = (profile: Profile) => {
const event = createEvent(PROFILE, createProfile(profile))
addSession({method: "nip01", secret, pubkey})
publishThunk({event, relays: INDEXER_RELAYS})
}
</script>
<ProfileEditForm hideAddress {onsubmit}>
{#snippet footer()}
<Button type="submit" class="btn btn-primary">Create Account</Button>
{/snippet}
</ProfileEditForm>
+1 -1
View File
@@ -32,7 +32,7 @@
{formatTimestamp(event.created_at)} {formatTimestamp(event.created_at)}
</p> </p>
{/if} {/if}
<Content {event} expandMode="inline" quoteProps={{relays: [url]}} /> <Content {event} expandMode="inline" relays={[url]} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"> <div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75"> <span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} /> Posted by <ProfileLink pubkey={event.pubkey} />
+23 -4
View File
@@ -1,5 +1,5 @@
import {get, writable} from "svelte/store" import {get, writable} from "svelte/store"
import {partition, int, YEAR, MONTH, insert, sortBy, assoc, now} from "@welshman/lib" import {partition, shuffle, int, YEAR, MONTH, insert, sortBy, assoc, now} from "@welshman/lib"
import { import {
MESSAGE, MESSAGE,
DELETE, DELETE,
@@ -55,6 +55,7 @@ export const makeFeed = ({
feedFilters, feedFilters,
subscriptionFilters, subscriptionFilters,
element, element,
onEvent,
onExhausted, onExhausted,
initialEvents = [], initialEvents = [],
}: { }: {
@@ -62,12 +63,21 @@ export const makeFeed = ({
feedFilters: Filter[] feedFilters: Filter[]
subscriptionFilters: Filter[] subscriptionFilters: Filter[]
element: HTMLElement element: HTMLElement
onEvent?: (event: TrustedEvent) => void
onExhausted?: () => void onExhausted?: () => void
initialEvents?: TrustedEvent[] initialEvents?: TrustedEvent[]
}) => { }) => {
const seen = new Set<string>()
const buffer = writable<TrustedEvent[]>([]) const buffer = writable<TrustedEvent[]>([])
const events = writable(initialEvents) const events = writable(initialEvents)
for (const event of initialEvents) {
if (!seen.has(event.id)) {
seen.add(event.id)
onEvent?.(event)
}
}
const insertEvent = (event: TrustedEvent) => { const insertEvent = (event: TrustedEvent) => {
buffer.update($buffer => { buffer.update($buffer => {
for (let i = 0; i < $buffer.length; i++) { for (let i = 0; i < $buffer.length; i++) {
@@ -77,6 +87,11 @@ export const makeFeed = ({
return [...$buffer, event] return [...$buffer, event]
}) })
if (!seen.has(event.id)) {
seen.add(event.id)
onEvent?.(event)
}
} }
const removeEvents = (ids: string[]) => { const removeEvents = (ids: string[]) => {
@@ -270,13 +285,17 @@ export const makeCalendarFeed = ({
export const listenForNotifications = () => { export const listenForNotifications = () => {
const subs: Subscription[] = [] const subs: Subscription[] = []
for (const [url, rooms] of userRoomsByUrl.get()) { for (const [url, allRooms] of userRoomsByUrl.get()) {
// Limit how many rooms we load at a time, since we have to send a separate filter
// for each one due to nip 29 breaking postel's law
const rooms = shuffle(Array.from(allRooms)).slice(0, 30)
load({ load({
relays: [url], relays: [url],
filters: [ filters: [
{kinds: [THREAD], limit: 1}, {kinds: [THREAD], limit: 1},
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1}, {kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
...Array.from(rooms).map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})), ...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
], ],
}) })
@@ -286,7 +305,7 @@ export const listenForNotifications = () => {
filters: [ filters: [
{kinds: [THREAD], since: now()}, {kinds: [THREAD], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD)], since: now()}, {kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
...Array.from(rooms).map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})), ...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
], ],
}), }),
) )
+3 -2
View File
@@ -24,15 +24,16 @@
import {fade} from "@lib/transition" import {fade} from "@lib/transition"
import {page} from "$app/stores" import {page} from "$app/stores"
const {children, href = "", notification = false, ...restProps} = $props() const {children, href = "", notification = false, replaceState = false, ...restProps} = $props()
const active = $derived($page.url.pathname === href) const active = $derived($page.url.pathname === href)
</script> </script>
{#if href} {#if href}
<a <a
{...restProps}
{href} {href}
{...restProps}
data-sveltekit-replacestate={replaceState}
class="{restProps.class} relative flex items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content" class="{restProps.class} relative flex items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content"
class:text-base-content={active} class:text-base-content={active}
class:bg-base-100={active}> class:bg-base-100={active}>
+13
View File
@@ -76,3 +76,16 @@ export const createScroller = ({
} }
export const isMobile = "ontouchstart" in document.documentElement export const isMobile = "ontouchstart" in document.documentElement
export const downloadText = (filename: string, text: string) => {
const blob = new Blob([text], {type: "text/plain"})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
+1 -1
View File
@@ -50,7 +50,7 @@
</Button> </Button>
</div> </div>
{#key $profile?.about} {#key $profile?.about}
<Content event={{content: $profile?.about || "", tags: []}} hideMedia /> <Content event={{content: $profile?.about || "", tags: []}} hideMediaAtDepth={0} />
{/key} {/key}
</div> </div>
{#if $session?.email} {#if $session?.email}
+25 -10
View File
@@ -50,8 +50,12 @@
const joinRoom = async () => { const joinRoom = async () => {
if (hasNip29($relay)) { if (hasNip29($relay)) {
joiningRoom = true
const message = await getThunkError(nip29.joinRoom(url, room)) const message = await getThunkError(nip29.joinRoom(url, room))
joiningRoom = false
if (message && !message.includes("already")) { if (message && !message.includes("already")) {
return pushToast({theme: "error", message}) return pushToast({theme: "error", message})
} }
@@ -126,7 +130,8 @@
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"}) const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
let loading = $state(true) let joiningRoom = $state(false)
let loadingEvents = $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()
@@ -147,6 +152,12 @@
let newMessagesSeen = false let newMessagesSeen = false
if (events) { if (events) {
const lastUserEvent = $events.find(e => e.pubkey === $pubkey)
// Adjust last checked to account for messages that came from a different device
const adjustedLastChecked =
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
for (const event of $events.toReversed()) { for (const event of $events.toReversed()) {
if (seen.has(event.id)) { if (seen.has(event.id)) {
continue continue
@@ -156,9 +167,9 @@
if ( if (
!newMessagesSeen && !newMessagesSeen &&
adjustedLastChecked &&
event.pubkey !== $pubkey && event.pubkey !== $pubkey &&
lastChecked && event.created_at > adjustedLastChecked
event.created_at > lastChecked
) { ) {
elements.push({type: "new-messages", id: "new-messages"}) elements.push({type: "new-messages", id: "new-messages"})
newMessagesSeen = true newMessagesSeen = true
@@ -196,7 +207,7 @@
subscriptionFilters: [{kinds: [DELETE, REACTION, MESSAGE], "#h": [room], since: now()}], subscriptionFilters: [{kinds: [DELETE, REACTION, MESSAGE], "#h": [room], since: now()}],
initialEvents: getEventsForUrl(url, [{...filter, limit: 20}]), initialEvents: getEventsForUrl(url, [{...filter, limit: 20}]),
onExhausted: () => { onExhausted: () => {
loading = false loadingEvents = false
}, },
})) }))
}) })
@@ -211,7 +222,7 @@
}) })
</script> </script>
<div class="saib relative flex h-full flex-col"> <div class="relative flex h-full flex-col">
<PageBar> <PageBar>
{#snippet icon()} {#snippet icon()}
<div class="center"> <div class="center">
@@ -232,8 +243,12 @@
Leave Room Leave Room
</Button> </Button>
{:else} {:else}
<Button class="btn btn-neutral btn-sm" onclick={joinRoom}> <Button class="btn btn-neutral btn-sm" disabled={joiningRoom} onclick={joinRoom}>
<Icon icon="login-2" /> {#if joiningRoom}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon="login-2" />
{/if}
Join Room Join Room
</Button> </Button>
{/if} {/if}
@@ -265,8 +280,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 loading} {#if loadingEvents}
<Spinner loading>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}
@@ -281,7 +296,7 @@
</div> </div>
</div> </div>
{/if} {/if}
<div class="saib"> <div>
{#if parent} {#if parent}
<ChannelComposeParent event={parent} clear={clearParent} verb="Replying to" /> <ChannelComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if} {/if}
@@ -126,7 +126,11 @@
<strong>Calendar</strong> <strong>Calendar</strong>
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
<div class="md:hidden"> <div class="row-2">
<Button class="btn btn-primary btn-sm" onclick={createEvent}>
<Icon icon="calendar-add" />
Create an Event
</Button>
<MenuSpaceButton {url} /> <MenuSpaceButton {url} />
</div> </div>
{/snippet} {/snippet}
@@ -157,12 +161,4 @@
<p class="flex h-10 items-center justify-center py-20" transition:fly>That's all!</p> <p class="flex h-10 items-center justify-center py-20" transition:fly>That's all!</p>
{/if} {/if}
</div> </div>
<Button
class="tooltip tooltip-left fixed bottom-16 right-2 z-feature p-1 md:bottom-4 md:right-4"
data-tip="Create an Event"
onclick={createEvent}>
<div class="btn btn-circle btn-primary flex h-12 w-12 items-center justify-center">
<Icon icon="calendar-add" />
</div>
</Button>
</div> </div>
@@ -1,17 +1,9 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {page} from "$app/stores" import {page} from "$app/stores"
import {sortBy, fromPairs, sleep} from "@welshman/lib" import {sortBy, sleep} from "@welshman/lib"
import {COMMENT, getTagValue} from "@welshman/util" import {COMMENT, getTagValue} from "@welshman/util"
import { import {repository, subscribe} from "@welshman/app"
repository,
subscribe,
formatTimestamp,
LOCALE,
secondsToDate,
formatTimestampAsDate,
formatTimestampAsTime,
} from "@welshman/app"
import {deriveEvents} from "@welshman/store" import {deriveEvents} from "@welshman/store"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import PageBar from "@lib/components/PageBar.svelte" import PageBar from "@lib/components/PageBar.svelte"
@@ -20,8 +12,10 @@
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import CalendarEventActions from "@app/components/CalendarEventActions.svelte" import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import CalendarEventMeta from "@app/components/CalendarEventMeta.svelte"
import CalendarEventDate from "@app/components/CalendarEventDate.svelte"
import EventReply from "@app/components/EventReply.svelte" import EventReply from "@app/components/EventReply.svelte"
import {deriveEvent, decodeRelay} from "@app/state" import {deriveEvent, decodeRelay} from "@app/state"
import {setChecked} from "@app/notifications" import {setChecked} from "@app/notifications"
@@ -31,13 +25,6 @@
const event = deriveEvent(id) const event = deriveEvent(id)
const filters = [{kinds: [COMMENT], "#E": [id]}] const filters = [{kinds: [COMMENT], "#E": [id]}]
const replies = deriveEvents(repository, {filters}) const replies = deriveEvents(repository, {filters})
const meta = $derived(fromPairs($event.tags) as Record<string, string>)
const end = $derived(parseInt(meta.end))
const start = $derived(parseInt(meta.start))
const startDate = $derived(secondsToDate(start))
const startDateDisplay = $derived(formatTimestampAsDate(start))
const endDateDisplay = $derived(formatTimestampAsDate(end))
const isSingleDay = $derived(startDateDisplay === endDateDisplay)
const back = () => history.back() const back = () => history.back()
@@ -95,39 +82,18 @@
{/if} {/if}
<div class="card2 bg-alt col-3 z-feature"> <div class="card2 bg-alt col-3 z-feature">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div <CalendarEventDate event={$event} />
class="flex h-24 w-24 flex-col items-center justify-center gap-1 rounded-box border border-solid border-base-content p-2">
<span class="text-lg"
>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</span>
<span class="text-4xl"
>{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
</div>
<div class="flex flex-grow flex-col"> <div class="flex flex-grow flex-col">
<div class="flex flex-grow justify-between gap-2"> <div class="flex flex-grow justify-between gap-2">
<p class="text-xl">{meta.title || meta.name}</p> <CalendarEventHeader event={$event} />
<div class="flex items-center gap-2 text-sm">
<Icon icon="clock-circle" size={4} />
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div>
</div> </div>
<div class="flex items-center gap-2 text-sm opacity-75"> <div class="flex items-center gap-2 text-sm opacity-75">
<span> <CalendarEventMeta event={$event} />
Posted by <ProfileLink pubkey={$event.pubkey} />
</span>
{#if meta.location}
<span></span>
<span class="flex items-center gap-1">
<Icon icon="map-point" size={4} />
{meta.location}
</span>
{/if}
</div> </div>
<div class="flex py-2 opacity-50"> <div class="flex py-2 opacity-50">
<div class="h-px flex-grow bg-base-content opacity-25"></div> <div class="h-px flex-grow bg-base-content opacity-25"></div>
</div> </div>
<Content showEntire event={$event} quoteProps={{relays: [url]}} /> <Content showEntire event={$event} relays={[url]} />
</div> </div>
</div> </div>
<div class="flex w-full flex-col justify-end sm:flex-row"> <div class="flex w-full flex-col justify-end sm:flex-row">
@@ -141,12 +107,12 @@
<p>Failed to load comments.</p> <p>Failed to load comments.</p>
{/await} {/await}
{/if} {/if}
<PageBar class="mx-0"> <PageBar class="!mx-0">
{#snippet icon()} {#snippet icon()}
<div> <div>
<Button class="btn btn-neutral btn-sm" onclick={back}> <Button class="btn btn-neutral btn-sm flex-nowrap whitespace-nowrap" onclick={back}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back <span class="hidden sm:inline">Go back</span>
</Button> </Button>
</div> </div>
{/snippet} {/snippet}
+54 -63
View File
@@ -1,13 +1,10 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {derived} from "svelte/store"
import {page} from "$app/stores" import {page} from "$app/stores"
import {sortBy, min, nthEq} from "@welshman/lib" import {sortBy, max, nthEq} from "@welshman/lib"
import {THREAD, COMMENT, getListTags, getPubkeyTagValues} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {throttled} from "@welshman/store" import {THREAD, REACTION, DELETE, COMMENT, getListTags, getPubkeyTagValues} from "@welshman/util"
import {feedFromFilters, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds" import {userMutes} from "@welshman/app"
import {createFeedController, userMutes} from "@welshman/app"
import {createScroller, type Scroller} from "@lib/html"
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 Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -16,69 +13,63 @@
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte" import ThreadItem from "@app/components/ThreadItem.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte" import ThreadCreate from "@app/components/ThreadCreate.svelte"
import {decodeRelay, deriveEventsForUrl} from "@app/state" import {decodeRelay, getEventsForUrl} from "@app/state"
import {setChecked} from "@app/notifications" import {setChecked} from "@app/notifications"
import {makeFeed} from "@app/requests"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay) const url = decodeRelay($page.params.relay)
const threadFilter = {kinds: [THREAD]}
const commentFilter = {kinds: [COMMENT], "#K": [String(THREAD)]}
const feed = feedFromFilters([threadFilter, commentFilter])
const threads = deriveEventsForUrl(url, [threadFilter])
const comments = deriveEventsForUrl(url, [commentFilter])
const mutedPubkeys = getPubkeyTagValues(getListTags($userMutes)) const mutedPubkeys = getPubkeyTagValues(getListTags($userMutes))
const threads: TrustedEvent[] = $state([])
const comments: TrustedEvent[] = $state([])
const events = throttled( let loading = $state(true)
800, let element: HTMLElement | undefined = $state()
derived([threads, comments], ([$threads, $comments]) => {
const scores = new Map<string, number>()
for (const comment of $comments) {
const id = comment.tags.find(nthEq(0, "E"))?.[1]
if (id) {
scores.set(id, min([scores.get(id), -comment.created_at]))
}
}
return sortBy(
e => min([scores.get(e.id), -e.created_at]),
$threads.filter(e => !mutedPubkeys.includes(e.pubkey)),
)
}),
)
const createThread = () => pushModal(ThreadCreate, {url}) const createThread = () => pushModal(ThreadCreate, {url})
const ctrl = createFeedController({ const events = $derived.by(() => {
useWindowing: true, const scores = new Map<string, number>()
feed: makeIntersectionFeed(makeRelayFeed(url), feed),
onExhausted: () => { for (const comment of comments) {
loading = false const id = comment.tags.find(nthEq(0, "E"))?.[1]
},
if (id) {
scores.set(id, max([scores.get(id), comment.created_at]))
}
}
return sortBy(e => -max([scores.get(e.id), e.created_at]), threads)
}) })
let limit = 10 $inspect({threads, comments, events})
let loading = $state(true)
let element: Element | undefined = $state()
let scroller: Scroller
onMount(() => { onMount(() => {
scroller = createScroller({ const {cleanup} = makeFeed({
element: element!, element: element!,
delay: 300, relays: [url],
threshold: 3000, feedFilters: [{kinds: [THREAD, COMMENT]}],
onScroll: () => { subscriptionFilters: [
limit += 10 {kinds: [THREAD, REACTION, DELETE]},
{kinds: [COMMENT], "#K": [String(THREAD)]},
if ($events.length - limit < 10) { ],
ctrl.load(50) initialEvents: getEventsForUrl(url, [{kinds: [THREAD, COMMENT], limit: 10}]),
onEvent: event => {
if (event.kind === THREAD && !mutedPubkeys.includes(event.pubkey)) {
threads.push(event)
} }
if (event.kind === COMMENT) {
comments.push(event)
}
},
onExhausted: () => {
loading = false
}, },
}) })
return () => { return () => {
scroller?.stop() cleanup()
setChecked($page.url.pathname) setChecked($page.url.pathname)
} }
}) })
@@ -105,21 +96,21 @@
{/snippet} {/snippet}
</PageBar> </PageBar>
<div class="flex flex-grow flex-col gap-2 overflow-auto p-2"> <div class="flex flex-grow flex-col gap-2 overflow-auto p-2">
{#each $events as event (event.id)} {#each events as event (event.id)}
<div in:fly> <div in:fly>
<ThreadItem {url} {event} /> <ThreadItem {url} {event} />
</div> </div>
{/each} {/each}
{#if loading || $events.length === 0} <p class="flex h-10 items-center justify-center py-20">
<p class="flex h-10 items-center justify-center py-20" out:fly> <Spinner {loading}>
<Spinner {loading}> {#if loading}
{#if loading} Looking for threads...
Looking for threads... {:else if events.length === 0}
{:else if $events.length === 0} No threads found.
No threads found. {:else}
{/if} That's all!
</Spinner> {/if}
</p> </Spinner>
{/if} </p>
</div> </div>
</div> </div>
@@ -79,7 +79,7 @@
{/if} {/if}
<NoteCard event={$event} class="card2 bg-alt z-feature w-full"> <NoteCard event={$event} class="card2 bg-alt z-feature w-full">
<div class="col-3 ml-12"> <div class="col-3 ml-12">
<Content showEntire event={$event} quoteProps={{relays: [url]}} /> <Content showEntire event={$event} relays={[url]} />
<ThreadActions event={$event} {url} /> <ThreadActions event={$event} {url} />
</div> </div>
</NoteCard> </NoteCard>
@@ -90,12 +90,12 @@
<p>Failed to load thread.</p> <p>Failed to load thread.</p>
{/await} {/await}
{/if} {/if}
<PageBar class="mx-0"> <PageBar class="!mx-0">
{#snippet icon()} {#snippet icon()}
<div> <div>
<Button class="btn btn-neutral btn-sm" onclick={back}> <Button class="btn btn-neutral btn-sm flex-nowrap whitespace-nowrap" onclick={back}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back <span class="hidden sm:inline">Go back</span>
</Button> </Button>
</div> </div>
{/snippet} {/snippet}