diff --git a/package.json b/package.json index 17e6c4207..0d17d50e4 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "applesauce-signers": "^1.2.0", "applesauce-wallet": "^1.0.0", "bech32": "^2.0.0", - "blossom-client-sdk": "^4.0.0", + "blossom-client-sdk": "next", "blurhash": "^2.0.5", "canvas-confetti": "^1.9.3", "chart.js": "^4.4.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d185e0259..e03eccbdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,8 +137,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 blossom-client-sdk: - specifier: ^4.0.0 - version: 4.0.0 + specifier: next + version: 0.0.0-next-20250516125901 blurhash: specifier: ^2.0.5 version: 2.0.5 @@ -2325,8 +2325,8 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - blossom-client-sdk@4.0.0: - resolution: {integrity: sha512-1tjFVwSRPo2fq1HskHzfneWSBa4JmfyveFSiyPas/weyLpMKD7lgN8/F2AxdB9v7TaEFO/xhPwE/KTBGcyofWw==} + blossom-client-sdk@0.0.0-next-20250516125901: + resolution: {integrity: sha512-yd44m5TeET5G1TBCrdphJK5at0xcpXTi8cIGZd538DPKhJcIxC3Te2Jbi2wUP9y68bSkti5+6XhFj3Xc6tVUvQ==} engines: {node: '>=18'} blurhash@2.0.5: @@ -8193,7 +8193,7 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - blossom-client-sdk@4.0.0: + blossom-client-sdk@0.0.0-next-20250516125901: dependencies: '@cashu/cashu-ts': 2.5.2 '@noble/hashes': 1.8.0 diff --git a/src/helpers/media-upload/blossom.ts b/src/helpers/media-upload/blossom.ts index 37c07d166..711ce594e 100644 --- a/src/helpers/media-upload/blossom.ts +++ b/src/helpers/media-upload/blossom.ts @@ -1,5 +1,6 @@ import { BlobDescriptor, createUploadAuth, ServerType, Signer } from "blossom-client-sdk"; import { multiServerUpload, MultiServerUploadOptions } from "blossom-client-sdk/actions/multi-server"; +import localSettings from "../../services/local-settings"; export async function simpleMultiServerUpload( servers: T[], @@ -7,10 +8,14 @@ export async function simpleMultiServerUpload signer: Signer, opts?: MultiServerUploadOptions, ): Promise { + const isMedia = file.type.startsWith("image/") || file.type.startsWith("video/"); + const auth = localSettings.alwaysAuthUpload.value || undefined; + const results = await multiServerUpload(servers, file, { - isMedia: file.type.startsWith("image/") || file.type.startsWith("video/"), + isMedia, mediaUploadBehavior: "any", mediaUploadFallback: true, + auth, ...opts, onAuth: (_server, blob, type) => createUploadAuth(signer, blob, { type }), }); diff --git a/src/services/local-settings.ts b/src/services/local-settings.ts index 499450090..14b40da53 100644 --- a/src/services/local-settings.ts +++ b/src/services/local-settings.ts @@ -52,6 +52,7 @@ const enableKeyboardShortcuts = new BooleanLocalStorageEntry("enable-keyboard-sh // privacy const debugApi = new BooleanLocalStorageEntry("debug-api", false); +const alwaysAuthUpload = new BooleanLocalStorageEntry("always-auth-upload", true); // relay authentication const defaultAuthenticationMode = new LocalStorageEntry("default-authentication-mode", "ask"); @@ -90,6 +91,7 @@ const localSettings = { ntfyTopic, ntfyServer, cacheRelayURL, + alwaysAuthUpload, }; if (import.meta.env.DEV) { diff --git a/src/services/social-graph.ts b/src/services/social-graph.ts index a94af9154..bb51377f1 100644 --- a/src/services/social-graph.ts +++ b/src/services/social-graph.ts @@ -1,15 +1,31 @@ -import { getProfilePointersFromList } from "applesauce-core/helpers"; +import { getProfilePointersFromList, mergeRelaySets } from "applesauce-core/helpers"; import { SerializedSocialGraph, SocialGraph } from "nostr-social-graph"; -import { kinds } from "nostr-tools"; +import { Filter, NostrEvent, kinds as nostrKinds } from "nostr-tools"; import { ProfilePointer } from "nostr-tools/nip19"; -import { distinct, filter, mergeMap, share, startWith, Subject, take, tap } from "rxjs"; +import { + bufferTime, + combineLatest, + distinct, + filter, + ignoreElements, + merge, + mergeMap, + Observable, + of, + scan, + startWith, + Subject, + Subscription, + take, + tap, +} from "rxjs"; import { SOCIAL_GRAPH_FALLBACK_PUBKEY } from "../const"; +import { logger } from "../helpers/debug"; import accounts from "./accounts"; import idbKeyValueStore from "./database/kv"; import { eventStore } from "./event-store"; import replaceableEventLoader from "./replaceable-loader"; -import { logger } from "../helpers/debug"; const log = logger.extend("SocialGraph"); const cacheKey = "social-graph"; @@ -28,38 +44,161 @@ export async function saveSocialGraph() { await idbKeyValueStore.setItem(cacheKey, socialGraph.serialize()); } -export function crawlFollowGraph(root: string | ProfilePointer, maxDistance: number = 2) { - log(`Started crawling follow graph`); - const loadUser = (user: ProfilePointer) => { - const pubkey = typeof user === "string" ? user : user.pubkey; +/** Make a request to load a list of users in the social graph and return an observable that completes with finished loading */ +type GraphLoaderRequest = (pointers: ProfilePointer) => Observable; - replaceableEventLoader.next({ kind: kinds.Contacts, ...user }); - return eventStore.filters({ kinds: [kinds.Contacts], authors: [pubkey] }); +export function createBatchUserLoader( + request: (relays: string[], filters: Filter[]) => Observable, + kinds: number[] = [nostrKinds.Contacts, nostrKinds.Metadata, nostrKinds.Mutelist], +): GraphLoaderRequest { + const input = new Subject(); + + const output = input.pipe( + // skip duplicates + distinct((user) => user.pubkey), + // buffer for 1 second + bufferTime(1_000), + // merge users into a single request + mergeMap((users) => { + const filter: Filter = { kinds, authors: users.map((u) => u.pubkey) }; + const relays = mergeRelaySets(...users.map((u) => u.relays)); + return request(relays, [filter]); + }), + ); + + return (user) => { + input.next(user); + return output; }; +} - const queue = new Subject<[ProfilePointer, number]>(); +export function graphLoader(root: string | ProfilePointer, maxDistance: number = 2, request: GraphLoaderRequest) { + return new Observable<{ total: number; loaded: number }>((observer) => { + // Keep track of all loaded and discovered users + const loaded = new Set(); + const found = new Set(); - return queue.pipe( + // Queue of users to load + const queue: [ProfilePointer, number][] = []; + + // Add root to queue + queue.push([typeof root === "string" ? { pubkey: root } : root, 0]); + + // Keep track of all subscriptions + const subscriptions: Subscription[] = []; + + // Start loading users + while (queue.length > 0) { + const [pointer, distance] = queue.shift()!; + if (distance > maxDistance) continue; + + // Don't load the same user twice + if (loaded.has(pointer.pubkey)) continue; + loaded.add(pointer.pubkey); + found.add(pointer.pubkey); + + // Update progress + observer.next({ total: found.size, loaded: loaded.size }); + + // Load the user + const sub = request(pointer) + .pipe( + filter((e) => e.kind === nostrKinds.Contacts && e.pubkey === pointer.pubkey), + take(1), + ) + .subscribe((event) => { + // Temp: add events to the store + eventStore.add(event); + + const contacts = getProfilePointersFromList(event); + log(`Loaded contacts for ${pointer.pubkey}`, contacts); + + if (distance < maxDistance) { + for (const pointer of contacts) { + if (found.has(pointer.pubkey)) continue; + found.add(pointer.pubkey); + + // Add the user to the queue to be loaded + queue.push([pointer, distance + 1]); + } + } + + // Update progress + observer.next({ total: found.size, loaded: loaded.size }); + }); + + subscriptions.push(sub); + } + + // complete the loader when queue is empty + // observer.complete(); + + // return cleanup + return () => { + for (const subscription of subscriptions) subscription.unsubscribe(); + }; + }); +} + +export function crawlFollowGraph( + root: string | ProfilePointer, + maxDistance: number = 2, +): Observable<{ total: number; loaded: number }> { + log(`Started crawling follow graph`); + + const loaded = new Subject(); + const input = new Subject<[ProfilePointer, number]>(); + + const queue = input.pipe( // Don't load users who are too far filter(([_user, distance]) => distance <= maxDistance), // only load users once distinct(([user]) => user.pubkey), // Start with the root user startWith([typeof root === "string" ? { pubkey: root } : root, 0] as const), - // create new observable to load each user - mergeMap(([user, distance]) => - loadUser(user).pipe( - take(1), - tap((event) => { - const contacts = getProfilePointersFromList(event); - // Add the user to the queue to be loaded - for (const pointer of contacts) queue.next([pointer, distance + 1]); - }), - ), - ), - // only create a single loader - share(), ); + + // Create observable for progress + const progress = combineLatest({ + total: queue.pipe(scan((acc) => acc + 1, 0)), + loaded: loaded.pipe(scan((acc) => acc + 1, 0)), + }); + + const loader = queue.pipe( + // create new observable to load each user + mergeMap(([user, distance]) => { + log(`Loading contacts for ${user.pubkey}`); + replaceableEventLoader.next({ kind: nostrKinds.Contacts, ...user }); + + const event = eventStore.getReplaceable(nostrKinds.Contacts, user.pubkey); + + return ( + event + ? of(event) + : eventStore.inserts.pipe( + // Wait for the contacts event to be loaded + filter((e) => e.kind === nostrKinds.Contacts && e.pubkey === user.pubkey), + // Take the first event + take(1), + ) + ).pipe( + // Add contacts to the queue + tap((event) => { + loaded.next(event); + + const contacts = getProfilePointersFromList(event); + log(`Loaded contacts for ${user.pubkey}`, contacts); + + // Add the user to the queue to be loaded + for (const pointer of contacts) input.next([pointer, distance + 1]); + }), + // Ignore loaded events + ignoreElements(), + ); + }), + ); + + return merge(loader, progress); } /** Exports the social graph to a file */ diff --git a/src/views/settings/post/index.tsx b/src/views/settings/post/index.tsx index 0386a31dd..ef0b61d12 100644 --- a/src/views/settings/post/index.tsx +++ b/src/views/settings/post/index.tsx @@ -30,6 +30,7 @@ export default function PostSettings() { watch("mediaUploadService"); const addClientTag = useObservable(localSettings.addClientTag); + const alwaysAuthUpload = useObservable(localSettings.alwaysAuthUpload); return ( - Always mirror media + Mirror media when sharing - Copy all media to your personal blossom servers when sharing notes + Mirror all media to your personal blossom servers when sharing notes + + + + + + Always authenticate media uploads + + localSettings.alwaysAuthUpload.next(!localSettings.alwaysAuthUpload.value)} + /> + + Always authenticate with media servers when uploading media ); diff --git a/src/views/settings/social-graph/index.tsx b/src/views/settings/social-graph/index.tsx index a6ab1ce84..d8115c496 100644 --- a/src/views/settings/social-graph/index.tsx +++ b/src/views/settings/social-graph/index.tsx @@ -10,20 +10,29 @@ import { NumberInputField, Text, } from "@chakra-ui/react"; -import { useActiveAccount, useObservable } from "applesauce-react/hooks"; -import { SocialGraph } from "nostr-social-graph"; +import { useActiveAccount } from "applesauce-react/hooks"; import { useCallback, useEffect, useState } from "react"; -import { tap, throttleTime } from "rxjs"; import SimpleView from "../../../components/layout/presets/simple-view"; import UserAvatar from "../../../components/user/user-avatar"; import { UserAvatarLink } from "../../../components/user/user-avatar-link"; import UserDnsIdentity from "../../../components/user/user-dns-identity"; import UserName from "../../../components/user/user-name"; -import { crawlFollowGraph, exportGraph, importGraph, socialGraph } from "../../../services/social-graph"; +import { useAppTitle } from "../../../hooks/use-app-title"; import { useBreakpointValue } from "../../../providers/global/breakpoint-provider"; +import pool from "../../../services/pool"; +import { + createBatchUserLoader, + exportGraph, + graphLoader, + importGraph, + socialGraph, +} from "../../../services/social-graph"; +import { useUnmount } from "react-use"; +import { Subscription } from "rxjs"; export default function SocialGraphSettings() { + useAppTitle("Social Graph"); const [followDistances, setFollowDistances] = useState<{ distance: number; count: number; randomUsers: string[] }[]>( [], ); @@ -50,28 +59,21 @@ export default function SocialGraphSettings() { setFollowDistances(generateFollowDistances()); }, [socialGraph, generateFollowDistances]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(); const handleLoadGraph = () => { if (account?.pubkey && socialGraph) { - setLoading(true); - - // Create and start the loader - crawlFollowGraph(account.pubkey, distance) - .pipe( - // send event to graph - tap((e) => socialGraph.handleEvent(e)), - // Update the follow distance every second while loading - throttleTime(1000), - tap(() => { - socialGraph.recalculateFollowDistances(); - setFollowDistances(generateFollowDistances()); - }), - ) - .subscribe({ - complete: () => setLoading(false), - }); + setLoading( + graphLoader(account.pubkey, distance, createBatchUserLoader(pool.request.bind(pool))).subscribe({ + next: (progress) => { + console.log(progress); + }, + }), + ); } }; + useUnmount(() => { + loading?.unsubscribe(); + }); const displayMaxPeople = useBreakpointValue({ base: 4, lg: 5, xl: 10 }) || 4;