mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-06-24 15:52:27 +02:00
add setting to always send authorization on blossom uploads
This commit is contained in:
parent
37bada4390
commit
37fe7a05f4
@ -60,7 +60,7 @@
|
|||||||
"applesauce-signers": "^1.2.0",
|
"applesauce-signers": "^1.2.0",
|
||||||
"applesauce-wallet": "^1.0.0",
|
"applesauce-wallet": "^1.0.0",
|
||||||
"bech32": "^2.0.0",
|
"bech32": "^2.0.0",
|
||||||
"blossom-client-sdk": "^4.0.0",
|
"blossom-client-sdk": "next",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.3",
|
||||||
"chart.js": "^4.4.9",
|
"chart.js": "^4.4.9",
|
||||||
|
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@ -137,8 +137,8 @@ importers:
|
|||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
blossom-client-sdk:
|
blossom-client-sdk:
|
||||||
specifier: ^4.0.0
|
specifier: next
|
||||||
version: 4.0.0
|
version: 0.0.0-next-20250516125901
|
||||||
blurhash:
|
blurhash:
|
||||||
specifier: ^2.0.5
|
specifier: ^2.0.5
|
||||||
version: 2.0.5
|
version: 2.0.5
|
||||||
@ -2325,8 +2325,8 @@ packages:
|
|||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||||
|
|
||||||
blossom-client-sdk@4.0.0:
|
blossom-client-sdk@0.0.0-next-20250516125901:
|
||||||
resolution: {integrity: sha512-1tjFVwSRPo2fq1HskHzfneWSBa4JmfyveFSiyPas/weyLpMKD7lgN8/F2AxdB9v7TaEFO/xhPwE/KTBGcyofWw==}
|
resolution: {integrity: sha512-yd44m5TeET5G1TBCrdphJK5at0xcpXTi8cIGZd538DPKhJcIxC3Te2Jbi2wUP9y68bSkti5+6XhFj3Xc6tVUvQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
blurhash@2.0.5:
|
blurhash@2.0.5:
|
||||||
@ -8193,7 +8193,7 @@ snapshots:
|
|||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
|
|
||||||
blossom-client-sdk@4.0.0:
|
blossom-client-sdk@0.0.0-next-20250516125901:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cashu/cashu-ts': 2.5.2
|
'@cashu/cashu-ts': 2.5.2
|
||||||
'@noble/hashes': 1.8.0
|
'@noble/hashes': 1.8.0
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { BlobDescriptor, createUploadAuth, ServerType, Signer } from "blossom-client-sdk";
|
import { BlobDescriptor, createUploadAuth, ServerType, Signer } from "blossom-client-sdk";
|
||||||
import { multiServerUpload, MultiServerUploadOptions } from "blossom-client-sdk/actions/multi-server";
|
import { multiServerUpload, MultiServerUploadOptions } from "blossom-client-sdk/actions/multi-server";
|
||||||
|
import localSettings from "../../services/local-settings";
|
||||||
|
|
||||||
export async function simpleMultiServerUpload<T extends ServerType = ServerType>(
|
export async function simpleMultiServerUpload<T extends ServerType = ServerType>(
|
||||||
servers: T[],
|
servers: T[],
|
||||||
@ -7,10 +8,14 @@ export async function simpleMultiServerUpload<T extends ServerType = ServerType>
|
|||||||
signer: Signer,
|
signer: Signer,
|
||||||
opts?: MultiServerUploadOptions<T, File>,
|
opts?: MultiServerUploadOptions<T, File>,
|
||||||
): Promise<BlobDescriptor> {
|
): Promise<BlobDescriptor> {
|
||||||
|
const isMedia = file.type.startsWith("image/") || file.type.startsWith("video/");
|
||||||
|
const auth = localSettings.alwaysAuthUpload.value || undefined;
|
||||||
|
|
||||||
const results = await multiServerUpload(servers, file, {
|
const results = await multiServerUpload(servers, file, {
|
||||||
isMedia: file.type.startsWith("image/") || file.type.startsWith("video/"),
|
isMedia,
|
||||||
mediaUploadBehavior: "any",
|
mediaUploadBehavior: "any",
|
||||||
mediaUploadFallback: true,
|
mediaUploadFallback: true,
|
||||||
|
auth,
|
||||||
...opts,
|
...opts,
|
||||||
onAuth: (_server, blob, type) => createUploadAuth(signer, blob, { type }),
|
onAuth: (_server, blob, type) => createUploadAuth(signer, blob, { type }),
|
||||||
});
|
});
|
||||||
|
@ -52,6 +52,7 @@ const enableKeyboardShortcuts = new BooleanLocalStorageEntry("enable-keyboard-sh
|
|||||||
|
|
||||||
// privacy
|
// privacy
|
||||||
const debugApi = new BooleanLocalStorageEntry("debug-api", false);
|
const debugApi = new BooleanLocalStorageEntry("debug-api", false);
|
||||||
|
const alwaysAuthUpload = new BooleanLocalStorageEntry("always-auth-upload", true);
|
||||||
|
|
||||||
// relay authentication
|
// relay authentication
|
||||||
const defaultAuthenticationMode = new LocalStorageEntry<RelayAuthMode>("default-authentication-mode", "ask");
|
const defaultAuthenticationMode = new LocalStorageEntry<RelayAuthMode>("default-authentication-mode", "ask");
|
||||||
@ -90,6 +91,7 @@ const localSettings = {
|
|||||||
ntfyTopic,
|
ntfyTopic,
|
||||||
ntfyServer,
|
ntfyServer,
|
||||||
cacheRelayURL,
|
cacheRelayURL,
|
||||||
|
alwaysAuthUpload,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
|
@ -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 { 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 { 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 { SOCIAL_GRAPH_FALLBACK_PUBKEY } from "../const";
|
||||||
|
import { logger } from "../helpers/debug";
|
||||||
import accounts from "./accounts";
|
import accounts from "./accounts";
|
||||||
import idbKeyValueStore from "./database/kv";
|
import idbKeyValueStore from "./database/kv";
|
||||||
import { eventStore } from "./event-store";
|
import { eventStore } from "./event-store";
|
||||||
import replaceableEventLoader from "./replaceable-loader";
|
import replaceableEventLoader from "./replaceable-loader";
|
||||||
import { logger } from "../helpers/debug";
|
|
||||||
|
|
||||||
const log = logger.extend("SocialGraph");
|
const log = logger.extend("SocialGraph");
|
||||||
const cacheKey = "social-graph";
|
const cacheKey = "social-graph";
|
||||||
@ -28,38 +44,161 @@ export async function saveSocialGraph() {
|
|||||||
await idbKeyValueStore.setItem(cacheKey, socialGraph.serialize());
|
await idbKeyValueStore.setItem(cacheKey, socialGraph.serialize());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function crawlFollowGraph(root: string | ProfilePointer, maxDistance: number = 2) {
|
/** Make a request to load a list of users in the social graph and return an observable that completes with finished loading */
|
||||||
log(`Started crawling follow graph`);
|
type GraphLoaderRequest = (pointers: ProfilePointer) => Observable<NostrEvent>;
|
||||||
const loadUser = (user: ProfilePointer) => {
|
|
||||||
const pubkey = typeof user === "string" ? user : user.pubkey;
|
|
||||||
|
|
||||||
replaceableEventLoader.next({ kind: kinds.Contacts, ...user });
|
export function createBatchUserLoader(
|
||||||
return eventStore.filters({ kinds: [kinds.Contacts], authors: [pubkey] });
|
request: (relays: string[], filters: Filter[]) => Observable<NostrEvent>,
|
||||||
|
kinds: number[] = [nostrKinds.Contacts, nostrKinds.Metadata, nostrKinds.Mutelist],
|
||||||
|
): GraphLoaderRequest {
|
||||||
|
const input = new Subject<ProfilePointer>();
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
const found = new Set<string>();
|
||||||
|
|
||||||
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<NostrEvent>();
|
||||||
|
const input = new Subject<[ProfilePointer, number]>();
|
||||||
|
|
||||||
|
const queue = input.pipe(
|
||||||
// Don't load users who are too far
|
// Don't load users who are too far
|
||||||
filter(([_user, distance]) => distance <= maxDistance),
|
filter(([_user, distance]) => distance <= maxDistance),
|
||||||
// only load users once
|
// only load users once
|
||||||
distinct(([user]) => user.pubkey),
|
distinct(([user]) => user.pubkey),
|
||||||
// Start with the root user
|
// Start with the root user
|
||||||
startWith([typeof root === "string" ? { pubkey: root } : root, 0] as const),
|
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 */
|
/** Exports the social graph to a file */
|
||||||
|
@ -30,6 +30,7 @@ export default function PostSettings() {
|
|||||||
watch("mediaUploadService");
|
watch("mediaUploadService");
|
||||||
|
|
||||||
const addClientTag = useObservable(localSettings.addClientTag);
|
const addClientTag = useObservable(localSettings.addClientTag);
|
||||||
|
const alwaysAuthUpload = useObservable(localSettings.alwaysAuthUpload);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleView
|
<SimpleView
|
||||||
@ -120,11 +121,25 @@ export default function PostSettings() {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Flex alignItems="center">
|
<Flex alignItems="center">
|
||||||
<FormLabel htmlFor="mirrorBlobsOnShare" mb="0">
|
<FormLabel htmlFor="mirrorBlobsOnShare" mb="0">
|
||||||
Always mirror media
|
Mirror media when sharing
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Switch id="mirrorBlobsOnShare" {...register("mirrorBlobsOnShare")} />
|
<Switch id="mirrorBlobsOnShare" {...register("mirrorBlobsOnShare")} />
|
||||||
</Flex>
|
</Flex>
|
||||||
<FormHelperText>Copy all media to your personal blossom servers when sharing notes</FormHelperText>
|
<FormHelperText>Mirror all media to your personal blossom servers when sharing notes</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Flex alignItems="center">
|
||||||
|
<FormLabel htmlFor="alwaysAuthUpload" mb="0">
|
||||||
|
Always authenticate media uploads
|
||||||
|
</FormLabel>
|
||||||
|
<Switch
|
||||||
|
id="alwaysAuthUpload"
|
||||||
|
isChecked={alwaysAuthUpload}
|
||||||
|
onChange={() => localSettings.alwaysAuthUpload.next(!localSettings.alwaysAuthUpload.value)}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<FormHelperText>Always authenticate with media servers when uploading media</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</SimpleView>
|
</SimpleView>
|
||||||
);
|
);
|
||||||
|
@ -10,20 +10,29 @@ import {
|
|||||||
NumberInputField,
|
NumberInputField,
|
||||||
Text,
|
Text,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useActiveAccount, useObservable } from "applesauce-react/hooks";
|
import { useActiveAccount } from "applesauce-react/hooks";
|
||||||
import { SocialGraph } from "nostr-social-graph";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { tap, throttleTime } from "rxjs";
|
|
||||||
|
|
||||||
import SimpleView from "../../../components/layout/presets/simple-view";
|
import SimpleView from "../../../components/layout/presets/simple-view";
|
||||||
import UserAvatar from "../../../components/user/user-avatar";
|
import UserAvatar from "../../../components/user/user-avatar";
|
||||||
import { UserAvatarLink } from "../../../components/user/user-avatar-link";
|
import { UserAvatarLink } from "../../../components/user/user-avatar-link";
|
||||||
import UserDnsIdentity from "../../../components/user/user-dns-identity";
|
import UserDnsIdentity from "../../../components/user/user-dns-identity";
|
||||||
import UserName from "../../../components/user/user-name";
|
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 { 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() {
|
export default function SocialGraphSettings() {
|
||||||
|
useAppTitle("Social Graph");
|
||||||
const [followDistances, setFollowDistances] = useState<{ distance: number; count: number; randomUsers: string[] }[]>(
|
const [followDistances, setFollowDistances] = useState<{ distance: number; count: number; randomUsers: string[] }[]>(
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@ -50,28 +59,21 @@ export default function SocialGraphSettings() {
|
|||||||
setFollowDistances(generateFollowDistances());
|
setFollowDistances(generateFollowDistances());
|
||||||
}, [socialGraph, generateFollowDistances]);
|
}, [socialGraph, generateFollowDistances]);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState<Subscription>();
|
||||||
const handleLoadGraph = () => {
|
const handleLoadGraph = () => {
|
||||||
if (account?.pubkey && socialGraph) {
|
if (account?.pubkey && socialGraph) {
|
||||||
setLoading(true);
|
setLoading(
|
||||||
|
graphLoader(account.pubkey, distance, createBatchUserLoader(pool.request.bind(pool))).subscribe({
|
||||||
// Create and start the loader
|
next: (progress) => {
|
||||||
crawlFollowGraph(account.pubkey, distance)
|
console.log(progress);
|
||||||
.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),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
useUnmount(() => {
|
||||||
|
loading?.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
const displayMaxPeople = useBreakpointValue({ base: 4, lg: 5, xl: 10 }) || 4;
|
const displayMaxPeople = useBreakpointValue({ base: 4, lg: 5, xl: 10 }) || 4;
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user