feat: translations

This commit is contained in:
Alejandro 2023-02-08 22:10:26 +01:00 committed by GitHub
parent 2b44d3b264
commit 2b29fb0897
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1505 additions and 315 deletions

View File

@ -1,13 +1,17 @@
import "./BackButton.css";
import { useIntl } from "react-intl";
import ArrowBack from "Icons/ArrowBack";
import messages from "./messages";
interface BackButtonProps {
text?: string;
onClick?(): void;
}
const BackButton = ({ text = "Back", onClick }: BackButtonProps) => {
const BackButton = ({ text, onClick }: BackButtonProps) => {
const { formatMessage } = useIntl();
const onClickHandler = () => {
if (onClick) {
onClick();
@ -17,7 +21,7 @@ const BackButton = ({ text = "Back", onClick }: BackButtonProps) => {
return (
<button className="back-button" type="button" onClick={onClickHandler}>
<ArrowBack />
{text}
{text || formatMessage(messages.Back)}
</button>
);
};

View File

@ -1,6 +1,9 @@
import { FormattedMessage } from "react-intl";
import { HexKey } from "Nostr";
import useModeration from "Hooks/useModeration";
import messages from "./messages";
interface BlockButtonProps {
pubkey: HexKey;
}
@ -9,11 +12,11 @@ const BlockButton = ({ pubkey }: BlockButtonProps) => {
const { block, unblock, isBlocked } = useModeration();
return isBlocked(pubkey) ? (
<button className="secondary" type="button" onClick={() => unblock(pubkey)}>
Unblock
<FormattedMessage {...messages.Unblock} />
</button>
) : (
<button className="secondary" type="button" onClick={() => block(pubkey)}>
Block
<FormattedMessage {...messages.Block} />
</button>
);
};

View File

@ -1,5 +1,6 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { FormattedMessage } from "react-intl";
import { HexKey } from "Nostr";
import type { RootState } from "State/Store";
@ -9,6 +10,8 @@ import ProfilePreview from "Element/ProfilePreview";
import useMutedFeed, { getMuted } from "Feed/MuteList";
import useModeration from "Hooks/useModeration";
import messages from "./messages";
interface BlockListProps {
variant: "muted" | "blocked";
}
@ -21,7 +24,12 @@ export default function BlockList({ variant }: BlockListProps) {
<div className="main-content">
{variant === "muted" && (
<>
<h4>{muted.length} muted</h4>
<h4>
<FormattedMessage
{...messages.MuteCount}
values={{ n: muted.length }}
/>
</h4>
{muted.map((a) => {
return (
<ProfilePreview
@ -36,7 +44,12 @@ export default function BlockList({ variant }: BlockListProps) {
)}
{variant === "blocked" && (
<>
<h4>{blocked.length} blocked</h4>
<h4>
<FormattedMessage
{...messages.BlockCount}
values={{ n: blocked.length }}
/>
</h4>
{blocked.map((a) => {
return (
<ProfilePreview

View File

@ -1,6 +1,7 @@
import "./DM.css";
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useIntl } from "react-intl";
import { useInView } from "react-intersection-observer";
import useEventPublisher from "Feed/EventPublisher";
@ -12,6 +13,8 @@ import { RootState } from "State/Store";
import { HexKey, TaggedRawEvent } from "Nostr";
import { incDmInteraction } from "State/Login";
import messages from "./messages";
export type DMProps = {
data: TaggedRawEvent;
};
@ -25,6 +28,7 @@ export default function DM(props: DMProps) {
const [content, setContent] = useState("Loading...");
const [decrypted, setDecrypted] = useState(false);
const { ref, inView } = useInView();
const { formatMessage } = useIntl();
const isMe = props.data.pubkey === pubKey;
const otherPubkey = isMe
? pubKey
@ -50,7 +54,10 @@ export default function DM(props: DMProps) {
return (
<div className={`flex dm f-col${isMe ? " me" : ""}`} ref={ref}>
<div>
<NoteTime from={props.data.created_at * 1000} fallback={"Just now"} />
<NoteTime
from={props.data.created_at * 1000}
fallback={formatMessage(messages.JustNow)}
/>
</div>
<div className="w-max">
<Text

View File

@ -1,12 +1,13 @@
import "./FollowButton.css";
import { useSelector } from "react-redux";
import { FormattedMessage } from "react-intl";
import useEventPublisher from "Feed/EventPublisher";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUserMinus, faUserPlus } from "@fortawesome/free-solid-svg-icons";
import { HexKey } from "Nostr";
import { RootState } from "State/Store";
import { parseId } from "Util";
import messages from "./messages";
export interface FollowButtonProps {
pubkey: HexKey;
className?: string;
@ -35,7 +36,11 @@ export default function FollowButton(props: FollowButtonProps) {
className={isFollowing ? `${baseClassname} secondary` : baseClassname}
onClick={() => (isFollowing ? unfollow(pubkey) : follow(pubkey))}
>
{isFollowing ? "Unfollow" : "Follow"}
{isFollowing ? (
<FormattedMessage {...messages.Unfollow} />
) : (
<FormattedMessage {...messages.Follow} />
)}
</button>
);
}

View File

@ -1,7 +1,11 @@
import { FormattedMessage } from "react-intl";
import useEventPublisher from "Feed/EventPublisher";
import { HexKey } from "Nostr";
import ProfilePreview from "Element/ProfilePreview";
import messages from "./messages";
export interface FollowListBaseProps {
pubkeys: HexKey[];
title?: string;
@ -26,7 +30,7 @@ export default function FollowListBase({
type="button"
onClick={() => followAll()}
>
Follow All
<FormattedMessage {...messages.FollowAll} />
</button>
</div>
{pubkeys?.map((a) => (

View File

@ -1,14 +1,19 @@
import { useMemo } from "react";
import { useIntl } from "react-intl";
import useFollowersFeed from "Feed/FollowersFeed";
import { HexKey } from "Nostr";
import EventKind from "Nostr/EventKind";
import FollowListBase from "Element/FollowListBase";
import messages from "./messages";
export interface FollowersListProps {
pubkey: HexKey;
}
export default function FollowersList({ pubkey }: FollowersListProps) {
const { formatMessage } = useIntl();
const feed = useFollowersFeed(pubkey);
const pubkeys = useMemo(() => {
@ -18,9 +23,12 @@ export default function FollowersList({ pubkey }: FollowersListProps) {
a.tags.some((b) => b[0] === "p" && b[1] === pubkey)
);
return [...new Set(contactLists?.map((a) => a.pubkey))];
}, [feed]);
}, [feed, pubkey]);
return (
<FollowListBase pubkeys={pubkeys} title={`${pubkeys?.length} followers`} />
<FollowListBase
pubkeys={pubkeys}
title={formatMessage(messages.FollowerCount, { n: pubkeys?.length })}
/>
);
}

View File

@ -1,21 +1,29 @@
import { useMemo } from "react";
import { useIntl } from "react-intl";
import useFollowsFeed from "Feed/FollowsFeed";
import { HexKey } from "Nostr";
import FollowListBase from "Element/FollowListBase";
import { getFollowers } from "Feed/FollowsFeed";
import messages from "./messages";
export interface FollowsListProps {
pubkey: HexKey;
}
export default function FollowsList({ pubkey }: FollowsListProps) {
const feed = useFollowsFeed(pubkey);
const { formatMessage } = useIntl();
const pubkeys = useMemo(() => {
return getFollowers(feed.store, pubkey);
}, [feed]);
}, [feed, pubkey]);
return (
<FollowListBase pubkeys={pubkeys} title={`Following ${pubkeys?.length}`} />
<FollowListBase
pubkeys={pubkeys}
title={formatMessage(messages.FollowingCount, { n: pubkeys?.length })}
/>
);
}

View File

@ -1,16 +1,21 @@
import "./FollowsYou.css";
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { useIntl } from "react-intl";
import { HexKey } from "Nostr";
import { RootState } from "State/Store";
import useFollowsFeed from "Feed/FollowsFeed";
import { getFollowers } from "Feed/FollowsFeed";
import messages from "./messages";
export interface FollowsYouProps {
pubkey: HexKey;
}
export default function FollowsYou({ pubkey }: FollowsYouProps) {
const { formatMessage } = useIntl();
const feed = useFollowsFeed(pubkey);
const loginPubKey = useSelector<RootState, HexKey | undefined>(
(s) => s.login.publicKey
@ -18,11 +23,11 @@ export default function FollowsYou({ pubkey }: FollowsYouProps) {
const pubkeys = useMemo(() => {
return getFollowers(feed.store, pubkey);
}, [feed]);
}, [feed, pubkey]);
const followsMe = pubkeys.includes(loginPubKey!) ?? false;
return (
<>{followsMe ? <span className="follows-you">follows you</span> : null}</>
);
return followsMe ? (
<span className="follows-you">{formatMessage(messages.FollowsYou)}</span>
) : null;
}

View File

@ -1,13 +1,15 @@
import "./Invoice.css";
import { useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
// @ts-expect-error
import { decode as invoiceDecode } from "light-bolt11-decoder";
import { useMemo } from "react";
import NoteTime from "Element/NoteTime";
import SendSats from "Element/SendSats";
import ZapCircle from "Icons/ZapCircle";
import useWebln from "Hooks/useWebln";
import messages from "./messages";
export interface InvoiceProps {
invoice: string;
}
@ -15,6 +17,7 @@ export default function Invoice(props: InvoiceProps) {
const invoice = props.invoice;
const webln = useWebln();
const [showInvoice, setShowInvoice] = useState(false);
const { formatMessage } = useIntl();
const info = useMemo(() => {
try {
@ -55,10 +58,12 @@ export default function Invoice(props: InvoiceProps) {
function header() {
return (
<>
<h4>Lightning Invoice</h4>
<h4>
<FormattedMessage {...messages.Invoice} />
</h4>
<ZapCircle className="zap-circle" />
<SendSats
title="Pay Invoice"
title={formatMessage(messages.PayInvoice)}
invoice={invoice}
show={showInvoice}
onClose={() => setShowInvoice(false)}
@ -102,10 +107,16 @@ export default function Invoice(props: InvoiceProps) {
<div className="invoice-body">
{description && <p>{description}</p>}
{isPaid ? (
<div className="paid">Paid</div>
<div className="paid">
<FormattedMessage {...messages.Paid} />
</div>
) : (
<button disabled={isExpired} type="button" onClick={payInvoice}>
{isExpired ? "Expired" : "Pay"}
{isExpired ? (
<FormattedMessage {...messages.Expired} />
) : (
<FormattedMessage {...messages.Pay} />
)}
</button>
)}
</div>

View File

@ -1,6 +1,9 @@
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useInView } from "react-intersection-observer";
import messages from "./messages";
export default function LoadMore({
onLoadMore,
shouldLoadMore,
@ -28,7 +31,7 @@ export default function LoadMore({
return (
<div ref={ref} className="mb10">
{children ?? "Loading..."}
{children ?? <FormattedMessage {...messages.Loading} />}
</div>
);
}

View File

@ -1,8 +1,11 @@
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { logout } from "State/Login";
import messages from "./messages";
export default function LogoutButton() {
const dispatch = useDispatch();
const navigate = useNavigate();
@ -15,7 +18,7 @@ export default function LogoutButton() {
navigate("/");
}}
>
Logout
<FormattedMessage {...messages.Logout} />
</button>
);
}

View File

@ -1,6 +1,9 @@
import { FormattedMessage } from "react-intl";
import { HexKey } from "Nostr";
import useModeration from "Hooks/useModeration";
import messages from "./messages";
interface MuteButtonProps {
pubkey: HexKey;
}
@ -9,11 +12,11 @@ const MuteButton = ({ pubkey }: MuteButtonProps) => {
const { mute, unmute, isMuted } = useModeration();
return isMuted(pubkey) ? (
<button className="secondary" type="button" onClick={() => unmute(pubkey)}>
Unmute
<FormattedMessage {...messages.Unmute} />
</button>
) : (
<button type="button" onClick={() => mute(pubkey)}>
Mute
<FormattedMessage {...messages.Mute} />
</button>
);
};

View File

@ -1,19 +1,20 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { FormattedMessage } from "react-intl";
import { HexKey } from "Nostr";
import type { RootState } from "State/Store";
import MuteButton from "Element/MuteButton";
import ProfilePreview from "Element/ProfilePreview";
import useMutedFeed, { getMuted } from "Feed/MuteList";
import useModeration from "Hooks/useModeration";
import messages from "./messages";
export interface MutedListProps {
pubkey: HexKey;
}
export default function MutedList({ pubkey }: MutedListProps) {
const { muted, isMuted, mute, unmute, muteAll } = useModeration();
const { isMuted, muteAll } = useModeration();
const feed = useMutedFeed(pubkey);
const pubkeys = useMemo(() => {
return getMuted(feed.store, pubkey);
@ -23,14 +24,19 @@ export default function MutedList({ pubkey }: MutedListProps) {
return (
<div className="main-content">
<div className="flex mt10">
<div className="f-grow bold">{`${pubkeys?.length} muted`}</div>
<div className="f-grow bold">
<FormattedMessage
{...messages.MuteCount}
values={{ n: pubkeys?.length }}
/>
</div>
<button
disabled={hasAllMuted || pubkeys.length === 0}
className="transparent"
type="button"
onClick={() => muteAll(pubkeys)}
>
Mute all
<FormattedMessage {...messages.MuteAll} />
</button>
</div>
{pubkeys?.map((a) => {

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import {
@ -18,6 +19,8 @@ import useEventPublisher from "Feed/EventPublisher";
import { debounce, hexToBech32 } from "Util";
import { UserMetadata } from "Nostr";
import messages from "./messages";
type Nip05ServiceProps = {
name: string;
service: URL | string;
@ -30,6 +33,7 @@ type ReduxStore = any;
export default function Nip5Service(props: Nip05ServiceProps) {
const navigate = useNavigate();
const { formatMessage } = useIntl();
const pubkey = useSelector<ReduxStore, string>((s) => s.login.publicKey);
const user = useUserProfile(pubkey);
const publisher = useEventPublisher();
@ -129,12 +133,12 @@ export default function Nip5Service(props: Nip05ServiceProps) {
function mapError(e: ServiceErrorCode, t: string | null): string | undefined {
let whyMap = new Map([
["TOO_SHORT", "name too short"],
["TOO_LONG", "name too long"],
["REGEX", "name has disallowed characters"],
["REGISTERED", "name is registered"],
["DISALLOWED_null", "name is blocked"],
["DISALLOWED_later", "name will be available later"],
["TOO_SHORT", formatMessage(messages.TooShort)],
["TOO_LONG", formatMessage(messages.TooLong)],
["REGEX", formatMessage(messages.Regex)],
["REGISTERED", formatMessage(messages.Registered)],
["DISALLOWED_null", formatMessage(messages.Disallowed)],
["DISALLOWED_later", formatMessage(messages.DisalledLater)],
]);
return whyMap.get(e === "DISALLOWED" ? `${e}_${t}` : e);
}
@ -171,10 +175,17 @@ export default function Nip5Service(props: Nip05ServiceProps) {
<h3>{props.name}</h3>
{props.about}
<p>
Find out more info about {props.name} at{" "}
<a href={props.link} target="_blank" rel="noreferrer">
{props.link}
</a>
<FormattedMessage
{...messages.FindMore}
values={{
service: props.name,
link: (
<a href={props.link} target="_blank" rel="noreferrer">
{props.link}
</a>
),
}}
/>
</p>
{error && <b className="error">{error.error}</b>}
{!registerStatus && (
@ -196,7 +207,10 @@ export default function Nip5Service(props: Nip05ServiceProps) {
{availabilityResponse?.available && !registerStatus && (
<div className="flex">
<div className="mr10">
{availabilityResponse.quote?.price.toLocaleString()} sats
<FormattedMessage
{...messages.Sats}
values={{ n: availabilityResponse.quote?.price }}
/>
<br />
<small>{availabilityResponse.quote?.data.type}</small>
</div>
@ -208,14 +222,14 @@ export default function Nip5Service(props: Nip05ServiceProps) {
disabled
/>
<AsyncButton onClick={() => startBuy(handle, domain)}>
Buy Now
<FormattedMessage {...messages.BuyNow} />
</AsyncButton>
</div>
)}
{availabilityResponse?.available === false && !registerStatus && (
<div className="flex">
<b className="error">
Not available:{" "}
<FormattedMessage {...messages.NotAvailable} />{" "}
{mapError(
availabilityResponse.why!,
availabilityResponse.reasonTag || null
@ -227,32 +241,37 @@ export default function Nip5Service(props: Nip05ServiceProps) {
invoice={registerResponse?.invoice}
show={showInvoice}
onClose={() => setShowInvoice(false)}
title={`Buying ${handle}@${domain}`}
title={formatMessage(messages.Buying, { item: `${handle}@${domain}` })}
/>
{registerStatus?.paid && (
<div className="flex f-col">
<h4>Order Paid!</h4>
<h4>
<FormattedMessage {...messages.OrderPaid} />
</h4>
<p>
Your new NIP-05 handle is:{" "}
<FormattedMessage {...messages.NewNip} />{" "}
<code>
{handle}@{domain}
</code>
</p>
<h3>Account Support</h3>
<h3>
<FormattedMessage {...messages.AccountSupport} />
</h3>
<p>
Please make sure to save the following password in order to manage
your handle in the future
<FormattedMessage {...messages.SavePassword} />
</p>
<Copy text={registerStatus.password} />
<p>
Go to{" "}
<FormattedMessage {...messages.GoTo} />{" "}
<a href={props.supportLink} target="_blank" rel="noreferrer">
account page
<FormattedMessage {...messages.AccountPage} />
</a>
</p>
<h4>Activate Now</h4>
<h4>
<FormattedMessage {...messages.ActivateNow} />
</h4>
<AsyncButton onClick={() => updateProfile(handle, domain)}>
Add to Profile
<FormattedMessage {...messages.AddToProfile} />
</AsyncButton>
</div>
)}

View File

@ -8,6 +8,7 @@ import {
} from "react";
import { useNavigate, Link } from "react-router-dom";
import { useInView } from "react-intersection-observer";
import { useIntl, FormattedMessage } from "react-intl";
import { default as NEvent } from "Nostr/Event";
import ProfileImage from "Element/ProfileImage";
@ -22,6 +23,8 @@ import { useUserProfiles } from "Feed/ProfileFeed";
import { TaggedRawEvent, u256 } from "Nostr";
import useModeration from "Hooks/useModeration";
import messages from "./messages";
export interface NoteProps {
data?: TaggedRawEvent;
className?: string;
@ -43,8 +46,12 @@ const HiddenNote = ({ children }: any) => {
) : (
<div className="card note hidden-note">
<div className="header">
<p>This author has been muted</p>
<button onClick={() => setShow(true)}>Show</button>
<p>
<FormattedMessage {...messages.MutedAuthor} />
</p>
<button onClick={() => setShow(true)}>
<FormattedMessage {...messages.Show} />
</button>
</div>
</div>
);
@ -76,6 +83,7 @@ export default function Note(props: NoteProps) {
const baseClassname = `note card ${props.className ? props.className : ""}`;
const [translated, setTranslated] = useState<Translation>();
const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
const { formatMessage } = useIntl();
const options = {
showHeader: true,
@ -87,7 +95,11 @@ export default function Note(props: NoteProps) {
const transformBody = useCallback(() => {
let body = ev?.Content ?? "";
if (deletions?.length > 0) {
return <b className="error">Deleted</b>;
return (
<b className="error">
<FormattedMessage {...messages.Deleted} />
</b>
);
}
return (
<Text
@ -157,7 +169,7 @@ export default function Note(props: NoteProps) {
: mentions?.map(renderMention);
const others =
mentions.length > maxMentions
? ` & ${othersLength} other${othersLength > 1 ? "s" : ""}`
? formatMessage(messages.Others, { n: othersLength })
: "";
return (
<div className="reply">
@ -181,7 +193,12 @@ export default function Note(props: NoteProps) {
if (ev.Kind !== EventKind.TextNote) {
return (
<>
<h4>Unknown event kind: {ev.Kind}</h4>
<h4>
<FormattedMessage
{...messages.UnknownEventKind}
values={{ kind: ev.Kind }}
/>
</h4>
<pre>{JSON.stringify(ev.ToObject(), undefined, " ")}</pre>
</>
);
@ -192,13 +209,20 @@ export default function Note(props: NoteProps) {
return (
<>
<p className="highlight">
Translated from {translated.fromLanguage}:
<FormattedMessage
{...messages.TranslatedFrom}
values={{ lang: translated.fromLanguage }}
/>
</p>
{translated.text}
</>
);
} else if (translated) {
return <p className="highlight">Translation failed</p>;
return (
<p className="highlight">
<FormattedMessage {...messages.TranslationFailed} />
</p>
);
}
}
@ -206,19 +230,19 @@ export default function Note(props: NoteProps) {
if (!inView) return null;
return (
<>
{options.showHeader ? (
{options.showHeader && (
<div className="header flex">
<ProfileImage
pubkey={ev.RootPubKey}
subHeader={replyTag() ?? undefined}
/>
{options.showTime ? (
{options.showTime && (
<div className="info">
<NoteTime from={ev.CreatedAt * 1000} />
</div>
) : null}
)}
</div>
) : null}
)}
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
{transformBody()}
{translation()}
@ -228,7 +252,7 @@ export default function Note(props: NoteProps) {
className="expand-note mt10 flex f-center"
onClick={() => setShowMore(true)}
>
Show more
<FormattedMessage {...messages.ShowMore} />
</span>
)}
{options.showFooter && (

View File

@ -1,6 +1,7 @@
import "./NoteCreator.css";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import Attachment from "Icons/Attachment";
import useEventPublisher from "Feed/EventPublisher";
import { openFile } from "Util";
@ -10,6 +11,8 @@ import ProfileImage from "Element/ProfileImage";
import { default as NEvent } from "Nostr/Event";
import useFileUpload from "Upload";
import messages from "./messages";
interface NotePreviewProps {
note: NEvent;
}
@ -121,10 +124,14 @@ export function NoteCreator(props: NoteCreatorProps) {
</div>
<div className="note-creator-actions">
<button className="secondary" type="button" onClick={cancel}>
Cancel
<FormattedMessage {...messages.Cancel} />
</button>
<button type="button" onClick={onSubmit}>
{replyTo ? "Reply" : "Send"}
{replyTo ? (
<FormattedMessage {...messages.Reply} />
) : (
<FormattedMessage {...messages.Send} />
)}
</button>
</div>
</Modal>

View File

@ -1,7 +1,9 @@
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { useIntl, FormattedMessage } from "react-intl";
import {
faTrash,
faHeart,
faRepeat,
faShareNodes,
faCopy,
@ -19,10 +21,17 @@ import Zap from "Icons/Zap";
import Reply from "Icons/Reply";
import { formatShort } from "Number";
import useEventPublisher from "Feed/EventPublisher";
import { getReactions, hexToBech32, normalizeReaction, Reaction } from "Util";
import {
getReactions,
dedupeByPubkey,
hexToBech32,
normalizeReaction,
Reaction,
} from "Util";
import { NoteCreator } from "Element/NoteCreator";
import Reactions from "Element/Reactions";
import SendSats from "Element/SendSats";
import { parseZap, ZapsSummary } from "Element/Zap";
import { parseZap, ParsedZap, ZapsSummary } from "Element/Zap";
import { useUserProfile } from "Feed/ProfileFeed";
import { default as NEvent } from "Nostr/Event";
import { RootState } from "State/Store";
@ -32,6 +41,8 @@ import { UserPreferences } from "State/Login";
import useModeration from "Hooks/useModeration";
import { TranslateHost } from "Const";
import messages from "./messages";
export interface Translation {
text: string;
fromLanguage: string;
@ -46,7 +57,7 @@ export interface NoteFooterProps {
export default function NoteFooter(props: NoteFooterProps) {
const { related, ev } = props;
const { formatMessage } = useIntl();
const login = useSelector<RootState, HexKey | undefined>(
(s) => s.login.publicKey
);
@ -57,6 +68,7 @@ export default function NoteFooter(props: NoteFooterProps) {
const author = useUserProfile(ev.RootPubKey);
const publisher = useEventPublisher();
const [reply, setReply] = useState(false);
const [showReactions, setShowReactions] = useState(false);
const [tip, setTip] = useState(false);
const isMine = ev.RootPubKey === login;
const lang = window.navigator.language;
@ -68,31 +80,40 @@ export default function NoteFooter(props: NoteFooterProps) {
[related, ev]
);
const reposts = useMemo(
() => getReactions(related, ev.Id, EventKind.Repost),
() => dedupeByPubkey(getReactions(related, ev.Id, EventKind.Repost)),
[related, ev]
);
const zaps = useMemo(
() =>
getReactions(related, ev.Id, EventKind.ZapReceipt)
.map(parseZap)
.filter((z) => z.valid && z.zapper !== ev.PubKey),
[related]
);
const zaps = useMemo(() => {
const sortedZaps = getReactions(related, ev.Id, EventKind.ZapReceipt)
.map(parseZap)
.filter((z) => z.valid && z.zapper !== ev.PubKey);
sortedZaps.sort((a, b) => b.amount - a.amount);
return sortedZaps;
}, [related]);
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = zaps.some((a) => a.zapper === login);
const groupReactions = useMemo(() => {
return reactions?.reduce(
(acc, { content }) => {
let r = normalizeReaction(content);
const amount = acc[r] || 0;
return { ...acc, [r]: amount + 1 };
const result = reactions?.reduce(
(acc, reaction) => {
let kind = normalizeReaction(reaction.content);
const rs = acc[kind] || [];
if (rs.map((e) => e.pubkey).includes(reaction.pubkey)) {
return acc;
}
return { ...acc, [kind]: [...rs, reaction] };
},
{
[Reaction.Positive]: 0,
[Reaction.Negative]: 0,
[Reaction.Positive]: [] as TaggedRawEvent[],
[Reaction.Negative]: [] as TaggedRawEvent[],
}
);
return {
[Reaction.Positive]: dedupeByPubkey(result[Reaction.Positive]),
[Reaction.Negative]: dedupeByPubkey(result[Reaction.Negative]),
};
}, [reactions]);
const positive = groupReactions[Reaction.Positive];
const negative = groupReactions[Reaction.Negative];
function hasReacted(emoji: string) {
return reactions?.some(
@ -115,7 +136,7 @@ export default function NoteFooter(props: NoteFooterProps) {
async function deleteEvent() {
if (
window.confirm(
`Are you sure you want to delete ${ev.Id.substring(0, 8)}?`
formatMessage(messages.ConfirmDeletion, { id: ev.Id.substring(0, 8) })
)
) {
let evDelete = await publisher.delete(ev.Id);
@ -127,7 +148,7 @@ export default function NoteFooter(props: NoteFooterProps) {
if (!hasReposted()) {
if (
!prefs.confirmReposts ||
window.confirm(`Are you sure you want to repost: ${ev.Id}`)
window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.Id }))
) {
let evRepost = await publisher.repost(ev);
publisher.broadcast(evRepost);
@ -191,7 +212,7 @@ export default function NoteFooter(props: NoteFooterProps) {
<Heart />
</div>
<div className="reaction-pill-number">
{formatShort(groupReactions[Reaction.Positive])}
{formatShort(positive.length)}
</div>
</div>
{repostIcon()}
@ -250,42 +271,53 @@ export default function NoteFooter(props: NoteFooterProps) {
return (
<>
{prefs.enableReactions && (
<MenuItem onClick={() => react("-")}>
<Dislike />
{formatShort(groupReactions[Reaction.Negative])}
&nbsp; Dislike
<MenuItem onClick={() => setShowReactions(true)}>
<FontAwesomeIcon icon={faHeart} />
<FormattedMessage {...messages.Reactions} />
</MenuItem>
)}
<MenuItem onClick={() => share()}>
<FontAwesomeIcon icon={faShareNodes} />
Share
<FormattedMessage {...messages.Share} />
</MenuItem>
<MenuItem onClick={() => copyId()}>
<FontAwesomeIcon icon={faCopy} />
Copy ID
<FormattedMessage {...messages.CopyID} />
</MenuItem>
<MenuItem onClick={() => mute(ev.PubKey)}>
<FontAwesomeIcon icon={faCommentSlash} />
Mute
<FormattedMessage {...messages.Mute} />
</MenuItem>
{prefs.enableReactions && (
<MenuItem onClick={() => react("-")}>
<Dislike />
<FormattedMessage
{...messages.Dislike}
values={{ n: negative.length }}
/>
</MenuItem>
)}
<MenuItem onClick={() => block(ev.PubKey)}>
<FontAwesomeIcon icon={faBan} />
Block
<FormattedMessage {...messages.Block} />
</MenuItem>
<MenuItem onClick={() => translate()}>
<FontAwesomeIcon icon={faLanguage} />
Translate to {langNames.of(lang.split("-")[0])}
<FormattedMessage
{...messages.TranslateTo}
values={{ lang: langNames.of(lang.split("-")[0]) }}
/>
</MenuItem>
{prefs.showDebugMenus && (
<MenuItem onClick={() => copyEvent()}>
<FontAwesomeIcon icon={faCopy} />
Copy Event JSON
<FormattedMessage {...messages.CopyJSON} />
</MenuItem>
)}
{isMine && (
<MenuItem onClick={() => deleteEvent()}>
<FontAwesomeIcon icon={faTrash} className="red" />
Delete
<FormattedMessage {...messages.Delete} />
</MenuItem>
)}
</>
@ -326,6 +358,14 @@ export default function NoteFooter(props: NoteFooterProps) {
show={reply}
setShow={setReply}
/>
<Reactions
show={showReactions}
setShow={setShowReactions}
positive={positive}
negative={negative}
reposts={reposts}
zaps={zaps}
/>
<SendSats
svc={author?.lud16 || author?.lud06}
onClose={() => setTip(false)}

View File

@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { FormattedRelativeTime } from "react-intl";
const MinuteInMs = 1_000 * 60;
const HourInMs = MinuteInMs * 60;
@ -16,12 +17,12 @@ export default function NoteTime(props: NoteTimeProps) {
dateStyle: "medium",
timeStyle: "long",
}).format(from);
const isoDate = new Date(from).toISOString();
const fromDate = new Date(from);
const isoDate = fromDate.toISOString();
const ago = new Date().getTime() - from;
const absAgo = Math.abs(ago);
function calcTime() {
let fromDate = new Date(from);
let ago = new Date().getTime() - from;
let absAgo = Math.abs(ago);
if (absAgo > DayInMs) {
return fromDate.toLocaleDateString(undefined, {
year: "2-digit",

View File

@ -1,12 +1,14 @@
import "./NoteToSelf.css";
import { Link, useNavigate } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBook, faCertificate } from "@fortawesome/free-solid-svg-icons";
import { useUserProfile } from "Feed/ProfileFeed";
import Nip05 from "Element/Nip05";
import { profileLink } from "Util";
import messages from "./messages";
export interface NoteToSelfProps {
pubkey: string;
clickable?: boolean;
@ -18,7 +20,8 @@ function NoteLabel({ pubkey, link }: NoteToSelfProps) {
const user = useUserProfile(pubkey);
return (
<div>
Note to Self <FontAwesomeIcon icon={faCertificate} size="xs" />
<FormattedMessage {...messages.NoteToSelf} />{" "}
<FontAwesomeIcon icon={faCertificate} size="xs" />
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
</div>
);

118
src/Element/Reactions.css Normal file
View File

@ -0,0 +1,118 @@
.reactions-modal .modal-body {
padding: 0;
max-width: 586px;
}
.reactions-view {
padding: 24px 32px;
background-color: #1b1b1b;
border-radius: 16px;
position: relative;
}
@media (max-width: 720px) {
.reactions-view {
padding: 12px 16px;
margin-top: -160px;
}
}
.reactions-view .close {
position: absolute;
top: 12px;
right: 16px;
color: var(--font-secondary-color);
cursor: pointer;
}
.reactions-view .close:hover {
color: var(--font-tertiary-color);
}
.reactions-view .reactions-header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 32px;
}
.reactions-view .reactions-header h2 {
margin: 0;
flex-grow: 1;
font-weight: 600;
font-size: 16px;
line-height: 19px;
}
.reactions-view .body {
overflow: scroll;
height: 320px;
-ms-overflow-style: none; /* for Internet Explorer, Edge */
scrollbar-width: none; /* Firefox */
}
.reactions-view .body::-webkit-scrollbar {
display: none;
}
.reactions-item {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 24px;
}
.reactions-item .reaction-icon {
width: 52px;
display: flex;
align-items: center;
justify-content: center;
}
.reactions-item .follow-button {
margin-left: auto;
}
.reactions-item .zap-reaction-icon {
width: 52px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.reactions-item .zap-amount {
margin-top: 10px;
font-weight: 500;
font-size: 14px;
line-height: 17px;
}
@media (max-width: 520px) {
.reactions-view .tab.disabled {
display: none;
}
.reactions-item .reaction-icon {
width: 42px;
}
.reactions-item .avatar {
width: 21px;
height: 21px;
}
.reactions-item .pfp .username {
font-size: 14px;
}
.reactions-item .pfp .nip05 {
display: none;
}
.reactions-item button {
font-size: 14px;
}
.reactions-item .zap-reaction-icon svg {
width: 12px;
height: l2px;
}
.reactions-item .zap-amount {
font-size: 12px;
}
}

166
src/Element/Reactions.tsx Normal file
View File

@ -0,0 +1,166 @@
import "./Reactions.css";
import { useState, useMemo, useEffect } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { TaggedRawEvent } from "Nostr";
import { formatShort } from "Number";
import Dislike from "Icons/Dislike";
import Heart from "Icons/Heart";
import ZapIcon from "Icons/Zap";
import { Tab } from "Element/Tabs";
import { ParsedZap } from "Element/Zap";
import ProfileImage from "Element/ProfileImage";
import FollowButton from "Element/FollowButton";
import Tabs from "Element/Tabs";
import Close from "Icons/Close";
import Modal from "Element/Modal";
import messages from "./messages";
interface ReactionsProps {
show: boolean;
setShow(b: boolean): void;
positive: TaggedRawEvent[];
negative: TaggedRawEvent[];
reposts: TaggedRawEvent[];
zaps: ParsedZap[];
}
const Reactions = ({
show,
setShow,
positive,
negative,
reposts,
zaps,
}: ReactionsProps) => {
const { formatMessage } = useIntl();
const onClose = () => setShow(false);
const likes = useMemo(() => {
const sorted = [...positive];
sorted.sort((a, b) => b.created_at - a.created_at);
return sorted;
}, [positive]);
const dislikes = useMemo(() => {
const sorted = [...negative];
sorted.sort((a, b) => b.created_at - a.created_at);
return sorted;
}, [negative]);
const total =
positive.length + negative.length + zaps.length + reposts.length;
const defaultTabs: Tab[] = [
{
text: formatMessage(messages.Likes, { n: likes.length }),
value: 0,
},
{
text: formatMessage(messages.Zaps, { n: zaps.length }),
value: 1,
disabled: zaps.length === 0,
},
{
text: formatMessage(messages.Reposts, { n: reposts.length }),
value: 2,
disabled: reposts.length === 0,
},
];
const tabs = defaultTabs.concat(
dislikes.length !== 0
? [
{
text: formatMessage(messages.Dislikes, { n: dislikes.length }),
value: 3,
},
]
: []
);
const [tab, setTab] = useState(tabs[0]);
useEffect(() => {
if (!show) {
setTab(tabs[0]);
}
}, [show]);
return show ? (
<Modal className="reactions-modal" onClose={onClose}>
<div className="reactions-view">
<div className="close" onClick={onClose}>
<Close />
</div>
<div className="reactions-header">
<h2>
<FormattedMessage
{...messages.ReactionsCount}
values={{ n: total }}
/>
</h2>
</div>
<Tabs tabs={tabs} tab={tab} setTab={setTab} />
<div className="body" key={tab.value}>
{tab.value === 0 &&
likes.map((ev) => {
return (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">
{ev.content === "+" ? (
<Heart width={20} height={18} />
) : (
ev.content
)}
</div>
<ProfileImage pubkey={ev.pubkey} />
<FollowButton pubkey={ev.pubkey} />
</div>
);
})}
{tab.value === 1 &&
zaps.map((z) => {
return (
<div key={z.id} className="reactions-item">
<div className="zap-reaction-icon">
<ZapIcon width={17} height={20} />
<span className="zap-amount">{formatShort(z.amount)}</span>
</div>
<ProfileImage
pubkey={z.zapper!}
subHeader={<>{z.content}</>}
/>
<FollowButton pubkey={z.zapper!} />
</div>
);
})}
{tab.value === 2 &&
reposts.map((ev) => {
return (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">
<Heart width={20} height={18} />
</div>
<ProfileImage pubkey={ev.pubkey} />
<FollowButton pubkey={ev.pubkey} />
</div>
);
})}
{tab.value === 3 &&
dislikes.map((ev) => {
return (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">
<Dislike width={20} height={18} />
</div>
<ProfileImage pubkey={ev.pubkey} />
<FollowButton pubkey={ev.pubkey} />
</div>
);
})}
</div>
</div>
</Modal>
) : null;
};
export default Reactions;

View File

@ -1,5 +1,6 @@
import "./Relay.css";
import { useIntl, FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import {
faPlug,
faSquareCheck,
@ -15,7 +16,8 @@ import { useDispatch, useSelector } from "react-redux";
import { setRelays } from "State/Login";
import { RootState } from "State/Store";
import { RelaySettings } from "Nostr/Connection";
import { useNavigate } from "react-router-dom";
import messages from "./messages";
export interface RelayProps {
addr: string;
@ -23,6 +25,7 @@ export interface RelayProps {
export default function Relay(props: RelayProps) {
const dispatch = useDispatch();
const { formatMessage } = useIntl();
const navigate = useNavigate();
const allRelaySettings = useSelector<
RootState,
@ -55,7 +58,7 @@ export default function Relay(props: RelayProps) {
<div className="flex mb10">
<b className="f-2">{name}</b>
<div className="f-1">
Write
<FormattedMessage {...messages.Write} />
<span
className="checkmark"
onClick={() =>
@ -71,7 +74,7 @@ export default function Relay(props: RelayProps) {
</span>
</div>
<div className="f-1">
Read
<FormattedMessage {...messages.Read} />
<span
className="checkmark"
onClick={() =>
@ -91,8 +94,12 @@ export default function Relay(props: RelayProps) {
<div className="f-grow">
<FontAwesomeIcon icon={faWifi} />{" "}
{latency > 2000
? `${(latency / 1000).toFixed(0)} secs`
: `${latency.toLocaleString()} ms`}
? formatMessage(messages.Seconds, {
n: (latency / 1000).toFixed(0),
})
: formatMessage(messages.Milliseconds, {
n: latency.toLocaleString(),
})}
&nbsp;
<FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects}
</div>

View File

@ -61,15 +61,6 @@
line-height: 19px;
}
.lnurl-tip .btn {
background-color: inherit;
width: 210px;
margin: 0 0 10px 0;
}
.lnurl-tip .btn:hover {
}
.amounts {
display: flex;
width: 100%;

View File

@ -1,5 +1,6 @@
import "./SendSats.css";
import { useEffect, useMemo, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { formatShort } from "Number";
import { bech32ToText } from "Util";
@ -15,6 +16,8 @@ import Copy from "Element/Copy";
import useWebln from "Hooks/useWebln";
import useHorizontalScroll from "Hooks/useHorizontalScroll";
import messages from "./messages";
interface LNURLService {
nostrPubkey?: HexKey;
minSendable?: number;
@ -71,6 +74,7 @@ export default function LNURLTip(props: LNURLTipProps) {
const [error, setError] = useState<string>();
const [success, setSuccess] = useState<LNURLSuccessAction>();
const webln = useWebln(show);
const { formatMessage } = useIntl();
const publisher = useEventPublisher();
const horizontalScroll = useHorizontalScroll();
@ -78,7 +82,7 @@ export default function LNURLTip(props: LNURLTipProps) {
if (show && !props.invoice) {
loadService()
.then((a) => setPayService(a!))
.catch(() => setError("Failed to load LNURL service"));
.catch(() => setError(formatMessage(messages.LNURLFail)));
} else {
setPayService(undefined);
setError(undefined);
@ -170,10 +174,10 @@ export default function LNURLTip(props: LNURLTipProps) {
payWebLNIfEnabled(data);
}
} else {
setError("Failed to load invoice");
setError(formatMessage(messages.InvoiceFail));
}
} catch (e) {
setError("Failed to load invoice");
setError(formatMessage(messages.InvoiceFail));
}
}
@ -187,7 +191,7 @@ export default function LNURLTip(props: LNURLTipProps) {
min={min}
max={max}
className="f-grow mr10"
placeholder="Custom"
placeholder={formatMessage(messages.Custom)}
value={customAmount}
onChange={(e) => setCustomAmount(parseInt(e.target.value))}
/>
@ -197,7 +201,7 @@ export default function LNURLTip(props: LNURLTipProps) {
disabled={!Boolean(customAmount)}
onClick={() => selectAmount(customAmount!)}
>
Confirm
<FormattedMessage {...messages.Confirm} />
</button>
</div>
);
@ -220,7 +224,9 @@ export default function LNURLTip(props: LNURLTipProps) {
if (invoice) return null;
return (
<>
<h3>Zap amount in sats</h3>
<h3>
<FormattedMessage {...messages.ZapAmount} />
</h3>
<div className="amounts" ref={horizontalScroll}>
{serviceAmounts.map((a) => (
<span
@ -235,15 +241,16 @@ export default function LNURLTip(props: LNURLTipProps) {
</div>
{payService && custom()}
<div className="flex">
{(payService?.commentAllowed ?? 0) > 0 && (
<input
type="text"
placeholder="Comment"
className="f-grow"
maxLength={payService?.commentAllowed}
onChange={(e) => setComment(e.target.value)}
/>
)}
{(payService?.commentAllowed ?? 0) > 0 ||
(payService?.nostrPubkey && (
<input
type="text"
placeholder={formatMessage(messages.Comment)}
className="f-grow"
maxLength={payService?.commentAllowed || 120}
onChange={(e) => setComment(e.target.value)}
/>
))}
</div>
{(amount ?? 0) > 0 && (
<button
@ -252,9 +259,18 @@ export default function LNURLTip(props: LNURLTipProps) {
onClick={() => loadInvoice()}
>
<div className="zap-action-container">
<Zap /> Zap
{target && ` ${target} `}
{formatShort(amount)} sats
<Zap />
{target ? (
<FormattedMessage
{...messages.ZapTarget}
values={{ target, n: formatShort(amount) }}
/>
) : (
<FormattedMessage
{...messages.ZapSats}
values={{ n: formatShort(amount) }}
/>
)}
</div>
</button>
)}
@ -281,7 +297,7 @@ export default function LNURLTip(props: LNURLTipProps) {
type="button"
onClick={() => window.open(`lightning:${pr}`)}
>
Open Wallet
<FormattedMessage {...messages.OpenWallet} />
</button>
</>
)}
@ -297,7 +313,7 @@ export default function LNURLTip(props: LNURLTipProps) {
<div className="success-action">
<p className="paid">
<Check className="success mr10" />
{success?.description ?? "Paid!"}
{success?.description ?? <FormattedMessage {...messages.Paid} />}
</p>
{success.url && (
<p>
@ -310,8 +326,15 @@ export default function LNURLTip(props: LNURLTipProps) {
);
}
const defaultTitle = payService?.nostrPubkey ? "Send zap" : "Send sats";
const title = target ? `${defaultTitle} to ${target}` : defaultTitle;
const defaultTitle = payService?.nostrPubkey
? formatMessage(messages.SendZap)
: formatMessage(messages.SendSats);
const title = target
? formatMessage(messages.ToTarget, {
action: defaultTitle,
target,
})
: defaultTitle;
if (!show) return null;
return (
<Modal className="lnurl-modal" onClose={onClose}>

View File

@ -1,4 +1,7 @@
import "./ShowMore.css";
import { useIntl } from "react-intl";
import messages from "./messages";
interface ShowMoreProps {
text?: string;
@ -6,16 +9,14 @@ interface ShowMoreProps {
onClick: () => void;
}
const ShowMore = ({
text = "Show more",
onClick,
className = "",
}: ShowMoreProps) => {
const ShowMore = ({ text, onClick, className = "" }: ShowMoreProps) => {
const { formatMessage } = useIntl();
const defaultText = formatMessage(messages.ShowMore);
const classNames = className ? `show-more ${className}` : "show-more";
return (
<div className="show-more-container">
<button className={classNames} onClick={onClick}>
{text}
{text || defaultText}
</button>
</div>
);

View File

@ -34,3 +34,9 @@
.tabs > div {
cursor: pointer;
}
.tab.disabled {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}

View File

@ -1,8 +1,10 @@
import "./Tabs.css";
import { ReactElement } from "react";
export interface Tab {
text: string;
text: ReactElement | string;
value: number;
disabled?: boolean;
}
interface TabsProps {
@ -18,8 +20,10 @@ interface TabElementProps extends Omit<TabsProps, "tabs"> {
export const TabElement = ({ t, tab, setTab }: TabElementProps) => {
return (
<div
className={`tab ${tab.value === t.value ? "active" : ""}`}
onClick={() => setTab(t)}
className={`tab ${tab.value === t.value ? "active" : ""} ${
t.disabled ? "disabled" : ""
}`}
onClick={() => !t.disabled && setTab(t)}
>
{t.text}
</div>
@ -29,17 +33,9 @@ export const TabElement = ({ t, tab, setTab }: TabElementProps) => {
const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
return (
<div className="tabs">
{tabs.map((t) => {
return (
<div
key={t.value}
className={`tab ${tab.value === t.value ? "active" : ""}`}
onClick={() => setTab(t)}
>
{t.text}
</div>
);
})}
{tabs.map((t) => (
<TabElement tab={tab} setTab={setTab} t={t} />
))}
</div>
);
};

View File

@ -2,6 +2,7 @@ import "@webscopeio/react-textarea-autocomplete/style.css";
import "./Textarea.css";
import { useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
import emoji from "@jukben/emoji-search";
import TextareaAutosize from "react-textarea-autosize";
@ -12,6 +13,8 @@ import { hexToBech32 } from "Util";
import { MetadataCache } from "State/Users";
import { useQuery } from "State/Users/Hooks";
import messages from "./messages";
interface EmojiItemProps {
name: string;
char: string;
@ -43,6 +46,7 @@ const UserItem = (metadata: MetadataCache) => {
const Textarea = ({ users, onChange, ...rest }: any) => {
const [query, setQuery] = useState("");
const { formatMessage } = useIntl();
const allUsers = useQuery(query);
@ -61,7 +65,7 @@ const Textarea = ({ users, onChange, ...rest }: any) => {
<ReactTextareaAutocomplete
{...rest}
loadingComponent={() => <span>Loading....</span>}
placeholder="What's on your mind?"
placeholder={formatMessage(messages.NotePlaceholder)}
onChange={onChange}
textAreaComponent={TextareaAutosize}
trigger={{

View File

@ -1,6 +1,6 @@
import "./Thread.css";
import { useMemo, useState, useEffect, ReactNode } from "react";
import { useSelector } from "react-redux";
import { useIntl, FormattedMessage } from "react-intl";
import { useNavigate, useLocation, Link } from "react-router-dom";
import { TaggedRawEvent, u256, HexKey } from "Nostr";
@ -11,7 +11,8 @@ import BackButton from "Element/BackButton";
import Note from "Element/Note";
import NoteGhost from "Element/NoteGhost";
import Collapsed from "Element/Collapsed";
import type { RootState } from "State/Store";
import messages from "./messages";
function getParent(
ev: HexKey,
@ -115,6 +116,7 @@ const ThreadNote = ({
chains,
onNavigate,
}: ThreadNoteProps) => {
const { formatMessage } = useIntl();
const replies = getReplies(note.Id, chains);
const activeInReplies = replies.map((r) => r.Id).includes(active);
const [collapsed, setCollapsed] = useState(!activeInReplies);
@ -150,7 +152,7 @@ const ThreadNote = ({
/>
) : (
<Collapsed
text="Show replies"
text={formatMessage(messages.ShowReplies)}
collapsed={collapsed}
setCollapsed={setCollapsed}
>
@ -261,7 +263,7 @@ const TierThree = ({
type="button"
onClick={() => onNavigate(from)}
>
Show replies
<FormattedMessage {...messages.ShowReplies} />
</button>
</div>
)

View File

@ -1,4 +1,5 @@
import "./Timeline.css";
import { FormattedMessage } from "react-intl";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faForward } from "@fortawesome/free-solid-svg-icons";
import { useCallback, useMemo } from "react";
@ -14,6 +15,8 @@ import useModeration from "Hooks/useModeration";
import ProfilePreview from "./ProfilePreview";
import Skeleton from "Element/Skeleton";
import messages from "./messages";
export interface TimelineProps {
postsOnly: boolean;
subject: TimelineSubject;
@ -96,8 +99,11 @@ export default function Timeline({
<div className="main-content">
{latestFeed.length > 1 && (
<div className="card latest-notes pointer" onClick={() => showLatest()}>
<FontAwesomeIcon icon={faForward} size="xl" />
&nbsp; Show latest {latestFeed.length - 1} notes
<FontAwesomeIcon icon={faForward} size="xl" />{" "}
<FormattedMessage
{...messages.ShowLatest}
values={{ n: latestFeed.length - 1 }}
/>
</div>
)}
{mainFeed.map(eventElement)}

View File

@ -87,3 +87,7 @@
.amount-number {
font-weight: bold;
}
.zap.note .body {
margin-bottom: 0;
}

View File

@ -1,5 +1,6 @@
import "./Zap.css";
import { useMemo } from "react";
import { FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
// @ts-expect-error
import { decode as invoiceDecode } from "light-bolt11-decoder";
@ -14,6 +15,8 @@ import Text from "Element/Text";
import ProfileImage from "Element/ProfileImage";
import { RootState } from "State/Store";
import messages from "./messages";
function findTag(e: TaggedRawEvent, tag: string) {
const maybeTag = e.tags.find((evTag) => {
return evTag[0] === tag;
@ -55,7 +58,7 @@ function getZapper(zap: TaggedRawEvent, dhash: string): Zapper {
return { isValid: false };
}
interface ParsedZap {
export interface ParsedZap {
id: HexKey;
e?: HexKey;
p: HexKey;
@ -91,23 +94,30 @@ const Zap = ({
const { amount, content, zapper, valid, p } = zap;
const pubKey = useSelector((s: RootState) => s.login.publicKey);
return valid ? (
return valid && zapper ? (
<div className="zap note card">
<div className="header">
{zapper ? <ProfileImage pubkey={zapper} /> : <div>Anon&nbsp;</div>}
<ProfileImage pubkey={zapper} />
{p !== pubKey && showZapped && <ProfileImage pubkey={p} />}
<div className="amount">
<span className="amount-number">{formatShort(amount)}</span> sats
<span className="amount-number">
<FormattedMessage
{...messages.Sats}
values={{ n: formatShort(amount) }}
/>
</span>
</div>
</div>
<div className="body">
<Text
creator={zapper || ""}
content={content}
tags={[]}
users={new Map()}
/>
</div>
{content.length > 0 && zapper && (
<div className="body">
<Text
creator={zapper}
content={content}
tags={[]}
users={new Map()}
/>
</div>
)}
</div>
) : null;
};
@ -118,8 +128,8 @@ interface ZapsSummaryProps {
export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
const sortedZaps = useMemo(() => {
const pub = [...zaps.filter((z) => z.zapper)];
const priv = [...zaps.filter((z) => !z.zapper)];
const pub = [...zaps.filter((z) => z.zapper && z.valid)];
const priv = [...zaps.filter((z) => !z.zapper && z.valid)];
pub.sort((a, b) => b.amount - a.amount);
return pub.concat(priv);
}, [zaps]);
@ -129,8 +139,7 @@ export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
}
const [topZap, ...restZaps] = sortedZaps;
const restZapsTotal = restZaps.reduce((acc, z) => acc + z.amount, 0);
const { zapper, amount, content, valid } = topZap;
const { zapper, amount } = topZap;
return (
<div className="zaps-summary">
@ -139,11 +148,15 @@ export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
<div className="summary">
{zapper && <ProfileImage pubkey={zapper} />}
{restZaps.length > 0 && (
<span>
and {restZaps.length} other{restZaps.length > 1 ? "s" : ""}
</span>
)}
<span>&nbsp;zapped</span>
<FormattedMessage
{...messages.Others}
values={{ n: restZaps.length }}
/>
)}{" "}
<FormattedMessage
{...messages.OthersZapped}
values={{ n: restZaps.length }}
/>
</div>
</div>
)}

96
src/Element/messages.js Normal file
View File

@ -0,0 +1,96 @@
import { defineMessages } from "react-intl";
import { addIdAndDefaultMessageToMessages } from "Util";
const messages = defineMessages({
Cancel: "Cancel",
Reply: "Reply",
Send: "Send",
NotePlaceholder: "What's on your mind?",
Back: "Back",
Block: "Block",
Unblock: "Unblock",
MuteCount: "{n} muted",
Mute: "Mute",
MutedAuthor: "This author has been muted",
Others: ` & {n} {n, plural, =1 {other} other {others}}`,
Show: "Show",
Delete: "Delete",
Deleted: "Deleted",
Unmute: "Unmute",
MuteAll: "Mute all",
BlockCount: "{n} blocked",
JustNow: "Just now",
Follow: "Follow",
FollowAll: "Follow all",
Unfollow: "Unfollow",
FollowerCount: "{n} followers",
FollowingCount: "Follows {n}",
FollowsYou: "follows you",
Invoice: "Lightning Invoice",
PayInvoice: "Pay Invoice",
Expired: "Expired",
Pay: "Pay",
Paid: "Paid",
Loading: "Loading...",
Logout: "Logout",
ShowMore: "Show more",
TranslateTo: "Translate to {lang}",
TranslatedFrom: "Translated from {lang}",
TranslationFailed: "Translation failed",
UnknownEventKind: "Unknown event kind: {kind}",
ConfirmDeletion: `Are you sure you want to delete {id}`,
ConfirmRepost: `Are you sure you want to repost: {id}`,
Reactions: "Reactions",
ReactionsCount: "Reactions ({n})",
Share: "Share",
CopyID: "Copy ID",
CopyJSON: "Copy Event JSON",
Dislike: "{n} Dislike",
Sats: `{n} {n, plural, =1 {sat} other {sats}}`,
Zapped: "zapped",
OthersZapped: `{n, plural, =0 {} =1 {zapped} other {zapped}}`,
Likes: "Likes ({n})",
Zaps: "Zaps ({n})",
Dislikes: "Dislikes ({n})",
Reposts: "Reposts ({n})",
NoteToSelf: "Note to Self",
Read: "Read",
Write: "Write",
Seconds: "{n} secs",
Milliseconds: "{n} ms",
ShowLatest: "Show latest {n} notes",
LNURLFail: "Failed to load LNURL service",
InvoiceFail: "Failed to load invoice",
Custom: "Custom",
Confirm: "Confirm",
ZapAmount: "Zap amount in sats",
Comment: "Comment",
ZapTarget: "Zap {target} {n} sats",
ZapSats: "Zap {n} sats",
OpenWallet: "Open Wallet",
SendZap: "Send zap",
SendSats: "Send sats",
ToTarget: "{action} to {target}",
ShowReplies: "Show replies",
TooShort: "name too short",
TooLong: "name too long",
Regex: "name has disallowed characters",
Registered: "name is registered",
Disallowed: "name is blocked",
DisalledLater: "name will be available later",
BuyNow: "Buy Now",
NotAvailable: "Not available:",
Buying: "Buying {item}",
OrderPaid: "Order Paid!",
NewNip: "Your new NIP-05 handle is:",
ActivateNow: "Activate Now",
AddToProfile: "Add to Profile",
AccountPage: "account page",
AccountSupport: "Account Support",
GoTo: "Go to",
FindMore: "Find out more info about {service} at {link}",
SavePassword:
"Please make sure to save the following password in order to manage your handle in the future",
});
export default addIdAndDefaultMessageToMessages(messages, "Element");

View File

@ -118,6 +118,7 @@ export default function useTimelineFeed(
sub.Id = `timeline-related:${subject.type}`;
sub.Kinds = new Set([
EventKind.Reaction,
EventKind.Repost,
EventKind.Deletion,
EventKind.ZapReceipt,
]);

View File

@ -1,4 +1,6 @@
const Dislike = () => {
import IconProps from "./IconProps";
const Dislike = (props: IconProps) => {
return (
<svg
width="19"
@ -6,6 +8,7 @@ const Dislike = () => {
viewBox="0 0 19 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M13.1667 1.66667V10.8333M17.3333 8.16667V4.33334C17.3333 3.39992 17.3333 2.93321 17.1517 2.57669C16.9919 2.26308 16.7369 2.00812 16.4233 1.84833C16.0668 1.66667 15.6001 1.66667 14.6667 1.66667H5.76501C4.54711 1.66667 3.93816 1.66667 3.44632 1.88953C3.01284 2.08595 2.64442 2.40202 2.38437 2.8006C2.08931 3.25283 1.99672 3.8547 1.81153 5.05844L1.37563 7.89178C1.13137 9.47943 1.00925 10.2733 1.24484 10.8909C1.45162 11.4331 1.84054 11.8864 2.34494 12.1732C2.91961 12.5 3.72278 12.5 5.32912 12.5H6C6.46671 12.5 6.70007 12.5 6.87833 12.5908C7.03513 12.6707 7.16261 12.7982 7.24251 12.955C7.33334 13.1333 7.33334 13.3666 7.33334 13.8333V16.2785C7.33334 17.4133 8.25333 18.3333 9.3882 18.3333C9.65889 18.3333 9.90419 18.1739 10.0141 17.9266L12.8148 11.6252C12.9421 11.3385 13.0058 11.1952 13.1065 11.0902C13.1955 10.9973 13.3048 10.9263 13.4258 10.8827C13.5627 10.8333 13.7195 10.8333 14.0332 10.8333H14.6667C15.6001 10.8333 16.0668 10.8333 16.4233 10.6517C16.7369 10.4919 16.9919 10.2369 17.1517 9.92332C17.3333 9.5668 17.3333 9.10009 17.3333 8.16667Z"

View File

@ -1,4 +1,6 @@
const Heart = () => {
import IconProps from "./IconProps";
const Heart = (props: IconProps) => {
return (
<svg
width="20"
@ -6,6 +8,7 @@ const Heart = () => {
viewBox="0 0 20 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"

View File

@ -1,4 +1,5 @@
import { useMemo } from "react";
import { FormattedMessage } from "react-intl";
import { useDispatch, useSelector } from "react-redux";
import { HexKey, RawEvent } from "Nostr";
@ -10,6 +11,8 @@ import { RootState } from "State/Store";
import NoteToSelf from "Element/NoteToSelf";
import useModeration from "Hooks/useModeration";
import messages from "./messages";
type DmChat = {
pubkey: HexKey;
unreadMessages: number;
@ -71,9 +74,11 @@ export default function MessagesPage() {
return (
<div className="main-content">
<div className="flex">
<h3 className="f-grow">Messages</h3>
<h3 className="f-grow">
<FormattedMessage {...messages.Messages} />
</h3>
<button type="button" onClick={() => markAllRead()}>
Mark All Read
<FormattedMessage {...messages.MarkAllRead} />
</button>
</div>
{chats

View File

@ -1,6 +1,6 @@
import "./ProfilePage.css";
import { useEffect, useMemo, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
@ -37,17 +37,18 @@ import Modal from "Element/Modal";
import { ProxyImg } from "Element/ProxyImg";
import useHorizontalScroll from "Hooks/useHorizontalScroll";
const ProfileTab = {
Notes: { text: "Notes", value: 0 },
Reactions: { text: "Reactions", value: 1 },
Followers: { text: "Followers", value: 2 },
Follows: { text: "Follows", value: 3 },
Zaps: { text: "Zaps", value: 4 },
Muted: { text: "Muted", value: 5 },
Blocked: { text: "Blocked", value: 6 },
};
import messages from "./messages";
const NOTES = 0;
const REACTIONS = 1;
const FOLLOWERS = 2;
const FOLLOWS = 3;
const ZAPS = 4;
const MUTED = 5;
const BLOCKED = 6;
export default function ProfilePage() {
const { formatMessage } = useIntl();
const params = useParams();
const navigate = useNavigate();
const id = useMemo(() => parseId(params.id!), [params]);
@ -61,7 +62,6 @@ export default function ProfilePage() {
const follows = useSelector<RootState, HexKey[]>((s) => s.login.follows);
const isMe = loginPubKey === id;
const [showLnQr, setShowLnQr] = useState<boolean>(false);
const [tab, setTab] = useState<Tab>(ProfileTab.Notes);
const [showProfileQr, setShowProfileQr] = useState<boolean>(false);
const aboutText = user?.about || "";
const about = Text({
@ -85,6 +85,16 @@ export default function ProfilePage() {
}, [zapFeed.store, id]);
const zapsTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const horizontalScroll = useHorizontalScroll();
const ProfileTab = {
Notes: { text: formatMessage(messages.Notes), value: NOTES },
Reactions: { text: formatMessage(messages.Reactions), value: REACTIONS },
Followers: { text: formatMessage(messages.Followers), value: FOLLOWERS },
Follows: { text: formatMessage(messages.Follows), value: FOLLOWS },
Zaps: { text: formatMessage(messages.Zaps), value: ZAPS },
Muted: { text: formatMessage(messages.Muted), value: MUTED },
Blocked: { text: formatMessage(messages.Blocked), value: BLOCKED },
};
const [tab, setTab] = useState<Tab>(ProfileTab.Notes);
useEffect(() => {
setTab(ProfileTab.Notes);
@ -149,8 +159,8 @@ export default function ProfilePage() {
}
function tabContent() {
switch (tab) {
case ProfileTab.Notes:
switch (tab.value) {
case NOTES:
return (
<Timeline
key={id}
@ -164,10 +174,15 @@ export default function ProfilePage() {
ignoreModeration={true}
/>
);
case ProfileTab.Zaps: {
case ZAPS: {
return (
<div className="main-content">
<h4 className="zaps-total">{formatShort(zapsTotal)} sats</h4>
<h4 className="zaps-total">
<FormattedMessage
{...messages.Sats}
values={{ n: formatShort(zapsTotal) }}
/>
</h4>
{zaps.map((z) => (
<ZapElement showZapped={false} zap={z} />
))}
@ -175,11 +190,16 @@ export default function ProfilePage() {
);
}
case ProfileTab.Follows: {
case FOLLOWS: {
if (isMe) {
return (
<div className="main-content">
<h4>Following {follows.length}</h4>
<h4>
<FormattedMessage
{...messages.Following}
values={{ n: follows.length }}
/>
</h4>
{follows.map((a) => (
<ProfilePreview
key={a}
@ -193,13 +213,13 @@ export default function ProfilePage() {
return <FollowsList pubkey={id} />;
}
}
case ProfileTab.Followers: {
case FOLLOWERS: {
return <FollowersList pubkey={id} />;
}
case ProfileTab.Muted: {
case MUTED: {
return isMe ? <BlockList variant="muted" /> : <MutedList pubkey={id} />;
}
case ProfileTab.Blocked: {
case BLOCKED: {
return isMe ? <BlockList variant="blocked" /> : null;
}
}
@ -233,7 +253,7 @@ export default function ProfilePage() {
<>
<LogoutButton />
<button type="button" onClick={() => navigate("/settings")}>
Settings
<FormattedMessage {...messages.Settings} />
</button>
</>
) : (

View File

@ -2,6 +2,7 @@ import "./Root.css";
import { useState } from "react";
import { useSelector } from "react-redux";
import { Link } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import Tabs, { Tab } from "Element/Tabs";
import { RootState } from "State/Store";
@ -9,10 +10,21 @@ import Timeline from "Element/Timeline";
import { HexKey } from "Nostr";
import { TimelineSubject } from "Feed/TimelineFeed";
import messages from "./messages";
const RootTab: Record<string, Tab> = {
Posts: { text: "Posts", value: 0 },
PostsAndReplies: { text: "Conversations", value: 1 },
Global: { text: "Global", value: 2 },
Posts: {
text: <FormattedMessage {...messages.Posts} />,
value: 0,
},
PostsAndReplies: {
text: <FormattedMessage {...messages.Conversations} />,
value: 1,
},
Global: {
text: <FormattedMessage {...messages.Global} />,
value: 2,
},
};
export default function RootPage() {
@ -25,10 +37,16 @@ export default function RootPage() {
function followHints() {
if (follows?.length === 0 && pubKey && tab !== RootTab.Global) {
return (
<>
Hmm nothing here.. Checkout <Link to={"/new"}>New users page</Link> to
follow some recommended nostrich's!
</>
<FormattedMessage
{...messages.NoFollows}
values={{
newUsersPage: (
<Link to={"/new"}>
<FormattedMessage {...messages.NewUsers} />
</Link>
),
}}
/>
);
}
}

View File

@ -1,3 +1,4 @@
import { useIntl, FormattedMessage } from "react-intl";
import { useParams } from "react-router-dom";
import Timeline from "Element/Timeline";
import { useEffect, useState } from "react";
@ -6,8 +7,11 @@ import { router } from "index";
import { SearchRelays } from "Const";
import { System } from "Nostr/System";
import messages from "./messages";
const SearchPage = () => {
const params: any = useParams();
const { formatMessage } = useIntl();
const [search, setSearch] = useState<string>();
const [keyword, setKeyword] = useState<string | undefined>(params.keyword);
@ -39,12 +43,14 @@ const SearchPage = () => {
return (
<div className="main-content">
<h2>Search</h2>
<h2>
<FormattedMessage {...messages.Search} />
</h2>
<div className="flex mb10">
<input
type="text"
className="f-grow mr10"
placeholder="Search.."
placeholder={formatMessage(messages.SearchPlaceholder)}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>

View File

@ -1,3 +1,4 @@
import { FormattedMessage } from "react-intl";
import { Outlet, RouteObject, useNavigate } from "react-router-dom";
import SettingsIndex from "Pages/settings/Index";
import Profile from "Pages/settings/Profile";
@ -5,13 +6,15 @@ import Relay from "Pages/settings/Relays";
import Preferences from "Pages/settings/Preferences";
import RelayInfo from "Pages/settings/RelayInfo";
import messages from "./messages";
export default function SettingsPage() {
const navigate = useNavigate();
return (
<div className="main-content">
<h2 onClick={() => navigate("/settings")} className="pointer">
Settings
<FormattedMessage {...messages.Settings} />
</h2>
<Outlet />
</div>

View File

@ -1,6 +1,10 @@
import { FormattedMessage } from "react-intl";
import { ApiHost } from "Const";
import Nip5Service from "Element/Nip5Service";
import messages from "./messages";
import "./Verification.css";
export default function VerificationPage() {
@ -10,42 +14,37 @@ export default function VerificationPage() {
service: `${ApiHost}/api/v1/n5sp`,
link: "https://snort.social/",
supportLink: "https://snort.social/help",
about: (
<>
Our very own NIP-05 verification service, help support the development
of this site and get a shiny special badge on our site!
</>
),
about: <FormattedMessage {...messages.SnortSocialNip} />,
},
{
name: "Nostr Plebs",
service: "https://nostrplebs.com/api/v1",
link: "https://nostrplebs.com/",
supportLink: "https://nostrplebs.com/manage",
about: (
<>
<p>
Nostr Plebs is one of the first NIP-05 providers in the space and
offers a good collection of domains at reasonable prices
</p>
</>
),
about: <FormattedMessage {...messages.NostrPlebsNip} />,
},
];
return (
<div className="main-content verification">
<h2>Get Verified</h2>
<h2>
<FormattedMessage {...messages.GetVerified} />
</h2>
<p>
NIP-05 is a DNS based verification spec which helps to validate you as a
real user.
<FormattedMessage {...messages.Nip05} />
</p>
<p>
<FormattedMessage {...messages.Nip05Pros} />
</p>
<p>Getting NIP-05 verified can help:</p>
<ul>
<li>Prevent fake accounts from imitating you</li>
<li>Make your profile easier to find and share</li>
<li>
Fund developers and platforms providing NIP-05 verification services
<FormattedMessage {...messages.AvoidImpersonators} />
</li>
<li>
<FormattedMessage {...messages.EasierToFind} />
</li>
<li>
<FormattedMessage {...messages.Funding} />
</li>
</ul>

View File

@ -3,6 +3,35 @@ import { addIdAndDefaultMessageToMessages } from "Util";
const messages = defineMessages({
Login: "Login",
Posts: "Posts",
Conversations: "Conversations",
Global: "Global",
NewUsers: "New users page",
NoFollows:
"Hmm nothing here.. Checkout {newUsersPage} to follow some recommended nostrich's!",
Notes: "Notes",
Reactions: "Reactions",
Followers: "Followers",
Follows: "Follows",
Zaps: "Zaps",
Muted: "Muted",
Blocked: "Blocked",
Sats: "{n} {n, plural, =1 {sat} other {sats}}",
Following: "Following {n}",
Settings: "Settings",
Search: "Search",
SearchPlaceholder: "Search...",
Messages: "Messages",
MarkAllRead: "Mark All Read",
GetVerified: "Get Verified",
Nip05: `NIP-05 is a DNS based verification spec which helps to validate you as a real user.`,
Nip05Pros: `Getting NIP-05 verified can help:`,
AvoidImpersonators: "Prevent fake accounts from imitating you",
EasierToFind: "Make your profile easier to find and share",
Funding:
"Fund developers and platforms providing NIP-05 verification services",
SnortSocialNip: `Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!`,
NostrPlebsNip: `Nostr Plebs is one of the first NIP-05 providers in the space and offers a good collection of domains at reasonable prices`,
});
export default addIdAndDefaultMessageToMessages(messages, 'Pages');
export default addIdAndDefaultMessageToMessages(messages, "Pages");

View File

@ -1,5 +1,5 @@
import "./Index.css";
import { FormattedMessage } from "react-intl";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import ArrowFront from "Icons/ArrowFront";
@ -8,9 +8,10 @@ import Profile from "Icons/Profile";
import Relay from "Icons/Relay";
import Heart from "Icons/Heart";
import Logout from "Icons/Logout";
import { logout } from "State/Login";
import messages from "./messages";
const SettingsIndex = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
@ -27,7 +28,9 @@ const SettingsIndex = () => {
<div className="mr10">
<Profile />
</div>
<span>Profile</span>
<span>
<FormattedMessage {...messages.Profile} />
</span>
<div className="align-end">
<ArrowFront />
</div>
@ -36,7 +39,7 @@ const SettingsIndex = () => {
<div className="mr10">
<Relay />
</div>
Relays
<FormattedMessage {...messages.Relays} />
<div className="align-end">
<ArrowFront />
</div>
@ -45,7 +48,7 @@ const SettingsIndex = () => {
<div className="mr10">
<Gear />
</div>
Preferences
<FormattedMessage {...messages.Preferences} />
<div className="align-end">
<ArrowFront />
</div>
@ -63,7 +66,7 @@ const SettingsIndex = () => {
<div className="mr10">
<Logout />
</div>
Log Out
<FormattedMessage {...messages.LogOut} />
<div className="align-end">
<ArrowFront />
</div>

View File

@ -1,7 +1,12 @@
import "./Preferences.css";
import { useDispatch, useSelector } from "react-redux";
import { FormattedMessage } from "react-intl";
import { DefaultImgProxy, setPreferences, UserPreferences } from "State/Login";
import { RootState } from "State/Store";
import "./Preferences.css";
import messages from "./messages";
const PreferencesPage = () => {
const dispatch = useDispatch();
@ -11,11 +16,15 @@ const PreferencesPage = () => {
return (
<div className="preferences">
<h3>Preferences</h3>
<h3>
<FormattedMessage {...messages.Preferences} />
</h3>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Theme</div>
<div>
<FormattedMessage {...messages.Theme} />
</div>
</div>
<div>
<select
@ -29,18 +38,25 @@ const PreferencesPage = () => {
)
}
>
<option value="system">System (Default)</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">
<FormattedMessage {...messages.System} />
</option>
<option value="light">
<FormattedMessage {...messages.Light} />
</option>
<option value="dark">
<FormattedMessage {...messages.Dark} />
</option>
</select>
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Automatically load media</div>
<div>
<FormattedMessage {...messages.AutoloadMedia} />
</div>
<small>
Media in posts will automatically be shown for selected people,
otherwise only the link will show
<FormattedMessage {...messages.AutoloadMediaHelp} />
</small>
</div>
<div>
@ -55,17 +71,27 @@ const PreferencesPage = () => {
)
}
>
<option value="none">None</option>
<option value="follows-only">Follows only</option>
<option value="all">All</option>
<option value="none">
<FormattedMessage {...messages.None} />
</option>
<option value="follows-only">
<FormattedMessage {...messages.FollowsOnly} />
</option>
<option value="all">
<FormattedMessage {...messages.All} />
</option>
</select>
</div>
</div>
<div className="card flex f-col">
<div className="flex w-max">
<div className="flex f-col f-grow">
<div>Image proxy service</div>
<small>Use imgproxy to compress images</small>
<div>
<FormattedMessage {...messages.ImgProxy} />
</div>
<small>
<FormattedMessage {...messages.ImgProxyHelp} />
</small>
</div>
<div>
<input
@ -85,12 +111,14 @@ const PreferencesPage = () => {
{perf.imgProxyConfig && (
<div className="w-max mt10 form">
<div className="form-group">
<div>Service Url</div>
<div>
<FormattedMessage {...messages.ServiceUrl} />
</div>
<div className="w-max">
<input
type="text"
value={perf.imgProxyConfig?.url}
placeholder="Url.."
placeholder="URL.."
onChange={(e) =>
dispatch(
setPreferences({
@ -106,7 +134,9 @@ const PreferencesPage = () => {
</div>
</div>
<div className="form-group">
<div>Service Key</div>
<div>
<FormattedMessage {...messages.ServiceKey} />
</div>
<div className="w-max">
<input
type="password"
@ -127,7 +157,9 @@ const PreferencesPage = () => {
</div>
</div>
<div className="form-group">
<div>Service Salt</div>
<div>
<FormattedMessage {...messages.ServiceSalt} />
</div>
<div className="w-max">
<input
type="password"
@ -152,10 +184,11 @@ const PreferencesPage = () => {
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Enable reactions</div>
<div>
<FormattedMessage {...messages.EnableReactions} />
</div>
<small>
Reactions will be shown on every page, if disabled no reactions will
be shown
<FormattedMessage {...messages.EnableReactionsHelp} />
</small>
</div>
<div>
@ -172,8 +205,12 @@ const PreferencesPage = () => {
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Confirm reposts</div>
<small>Reposts need to be manually confirmed</small>
<div>
<FormattedMessage {...messages.ConfirmReposts} />
</div>
<small>
<FormattedMessage {...messages.ConfirmRepostsHelp} />
</small>
</div>
<div>
<input
@ -189,9 +226,11 @@ const PreferencesPage = () => {
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Automatically show latest notes</div>
<div>
<FormattedMessage {...messages.ShowLatest} />
</div>
<small>
Notes will stream in real time into global and posts tab
<FormattedMessage {...messages.ShowLatestHelp} />
</small>
</div>
<div>
@ -208,9 +247,11 @@ const PreferencesPage = () => {
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>File upload service</div>
<div>
<FormattedMessage {...messages.FileUpload} />
</div>
<small>
Pick which upload service you want to upload attachments to
<FormattedMessage {...messages.FileUploadHelp} />
</small>
</div>
<div>
@ -225,7 +266,9 @@ const PreferencesPage = () => {
)
}
>
<option value="void.cat">void.cat (Default)</option>
<option value="void.cat">
void.cat <FormattedMessage {...messages.Default} />
</option>
<option value="nostr.build">nostr.build</option>
<option value="nostrimg.com">nostrimg.com</option>
</select>
@ -233,10 +276,11 @@ const PreferencesPage = () => {
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Debug Menus</div>
<div>
<FormattedMessage {...messages.DebugMenus} />
</div>
<small>
Shows "Copy ID" and "Copy Event JSON" in the context menu on each
message
<FormattedMessage {...messages.DebugMenusHelp} />
</small>
</div>
<div>

View File

@ -1,7 +1,7 @@
import "./Profile.css";
import Nostrich from "nostrich.webp";
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@ -15,6 +15,8 @@ import { RootState } from "State/Store";
import { HexKey } from "Nostr";
import useFileUpload from "Upload";
import messages from "./messages";
export interface ProfileSettingsProps {
avatar?: boolean;
banner?: boolean;
@ -112,7 +114,9 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
return (
<div className="editor form">
<div className="form-group">
<div>Name:</div>
<div>
<FormattedMessage {...messages.Name} />:
</div>
<div>
<input
type="text"
@ -122,7 +126,9 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
</div>
</div>
<div className="form-group">
<div>Display name:</div>
<div>
<FormattedMessage {...messages.DisplayName} />:
</div>
<div>
<input
type="text"
@ -132,7 +138,9 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
</div>
</div>
<div className="form-group form-col">
<div>About:</div>
<div>
<FormattedMessage {...messages.About} />:
</div>
<div className="w-max">
<textarea
className="w-max"
@ -142,7 +150,9 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
</div>
</div>
<div className="form-group">
<div>Website:</div>
<div>
<FormattedMessage {...messages.Website} />:
</div>
<div>
<input
type="text"
@ -152,7 +162,9 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
</div>
</div>
<div className="form-group">
<div>NIP-05:</div>
<div>
<FormattedMessage {...messages.Nip05} />:
</div>
<div>
<input
type="text"
@ -162,12 +174,14 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
/>
<button type="button" onClick={() => navigate("/verification")}>
<FontAwesomeIcon icon={faShop} />
&nbsp; Buy
&nbsp; <FormattedMessage {...messages.Buy} />
</button>
</div>
</div>
<div className="form-group">
<div>LN Address:</div>
<div>
<FormattedMessage {...messages.LnAddress} />:
</div>
<div>
<input
type="text"
@ -180,7 +194,7 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
<div></div>
<div>
<button type="button" onClick={() => saveProfile()}>
Save
<FormattedMessage {...messages.Save} />
</button>
</div>
</div>
@ -195,20 +209,24 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
<div className="flex f-center image-settings">
{(props.avatar ?? true) && (
<div>
<h2>Avatar</h2>
<h2>
<FormattedMessage {...messages.Avatar} />
</h2>
<div
style={{ backgroundImage: `url(${avatarPicture})` }}
className="avatar"
>
<div className="edit" onClick={() => setNewAvatar()}>
Edit
<FormattedMessage {...messages.Edit} />
</div>
</div>
</div>
)}
{(props.banner ?? true) && (
<div>
<h2>Header</h2>
<h2>
<FormattedMessage {...messages.Banner} />
</h2>
<div
style={{
backgroundImage: `url(${
@ -218,7 +236,7 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
className="banner"
>
<div className="edit" onClick={() => setNewBanner()}>
Edit
<FormattedMessage {...messages.Edit} />
</div>
</div>
</div>
@ -231,12 +249,16 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
return (
<div className="settings">
<h3>Edit Profile</h3>
<h3>
<FormattedMessage {...messages.EditProfile} />
</h3>
{settings()}
{privKey && (props.privateKey ?? true) && (
<div className="flex f-col bg-grey">
<div>
<h4>Your Private Key Is (do not share this with anyone):</h4>
<h4>
<FormattedMessage {...messages.PrivateKey} />:
</h4>
</div>
<div>
<Copy text={hexToBech32("nsec", privKey)} />

View File

@ -1,3 +1,4 @@
import { FormattedMessage } from "react-intl";
import ProfilePreview from "Element/ProfilePreview";
import useRelayState from "Feed/RelayState";
import { System } from "Nostr/System";
@ -6,6 +7,8 @@ import { useNavigate, useParams } from "react-router-dom";
import { removeRelay } from "State/Login";
import { parseId } from "Util";
import messages from "./messages";
const RelayInfo = () => {
const params = useParams();
const navigate = useNavigate();
@ -20,7 +23,7 @@ const RelayInfo = () => {
return (
<>
<h3 className="pointer" onClick={() => navigate("/settings/relays")}>
Relays
<FormattedMessage {...messages.Relays} />
</h3>
<div className="card">
<h3>{stats?.info?.name}</h3>
@ -28,13 +31,17 @@ const RelayInfo = () => {
{stats?.info?.pubkey && (
<>
<h4>Owner</h4>
<h4>
<FormattedMessage {...messages.Owner} />
</h4>
<ProfilePreview pubkey={parseId(stats.info.pubkey)} />
</>
)}
{stats?.info?.software && (
<div className="flex">
<h4 className="f-grow">Software</h4>
<h4 className="f-grow">
<FormattedMessage {...messages.Software} />
</h4>
<div className="flex f-col">
{stats.info.software.startsWith("http") ? (
<a href={stats.info.software} target="_blank" rel="noreferrer">
@ -52,7 +59,9 @@ const RelayInfo = () => {
)}
{stats?.info?.contact && (
<div className="flex">
<h4 className="f-grow">Contact</h4>
<h4 className="f-grow">
<FormattedMessage {...messages.Contact} />
</h4>
<a
href={`${
stats.info.contact.startsWith("mailto:") ? "" : "mailto:"
@ -66,7 +75,9 @@ const RelayInfo = () => {
)}
{stats?.info?.supported_nips && (
<>
<h4>Supports</h4>
<h4>
<FormattedMessage {...messages.Supports} />
</h4>
<div className="f-grow">
{stats.info.supported_nips.map((a) => (
<span
@ -93,7 +104,7 @@ const RelayInfo = () => {
navigate("/settings/relays");
}}
>
Remove
<FormattedMessage {...messages.Remove} />
</div>
</div>
</div>

View File

@ -1,4 +1,5 @@
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import { useDispatch, useSelector } from "react-redux";
import Relay from "Element/Relay";
@ -7,6 +8,8 @@ import { RootState } from "State/Store";
import { RelaySettings } from "Nostr/Connection";
import { setRelays } from "State/Login";
import messages from "./messages";
const RelaySettingsPage = () => {
const dispatch = useDispatch();
const publisher = useEventPublisher();
@ -24,7 +27,9 @@ const RelaySettingsPage = () => {
function addRelay() {
return (
<>
<h4>Add Relays</h4>
<h4>
<FormattedMessage {...messages.AddRelays} />
</h4>
<div className="flex mb10">
<input
type="text"
@ -35,7 +40,7 @@ const RelaySettingsPage = () => {
/>
</div>
<button className="secondary mb10" onClick={() => addNewRelay()}>
Add
<FormattedMessage {...messages.Add} />
</button>
</>
);
@ -66,7 +71,7 @@ const RelaySettingsPage = () => {
<div className="flex mt10">
<div className="f-grow"></div>
<button type="button" onClick={() => saveRelays()}>
Save
<FormattedMessage {...messages.Save} />
</button>
</div>
{addRelay()}

View File

@ -0,0 +1,60 @@
import { defineMessages } from "react-intl";
import { addIdAndDefaultMessageToMessages } from "Util";
const messages = defineMessages({
Profile: "Profile",
Relays: "Relays",
Owner: "Owner",
Software: "Software",
Contact: "Contact",
Supports: "Supports",
Remove: "Remove",
Preferences: "Preferences",
Donate: "Donate",
LogOut: "Log Out",
Theme: "Theme",
System: "System (Default)",
Light: "Light",
Dark: "Dark",
AutoloadMedia: "Automatically load media",
AutoloadMediaHelp:
"Media in posts will automatically be shown for selected people, otherwise only the link will show",
None: "None",
FollowsOnly: "Follows only",
All: "All",
ImgProxy: "Image proxy service",
ImgProxyHelp: "Use imgproxy to compress images",
ServiceUrl: "Service URL",
ServiceKey: "Service Key",
ServiceSalt: "Service Salt",
EnableReactions: "Enable reactions",
EnableReactionsHelp:
"Reactions will be shown on every page, if disabled no reactions will be shown",
ConfirmReposts: "Confirm Reposts",
ConfirmRepostsHelp: "Reposts need to be manually confirmed",
ShowLatest: "Automatically show latest notes",
ShowLatestHelp: "Notes will stream in real time into global and posts tab",
FileUpload: "File upload service",
FileUploadHelp: "Pick which upload service you want to upload attachments to",
Default: "(Default)",
DebugMenus: "Debug Menus",
DebugMenusHelp: `Shows "Copy ID" and "Copy Event JSON" in the context menu on each message`,
EditProfile: "Edit Profile",
About: "About",
LnAddress: "LN Address",
Avatar: "Avatar",
Banner: "Banner",
Edit: "Edit",
PrivateKey: "Your Private Key Is (do not share this with anyone)",
Add: "Add",
AddRelays: "Add Relays",
Name: "Name",
Profile: "Profile",
Website: "Website",
Save: "Save",
DisplayName: "Display name",
Buy: "Buy",
Nip05: "NIP-05",
});
export default addIdAndDefaultMessageToMessages(messages, "Pages.settings");

View File

@ -195,3 +195,20 @@ export function addIdAndDefaultMessageToMessages(
return result;
}
export function dedupeByPubkey(events: TaggedRawEvent[]) {
const deduped = events.reduce(
({ list, seen }: { list: TaggedRawEvent[]; seen: Set<HexKey> }, ev) => {
if (seen.has(ev.pubkey)) {
return { list, seen };
}
seen.add(ev.pubkey);
return {
seen,
list: [...list, ev],
};
},
{ list: [], seen: new Set([]) }
);
return deduped.list as TaggedRawEvent[];
}

View File

@ -271,6 +271,7 @@ button.icon:hover {
.btn-rnd {
border: none;
border-radius: 100%;
width: 21px;
height: 21px;
display: flex;

View File

@ -1,3 +1,168 @@
{
"Pages.Login": "Login"
}
"Element.AccountPage": "account page",
"Element.AccountSupport": "Account Support",
"Element.ActivateNow": "Activate Now",
"Element.AddToProfile": "Add to Profile",
"Element.Back": "Back",
"Element.Block": "Block",
"Element.BlockCount": "{n} blocked",
"Element.BuyNow": "Buy Now",
"Element.Buying": "Buying {item}",
"Element.Cancel": "Cancel",
"Element.Comment": "Comment",
"Element.Confirm": "Confirm",
"Element.ConfirmDeletion": "Are you sure you want to delete {id}",
"Element.ConfirmRepost": "Are you sure you want to repost: {id}",
"Element.CopyID": "Copy ID",
"Element.CopyJSON": "Copy Event JSON",
"Element.Custom": "Custom",
"Element.Delete": "Delete",
"Element.Deleted": "Deleted",
"Element.DisalledLater": "name will be available later",
"Element.Disallowed": "name is blocked",
"Element.Dislike": "{n} Dislike",
"Element.Dislikes": "Dislikes ({n})",
"Element.Expired": "Expired",
"Element.FindMore": "Find out more info about {service} at {link}",
"Element.Follow": "Follow",
"Element.FollowAll": "Follow all",
"Element.FollowerCount": "{n} followers",
"Element.FollowingCount": "Follows {n}",
"Element.FollowsYou": "follows you",
"Element.GoTo": "Go to",
"Element.Invoice": "Lightning Invoice",
"Element.InvoiceFail": "Failed to load invoice",
"Element.JustNow": "Just now",
"Element.LNURLFail": "Failed to load LNURL service",
"Element.Likes": "Likes ({n})",
"Element.Loading": "Loading...",
"Element.Logout": "Logout",
"Element.Milliseconds": "{n} ms",
"Element.Mute": "Mute",
"Element.MuteAll": "Mute all",
"Element.MuteCount": "{n} muted",
"Element.MutedAuthor": "This author has been muted",
"Element.NewNip": "Your new NIP-05 handle is:",
"Element.NotAvailable": "Not available:",
"Element.NotePlaceholder": "What's on your mind?",
"Element.NoteToSelf": "Note to Self",
"Element.OpenWallet": "Open Wallet",
"Element.OrderPaid": "Order Paid!",
"Element.Others": " & {n} {n, plural, =1 {other} other {others}}",
"Element.OthersZapped": "{n, plural, =0 {} =1 {zapped} other {zapped}}",
"Element.Paid": "Paid",
"Element.Pay": "Pay",
"Element.PayInvoice": "Pay Invoice",
"Element.Reactions": "Reactions",
"Element.ReactionsCount": "Reactions ({n})",
"Element.Read": "Read",
"Element.Regex": "name has disallowed characters",
"Element.Registered": "name is registered",
"Element.Reply": "Reply",
"Element.Reposts": "Reposts ({n})",
"Element.Sats": "{n} {n, plural, =1 {sat} other {sats}}",
"Element.SavePassword": "Please make sure to save the following password in order to manage your handle in the future",
"Element.Seconds": "{n} secs",
"Element.Send": "Send",
"Element.SendSats": "Send sats",
"Element.SendZap": "Send zap",
"Element.Share": "Share",
"Element.Show": "Show",
"Element.ShowLatest": "Show latest {n} notes",
"Element.ShowMore": "Show more",
"Element.ShowReplies": "Show replies",
"Element.ToTarget": "{action} to {target}",
"Element.TooLong": "name too long",
"Element.TooShort": "name too short",
"Element.TranslateTo": "Translate to {lang}",
"Element.TranslatedFrom": "Translated from {lang}",
"Element.TranslationFailed": "Translation failed",
"Element.Unblock": "Unblock",
"Element.Unfollow": "Unfollow",
"Element.UnknownEventKind": "Unknown event kind: {kind}",
"Element.Unmute": "Unmute",
"Element.Write": "Write",
"Element.ZapAmount": "Zap amount in sats",
"Element.ZapSats": "Zap {n} sats",
"Element.ZapTarget": "Zap {target} {n} sats",
"Element.Zapped": "zapped",
"Element.Zaps": "Zaps ({n})",
"Pages.AvoidImpersonators": "Prevent fake accounts from imitating you",
"Pages.Blocked": "Blocked",
"Pages.Conversations": "Conversations",
"Pages.EasierToFind": "Make your profile easier to find and share",
"Pages.Followers": "Followers",
"Pages.Following": "Following {n}",
"Pages.Follows": "Follows",
"Pages.Funding": "Fund developers and platforms providing NIP-05 verification services",
"Pages.GetVerified": "Get Verified",
"Pages.Global": "Global",
"Pages.Login": "Login",
"Pages.MarkAllRead": "Mark All Read",
"Pages.Messages": "Messages",
"Pages.Muted": "Muted",
"Pages.NewUsers": "New users page",
"Pages.Nip05": "NIP-05 is a DNS based verification spec which helps to validate you as a real user.",
"Pages.Nip05Pros": "Getting NIP-05 verified can help:",
"Pages.NoFollows": "Hmm nothing here.. Checkout {newUsersPage} to follow some recommended nostrich's!",
"Pages.NostrPlebsNip": "Nostr Plebs is one of the first NIP-05 providers in the space and offers a good collection of domains at reasonable prices",
"Pages.Notes": "Notes",
"Pages.Posts": "Posts",
"Pages.Reactions": "Reactions",
"Pages.Sats": "{n} {n, plural, =1 {sat} other {sats}}",
"Pages.Search": "Search",
"Pages.SearchPlaceholder": "Search...",
"Pages.Settings": "Settings",
"Pages.SnortSocialNip": "Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!",
"Pages.Zaps": "Zaps",
"Pages.settings.About": "About",
"Pages.settings.Add": "Add",
"Pages.settings.AddRelays": "Add Relays",
"Pages.settings.All": "All",
"Pages.settings.AutoloadMedia": "Automatically load media",
"Pages.settings.AutoloadMediaHelp": "Media in posts will automatically be shown for selected people, otherwise only the link will show",
"Pages.settings.Avatar": "Avatar",
"Pages.settings.Banner": "Banner",
"Pages.settings.Buy": "Buy",
"Pages.settings.ConfirmReposts": "Confirm Reposts",
"Pages.settings.ConfirmRepostsHelp": "Reposts need to be manually confirmed",
"Pages.settings.Contact": "Contact",
"Pages.settings.Dark": "Dark",
"Pages.settings.DebugMenus": "Debug Menus",
"Pages.settings.DebugMenusHelp": "Shows \"Copy ID\" and \"Copy Event JSON\" in the context menu on each message",
"Pages.settings.Default": "(Default)",
"Pages.settings.DisplayName": "Display name",
"Pages.settings.Donate": "Donate",
"Pages.settings.Edit": "Edit",
"Pages.settings.EditProfile": "Edit Profile",
"Pages.settings.EnableReactions": "Enable reactions",
"Pages.settings.EnableReactionsHelp": "Reactions will be shown on every page, if disabled no reactions will be shown",
"Pages.settings.FileUpload": "File upload service",
"Pages.settings.FileUploadHelp": "Pick which upload service you want to upload attachments to",
"Pages.settings.FollowsOnly": "Follows only",
"Pages.settings.ImgProxy": "Image proxy service",
"Pages.settings.ImgProxyHelp": "Use imgproxy to compress images",
"Pages.settings.Light": "Light",
"Pages.settings.LnAddress": "LN Address",
"Pages.settings.LogOut": "Log Out",
"Pages.settings.Name": "Name",
"Pages.settings.Nip05": "NIP-05",
"Pages.settings.None": "None",
"Pages.settings.Owner": "Owner",
"Pages.settings.Preferences": "Preferences",
"Pages.settings.PrivateKey": "Your Private Key Is (do not share this with anyone)",
"Pages.settings.Profile": "Profile",
"Pages.settings.Relays": "Relays",
"Pages.settings.Remove": "Remove",
"Pages.settings.Save": "Save",
"Pages.settings.ServiceKey": "Service Key",
"Pages.settings.ServiceSalt": "Service Salt",
"Pages.settings.ServiceUrl": "Service URL",
"Pages.settings.ShowLatest": "Automatically show latest notes",
"Pages.settings.ShowLatestHelp": "Notes will stream in real time into global and posts tab",
"Pages.settings.Software": "Software",
"Pages.settings.Supports": "Supports",
"Pages.settings.System": "System (Default)",
"Pages.settings.Theme": "Theme",
"Pages.settings.Website": "Website"
}

View File

@ -1,3 +1,168 @@
{
"Pages.Login": "Acceso"
"Element.AccountPage": "página de perfil",
"Element.AccountSupport": "Soporte de Cuenta",
"Element.ActivateNow": "Activar Ahora",
"Element.AddToProfile": "Añadir a tu perfil",
"Element.Back": "Atrás",
"Element.Block": "Bloquear",
"Element.BlockCount": "{n} bloqueados",
"Element.BuyNow": "Comprar",
"Element.Buying": "Comprando {item}",
"Element.Cancel": "Cancelar",
"Element.Comment": "Comentario",
"Element.Confirm": "Confirmar",
"Element.ConfirmDeletion": "¿Estás seguro de que quieres eliminar {id}?",
"Element.ConfirmRepost": "¿Estás seguro de que quieres republicar {id}",
"Element.CopyID": "Copiar ID",
"Element.CopyJSON": "Copiar JSON",
"Element.Custom": "Personalizar",
"Element.Delete": "Eliminar",
"Element.Deleted": "Eliminado",
"Element.DisalledLater": "",
"Element.Disallowed": "el nombre no está permitido",
"Element.Dislike": "{n} No me gusta",
"Element.Dislikes": "No me gusta ({n})",
"Element.Expired": "Caducada",
"Element.FindMore": "Aprende más sobre {service} en {link}",
"Element.Follow": "Seguir",
"Element.FollowAll": "Seguir todos",
"Element.FollowerCount": "{n} seguidores",
"Element.FollowingCount": "Siguiendo a {n}",
"Element.FollowsYou": "te sigue",
"Element.GoTo": "Ir a",
"Element.Invoice": "Factura Lightning",
"Element.InvoiceFail": "Error al cargar factura",
"Element.JustNow": "Justo ahora",
"Element.LNURLFail": "Error al contactar con el servicio LNURL",
"Element.Likes": "Me gusta ({n})",
"Element.Loading": "Cargando...",
"Element.Logout": "Cerrar sesión",
"Element.Milliseconds": "{n} ms",
"Element.Mute": "Silenciar",
"Element.MuteAll": "Silenciar todos",
"Element.MuteCount": "{n} silenciados",
"Element.MutedAuthor": "Este autor ha sido silenciado",
"Element.NewNip": "Tu nuevo NIP-05 es:",
"Element.NotAvailable": "No disponible:",
"Element.NotePlaceholder": "¿Qué tienes en mente?",
"Element.NoteToSelf": "Mensajes guardados",
"Element.OpenWallet": "Abrir Wallet",
"Element.OrderPaid": "Orden pagada!",
"Element.Others": " y {n} {n, plural, =1 {otro} other {otros}}",
"Element.OthersZapped": "{n, plural, =0 {} =1 {zapeó} other {zapearon}}",
"Element.Paid": "Pagado",
"Element.Pay": "Pagar",
"Element.PayInvoice": "Pagar Factura",
"Element.Reactions": "Reacciones",
"Element.ReactionsCount": "Reacciones ({n})",
"Element.Read": "Leer",
"Element.Regex": "el nombre tiene caracteres inválidos",
"Element.Registered": "el nombre ya está registrado",
"Element.Reply": "Responder",
"Element.Reposts": "Reposts ({n})",
"Element.Sats": "{n} {n, plural, =1 {sat} other {sats}}",
"Element.SavePassword": "Asegúrate de guardar ésta contraseña para gestionar tu NIP-05 en el futuro",
"Element.Seconds": "{n} seg",
"Element.Send": "Enviar",
"Element.SendSats": "Enviar sats",
"Element.SendZap": "Enviar zap",
"Element.Share": "Compartir",
"Element.Show": "Mostrar",
"Element.ShowLatest": "Mostrar últimas {n} notas",
"Element.ShowMore": "Mostrar más",
"Element.ShowReplies": "Mostrar respuestas",
"Element.ToTarget": "{action} a {target}",
"Element.TooLong": "el nombre es demasiado largo",
"Element.TooShort": "el nombre es demasiado corto",
"Element.TranslateTo": "Traducir a {lang}",
"Element.TranslatedFrom": "Traducido de {lang}",
"Element.TranslationFailed": "Traducción fallida",
"Element.Unblock": "Desbloquear",
"Element.Unfollow": "No seguir",
"Element.UnknownEventKind": "Evento de tipo desconocido {kind}",
"Element.Unmute": "Desilenciar",
"Element.Write": "Escribir",
"Element.ZapAmount": "Total en sats",
"Element.ZapSats": "Zapear {n} sats",
"Element.ZapTarget": "Zapear {target} {n} sats",
"Element.Zapped": "zapeó",
"Element.Zaps": "Zaps ({n})",
"Pages.AvoidImpersonators": "",
"Pages.Blocked": "Bloqueados",
"Pages.Conversations": "Conversaciones",
"Pages.EasierToFind": "Haz tu perfil más fácil de encontrar y compartir",
"Pages.Followers": "Seguidores",
"Pages.Following": "Siguiendo ({n})",
"Pages.Follows": "Siguiendo",
"Pages.Funding": "Apoya a las plataformas y desarrolladores que proporcionan servicios de verificación",
"Pages.GetVerified": "Verifica tu perfil",
"Pages.Global": "Global",
"Pages.Login": "Acceso",
"Pages.MarkAllRead": "Marcar todo como leído",
"Pages.Messages": "Mensajes",
"Pages.Muted": "Silenciados",
"Pages.NewUsers": "Página de nuevos usuarios",
"Pages.Nip05": "NIP-05 es un sistema de verification basado en DNS que permite verificarte como un usuario real.",
"Pages.Nip05Pros": "Obtener un NIP-05 ayuda a:",
"Pages.NoFollows": "Hmmm no hay nada que mostrarte aquí... Echa un vistazo a la {newUsersPage} para seguir a algunas cuentas recomendadas!",
"Pages.NostrPlebsNip": "Nostr Plbes es uno de los primeros proveedores de NIP-05 y ofrece una buena colección de dominios a precios razonables.",
"Pages.Notes": "Notas",
"Pages.Posts": "Notas",
"Pages.Reactions": "Reacciones",
"Pages.Sats": "{n} {n, plural, =1 {sat} other {sats}}",
"Pages.Search": "Búsqueda",
"Pages.SearchPlaceholder": "Buscar...",
"Pages.Settings": "Configuración",
"Pages.SnortSocialNip": "Nuestro servicio de verificación NIP-05, apoya el desarrollo de este proyecto y obtén una apariencia especial en nuestra web!",
"Pages.Zaps": "Zaps",
"Pages.settings.About": "Bio",
"Pages.settings.Add": "Añadir",
"Pages.settings.AddRelays": "Añadir Relays",
"Pages.settings.All": "Todos",
"Pages.settings.AutoloadMedia": "Cargar medios automáticamente",
"Pages.settings.AutoloadMediaHelp": "Se cargarán las imágenes y vídeos automáticamente",
"Pages.settings.Avatar": "Avatar",
"Pages.settings.Banner": "Banner",
"Pages.settings.Buy": "Comprar",
"Pages.settings.ConfirmReposts": "Confirmar reposts",
"Pages.settings.ConfirmRepostsHelp": "",
"Pages.settings.Contact": "Contacto",
"Pages.settings.Dark": "Oscuro",
"Pages.settings.DebugMenus": "Menús de desarrolladores",
"Pages.settings.DebugMenusHelp": "Se mostrarán opciones adicionales para desarrolladores en el menú de las notas",
"Pages.settings.Default": "(Por defecto)",
"Pages.settings.DisplayName": "Nombre",
"Pages.settings.Donate": "Donar",
"Pages.settings.Edit": "Editar",
"Pages.settings.EditProfile": "Editar Perfil",
"Pages.settings.EnableReactions": "Activar reacciones",
"Pages.settings.EnableReactionsHelp": "Se mostrarán las reacciones a las notas",
"Pages.settings.FileUpload": "Subida de ficheros",
"Pages.settings.FileUploadHelp": "Elige qué servicio de subida de ficheros quieres utilizar",
"Pages.settings.FollowsOnly": "Sólo seguidos",
"Pages.settings.ImgProxy": "Proxy de imágines",
"Pages.settings.ImgProxyHelp": "",
"Pages.settings.Light": "Claro",
"Pages.settings.LnAddress": "Dirección Lightning",
"Pages.settings.LogOut": "Salir",
"Pages.settings.Name": "Nombre",
"Pages.settings.Nip05": "NIP-05",
"Pages.settings.None": "Ninguno",
"Pages.settings.Owner": "Dueño",
"Pages.settings.Preferences": "Preferencias",
"Pages.settings.PrivateKey": "Tu Clave Privada (no la compartas con nadie) es",
"Pages.settings.Profile": "Perfil",
"Pages.settings.Relays": "Relays",
"Pages.settings.Remove": "Eliminar",
"Pages.settings.Save": "Guardar",
"Pages.settings.ServiceKey": "Clave del Servicio",
"Pages.settings.ServiceSalt": "Salt del Servicio",
"Pages.settings.ServiceUrl": "URL del Servicio",
"Pages.settings.ShowLatest": "Mostrar notas nuevas automáticamente",
"Pages.settings.ShowLatestHelp": "Las notas nuevas se mostrarán automáticamente en tu línea de tiempo",
"Pages.settings.Software": "Software",
"Pages.settings.Supports": "Soporta",
"Pages.settings.System": "Sistema (por defecto)",
"Pages.settings.Theme": "Tema",
"Pages.settings.Website": "Web"
}