Add note translation modal using DVMs

This commit is contained in:
hzrd149 2023-11-14 16:30:19 -06:00
parent 3e5d1b03a5
commit 32c3e74aa9
6 changed files with 251 additions and 7 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add note translations modal using DVMs

View File

@ -34,7 +34,8 @@
"hls.js": "^1.4.10",
"idb": "^7.1.1",
"identicon.js": "^2.3.3",
"json-stringify-deterministic": "^1.0.11",
"iso-language-codes": "^2.0.0",
"json-stringify-deterministic": "^1.0.12",
"leaflet": "^1.9.4",
"leaflet.locatecontrol": "^0.79.0",
"light-bolt11-decoder": "^3.0.0",

View File

@ -0,0 +1,224 @@
import { useCallback, useState } from "react";
import {
Button,
Card,
CardBody,
CardHeader,
Flex,
Heading,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
ModalProps,
Select,
Spinner,
Text,
useToast,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import codes from "iso-language-codes";
import { DraftNostrEvent, NostrEvent, isETag, isPTag } from "../../types/nostr-event";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { getEventUID } from "../../helpers/nostr/events";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useSubject from "../../hooks/use-subject";
import UserAvatarLink from "../user-avatar-link";
import { UserLink } from "../user-link";
import { useSigningContext } from "../../providers/signing-provider";
import relayScoreboardService from "../../services/relay-scoreboard";
import NostrPublishAction from "../../classes/nostr-publish-action";
import clientRelaysService from "../../services/client-relays";
import { NoteContents } from "../note/text-note-contents";
import Timestamp from "../timestamp";
import { readablizeSats } from "../../helpers/bolt11";
import { LightningIcon } from "../icons";
function TranslationResult({ result }: { result: NostrEvent }) {
const requester = result.tags.find(isPTag)?.[1];
return (
<Card variant="outline">
<CardHeader px="4" py="4" pb="2" display="flex" gap="2" alignItems="center">
<UserAvatarLink pubkey={result.pubkey} size="sm" />
<UserLink pubkey={result.pubkey} fontWeight="bold" />
<Timestamp timestamp={result.created_at} />
</CardHeader>
<CardBody px="4" pt="0" pb="4">
{requester && (
<Text fontStyle="italic" mb="2">
Requested by <UserLink pubkey={requester} fontWeight="bold" />
</Text>
)}
<NoteContents event={result} />
</CardBody>
</Card>
);
}
function TranslationRequest({ request }: { request: NostrEvent }) {
const targetLanguage = request.tags.find((t) => t[0] === "param" && t[1] === "language")?.[2];
const lang = codes.find((code) => code.iso639_1 === targetLanguage);
const requestRelays = request.tags.find((t) => t[0] === "relays")?.slice(1);
const readRelays = useReadRelayUrls();
const timeline = useTimelineLoader(`${getEventUID(request)}-offers`, requestRelays || readRelays, {
kinds: [7000],
"#e": [request.id],
});
const offers = useSubject(timeline.timeline);
return (
<Card variant="outline">
<CardHeader px="4" py="4" pb="2" display="flex" gap="2" alignItems="center" flexWrap="wrap">
<UserAvatarLink pubkey={request.pubkey} size="sm" />
<UserLink pubkey={request.pubkey} fontWeight="bold" />
<Text>Requested translation to {lang?.nativeName}</Text>
<Timestamp timestamp={request.created_at} />
</CardHeader>
<CardBody px="4" pt="0" pb="4">
{offers.length === 0 ? (
<Flex gap="2" alignItems="center">
<Spinner />
Waiting for offers
</Flex>
) : (
<Heading size="md" mb="2">
Offers ({offers.length})
</Heading>
)}
{offers.map((offer) => (
<TranslationOffer key={offer.id} offer={offer} />
))}
</CardBody>
</Card>
);
}
function TranslationOffer({ offer }: { offer: NostrEvent }) {
const toast = useToast();
const amountTag = offer.tags.find((t) => t[0] === "amount" && t[1] && t[2]);
const amountMsat = amountTag?.[1] && parseInt(amountTag[1]);
const invoice = amountTag?.[2];
const [paid, setPaid] = useState(false);
const [paying, setPaying] = useState(false);
const payInvoice = async () => {
try {
if (window.webln && invoice) {
setPaying(true);
await window.webln.sendPayment(invoice);
setPaid(true);
}
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
setPaying(false);
};
return (
<Flex gap="2" direction="column">
<Flex gap="2" alignItems="center">
<UserAvatarLink pubkey={offer.pubkey} size="sm" />
<UserLink pubkey={offer.pubkey} fontWeight="bold" />
{invoice && amountMsat && (
<Button
colorScheme="yellow"
ml="auto"
size="sm"
leftIcon={<LightningIcon />}
onClick={payInvoice}
isLoading={paying || paid}
isDisabled={!window.webln}
>
Pay {readablizeSats(amountMsat / 1000)} sats
</Button>
)}
</Flex>
<Text>{offer.content}</Text>
</Flex>
);
}
export default function NoteTranslationModal({
onClose,
isOpen,
note,
...props
}: Omit<ModalProps, "children"> & { note: NostrEvent }) {
const { requestSignature } = useSigningContext();
const toast = useToast();
const [lang, setLang] = useState(navigator.language.split("-")[0] ?? "en");
const readRelays = useReadRelayUrls();
const requestTranslation = useCallback(async () => {
try {
const top8Relays = relayScoreboardService.getRankedRelays(readRelays).slice(0, 8);
const draft: DraftNostrEvent = {
kind: 5002,
content: "",
created_at: dayjs().unix(),
tags: [
["i", note.id, "event"],
["param", "language", lang],
["output", "text/plain"],
["relays", ...top8Relays],
],
};
const signed = await requestSignature(draft);
new NostrPublishAction("Request Translation", clientRelaysService.getWriteUrls(), signed);
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
}, [requestSignature, note, readRelays]);
const timeline = useTimelineLoader(`${getEventUID(note)}-translations`, readRelays, {
kinds: [5002, 6002],
"#i": [note.id],
});
const events = useSubject(timeline.timeline);
const filteredEvents = events.filter(
(e, i, arr) =>
e.kind === 6002 || (e.kind === 5002 && !arr.some((r) => r.tags.some((t) => isETag(t) && t[1] === e.id))),
);
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl" {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader p="4">Note Translations</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" pt="0" pb="4" display="flex" gap="2" flexDirection="column">
<Flex gap="2">
<Select value={lang} onChange={(e) => setLang(e.target.value)} w="60">
{codes.map((code) => (
<option value={code.iso639_1}>
{code.name} ({code.nativeName})
</option>
))}
</Select>
<Button colorScheme="primary" onClick={requestTranslation} flexShrink={0}>
Request new translation
</Button>
</Flex>
{filteredEvents.map((event) => {
switch (event.kind) {
case 5002:
return <TranslationRequest key={event.id} request={event} />;
case 6002:
return <TranslationResult key={event.id} result={event} />;
}
})}
</ModalBody>
</ModalContent>
</Modal>
);
}

View File

@ -28,11 +28,14 @@ import { handleEventFromRelay } from "../../services/event-relays";
import NostrPublishAction from "../../classes/nostr-publish-action";
import useUserMuteFunctions from "../../hooks/use-user-mute-functions";
import { useMuteModalContext } from "../../providers/mute-modal-provider";
import NoteTranslationModal from "../note-translation-modal";
import Translate01 from "../icons/translate-01";
export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) {
const account = useCurrentAccount();
const infoModal = useDisclosure();
const reactionsModal = useDisclosure();
const translationsModal = useDisclosure();
const { isMuted, mute, unmute } = useUserMuteFunctions(event.pubkey);
const { openModal } = useMuteModalContext();
@ -81,6 +84,9 @@ export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Om
Delete Note
</MenuItem>
)}
<MenuItem onClick={translationsModal.onOpen} icon={<Translate01 />}>
Translations
</MenuItem>
<MenuItem onClick={broadcast} icon={<BroadcastEventIcon />}>
Broadcast
</MenuItem>
@ -99,6 +105,8 @@ export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Om
{reactionsModal.isOpen && (
<NoteReactionsModal noteId={event.id} isOpen={reactionsModal.isOpen} onClose={reactionsModal.onClose} />
)}
{translationsModal.isOpen && <NoteTranslationModal isOpen onClose={translationsModal.onClose} note={event} />}
</>
);
}

