mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-28 20:43:33 +02:00
Add note translation modal using DVMs
This commit is contained in:
5
.changeset/chatty-impalas-join.md
Normal file
5
.changeset/chatty-impalas-join.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add note translations modal using DVMs
|
@@ -34,7 +34,8 @@
|
|||||||
"hls.js": "^1.4.10",
|
"hls.js": "^1.4.10",
|
||||||
"idb": "^7.1.1",
|
"idb": "^7.1.1",
|
||||||
"identicon.js": "^2.3.3",
|
"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": "^1.9.4",
|
||||||
"leaflet.locatecontrol": "^0.79.0",
|
"leaflet.locatecontrol": "^0.79.0",
|
||||||
"light-bolt11-decoder": "^3.0.0",
|
"light-bolt11-decoder": "^3.0.0",
|
||||||
|
224
src/components/note-translation-modal/index.tsx
Normal file
224
src/components/note-translation-modal/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -28,11 +28,14 @@ import { handleEventFromRelay } from "../../services/event-relays";
|
|||||||
import NostrPublishAction from "../../classes/nostr-publish-action";
|
import NostrPublishAction from "../../classes/nostr-publish-action";
|
||||||
import useUserMuteFunctions from "../../hooks/use-user-mute-functions";
|
import useUserMuteFunctions from "../../hooks/use-user-mute-functions";
|
||||||
import { useMuteModalContext } from "../../providers/mute-modal-provider";
|
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">) {
|
export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) {
|
||||||
const account = useCurrentAccount();
|
const account = useCurrentAccount();
|
||||||
const infoModal = useDisclosure();
|
const infoModal = useDisclosure();
|
||||||
const reactionsModal = useDisclosure();
|
const reactionsModal = useDisclosure();
|
||||||
|
const translationsModal = useDisclosure();
|
||||||
const { isMuted, mute, unmute } = useUserMuteFunctions(event.pubkey);
|
const { isMuted, mute, unmute } = useUserMuteFunctions(event.pubkey);
|
||||||
const { openModal } = useMuteModalContext();
|
const { openModal } = useMuteModalContext();
|
||||||
|
|
||||||
@@ -81,6 +84,9 @@ export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Om
|
|||||||
Delete Note
|
Delete Note
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
<MenuItem onClick={translationsModal.onOpen} icon={<Translate01 />}>
|
||||||
|
Translations
|
||||||
|
</MenuItem>
|
||||||
<MenuItem onClick={broadcast} icon={<BroadcastEventIcon />}>
|
<MenuItem onClick={broadcast} icon={<BroadcastEventIcon />}>
|
||||||
Broadcast
|
Broadcast
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -99,6 +105,8 @@ export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Om
|
|||||||
{reactionsModal.isOpen && (
|
{reactionsModal.isOpen && (
|
||||||
<NoteReactionsModal noteId={event.id} isOpen={reactionsModal.isOpen} onClose={reactionsModal.onClose} />
|
<NoteReactionsModal noteId={event.id} isOpen={reactionsModal.isOpen} onClose={reactionsModal.onClose} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{translationsModal.isOpen && <NoteTranslationModal isOpen onClose={translationsModal.onClose} note={event} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -11,14 +11,15 @@ export type NostrQuery = {
|
|||||||
ids?: string[];
|
ids?: string[];
|
||||||
authors?: string[];
|
authors?: string[];
|
||||||
kinds?: number[];
|
kinds?: number[];
|
||||||
"#e"?: string[];
|
|
||||||
"#a"?: string[];
|
"#a"?: string[];
|
||||||
"#p"?: string[];
|
|
||||||
"#d"?: string[];
|
"#d"?: string[];
|
||||||
"#t"?: string[];
|
"#e"?: string[];
|
||||||
"#r"?: string[];
|
|
||||||
"#l"?: string[];
|
|
||||||
"#g"?: string[];
|
"#g"?: string[];
|
||||||
|
"#i"?: string[];
|
||||||
|
"#l"?: string[];
|
||||||
|
"#p"?: string[];
|
||||||
|
"#r"?: string[];
|
||||||
|
"#t"?: string[];
|
||||||
since?: number;
|
since?: number;
|
||||||
until?: number;
|
until?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
@@ -4475,6 +4475,11 @@ isexe@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
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:
|
jake@^10.8.5:
|
||||||
version "10.8.7"
|
version "10.8.7"
|
||||||
resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.7.tgz#63a32821177940c33f356e0ba44ff9d34e1c7d8f"
|
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"
|
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
|
||||||
integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
|
integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
|
||||||
|
|
||||||
json-stringify-deterministic@^1.0.11:
|
json-stringify-deterministic@^1.0.12:
|
||||||
version "1.0.12"
|
version "1.0.12"
|
||||||
resolved "https://registry.yarnpkg.com/json-stringify-deterministic/-/json-stringify-deterministic-1.0.12.tgz#aaa3f907466ed01e3afd77b898d0a2b3b132820a"
|
resolved "https://registry.yarnpkg.com/json-stringify-deterministic/-/json-stringify-deterministic-1.0.12.tgz#aaa3f907466ed01e3afd77b898d0a2b3b132820a"
|
||||||
integrity sha512-q3PN0lbUdv0pmurkBNdJH3pfFvOTL/Zp0lquqpvcjfKzt6Y0j49EPHAmVHCAS4Ceq/Y+PejWTzyiVpoY71+D6g==
|
integrity sha512-q3PN0lbUdv0pmurkBNdJH3pfFvOTL/Zp0lquqpvcjfKzt6Y0j49EPHAmVHCAS4Ceq/Y+PejWTzyiVpoY71+D6g==
|
||||||
|
Reference in New Issue
Block a user