Add option to mirror blobs when sharing notes

This commit is contained in:
hzrd149 2025-01-24 09:56:26 -06:00
parent c88e2df5f0
commit ab394aa6e3
10 changed files with 598 additions and 519 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add option to mirror blobs when sharing notes

View File

@ -142,7 +142,7 @@
"@capacitor/core": "^6.2.0",
"@capacitor/ios": "^6.2.0",
"@capacitor/preferences": "^6.0.3",
"@changesets/cli": "^2.27.11",
"@changesets/cli": "^2.27.12",
"@types/canvas-confetti": "^1.9.0",
"@types/chroma-js": "^2.4.5",
"@types/debug": "^4.1.12",

928
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +0,0 @@
import { memo } from "react";
import { verifyEvent } from "nostr-tools";
import { NostrEvent } from "../../types/nostr-event";
import { CheckIcon, VerificationFailed } from "../icons";
import useAppSettings from "../../hooks/use-user-app-settings";
function EventVerificationIcon({ event }: { event: NostrEvent }) {
const { showSignatureVerification } = useAppSettings();
if (!showSignatureVerification) return null;
if (!verifyEvent(event)) {
return <VerificationFailed color="red.500" />;
}
return <CheckIcon color="green.500" />;
}
export default memo(EventVerificationIcon);

View File

