diff --git a/src/Const.ts b/src/Const.ts index 6f9e080c..8b62129a 100644 --- a/src/Const.ts +++ b/src/Const.ts @@ -5,6 +5,11 @@ import { RelaySettings } from "Nostr/Connection"; */ export const ApiHost = "https://api.snort.social"; +/** + * LibreTranslate endpoint + */ +export const TranslateHost = "https://translate.snort.social"; + /** * Void.cat file upload service url */ diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index 146946b1..2bb5efc4 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -7,7 +7,7 @@ import ProfileImage from "Element/ProfileImage"; import Text from "Element/Text"; import { eventLink, getReactions, hexToBech32 } from "Util"; -import NoteFooter from "Element/NoteFooter"; +import NoteFooter, { Translation } from "Element/NoteFooter"; import NoteTime from "Element/NoteTime"; import EventKind from "Nostr/EventKind"; import { useUserProfiles } from "Feed/ProfileFeed"; @@ -16,17 +16,17 @@ import { useInView } from "react-intersection-observer"; import useModeration from "Hooks/useModeration"; export interface NoteProps { - data?: TaggedRawEvent, - isThread?: boolean, - related: TaggedRawEvent[], - highlight?: boolean, - ignoreModeration?: boolean, - options?: { - showHeader?: boolean, - showTime?: boolean, - showFooter?: boolean - }, - ["data-ev"]?: NEvent + data?: TaggedRawEvent, + isThread?: boolean, + related: TaggedRawEvent[], + highlight?: boolean, + ignoreModeration?: boolean, + options?: { + showHeader?: boolean, + showTime?: boolean, + showFooter?: boolean + }, + ["data-ev"]?: NEvent } const HiddenNote = ({ children }: any) => { @@ -47,150 +47,163 @@ const HiddenNote = ({ children }: any) => { export default function Note(props: NoteProps) { - const navigate = useNavigate(); - const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false} = props - const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]); - const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]); - const users = useUserProfiles(pubKeys); - const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]); - const { isMuted } = useModeration() - const isOpMuted = isMuted(ev.PubKey) - const { ref, inView, entry } = useInView({ triggerOnce: true }); - const [extendable, setExtendable] = useState(false); - const [showMore, setShowMore] = useState(false); + const navigate = useNavigate(); + const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false } = props + const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]); + const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]); + const users = useUserProfiles(pubKeys); + const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]); + const { isMuted } = useModeration() + const isOpMuted = isMuted(ev.PubKey) + const { ref, inView, entry } = useInView({ triggerOnce: true }); + const [extendable, setExtendable] = useState(false); + const [showMore, setShowMore] = useState(false); + const [translated, setTranslated] = useState(); - const options = { - showHeader: true, - showTime: true, - showFooter: true, - ...opt - }; + const options = { + showHeader: true, + showTime: true, + showFooter: true, + ...opt + }; - const transformBody = useCallback(() => { - let body = ev?.Content ?? ""; - if (deletions?.length > 0) { - return (Deleted); - } - return ; - }, [ev]); + const transformBody = useCallback(() => { + let body = ev?.Content ?? ""; + if (deletions?.length > 0) { + return (Deleted); + } + return ; + }, [ev]); - useLayoutEffect(() => { - if (entry && inView && extendable === false) { - let h = entry?.target.clientHeight ?? 0; - if (h > 650) { - setExtendable(true); - } - } - }, [inView, entry, extendable]); + useLayoutEffect(() => { + if (entry && inView && extendable === false) { + let h = entry?.target.clientHeight ?? 0; + if (h > 650) { + setExtendable(true); + } + } + }, [inView, entry, extendable]); - function goToEvent(e: any, id: u256) { - if (!window.location.pathname.startsWith("/e/")) { - e.stopPropagation(); - navigate(eventLink(id)); - } + function goToEvent(e: any, id: u256) { + if (!window.location.pathname.startsWith("/e/")) { + e.stopPropagation(); + navigate(eventLink(id)); + } + } + + function replyTag() { + if (ev.Thread === null) { + return null; } - function replyTag() { - if (ev.Thread === null) { - return null; - } - - const maxMentions = 2; - let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event; - let mentions: {pk: string, name: string, link: ReactNode}[] = []; - for (let pk of ev.Thread?.PubKeys) { - const u = users?.get(pk); - const npub = hexToBech32("npub", pk) - const shortNpub = npub.substring(0, 12); - if (u) { - mentions.push({ - pk, - name: u.name ?? shortNpub, - link: ( - - {u.name ? `@${u.name}` : shortNpub} - - ) - }); - } else { - mentions.push({ - pk, - name: shortNpub, - link: ( - - {shortNpub} - - ) - }); - } - } - mentions.sort((a, b) => a.name.startsWith("npub") ? 1 : -1); - let othersLength = mentions.length - maxMentions - const renderMention = (m: any, idx: number) => { - return ( - <> - {idx > 0 && ", "} - {m.link} - + const maxMentions = 2; + let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event; + let mentions: { pk: string, name: string, link: ReactNode }[] = []; + for (let pk of ev.Thread?.PubKeys) { + const u = users?.get(pk); + const npub = hexToBech32("npub", pk) + const shortNpub = npub.substring(0, 12); + if (u) { + mentions.push({ + pk, + name: u.name ?? shortNpub, + link: ( + + {u.name ? `@${u.name}` : shortNpub} + ) - } - const pubMentions = mentions.length > maxMentions ? ( - mentions?.slice(0, maxMentions).map(renderMention) - ) : mentions?.map(renderMention); - const others = mentions.length > maxMentions ? ` & ${othersLength} other${othersLength > 1 ? 's' : ''}` : '' - return ( -
- {(mentions?.length ?? 0) > 0 ? ( - <> - {pubMentions} - {others} - - ) : replyId ? ( - hexToBech32("note", replyId)?.substring(0, 12) // todo: link - ) : ""} -
- ) + }); + } else { + mentions.push({ + pk, + name: shortNpub, + link: ( + + {shortNpub} + + ) + }); + } } - - if (ev.Kind !== EventKind.TextNote) { - return ( - <> -

Unknown event kind: {ev.Kind}

-
-                    {JSON.stringify(ev.ToObject(), undefined, '  ')}
-                
- - ); + mentions.sort((a, b) => a.name.startsWith("npub") ? 1 : -1); + let othersLength = mentions.length - maxMentions + const renderMention = (m: any, idx: number) => { + return ( + <> + {idx > 0 && ", "} + {m.link} + + ) } - - function content() { - if (!inView) return null; - return ( - <> - {options.showHeader ? -
- - {options.showTime ? -
- -
: null} -
: null} -
goToEvent(e, ev.Id)}> - {transformBody()} -
- {extendable && !showMore && (
- -
)} - {options.showFooter ? : null} - - ) - } - - const note = ( -
- {content()} + const pubMentions = mentions.length > maxMentions ? ( + mentions?.slice(0, maxMentions).map(renderMention) + ) : mentions?.map(renderMention); + const others = mentions.length > maxMentions ? ` & ${othersLength} other${othersLength > 1 ? 's' : ''}` : '' + return ( +
+ {(mentions?.length ?? 0) > 0 ? ( + <> + {pubMentions} + {others} + + ) : replyId ? ( + hexToBech32("note", replyId)?.substring(0, 12) // todo: link + ) : ""}
) + } - return !ignoreModeration && isOpMuted ? {note} : note + if (ev.Kind !== EventKind.TextNote) { + return ( + <> +

Unknown event kind: {ev.Kind}

+
+          {JSON.stringify(ev.ToObject(), undefined, '  ')}
+        
+ + ); + } + + function translation() { + if (translated && translated.confidence > 0.5) { + return <> +

Translated from {translated.fromLanguage}:

+ {translated.text} + + } else if (translated) { + return

Translation failed

+ } + } + + function content() { + if (!inView) return null; + return ( + <> + {options.showHeader ? +
+ + {options.showTime ? +
+ +
: null} +
: null} +
goToEvent(e, ev.Id)}> + {transformBody()} + {translation()} +
+ {extendable && !showMore && (
+ +
)} + {options.showFooter ? setTranslated(t)} /> : null} + + ) + } + + const note = ( +
+ {content()} +
+ ) + + return !ignoreModeration && isOpMuted ? {note} : note } diff --git a/src/Element/NoteFooter.tsx b/src/Element/NoteFooter.tsx index 05217bed..86c68a5e 100644 --- a/src/Element/NoteFooter.tsx +++ b/src/Element/NoteFooter.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useSelector } from "react-redux"; -import { faTrash, faRepeat, faShareNodes, faCopy, faCommentSlash, faBan } from "@fortawesome/free-solid-svg-icons"; +import { faTrash, faRepeat, faShareNodes, faCopy, faCommentSlash, faBan, faLanguage } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Menu, MenuItem } from '@szhsin/react-menu'; @@ -21,10 +21,18 @@ import { HexKey, TaggedRawEvent } from "Nostr"; import EventKind from "Nostr/EventKind"; import { UserPreferences } from "State/Login"; import useModeration from "Hooks/useModeration"; +import { TranslateHost } from "Const"; + +export interface Translation { + text: string, + fromLanguage: string, + confidence: number +} export interface NoteFooterProps { related: TaggedRawEvent[], - ev: NEvent + ev: NEvent, + onTranslated?: (content: Translation) => void } export default function NoteFooter(props: NoteFooterProps) { @@ -38,6 +46,8 @@ export default function NoteFooter(props: NoteFooterProps) { const [reply, setReply] = useState(false); const [tip, setTip] = useState(false); const isMine = ev.RootPubKey === login; + const lang = window.navigator.language; + const langNames = new Intl.DisplayNames([...window.navigator.languages], { type: "language" }); const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related, ev]); const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related, ev]); const groupReactions = useMemo(() => { @@ -144,6 +154,29 @@ export default function NoteFooter(props: NoteFooterProps) { } } + async function translate() { + const res = await fetch(`${TranslateHost}/translate`, { + method: "POST", + body: JSON.stringify({ + q: ev.Content, + source: "auto", + target: "en" + }), + headers: { "Content-Type": "application/json" } + }); + + if (res.ok) { + let result = await res.json(); + if (typeof props.onTranslated === "function" && result) { + props.onTranslated({ + text: result.translatedText, + fromLanguage: langNames.of(result.detectedLanguage.language), + confidence: result.detectedLanguage.confidence + } as Translation); + } + } + } + async function copyId() { await navigator.clipboard.writeText(hexToBech32("note", ev.Id)); } @@ -179,6 +212,10 @@ export default function NoteFooter(props: NoteFooterProps) { Block + translate()}> + + Translate to {langNames.of(lang.split("-")[0])} + {prefs.showDebugMenus && ( copyEvent()}> @@ -206,10 +243,10 @@ export default function NoteFooter(props: NoteFooterProps) {
-
- -
- } +
+ +
+ } menuClassName="ctx-menu" > {menuItems()} diff --git a/src/Pages/settings/Profile.tsx b/src/Pages/settings/Profile.tsx index a47b3fd5..e2e3d349 100644 --- a/src/Pages/settings/Profile.tsx +++ b/src/Pages/settings/Profile.tsx @@ -9,7 +9,6 @@ import { faShop } from "@fortawesome/free-solid-svg-icons"; import useEventPublisher from "Feed/EventPublisher"; import { useUserProfile } from "Feed/ProfileFeed"; -import VoidUpload from "Feed/VoidUpload"; import LogoutButton from "Element/LogoutButton"; import { hexToBech32, openFile } from "Util"; import Copy from "Element/Copy";