feat: translations

This commit is contained in:
Alejandro
2023-02-08 22:10:26 +01:00
committed by GitHub
parent 2b44d3b264
commit 2b29fb0897
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");