@ -5,17 +5,14 @@ import { Link as RouterLink, useNavigate } from "react-router-dom";
import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user/user-avatar-link";
import UserLink from "../../user/user-link";
import EventVerificationIcon from "../../common-event/event-verification-icon";
import { TrustProvider } from "../../../providers/local/trust-provider";
import { NoteLink } from "../../note/note-link";
import Timestamp from "../../timestamp";
import { CompactNoteContent } from "../../compact-note-content";
import HoverLinkOverlay from "../../hover-link-overlay";
import { getSharableEventAddress } from "../../../services/relay-hints";
import useAppSettings from "../../../hooks/use-user-app-settings";
export default function EmbeddedNote({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
const { showSignatureVerification } = useAppSettings();
const navigate = useNavigate();
const to = `/n/${getSharableEventAddress(event)}`;
@ -38,7 +35,6 @@ export default function EmbeddedNote({ event, ...props }: Omit<CardProps, "child
</NoteLink>
<HoverLinkOverlay as={RouterLink} to={to} onClick={handleClick} />
<Spacer />
{showSignatureVerification && <EventVerificationIcon event={event} />}
</Flex>
<CompactNoteContent px="2" event={event} maxLength={96} />
</Card>

View File

@ -1,3 +1,4 @@
import { MouseEventHandler, useCallback } from "react";
import { Card, CardProps, Flex, LinkBox, Spacer, Text } from "@chakra-ui/react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { nip19 } from "nostr-tools";
@ -5,7 +6,6 @@ import { nip19 } from "nostr-tools";
import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user/user-avatar-link";
import UserLink from "../../user/user-link";
import EventVerificationIcon from "../../common-event/event-verification-icon";
import { TrustProvider } from "../../../providers/local/trust-provider";
import Timestamp from "../../timestamp";
import { CompactNoteContent } from "../../compact-note-content";
@ -13,15 +13,12 @@ import HoverLinkOverlay from "../../hover-link-overlay";
import { getThreadReferences } from "../../../helpers/nostr/event";
import useSingleEvent from "../../../hooks/use-single-event";
import { getTorrentTitle } from "../../../helpers/nostr/torrents";
import { MouseEventHandler, useCallback } from "react";
import useAppSettings from "../../../hooks/use-user-app-settings";
export default function EmbeddedTorrentComment({
comment,
...props
}: Omit<CardProps, "children"> & { comment: NostrEvent }) {
const navigate = useNavigate();
const { showSignatureVerification } = useAppSettings();
const refs = getThreadReferences(comment);
const torrent = useSingleEvent(refs.root?.e?.id, refs.root?.e?.relays);
const linkToTorrent = refs.root?.e && `/torrents/${nip19.neventEncode(refs.root.e)}`;
@ -45,7 +42,6 @@ export default function EmbeddedTorrentComment({
{torrent ? getTorrentTitle(torrent) : "torrent"}
</HoverLinkOverlay>
<Spacer />
{showSignatureVerification && <EventVerificationIcon event={comment} />}
<Timestamp timestamp={comment.created_at} />
</Flex>
<CompactNoteContent px="2" event={comment} maxLength={96} />

View File

@ -1,6 +1,8 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import {
Button,
Center,
Checkbox,
Modal,
ModalBody,
ModalCloseButton,
@ -9,12 +11,22 @@ import {
ModalHeader,
ModalOverlay,
ModalProps,
Spinner,
Text,
useToast,
} from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { useEventFactory } from "applesauce-react/hooks";
import { getMediaAttachments } from "applesauce-core/helpers";
import { getMediaAttachmentURLsFromContent } from "applesauce-content/helpers";
import { BlossomClient } from "blossom-client-sdk";
import { usePublishEvent } from "../../../../providers/global/publish-provider";
import { EmbedEvent } from "../../../embed-event";
import useAppSettings from "../../../../hooks/use-user-app-settings";
import useUsersMediaServers from "../../../../hooks/use-user-media-servers";
import useCurrentAccount from "../../../../hooks/use-current-account";
import { useSigningContext } from "../../../../providers/global/signing-provider";
export default function ShareModal({
event,
@ -22,17 +34,76 @@ export default function ShareModal({
onClose,
...props
}: Omit<ModalProps, "children"> & { event: NostrEvent }) {
const { mirrorBlobsOnShare } = useAppSettings();
const account = useCurrentAccount();
const publish = usePublishEvent();
const factory = useEventFactory();
const toast = useToast();
const [loading, setLoading] = useState(false);
const { requestSignature } = useSigningContext();
const { servers } = useUsersMediaServers(account?.pubkey);
const [mirror, setMirror] = useState(mirrorBlobsOnShare);
const mediaAttachments = useMemo(() => {
const attachments = getMediaAttachments(event)
// filter out media attachments without hashes
.filter((media) => !!media.sha256);
// extra media attachments from content
const content = getMediaAttachmentURLsFromContent(event.content)
// remove duplicates
.filter((media) => !attachments.some((a) => a.sha256 === media.sha256));
return [...attachments, ...content];
}, [event]);
const canMirror = servers.length > 0 && mediaAttachments.length > 0;
const [loading, setLoading] = useState("");
const share = async () => {
setLoading(true);
if (mirror && canMirror) {
try {
setLoading("Requesting signature for mirroring...");
const auth = await BlossomClient.createUploadAuth(
requestSignature,
mediaAttachments.filter((m) => !!m.sha256).map((m) => m.sha256!),
);
setLoading("Mirror blobs...");
for (const media of mediaAttachments) {
// send mirror request to all servers
await Promise.allSettled(
servers.map((server) =>
BlossomClient.mirrorBlob(
server,
{
sha256: media.sha256!,
url: media.url,
// TODO: these are not needed and should be removed
uploaded: 0,
size: media.size ?? 0,
},
{ auth },
).catch((err) => {
// ignore errors from individual servers
}),
),
);
}
} catch (error) {
if (error instanceof Error)
toast({ status: "error", title: `Failed to mirror media`, description: error.message });
}
}
setLoading("Sharing...");
const draft = await factory.share(event);
setLoading("Publishing...");
await publish("Share", draft);
setLoading("");
// close modal
onClose();
setLoading(false);
};
return (
@ -44,7 +115,26 @@ export default function ShareModal({
</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" py="0">
<EmbedEvent event={event} />
{loading ? (
<Center>
<Spinner /> {loading}
</Center>
) : (
<>
<EmbedEvent event={event} />
{canMirror && (
<>
<Checkbox isChecked={mirror} onChange={() => setMirror(!mirror)} mt="4">
Mirror media ({mediaAttachments.length}) to blossom servers ({servers.length})
</Checkbox>
<Text fontSize="sm" color="GrayText">
Copy media to your blossom servers so it can be found later
</Text>
</>
)}
</>
)}
</ModalBody>
<ModalFooter px="4" py="4">
@ -56,7 +146,7 @@ export default function ShareModal({
variant="solid"
onClick={() => share()}
size="md"
isLoading={loading}
isLoading={!!loading}
flexShrink={0}
>
Share

View File

@ -22,7 +22,6 @@ import NoteMenu from "../note-menu";
import UserLink from "../../user/user-link";
import EventZapButton from "../../zap/event-zap-button";
import { ExpandProvider } from "../../../providers/local/expanded";
import EventVerificationIcon from "../../common-event/event-verification-icon";
import EventShareButton from "./components/event-share-button";
import EventQuoteButton from "../event-quote-button";
import { ReplyIcon } from "../../icons";
@ -67,7 +66,7 @@ export function TimelineNote({
...props
}: TimelineNoteProps) {
const account = useCurrentAccount();
const { showReactions, showSignatureVerification } = useAppSettings();
const { showReactions } = useAppSettings();
const hideZapBubbles = useObservable(localSettings.hideZapBubbles);
const replyForm = useDisclosure();
@ -98,7 +97,6 @@ export function TimelineNote({
<POWIcon event={event} boxSize={5} />
<NotePublishedUsing event={event} />
<Flex grow={1} />
{showSignatureVerification && <EventVerificationIcon event={event} />}
</Flex>
<NoteCommunityMetadata event={event} />
{showReplyLine && <ReplyContext event={event} />}

View File

@ -4,7 +4,7 @@ import { kinds } from "nostr-tools";
export const APP_SETTINGS_KIND = kinds.Application;
export const APP_SETTING_IDENTIFIER = "nostrudel-settings";
export type AppSettingsV0 = {
type AppSettingsV0 = {
version: 0;
colorMode: ColorModeWithSystem;
defaultRelays: string[];
@ -12,7 +12,6 @@ export type AppSettingsV0 = {
autoShowMedia: boolean;
proxyUserMedia: boolean;
showReactions: boolean;
/** @deprecated */
showSignatureVerification: boolean;
autoPayWithWebLN: boolean;
@ -26,36 +25,41 @@ export type AppSettingsV0 = {
redditRedirect?: string;
youtubeRedirect?: string;
};
export type AppSettingsV1 = Omit<AppSettingsV0, "version"> & {
type AppSettingsV1 = Omit<AppSettingsV0, "version"> & {
version: 1;
mutedWords?: string;
maxPageWidth: "none" | "sm" | "md" | "lg" | "xl" | "full";
};
export type AppSettingsV2 = Omit<AppSettingsV1, "version"> & { version: 2; theme: string };
export type AppSettingsV3 = Omit<AppSettingsV2, "version"> & { version: 3; quickReactions: string[] };
export type AppSettingsV4 = Omit<AppSettingsV3, "version"> & { version: 4; loadOpenGraphData: boolean };
export type AppSettingsV5 = Omit<AppSettingsV4, "version"> & { version: 5; hideUsernames: boolean };
export type AppSettingsV6 = Omit<AppSettingsV5, "version"> & { version: 6; noteDifficulty: number | null };
export type AppSettingsV7 = Omit<AppSettingsV6, "version"> & { version: 7; autoDecryptDMs: boolean };
export type AppSettingsV8 = Omit<AppSettingsV7, "version"> & {
type AppSettingsV2 = Omit<AppSettingsV1, "version"> & { version: 2; theme: string };
type AppSettingsV3 = Omit<AppSettingsV2, "version"> & { version: 3; quickReactions: string[] };
type AppSettingsV4 = Omit<AppSettingsV3, "version"> & { version: 4; loadOpenGraphData: boolean };
type AppSettingsV5 = Omit<AppSettingsV4, "version"> & { version: 5; hideUsernames: boolean };
type AppSettingsV6 = Omit<AppSettingsV5, "version"> & { version: 6; noteDifficulty: number | null };
type AppSettingsV7 = Omit<AppSettingsV6, "version"> & { version: 7; autoDecryptDMs: boolean };
type AppSettingsV8 = Omit<AppSettingsV7, "version"> & {
version: 8;
mediaUploadService: "nostr.build" | "blossom";
};
export type AppSettingsV9 = Omit<AppSettingsV8, "version"> & { version: 9; removeEmojisInUsernames: boolean };
type AppSettingsV9 = Omit<AppSettingsV8, "version"> & { version: 9; removeEmojisInUsernames: boolean };
export type AppSettingsV10 = Omit<AppSettingsV9, "version" | "defaultRelays"> & {
type AppSettingsV10 = Omit<AppSettingsV9, "version" | "defaultRelays"> & {
version: 10;
showPubkeyColor: "none" | "avatar" | "underline";
};
export type AppSettingsV11 = Omit<AppSettingsV10, "quickReactions" | "version"> & {
type AppSettingsV11 = Omit<AppSettingsV10, "quickReactions" | "version"> & {
version: 11;
};
export type AppSettings = AppSettingsV11;
type AppSettingsV12 = Omit<AppSettingsV11, "showSignatureVerification" | "version"> & {
version: 12;
mirrorBlobsOnShare: boolean;
};
export type AppSettings = AppSettingsV12;
export const defaultSettings: AppSettings = {
version: 11,
version: 12,
// display
theme: "default",
@ -69,12 +73,11 @@ export const defaultSettings: AppSettings = {
autoShowMedia: true,
showContentWarning: true,
loadOpenGraphData: true,
/** @deprecated */
showSignatureVerification: false,
// posting
noteDifficulty: null,
proxyUserMedia: false,
mirrorBlobsOnShare: false,
// performance
showReactions: true,

View File

@ -12,7 +12,6 @@ import {
AlertIcon,
AlertTitle,
AlertDescription,
Heading,
Switch,
} from "@chakra-ui/react";
import { useObservable } from "applesauce-react/hooks";
@ -20,7 +19,6 @@ import { useObservable } from "applesauce-react/hooks";
import useUsersMediaServers from "../../../hooks/use-user-media-servers";
import useCurrentAccount from "../../../hooks/use-current-account";
import useSettingsForm from "../use-settings-form";
import VerticalPageLayout from "../../../components/vertical-page-layout";
import localSettings from "../../../services/local-settings";
import SimpleView from "../../../components/layout/presets/simple-view";
@ -118,6 +116,16 @@ export default function PostSettings() {
client tags on events
</FormHelperText>
</FormControl>
<FormControl>
<Flex alignItems="center">
<FormLabel htmlFor="mirrorBlobsOnShare" mb="0">
Always mirror media
</FormLabel>
<Switch id="mirrorBlobsOnShare" {...register("mirrorBlobsOnShare")} />
</Flex>
<FormHelperText>Copy all media to your personal blossom servers when sharing notes</FormHelperText>
</FormControl>
</SimpleView>
);
}