Compare commits

...

20 Commits

Author SHA1 Message Date
Jon Staab c3c65c3970 Use in-app onboarding on all native platforms 2025-02-14 16:07:40 -08:00
Jon Staab a5b868cd56 Update changelog 2025-02-14 16:02:11 -08:00
Jon Staab 8fcc56a408 Bump version 2025-02-14 16:00:18 -08:00
Jon Staab c8dfbc936b Spruce up nstart, add profile deletion 2025-02-14 15:59:20 -08:00
Jon Staab f1e76a1ed1 Bump versions, limit key generation to ios 2025-02-14 12:18:52 -08:00
Jon Staab 6ecc3e6770 Improve discover page 2025-02-14 12:14:00 -08:00
Jon Staab b05c408977 Move loadUserData to requests 2025-02-14 11:12:19 -08:00
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
48 changed files with 967 additions and 399 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
VITE_DEFAULT_PUBKEYS=fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
VITE_BURROW_URL=
VITE_PLATFORM_URL=https://flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
+22
View File
@@ -1,5 +1,27 @@
# Changelog
# 0.2.11
* Add in-app signup flow on ios
* Add profile deletion
# 0.2.10
* Improve space discovery
# 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
* Add calendar events
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "social.flotilla"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 7
versionName "0.2.7"
versionCode 10
versionName "0.2.11"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// 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
// server: {
// url: "http://192.168.1.251:1847",
// url: "http://192.168.1.250:1847",
// cleartext: true
// },
};
+8 -4
View File
@@ -351,12 +351,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 0.2.11;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -374,12 +376,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 0.2.11;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+5 -1
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Flotilla</string>
<string>Flotilla</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -30,6 +30,8 @@
<array>
<string>armv7</string>
</array>
<key>UIStatusBarStyle</key>
<string></string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
@@ -45,5 +47,7 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict>
</plist>
+44 -30
View File
@@ -1,12 +1,12 @@
{
"name": "flotilla",
"version": "0.2.7",
"version": "0.2.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flotilla",
"version": "0.2.7",
"version": "0.2.11",
"dependencies": {
"@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0",
@@ -22,15 +22,15 @@
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "~0.0.42",
"@welshman/content": "~0.0.17",
"@welshman/content": "~0.0.18",
"@welshman/dvm": "~0.0.14",
"@welshman/editor": "~0.0.13",
"@welshman/editor": "~0.0.15",
"@welshman/feeds": "~0.0.30",
"@welshman/lib": "~0.0.40",
"@welshman/net": "~0.0.46",
"@welshman/lib": "~0.0.41",
"@welshman/net": "~0.0.47",
"@welshman/signer": "~0.0.20",
"@welshman/store": "~0.0.16",
"@welshman/util": "~0.0.60",
"@welshman/util": "~0.0.61",
"daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5",
@@ -4766,13 +4766,13 @@
}
},
"node_modules/@welshman/content": {
"version": "0.0.17",
"resolved": "https://registry.npmjs.org/@welshman/content/-/content-0.0.17.tgz",
"integrity": "sha512-xiBSL8BSzHrwRmGqKXkR/S6EK7a1wT1rG1qdlQN30lBX5ZS+NSkoI0aNuF8p313mElHNZWgrqxFaat+FML4yOw==",
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@welshman/content/-/content-0.0.18.tgz",
"integrity": "sha512-7LHs9xKStrkaet9VY1PWSEUWrdIaIThIo+ByN6lF3nRZwPTExrBy4rPXnEa5roVAAwgmlhXw3zTkfGP15V6joQ==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.0.2",
"@welshman/lib": "~0.0.37",
"@welshman/lib": "~0.0.40",
"nostr-tools": "^2.7.2"
},
"engines": {
@@ -4793,9 +4793,9 @@
}
},
"node_modules/@welshman/editor": {
"version": "0.0.13",
"resolved": "https://registry.npmjs.org/@welshman/editor/-/editor-0.0.13.tgz",
"integrity": "sha512-860kn8iOXHKGBOnL3zalFQVw8eeILNU6YQ4V+xFtgqIxxCMk1c/9F5k0k0OyloUqRNjtSG6hvLdQLacBvhz2WQ==",
"version": "0.0.15",
"resolved": "https://registry.npmjs.org/@welshman/editor/-/editor-0.0.15.tgz",
"integrity": "sha512-Eg3alzv+cjCXtr6oEItRqoRSD4DTllt3c2JyJTxpF/KNiy8XHHMeUSpVFgph3+pAt5jwyl6b1feKPEwpShgqHw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^2.11.5",
@@ -4813,11 +4813,25 @@
"@tiptap/suggestion": "^2.11.5",
"@welshman/lib": "~0.0.40",
"@welshman/util": "^0.0.60",
"nostr-editor": "^0.0.4-pre.12",
"nostr-editor": "^0.0.4-pre.13",
"nostr-tools": "^2.10.4",
"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": {
"version": "0.0.30",
"resolved": "https://registry.npmjs.org/@welshman/feeds/-/feeds-0.0.30.tgz",
@@ -4829,9 +4843,9 @@
}
},
"node_modules/@welshman/lib": {
"version": "0.0.40",
"resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.0.40.tgz",
"integrity": "sha512-6Qk5fJABv+7HPqhNC5eLM4VZxCLpcu22nShmrNMbamkMwr4eLj2Bl4dRmuzFsvMcsL/Jc148zqpfuq37CY2NCw==",
"version": "0.0.41",
"resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.0.41.tgz",
"integrity": "sha512-FMJVoPZw8Vi1fd2/ulwqlBS1tvjkFAm9lg+Dz5SXItXxrNC06YMRTjGjInCBEkArrvNGPUjchzSFDNmbH0fxHQ==",
"license": "MIT",
"dependencies": {
"@scure/base": "^1.1.6",
@@ -4840,13 +4854,13 @@
}
},
"node_modules/@welshman/net": {
"version": "0.0.46",
"resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.46.tgz",
"integrity": "sha512-ehH4grz0VHjuofyVUE3r5GoynHTh+cIT/XFH6ov6nOGRU/LZXCLGk/9CUPlqNYHRfc/zBtaIyfVu0AelLqV6lw==",
"version": "0.0.47",
"resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.47.tgz",
"integrity": "sha512-/mIr+QyLH+RlD16rsPDTIW250lOm5eNaLO6dhZw8dMKznMhVtSWe/X/lJZOXmexzbB2z7WYZVN5x5TggZROyxA==",
"license": "MIT",
"dependencies": {
"@welshman/lib": "~0.0.37",
"@welshman/util": "~0.0.58",
"@welshman/lib": "~0.0.40",
"@welshman/util": "~0.0.59",
"isomorphic-ws": "^5.0.0",
"ws": "^8.16.0"
}
@@ -4917,13 +4931,13 @@
}
},
"node_modules/@welshman/util": {
"version": "0.0.60",
"resolved": "https://registry.npmjs.org/@welshman/util/-/util-0.0.60.tgz",
"integrity": "sha512-kqZgYnrwxKx0JTDZnTSaQYc2ev7E9ZjNDy5MclX36d5T/qPUspmwksAOodFJY9kJoJd49bf1omAmBTgnFJfeHw==",
"version": "0.0.61",
"resolved": "https://registry.npmjs.org/@welshman/util/-/util-0.0.61.tgz",
"integrity": "sha512-+l4YX01msAtnyylzpIFIAYubvnBLyr9hGx3iRO5LS3OPv/yUDOeyYJseWDqorkIiN5BRT7PCgnWJdlQP71ZtAw==",
"license": "MIT",
"dependencies": {
"@types/ws": "^8.5.13",
"@welshman/lib": "~0.0.37",
"@welshman/lib": "~0.0.40",
"nostr-tools": "^2.7.2"
},
"engines": {
@@ -10152,9 +10166,9 @@
}
},
"node_modules/nostr-editor": {
"version": "0.0.4-pre.12",
"resolved": "https://registry.npmjs.org/nostr-editor/-/nostr-editor-0.0.4-pre.12.tgz",
"integrity": "sha512-vztmbEKxt2jnO1JEoprwVf3s4TN4D3B0fcsrhckOITR1KaDX88QhIG+qTee92xp+n96vYj4GQt0W06rSv3NXHA==",
"version": "0.0.4-pre.13",
"resolved": "https://registry.npmjs.org/nostr-editor/-/nostr-editor-0.0.4-pre.13.tgz",
"integrity": "sha512-izIidrrIjQp41MAY2dNoticQSc0E5XOFKEe04tmZdTdF9Ry8CKxIdv6yvO3qh4gdhrOq+QPLTRii6X3X5iC/5Q==",
"license": "MIT",
"dependencies": {
"light-bolt11-decoder": "^3.1.1"
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "flotilla",
"version": "0.2.7",
"version": "0.2.11",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "./build.sh",
"sourcemaps": "./sourcemaps.sh",
"release:android": "cap build android --androidreleasetype APK --signing-type apksigner",
"sourcemaps": "./build.sh && ./sourcemaps.sh",
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src && eslint src",
@@ -51,15 +51,15 @@
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "~0.0.42",
"@welshman/content": "~0.0.17",
"@welshman/content": "~0.0.18",
"@welshman/dvm": "~0.0.14",
"@welshman/editor": "~0.0.13",
"@welshman/editor": "~0.0.15",
"@welshman/feeds": "~0.0.30",
"@welshman/lib": "~0.0.40",
"@welshman/net": "~0.0.46",
"@welshman/lib": "~0.0.41",
"@welshman/net": "~0.0.47",
"@welshman/signer": "~0.0.20",
"@welshman/store": "~0.0.16",
"@welshman/util": "~0.0.60",
"@welshman/util": "~0.0.61",
"daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5",
+6
View File
@@ -323,3 +323,9 @@ emoji-picker {
--input-font-color: var(--base-content);
--outline-color: var(--base-100);
}
/* progress */
progress[value]::-webkit-progress-value {
transition: width 0.5s;
}
+3 -52
View File
@@ -1,6 +1,6 @@
import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store"
import {ctx, sample, uniq, sleep, chunk, equals} from "@welshman/lib"
import {ctx, uniq, equals} from "@welshman/lib"
import {
DELETE,
REPORT,
@@ -26,12 +26,10 @@ import {
getTag,
getListTags,
getRelayTags,
isShareableRelayUrl,
getRelayTagValues,
toNostrURI,
} from "@welshman/util"
import type {TrustedEvent, EventContent, EventTemplate, List} from "@welshman/util"
import type {SubscribeRequestWithHandlers} from "@welshman/net"
import type {TrustedEvent, EventContent, EventTemplate} from "@welshman/util"
import {PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
import {
@@ -40,13 +38,9 @@ import {
repository,
publishThunk,
publishThunks,
loadProfile,
loadInboxRelaySelections,
profilesByPubkey,
relaySelectionsByPubkey,
getWriteRelayUrls,
loadFollows,
loadMutes,
tagEvent,
tagEventForReaction,
getRelayUrls,
@@ -67,11 +61,9 @@ import {
userMembership,
INDEXER_RELAYS,
NIP46_PERMS,
loadMembership,
loadSettings,
getDefaultPubkeys,
userRoomsByUrl,
} from "@app/state"
import {loadUserData} from "@app/requests"
// Utils
@@ -161,47 +153,6 @@ export const logout = async () => {
localStorage.clear()
}
// Loaders
export const loadUserData = (
pubkey: string,
request: Partial<SubscribeRequestWithHandlers> = {},
) => {
const promise = Promise.race([
sleep(3000),
Promise.all([
loadInboxRelaySelections(pubkey, request),
loadMembership(pubkey, request),
loadSettings(pubkey, request),
loadProfile(pubkey, request),
loadFollows(pubkey, request),
loadMutes(pubkey, request),
]),
])
// Load followed profiles slowly in the background without clogging other stuff up. Only use a single
// indexer relay to avoid too many redundant validations, which slow things down and eat bandwidth
promise.then(async () => {
for (const pubkeys of chunk(50, getDefaultPubkeys())) {
const relays = sample(1, INDEXER_RELAYS)
await sleep(1000)
for (const pubkey of pubkeys) {
loadMembership(pubkey, {relays})
loadProfile(pubkey, {relays})
loadFollows(pubkey, {relays})
loadMutes(pubkey, {relays})
}
}
})
return promise
}
export const discoverRelays = (lists: List[]) =>
Promise.all(uniq(lists.flatMap(getRelayUrls)).filter(isShareableRelayUrl).map(loadRelay))
// Synchronization
export const broadcastUserData = async (relays: string[]) => {
@@ -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">
import {fromPairs} from "@welshman/lib"
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 CalendarEventActions from "@app/components/CalendarEventActions.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import {makeCalendarPath} from "@app/routes"
const {
url,
event,
}: {
type Props = {
url: string
event: TrustedEvent
} = $props()
}
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
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)
const {url, event}: Props = $props()
</script>
<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">
<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>
<CalendarEventHeader {event} />
</div>
<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">
@@ -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 Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
const {
verb,
@@ -22,7 +22,12 @@
transition:slide>
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
{#key event.id}
<Content {event} hideMedia minLength={100} maxLength={300} expandMode="disabled" />
<NoteContent
{event}
hideMediaAtDepth={0}
minLength={100}
maxLength={300}
expandMode="disabled" />
{/key}
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Icon icon="close-circle" />
+1 -1
View File
@@ -89,7 +89,7 @@
</div>
{/if}
<div class="text-sm">
<Content {event} quoteProps={{minimal: true, relays: [url]}} />
<Content {event} relays={[url]} />
{#if thunk}
<ThunkStatus {thunk} class="mt-2" />
{/if}
+8 -14
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {fromNostrURI} from "@welshman/util"
import {nthEq} from "@welshman/lib"
import {
@@ -22,7 +21,6 @@
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import ContentToken from "@app/components/ContentToken.svelte"
import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
@@ -38,9 +36,9 @@
minLength?: number
maxLength?: number
showEntire?: boolean
hideMedia?: boolean
hideMediaAtDepth?: number
expandMode?: string
quoteProps?: Record<string, any>
relays?: string[]
depth?: number
}
@@ -49,9 +47,9 @@
minLength = 500,
maxLength = 700,
showEntire = $bindable(false),
hideMedia = false,
hideMediaAtDepth = 1,
expandMode = "block",
quoteProps = {},
relays = [],
depth = 0,
}: Props = $props()
@@ -64,13 +62,13 @@
const isBlock = (i: number) => {
const parsed = fullContent[i]
if (!parsed || hideMedia) return false
if (!parsed || hideMediaAtDepth <= depth) return false
if (isLink(parsed) && $userSettingValues.show_media && isStartOrEnd(i)) {
return true
}
if ((isEvent(parsed) || isAddress(parsed)) && isStartOrEnd(i) && depth < 1) {
if ((isEvent(parsed) || isAddress(parsed)) && isStartOrEnd(i)) {
return true
}
@@ -108,7 +106,7 @@
: truncate(fullContent, {
minLength,
maxLength,
mediaLength: hideMedia ? 20 : 200,
mediaLength: hideMediaAtDepth <= depth ? 20 : 200,
}),
)
@@ -151,11 +149,7 @@
<ContentMention value={parsed.value} />
{:else if isEvent(parsed) || isAddress(parsed)}
{#if isBlock(i)}
<ContentQuote {...quoteProps} value={parsed.value} {event}>
{#snippet noteContent({event, minimal}: {event: TrustedEvent; minimal: boolean})}
<Content {quoteProps} hideMedia={minimal || hideMedia} {event} depth={depth + 1} />
{/snippet}
</ContentQuote>
<ContentQuote {depth} {relays} {hideMediaAtDepth} value={parsed.value} {event} />
{:else}
<Link
external
+8 -1
View File
@@ -8,6 +8,8 @@
const {value} = $props()
let hideImage = $state(false)
const url = value.url.toString()
const loadPreview = async () => {
@@ -20,6 +22,10 @@
return json
}
const onError = () => {
hideImage = true
}
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
</script>
@@ -40,9 +46,10 @@
</div>
{:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal">
{#if preview.image}
{#if preview.image && !hideImage}
<img
alt="Link preview"
onerror={onError}
src={imgproxy(preview.image)}
class="bg-alt max-h-72 object-contain object-center" />
{/if}
+4 -3
View File
@@ -7,10 +7,11 @@
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import {deriveEvent, entityLink, ROOM} from "@app/state"
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 idOrAddress = id || new Address(kind, pubkey, identifier).toString()
@@ -103,8 +104,8 @@
<Button class="my-2 block max-w-full text-left" {onclick}>
{#if $quote}
<NoteCard {minimal} event={$quote} class="bg-alt rounded-box p-4">
{@render noteContent({event: $quote, minimal})}
<NoteCard event={$quote} class="bg-alt rounded-box p-4">
<NoteContent {hideMediaAtDepth} {relays} event={$quote} depth={depth + 1} />
</NoteCard>
{:else}
<div class="rounded-box p-4">
+12 -10
View File
@@ -13,9 +13,14 @@
import {pushModal, clearModals} from "@app/modal"
import {PLATFORM_NAME, BURROW_URL} from "@app/state"
import {pushToast} from "@app/toast"
import {loadUserData} from "@app/commands"
import {loadUserData} from "@app/requests"
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 onSuccess = async (session: Session, relays: string[] = []) => {
@@ -70,9 +75,6 @@
const loginWithBunker = () => pushModal(LogInBunker)
let signers: any[] = $state([])
let loading: string | undefined = $state()
const hasSigner = $derived(getNip07() || signers.length > 0)
onMount(async () => {
@@ -90,7 +92,7 @@
you to own your social identity.
</p>
{#if getNip07()}
<Button disabled={Boolean(loading)} onclick={loginWithNip07} class="btn btn-primary">
<Button {disabled} onclick={loginWithNip07} class="btn btn-primary">
{#if loading === "nip07"}
<span class="loading loading-spinner mr-3"></span>
{:else}
@@ -100,7 +102,7 @@
</Button>
{/if}
{#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"}
<span class="loading loading-spinner mr-3"></span>
{:else}
@@ -110,7 +112,7 @@
</Button>
{/each}
{#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"}
<span class="loading loading-spinner mr-3"></span>
{:else}
@@ -121,13 +123,13 @@
{/if}
<Button
onclick={loginWithBunker}
disabled={Boolean(loading)}
{disabled}
class="btn {hasSigner || BURROW_URL ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="cpu" />
Log in with Remote Signer
</Button>
{#if BURROW_URL && hasSigner}
<Button disabled={Boolean(loading)} onclick={loginWithPassword} class="btn">
<Button {disabled} onclick={loginWithPassword} class="btn">
{#if loading === "password"}
<span class="loading loading-spinner mr-3"></span>
{:else}
@@ -139,7 +141,7 @@
{#if !hasSigner || !BURROW_URL}
<Link
external
disabled={Boolean(loading)}
{disabled}
href="https://nostrapps.com#signers"
class="btn {hasSigner || BURROW_URL ? '' : 'btn-neutral'}">
<Icon icon="compass" />
+2 -1
View File
@@ -12,7 +12,8 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import QRCode from "@app/components/QRCode.svelte"
import InfoBunker from "@app/components/InfoBunker.svelte"
import {loginWithNip46, loadUserData} from "@app/commands"
import {loginWithNip46} from "@app/commands"
import {loadUserData} from "@app/requests"
import {pushModal, clearModals} from "@app/modal"
import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast"
+1 -1
View File
@@ -12,7 +12,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import PasswordResetRequest from "@app/components/PasswordResetRequest.svelte"
import {loadUserData} from "@app/commands"
import {loadUserData} from "@app/requests"
import {clearModals, pushModal} from "@app/modal"
import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast"
+14 -8
View File
@@ -57,14 +57,14 @@
const addRoom = () => pushModal(RoomCreate, {url}, {replaceState})
let showMenu = $state(false)
let replaceState = false
let replaceState = $state(false)
let element: Element | undefined = $state()
const members = $derived(
$memberships.filter(l => hasMembershipUrl(l, url)).map(l => l.event.pubkey),
)
onMount(async () => {
onMount(() => {
replaceState = Boolean(element?.closest(".drawer"))
pullConservatively({relays: [url], filters: [{kinds: [GROUP_META]}]})
})
@@ -112,19 +112,25 @@
{/if}
</div>
<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
</SecondaryNavItem>
<SecondaryNavItem href={threadsPath} notification={$notifications.has(threadsPath)}>
<SecondaryNavItem
{replaceState}
href={threadsPath}
notification={$notifications.has(threadsPath)}>
<Icon icon="notes-minimalistic" /> Threads
</SecondaryNavItem>
<SecondaryNavItem href={calendarPath} notification={$notifications.has(calendarPath)}>
<SecondaryNavItem
{replaceState}
href={calendarPath}
notification={$notifications.has(calendarPath)}>
<Icon icon="calendar-minimalistic" /> Calendar
</SecondaryNavItem>
<div class="h-2"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{#each $userRooms as room, i (room)}
<MenuSpaceRoomItem notify {url} {room} />
<MenuSpaceRoomItem {replaceState} notify {url} {room} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
@@ -137,9 +143,9 @@
</SecondaryNavHeader>
{/if}
{#each $otherRooms as room, i (room)}
<MenuSpaceRoomItem {url} {room} />
<MenuSpaceRoomItem {replaceState} {url} {room} />
{/each}
<SecondaryNavItem onclick={addRoom}>
<SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon="add-circle" />
Create room
</SecondaryNavItem>
+6 -2
View File
@@ -10,15 +10,19 @@
url: any
room: any
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 channel = deriveChannel(url, room)
</script>
<SecondaryNavItem href={path} notification={notify ? $notifications.has(path) : false}>
<SecondaryNavItem
href={path}
{replaceState}
notification={notify ? $notifications.has(path) : false}>
{#if channelIsLocked($channel)}
<Icon icon="lock" size={4} />
{: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 Icon from "@lib/components/Icon.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 ReactionSummary from "@app/components/ReactionSummary.svelte"
import {publishDelete, publishReaction} from "@app/commands"
@@ -26,7 +26,7 @@
</script>
<NoteCard {event} class="card2 bg-alt">
<Content {event} expandMode="inline" />
<NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right">
<EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box">
+144
View File
@@ -0,0 +1,144 @@
<script lang="ts">
import {chunk, sleep, uniq} from "@welshman/lib"
import {
createEvent,
createProfile,
PROFILE,
DELETE,
isReplaceable,
getAddress,
} from "@welshman/util"
import {pubkey, userRelaySelections, publishThunk, getRelayUrls, repository} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/toast"
import {logout} from "@app/commands"
import {INDEXER_RELAYS, userMembership, getMembershipUrls} from "@app/state"
let progress: number | undefined = $state(undefined)
let confirmText = $state("")
const CONFIRM_TEXT = "permanently delete my nostr account"
const confirmOk = $derived(confirmText.toLowerCase().trim() === CONFIRM_TEXT)
const showProgress = $derived(progress !== undefined)
const deleteProfile = async () => {
if (!confirmOk) {
return pushToast({
theme: "error",
message: "Please type your confirmation into the text box.",
})
}
const chunks = chunk(500, repository.query([{authors: [$pubkey!]}]))
const profileEvent = createEvent(PROFILE, createProfile({name: "[deleted]"}))
const vanishEvent = createEvent(62, {tags: [["relay", "ALL_RELAYS"]]})
const denominator = chunks.length + 2
const relays = uniq([
...INDEXER_RELAYS,
...getRelayUrls($userRelaySelections),
...getMembershipUrls($userMembership),
])
let step = 0
const incrementProgress = async () => {
progress = ++step / denominator
return sleep(800)
}
// First, blank out their profile in case relays don't support deletion by address
await publishThunk({relays, event: profileEvent})
await incrementProgress()
// Next, send a "right to vanish" event to all relays
await publishThunk({relays, event: vanishEvent})
await incrementProgress()
// Finally, send deletion requests for all known events in case relays don't support right to vanish
for (const events of chunks) {
const tags: string[][] = []
for (const event of events) {
tags.push(["e", event.id])
if (isReplaceable(event)) {
tags.push(["a", getAddress(event)])
}
}
await publishThunk({relays, event: createEvent(DELETE, {tags})})
await incrementProgress()
}
// Let them see that progress is complete
await sleep(2000)
// Goodbye forever!
await logout()
window.location.href = "/"
}
const confirm = async () => {
progress = 0
try {
await deleteProfile()
} catch (e) {
progress = undefined
throw e
}
}
const back = () => history.back()
</script>
<form class="column gap-4" onsubmit={preventDefault(confirm)}>
<ModalHeader>
{#snippet title()}
Delete your account
{/snippet}
{#snippet info()}
From the Nostr network
{/snippet}
</ModalHeader>
{#if showProgress}
<p>
We are currently sending deletion requests to your relay selections and space hosts. Please
wait while we complete this process. Once we're done, you'll be automatically logged out.
</p>
<progress class="progress progress-primary w-full" value={progress! * 100} max="100"></progress>
{:else}
<p>
Are you sure? To confirm, please type "{CONFIRM_TEXT}" into the text box below. This action
can't be undone.
</p>
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={confirmText} class="grow" type="text" />
</label>
<p>
<strong>Note:</strong> not all relays may honor your request for deletion. If you find that your
content continues to be available, please contact the offending relays directly.
</p>
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-error" disabled={showProgress || !confirmOk}>
<Spinner loading={progress !== undefined}>Confirm</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+14 -58
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {ctx} from "@welshman/lib"
import type {Profile} from "@welshman/util"
import {
createEvent,
makeProfile,
@@ -8,76 +9,31 @@
isPublishedProfile,
} from "@welshman/util"
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 InputProfilePicture from "@lib/components/InputProfilePicture.svelte"
import InfoHandle from "@app/components/InfoHandle.svelte"
import {pushModal, clearModals} from "@app/modal"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {clearModals} from "@app/modal"
import {pushToast} from "@app/toast"
const values = $state({...($profilesByPubkey.get($pubkey!) || makeProfile())})
const initialValues = {...($profilesByPubkey.get($pubkey!) || makeProfile())}
const back = () => history.back()
const saveEdit = () => {
const onsubmit = (profile: Profile) => {
const relays = ctx.app.router.FromUser().getUrls()
const template = isPublishedProfile(values)
? editProfile($state.snapshot(values))
: createProfile($state.snapshot(values))
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
const event = createEvent(template.kind, template)
publishThunk({event, relays})
pushToast({message: "Your profile has been updated!"})
clearModals()
}
let file: File | undefined = $state()
</script>
<form class="col-4" onsubmit={preventDefault(saveEdit)}>
<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}
</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>
<ProfileEditForm {initialValues} {onsubmit}>
{#snippet footer()}
<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>
{/snippet}
</ProfileEditForm>
+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>
{#if $profile}
<Content event={{content: $profile.about, tags: []}} hideMedia />
<Content event={{content: $profile.about, tags: []}} />
{/if}
+27 -11
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import {postJson} from "@welshman/lib"
import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
@@ -8,16 +9,22 @@
import Spinner from "@lib/components/Spinner.svelte"
import LogIn from "@app/components/LogIn.svelte"
import InfoNostr from "@app/components/InfoNostr.svelte"
import SignUpKey from "@app/components/SignUpKey.svelte"
import SignUpSuccess from "@app/components/SignUpSuccess.svelte"
import {pushModal} from "@app/modal"
import {BURROW_URL, PLATFORM_NAME} from "@app/state"
import {BURROW_URL, PLATFORM_NAME, PLATFORM_ACCENT} from "@app/state"
import {pushToast} from "@app/toast"
const ac = window.location.origin
const params = new URLSearchParams({
an: PLATFORM_NAME,
ac: window.location.origin,
at: isMobile ? "android" : "web",
aa: PLATFORM_ACCENT.slice(1),
am: "dark",
asf: "yes",
})
const at = isMobile ? "android" : "web"
const nstart = `https://start.njump.me/?an=Flotilla&at=${at}&ac=${ac}`
const nstart = `https://start.njump.me/?${params.toString()}`
const login = () => pushModal(LogIn)
@@ -37,18 +44,20 @@
}
}
const signup = () => {
const usePassword = () => {
if (BURROW_URL) {
signupPassword()
}
}
const useKey = () => pushModal(SignUpKey)
let email = $state("")
let password = $state("")
let loading = $state(false)
</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>
<p class="m-auto max-w-sm text-center">
{PLATFORM_NAME} is built using the
@@ -89,10 +98,17 @@
</p>
<Divider>Or</Divider>
{/if}
<a href={nstart} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="square-share-line" />
Get going on nstart
</a>
{#if Capacitor.isNativePlatform()}
<Button onclick={useKey} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="key" />
Generate a key
</Button>
{: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">
Already have an account?
<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)}
</p>
{/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">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} />
+93 -7
View File
@@ -1,5 +1,19 @@
import {get, writable} from "svelte/store"
import {partition, int, YEAR, MONTH, insert, sortBy, assoc, now} from "@welshman/lib"
import {
partition,
chunk,
sample,
sleep,
shuffle,
uniq,
int,
YEAR,
MONTH,
insert,
sortBy,
assoc,
now,
} from "@welshman/lib"
import {
MESSAGE,
DELETE,
@@ -9,10 +23,11 @@ import {
matchFilters,
getTagValues,
getTagValue,
isShareableRelayUrl,
} from "@welshman/util"
import type {TrustedEvent, Filter} from "@welshman/util"
import type {TrustedEvent, Filter, List} from "@welshman/util"
import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds"
import type {Subscription} from "@welshman/net"
import type {Subscription, SubscribeRequestWithHandlers} from "@welshman/net"
import type {AppSyncOpts, Thunk} from "@welshman/app"
import {
subscribe,
@@ -22,10 +37,23 @@ import {
hasNegentropy,
thunkWorker,
createFeedController,
loadRelay,
loadMutes,
loadFollows,
loadProfile,
loadInboxRelaySelections,
getRelayUrls,
} from "@welshman/app"
import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util"
import {userRoomsByUrl, getUrlsForEvent} from "@app/state"
import {
INDEXER_RELAYS,
getDefaultPubkeys,
userRoomsByUrl,
getUrlsForEvent,
loadMembership,
loadSettings,
} from "@app/state"
// Utils
@@ -55,6 +83,7 @@ export const makeFeed = ({
feedFilters,
subscriptionFilters,
element,
onEvent,
onExhausted,
initialEvents = [],
}: {
@@ -62,12 +91,21 @@ export const makeFeed = ({
feedFilters: Filter[]
subscriptionFilters: Filter[]
element: HTMLElement
onEvent?: (event: TrustedEvent) => void
onExhausted?: () => void
initialEvents?: TrustedEvent[]
}) => {
const seen = new Set<string>()
const buffer = writable<TrustedEvent[]>([])
const events = writable(initialEvents)
for (const event of initialEvents) {
if (!seen.has(event.id)) {
seen.add(event.id)
onEvent?.(event)
}
}
const insertEvent = (event: TrustedEvent) => {
buffer.update($buffer => {
for (let i = 0; i < $buffer.length; i++) {
@@ -77,6 +115,11 @@ export const makeFeed = ({
return [...$buffer, event]
})
if (!seen.has(event.id)) {
seen.add(event.id)
onEvent?.(event)
}
}
const removeEvents = (ids: string[]) => {
@@ -270,13 +313,17 @@ export const makeCalendarFeed = ({
export const listenForNotifications = () => {
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({
relays: [url],
filters: [
{kinds: [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 +333,7 @@ export const listenForNotifications = () => {
filters: [
{kinds: [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()})),
],
}),
)
@@ -298,3 +345,42 @@ export const listenForNotifications = () => {
}
}
}
export const loadUserData = (
pubkey: string,
request: Partial<SubscribeRequestWithHandlers> = {},
) => {
const promise = Promise.race([
sleep(3000),
Promise.all([
loadInboxRelaySelections(pubkey, request),
loadMembership(pubkey, request),
loadSettings(pubkey, request),
loadProfile(pubkey, request),
loadFollows(pubkey, request),
loadMutes(pubkey, request),
]),
])
// Load followed profiles slowly in the background without clogging other stuff up. Only use a single
// indexer relay to avoid too many redundant validations, which slow things down and eat bandwidth
promise.then(async () => {
for (const pubkeys of chunk(50, getDefaultPubkeys())) {
const relays = sample(1, INDEXER_RELAYS)
await sleep(1000)
for (const pubkey of pubkeys) {
loadMembership(pubkey, {relays})
loadProfile(pubkey, {relays})
loadFollows(pubkey, {relays})
loadMutes(pubkey, {relays})
}
}
})
return promise
}
export const discoverRelays = (lists: List[]) =>
Promise.all(uniq(lists.flatMap(getRelayUrls)).filter(isShareableRelayUrl).map(loadRelay))
+5 -1
View File
@@ -345,7 +345,11 @@ export const hasMembershipUrl = (list: List | undefined, url: string) =>
export const getMembershipUrls = (list?: List) => {
const tags = getListTags(list)
return sort(uniq([...getRelayTagValues(tags), ...getGroupTags(tags).map(nth(2))]))
return sort(
uniq([...getRelayTagValues(tags), ...getGroupTags(tags).map(nth(2))]).map(url =>
normalizeRelayUrl(url),
),
)
}
export const getMembershipRooms = (list?: List) =>
+3 -2
View File
@@ -24,15 +24,16 @@
import {fade} from "@lib/transition"
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)
</script>
{#if href}
<a
{...restProps}
{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:text-base-content={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 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)
}
+2 -2
View File
@@ -67,8 +67,8 @@
import {nsecDecode} from "@lib/util"
import {theme} from "@app/theme"
import {INDEXER_RELAYS, userMembership, ensureUnwrapped, canDecrypt} from "@app/state"
import {loadUserData, loginWithNip46} from "@app/commands"
import {listenForNotifications} from "@app/requests"
import {loadUserData, listenForNotifications} from "@app/requests"
import {loginWithNip46} from "@app/commands"
import * as commands from "@app/commands"
import * as requests from "@app/requests"
import * as notifications from "@app/notifications"
+32 -17
View File
@@ -2,8 +2,9 @@
import {onMount} from "svelte"
import {addToMapKey, dec, gt} from "@welshman/lib"
import type {Relay} from "@welshman/app"
import {relays, createSearch} from "@welshman/app"
import {relays, createSearch, loadRelay, loadRelaySelections} from "@welshman/app"
import {createScroller} from "@lib/html"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Page from "@lib/components/Page.svelte"
import Spinner from "@lib/components/Spinner.svelte"
@@ -14,15 +15,26 @@
import SpaceCheck from "@app/components/SpaceCheck.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {
memberships,
membershipByPubkey,
getMembershipUrls,
loadMembership,
userRoomsByUrl,
getDefaultPubkeys,
} from "@app/state"
import {discoverRelays} from "@app/commands"
import {pushModal} from "@app/modal"
const discoverRelays = () =>
Promise.all(
getDefaultPubkeys().map(async pubkey => {
await loadRelaySelections(pubkey)
const membership = await loadMembership(pubkey)
const urls = getMembershipUrls(membership)
await Promise.all(urls.map(url => loadRelay(url)))
}),
)
const wotGraph = $derived.by(() => {
const scores = new Map<string, Set<string>>()
@@ -36,20 +48,23 @@
})
const relaySearch = $derived(
createSearch($relays, {
getValue: (relay: Relay) => relay.url,
sortFn: ({score, item}) => {
if (score && score > 0.1) return -score!
createSearch(
$relays.filter(r => wotGraph.has(r.url)),
{
getValue: (relay: Relay) => relay.url,
sortFn: ({score, item}) => {
if (score && score > 0.1) return -score!
const wotScore = wotGraph.get(item.url)?.size || 0
const wotScore = wotGraph.get(item.url)?.size || 0
return score ? dec(score) * wotScore : -wotScore
return score ? dec(score) * wotScore : -wotScore
},
fuseOptions: {
keys: ["url", "name", {name: "description", weight: 0.3}],
shouldSort: false,
},
},
fuseOptions: {
keys: ["url", "name", {name: "description", weight: 0.3}],
shouldSort: false,
},
}),
),
)
const openSpace = (url: string) => pushModal(SpaceCheck, {url})
@@ -128,9 +143,9 @@
{/if}
</Button>
{/each}
{#await discoverRelays($memberships)}
<div class="flex justify-center">
<Spinner loading>Loading more relays...</Spinner>
{#await discoverRelays()}
<div class="flex justify-center py-20" out:fly>
<Spinner loading>Looking for spaces...</Spinner>
</div>
{/await}
</div>
+10 -1
View File
@@ -9,6 +9,7 @@
import Avatar from "@lib/components/Avatar.svelte"
import Content from "@app/components/Content.svelte"
import ProfileEdit from "@app/components/ProfileEdit.svelte"
import ProfileDelete from "@app/components/ProfileDelete.svelte"
import InfoKeys from "@app/components/InfoKeys.svelte"
import {PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal"
@@ -25,6 +26,8 @@
const startEdit = () => pushModal(ProfileEdit)
const startEject = () => pushModal(InfoKeys)
const startDelete = () => pushModal(ProfileDelete)
</script>
<div class="content column gap-4">
@@ -50,7 +53,7 @@
</Button>
</div>
{#key $profile?.about}
<Content event={{content: $profile?.about || "", tags: []}} hideMedia />
<Content event={{content: $profile?.about || "", tags: []}} hideMediaAtDepth={0} />
{/key}
</div>
{#if $session?.email}
@@ -117,4 +120,10 @@
</FieldInline>
{/if}
</div>
<div class="card2 bg-alt col-4 shadow-xl">
<Button class="btn btn-outline btn-error" onclick={startDelete}>
<Icon icon="trash-bin-2" />
Delete your profile
</Button>
</div>
</div>
+2 -1
View File
@@ -16,7 +16,8 @@
import RelayItem from "@app/components/RelayItem.svelte"
import RelayAdd from "@app/components/RelayAdd.svelte"
import {pushModal} from "@app/modal"
import {setRelayPolicy, discoverRelays, setInboxRelayPolicy} from "@app/commands"
import {discoverRelays} from "@app/requests"
import {setRelayPolicy, setInboxRelayPolicy} from "@app/commands"
const readRelayUrls = derived(userRelaySelections, getReadRelayUrls)
const writeRelayUrls = derived(userRelaySelections, getWriteRelayUrls)
+25 -10
View File
@@ -50,8 +50,12 @@
const joinRoom = async () => {
if (hasNip29($relay)) {
joiningRoom = true
const message = await getThunkError(nip29.joinRoom(url, room))
joiningRoom = false
if (message && !message.includes("already")) {
return pushToast({theme: "error", message})
}
@@ -126,7 +130,8 @@
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 parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state()
@@ -147,6 +152,12 @@
let newMessagesSeen = false
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()) {
if (seen.has(event.id)) {
continue
@@ -156,9 +167,9 @@
if (
!newMessagesSeen &&
adjustedLastChecked &&
event.pubkey !== $pubkey &&
lastChecked &&
event.created_at > lastChecked
event.created_at > adjustedLastChecked
) {
elements.push({type: "new-messages", id: "new-messages"})
newMessagesSeen = true
@@ -196,7 +207,7 @@
subscriptionFilters: [{kinds: [DELETE, REACTION, MESSAGE], "#h": [room], since: now()}],
initialEvents: getEventsForUrl(url, [{...filter, limit: 20}]),
onExhausted: () => {
loading = false
loadingEvents = false
},
}))
})
@@ -211,7 +222,7 @@
})
</script>
<div class="saib relative flex h-full flex-col">
<div class="relative flex h-full flex-col">
<PageBar>
{#snippet icon()}
<div class="center">
@@ -232,8 +243,12 @@
Leave Room
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" onclick={joinRoom}>
<Icon icon="login-2" />
<Button class="btn btn-neutral btn-sm" disabled={joiningRoom} onclick={joinRoom}>
{#if joiningRoom}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon="login-2" />
{/if}
Join Room
</Button>
{/if}
@@ -265,8 +280,8 @@
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loading}
<Spinner loading>Looking for messages...</Spinner>
{#if loadingEvents}
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
@@ -281,7 +296,7 @@
</div>
</div>
{/if}
<div class="saib">
<div>
{#if parent}
<ChannelComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
@@ -126,7 +126,11 @@
<strong>Calendar</strong>
{/snippet}
{#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} />
</div>
{/snippet}
@@ -157,12 +161,4 @@
<p class="flex h-10 items-center justify-center py-20" transition:fly>That's all!</p>
{/if}
</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>
@@ -1,17 +1,9 @@
<script lang="ts">
import {onMount} from "svelte"
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 {
repository,
subscribe,
formatTimestamp,
LOCALE,
secondsToDate,
formatTimestampAsDate,
formatTimestampAsTime,
} from "@welshman/app"
import {repository, subscribe} from "@welshman/app"
import {deriveEvents} from "@welshman/store"
import Icon from "@lib/components/Icon.svelte"
import PageBar from "@lib/components/PageBar.svelte"
@@ -20,8 +12,10 @@
import Content from "@app/components/Content.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ProfileLink from "@app/components/ProfileLink.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 {deriveEvent, decodeRelay} from "@app/state"
import {setChecked} from "@app/notifications"
@@ -31,13 +25,6 @@
const event = deriveEvent(id)
const filters = [{kinds: [COMMENT], "#E": [id]}]
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()
@@ -95,39 +82,18 @@
{/if}
<div class="card2 bg-alt col-3 z-feature">
<div class="flex items-start gap-4">
<div
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>
<CalendarEventDate event={$event} />
<div class="flex flex-grow flex-col">
<div class="flex flex-grow justify-between gap-2">
<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>
<CalendarEventHeader event={$event} />
</div>
<div class="flex items-center gap-2 text-sm opacity-75">
<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}
<CalendarEventMeta event={$event} />
</div>
<div class="flex py-2 opacity-50">
<div class="h-px flex-grow bg-base-content opacity-25"></div>
</div>
<Content showEntire event={$event} quoteProps={{relays: [url]}} />
<Content showEntire event={$event} relays={[url]} />
</div>
</div>
<div class="flex w-full flex-col justify-end sm:flex-row">
@@ -141,12 +107,12 @@
<p>Failed to load comments.</p>
{/await}
{/if}
<PageBar class="mx-0">
<PageBar class="!mx-0">
{#snippet icon()}
<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" />
Go back
<span class="hidden sm:inline">Go back</span>
</Button>
</div>
{/snippet}
+54 -63
View File
@@ -1,13 +1,10 @@
<script lang="ts">
import {onMount} from "svelte"
import {derived} from "svelte/store"
import {page} from "$app/stores"
import {sortBy, min, nthEq} from "@welshman/lib"
import {THREAD, COMMENT, getListTags, getPubkeyTagValues} from "@welshman/util"
import {throttled} from "@welshman/store"
import {feedFromFilters, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
import {createFeedController, userMutes} from "@welshman/app"
import {createScroller, type Scroller} from "@lib/html"
import {sortBy, max, nthEq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {THREAD, REACTION, DELETE, COMMENT, getListTags, getPubkeyTagValues} from "@welshman/util"
import {userMutes} from "@welshman/app"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -16,69 +13,63 @@
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ThreadItem from "@app/components/ThreadItem.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 {makeFeed} from "@app/requests"
import {pushModal} from "@app/modal"
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 threads: TrustedEvent[] = $state([])
const comments: TrustedEvent[] = $state([])
const events = throttled(
800,
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)),
)
}),
)
let loading = $state(true)
let element: HTMLElement | undefined = $state()
const createThread = () => pushModal(ThreadCreate, {url})
const ctrl = createFeedController({
useWindowing: true,
feed: makeIntersectionFeed(makeRelayFeed(url), feed),
onExhausted: () => {
loading = false
},
const events = $derived.by(() => {
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, max([scores.get(id), comment.created_at]))
}
}
return sortBy(e => -max([scores.get(e.id), e.created_at]), threads)
})
let limit = 10
let loading = $state(true)
let element: Element | undefined = $state()
let scroller: Scroller
$inspect({threads, comments, events})
onMount(() => {
scroller = createScroller({
const {cleanup} = makeFeed({
element: element!,
delay: 300,
threshold: 3000,
onScroll: () => {
limit += 10
if ($events.length - limit < 10) {
ctrl.load(50)
relays: [url],
feedFilters: [{kinds: [THREAD, COMMENT]}],
subscriptionFilters: [
{kinds: [THREAD, REACTION, DELETE]},
{kinds: [COMMENT], "#K": [String(THREAD)]},
],
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 () => {
scroller?.stop()
cleanup()
setChecked($page.url.pathname)
}
})
@@ -105,21 +96,21 @@
{/snippet}
</PageBar>
<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>
<ThreadItem {url} {event} />
</div>
{/each}
{#if loading || $events.length === 0}
<p class="flex h-10 items-center justify-center py-20" out:fly>
<Spinner {loading}>
{#if loading}
Looking for threads...
{:else if $events.length === 0}
No threads found.
{/if}
</Spinner>
</p>
{/if}
<p class="flex h-10 items-center justify-center py-20">
<Spinner {loading}>
{#if loading}
Looking for threads...
{:else if events.length === 0}
No threads found.
{:else}
That's all!
{/if}
</Spinner>
</p>
</div>
</div>
@@ -79,7 +79,7 @@
{/if}
<NoteCard event={$event} class="card2 bg-alt z-feature w-full">
<div class="col-3 ml-12">
<Content showEntire event={$event} quoteProps={{relays: [url]}} />
<Content showEntire event={$event} relays={[url]} />
<ThreadActions event={$event} {url} />
</div>
</NoteCard>
@@ -90,12 +90,12 @@
<p>Failed to load thread.</p>
{/await}
{/if}
<PageBar class="mx-0">
<PageBar class="!mx-0">
{#snippet icon()}
<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" />
Go back
<span class="hidden sm:inline">Go back</span>
</Button>
</div>
{/snippet}