View File

@ -11,14 +11,15 @@ export type NostrQuery = {
ids?: string[];
authors?: string[];
kinds?: number[];
"#e"?: string[];
"#a"?: string[];
"#p"?: string[];
"#d"?: string[];
"#t"?: string[];
"#r"?: string[];
"#l"?: string[];
"#e"?: string[];
"#g"?: string[];
"#i"?: string[];
"#l"?: string[];
"#p"?: string[];
"#r"?: string[];
"#t"?: string[];
since?: number;
until?: number;
limit?: number;

View File

@ -4475,6 +4475,11 @@ isexe@^2.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
iso-language-codes@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/iso-language-codes/-/iso-language-codes-2.0.0.tgz#2506da1becda1e5e7e9245734f4872ecbcca497a"
integrity sha512-krdJem8Yu0DfublYzvHViZxTXGjkvqV5j8wcDzrGNgWiISW1Ow9Su5cy1R+HyuWL84zpuZ/MuYFg3fawbx9IjA==
jake@^10.8.5:
version "10.8.7"
resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.7.tgz#63a32821177940c33f356e0ba44ff9d34e1c7d8f"
@ -4542,7 +4547,7 @@ json-schema@^0.4.0:
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
json-stringify-deterministic@^1.0.11:
json-stringify-deterministic@^1.0.12:
version "1.0.12"
resolved "https://registry.yarnpkg.com/json-stringify-deterministic/-/json-stringify-deterministic-1.0.12.tgz#aaa3f907466ed01e3afd77b898d0a2b3b132820a"
integrity sha512-q3PN0lbUdv0pmurkBNdJH3pfFvOTL/Zp0lquqpvcjfKzt6Y0j49EPHAmVHCAS4Ceq/Y+PejWTzyiVpoY71+D6g==