Merge pull request #282 from v0l/ui-fixes
UI fixes + counts on profile page tabs
This commit is contained in:
commit
0e673cab8e
@ -58,7 +58,7 @@
|
||||
"test": "react-app-rewired test",
|
||||
"eject": "react-scripts eject",
|
||||
"intl-extract": "formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file src/lang.json --format transifex --flatten true",
|
||||
"intl-compile": "formatjs compile src/lang.json --out-file src/translations/en.json --format transifex",
|
||||
"intl-compile": "formatjs compile src/lang.json --out-file src/translations/en.ts --format transifex",
|
||||
"transifex": "formatjs compile src/translations/$LNG.json --out-file src/translations/$LNG.json --format transifex",
|
||||
"format": "prettier --write .",
|
||||
"eslint": "eslint .",
|
||||
|
@ -8,7 +8,7 @@
|
||||
<meta name="description" content="Fast nostr web ui" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src wss://* ws://*:* 'self' https://*; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" />
|
||||
content="default-src 'self'; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src *; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
|
@ -2,7 +2,7 @@ import "./Avatar.css";
|
||||
import Nostrich from "nostrich.webp";
|
||||
import { CSSProperties, useEffect, useState } from "react";
|
||||
import type { UserMetadata } from "@snort/nostr";
|
||||
import useImgProxy from "Feed/ImgProxy";
|
||||
import useImgProxy from "Hooks/useImgProxy";
|
||||
|
||||
const Avatar = ({ user, ...rest }: { user?: UserMetadata; onClick?: () => void }) => {
|
||||
const [url, setUrl] = useState<string>(Nostrich);
|
||||
|
@ -1,42 +1,15 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import MuteButton from "Element/MuteButton";
|
||||
import BlockButton from "Element/BlockButton";
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
interface BlockListProps {
|
||||
variant: "muted" | "blocked";
|
||||
}
|
||||
|
||||
export default function BlockList({ variant }: BlockListProps) {
|
||||
const { blocked, muted } = useModeration();
|
||||
export default function BlockList() {
|
||||
const { blocked } = useModeration();
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
{variant === "muted" && (
|
||||
<>
|
||||
<h4>
|
||||
<FormattedMessage {...messages.MuteCount} values={{ n: muted.length }} />
|
||||
</h4>
|
||||
{muted.map(a => {
|
||||
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />;
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{variant === "blocked" && (
|
||||
<>
|
||||
<h4>
|
||||
<FormattedMessage {...messages.BlockCount} values={{ n: blocked.length }} />
|
||||
</h4>
|
||||
{blocked.map(a => {
|
||||
return (
|
||||
<ProfilePreview actions={<BlockButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{blocked.map(a => {
|
||||
return <ProfilePreview actions={<BlockButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -31,7 +31,7 @@ const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => {
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<div className="icon-title">
|
||||
<div className="mb10 flex-end">
|
||||
<select
|
||||
disabled={ps.length <= 1}
|
||||
value={onlyPubkey}
|
||||
|
@ -16,7 +16,7 @@ export default function Copy({ text, maxSize = 32 }: CopyProps) {
|
||||
<div className="flex flex-row copy" onClick={() => copy(text)}>
|
||||
<span className="body">{trimmed}</span>
|
||||
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
|
||||
{copied ? <Check width={13} height={13} /> : <CopyIcon width={13} height={13} />}
|
||||
{copied ? <Check width={14} height={14} /> : <CopyIcon width={14} height={14} />}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,27 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import useFollowersFeed from "Feed/FollowersFeed";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { EventKind } from "@snort/nostr";
|
||||
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(() => {
|
||||
const contactLists = feed?.store.notes.filter(
|
||||
a => a.kind === EventKind.ContactList && a.tags.some(b => b[0] === "p" && b[1] === pubkey)
|
||||
);
|
||||
return [...new Set(contactLists?.map(a => a.pubkey))];
|
||||
}, [feed, pubkey]);
|
||||
|
||||
return <FollowListBase pubkeys={pubkeys} title={formatMessage(messages.FollowerCount, { n: pubkeys?.length })} />;
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import useFollowsFeed from "Feed/FollowsFeed";
|
||||
import { HexKey } from "@snort/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, pubkey]);
|
||||
|
||||
return <FollowListBase pubkeys={pubkeys} title={formatMessage(messages.FollowingCount, { n: pubkeys?.length })} />;
|
||||
}
|
@ -1,29 +1,13 @@
|
||||
import "./FollowsYou.css";
|
||||
import { useMemo } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import { HexKey } from "@snort/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;
|
||||
followsMe: boolean;
|
||||
}
|
||||
|
||||
export default function FollowsYou({ pubkey }: FollowsYouProps) {
|
||||
export default function FollowsYou({ followsMe }: FollowsYouProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const feed = useFollowsFeed(pubkey);
|
||||
const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||
|
||||
const pubkeys = useMemo(() => {
|
||||
return getFollowers(feed.store, pubkey);
|
||||
}, [feed, pubkey]);
|
||||
|
||||
const followsMe = loginPubKey ? pubkeys.includes(loginPubKey) : false;
|
||||
|
||||
return followsMe ? <span className="follows-you">{formatMessage(messages.FollowsYou)}</span> : null;
|
||||
}
|
||||
|
12
packages/app/src/Element/Logo.tsx
Normal file
12
packages/app/src/Element/Logo.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const Logo = () => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<h1 className="logo" onClick={() => navigate("/")}>
|
||||
Snort
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
@ -1,23 +1,17 @@
|
||||
import { useMemo } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
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;
|
||||
pubkeys: HexKey[];
|
||||
}
|
||||
|
||||
export default function MutedList({ pubkey }: MutedListProps) {
|
||||
export default function MutedList({ pubkeys }: MutedListProps) {
|
||||
const { isMuted, muteAll } = useModeration();
|
||||
const feed = useMutedFeed(pubkey);
|
||||
const pubkeys = useMemo(() => {
|
||||
return getMuted(feed.store, pubkey);
|
||||
}, [feed, pubkey]);
|
||||
const hasAllMuted = pubkeys.every(isMuted);
|
||||
|
||||
return (
|
||||
|
@ -40,8 +40,22 @@
|
||||
.nip05 .domain[data-domain="nostriches.net"] {
|
||||
color: var(--highlight);
|
||||
background-color: var(--highlight);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nip05 .badge {
|
||||
margin: 0.1em 0.2em;
|
||||
color: var(--highlight);
|
||||
margin-left: 0.2em;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.zap .pfp .display-name {
|
||||
align-items: center;
|
||||
}
|
||||
.nip05 .nick {
|
||||
display: none;
|
||||
}
|
||||
.nip05 .domain {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,6 @@
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleCheck, faSpinner, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import "./Nip05.css";
|
||||
import { useQuery } from "react-query";
|
||||
import Badge from "Icons/Badge";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
|
||||
interface NostrJson {
|
||||
@ -59,15 +56,15 @@ const Nip05 = ({ nip05, pubkey, verifyNip = true }: Nip05Params) => {
|
||||
|
||||
return (
|
||||
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`} onClick={ev => ev.stopPropagation()}>
|
||||
{!isDefaultUser && <div className="nick">{`${name}@`}</div>}
|
||||
<span className="domain" data-domain={domain?.toLowerCase()}>
|
||||
{domain}
|
||||
</span>
|
||||
<span className="badge">
|
||||
{isVerified && <FontAwesomeIcon color={"var(--highlight)"} icon={faCircleCheck} size="xs" />}
|
||||
{!isVerified && !couldNotVerify && <FontAwesomeIcon color={"var(--fg-color)"} icon={faSpinner} size="xs" />}
|
||||
{couldNotVerify && <FontAwesomeIcon color={"var(--error)"} icon={faTriangleExclamation} size="xs" />}
|
||||
</span>
|
||||
{!isDefaultUser && isVerified && <span className="nick">{`${name}@`}</span>}
|
||||
{isVerified && (
|
||||
<>
|
||||
<span className="domain f-ellipsis" data-domain={domain?.toLowerCase()}>
|
||||
{domain}
|
||||
</span>
|
||||
<Badge className="badge" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -229,7 +229,13 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
{error && <b className="error">{error.error}</b>}
|
||||
{!registerStatus && (
|
||||
<div className="flex mb10">
|
||||
<input type="text" placeholder={formatMessage(messages.Handle)} value={handle} onChange={onHandleChange} />
|
||||
<input
|
||||
type="text"
|
||||
className="nip-handle"
|
||||
placeholder={formatMessage(messages.Handle)}
|
||||
value={handle}
|
||||
onChange={onHandleChange}
|
||||
/>
|
||||
@
|
||||
<select value={domain} onChange={onDomainChange}>
|
||||
{serviceConfig?.domains.map(a => (
|
||||
|
@ -115,12 +115,12 @@
|
||||
border-bottom-right-radius: 16px;
|
||||
}
|
||||
|
||||
.light .note > .footer .ctx-menu li:hover {
|
||||
.note > .footer .ctx-menu li:hover {
|
||||
color: white;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.note > .footer .ctx-menu li:hover {
|
||||
.light .note > .footer .ctx-menu li:hover {
|
||||
color: white;
|
||||
background: var(--font-secondary-color);
|
||||
}
|
||||
@ -151,6 +151,7 @@
|
||||
|
||||
.reaction-pill .reaction-pill-number {
|
||||
margin-left: 8px;
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
.reaction-pill.reacted {
|
||||
@ -196,3 +197,16 @@
|
||||
border-bottom-left-radius: 0;
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.note .reactions-link {
|
||||
color: var(--font-secondary-color);
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
margin-top: 4px;
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
.note .reactions-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
@ -8,14 +8,14 @@ import { useIntl, FormattedMessage } from "react-intl";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import Bookmark from "Icons/Bookmark";
|
||||
import Pin from "Icons/Pin";
|
||||
import { Event as NEvent, EventKind } from "@snort/nostr";
|
||||
import { parseZap } from "Element/Zap";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import Text from "Element/Text";
|
||||
import { eventLink, getReactions, hexToBech32 } from "Util";
|
||||
import { eventLink, getReactions, dedupeByPubkey, hexToBech32, normalizeReaction, Reaction } from "Util";
|
||||
import NoteFooter, { Translation } from "Element/NoteFooter";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import { useUserProfiles } from "Feed/ProfileFeed";
|
||||
import { TaggedRawEvent, u256, HexKey } from "@snort/nostr";
|
||||
import { TaggedRawEvent, u256, HexKey, Event as NEvent, EventKind } from "@snort/nostr";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import { setPinned, setBookmarked } from "State/Login";
|
||||
import type { RootState } from "State/Store";
|
||||
@ -34,6 +34,7 @@ export interface NoteProps {
|
||||
showPinned?: boolean;
|
||||
showBookmarked?: boolean;
|
||||
showFooter?: boolean;
|
||||
showReactionsLink?: boolean;
|
||||
canUnpin?: boolean;
|
||||
canUnbookmark?: boolean;
|
||||
};
|
||||
@ -62,6 +63,7 @@ export default function Note(props: NoteProps) {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { data, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false } = props;
|
||||
const [showReactions, setShowReactions] = useState(false);
|
||||
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
|
||||
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
|
||||
const users = useUserProfiles(pubKeys);
|
||||
@ -76,6 +78,35 @@ export default function Note(props: NoteProps) {
|
||||
const publisher = useEventPublisher();
|
||||
const [translated, setTranslated] = useState<Translation>();
|
||||
const { formatMessage } = useIntl();
|
||||
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related, ev]);
|
||||
const groupReactions = useMemo(() => {
|
||||
const result = reactions?.reduce(
|
||||
(acc, reaction) => {
|
||||
const kind = normalizeReaction(reaction.content);
|
||||
const rs = acc[kind] || [];
|
||||
return { ...acc, [kind]: [...rs, reaction] };
|
||||
},
|
||||
{
|
||||
[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];
|
||||
const reposts = useMemo(() => dedupeByPubkey(getReactions(related, ev.Id, EventKind.Repost)), [related, ev]);
|
||||
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 totalReactions = positive.length + negative.length + reposts.length + zaps.length;
|
||||
|
||||
const options = {
|
||||
showHeader: true,
|
||||
@ -178,8 +209,7 @@ export default function Note(props: NoteProps) {
|
||||
re:
|
||||
{(mentions?.length ?? 0) > 0 ? (
|
||||
<>
|
||||
{pubMentions}
|
||||
{others}
|
||||
{pubMentions} {others}
|
||||
</>
|
||||
) : (
|
||||
replyId && <Link to={eventLink(replyId)}>{hexToBech32("note", replyId)?.substring(0, 12)}</Link>
|
||||
@ -224,7 +254,7 @@ export default function Note(props: NoteProps) {
|
||||
<>
|
||||
{options.showHeader && (
|
||||
<div className="header flex">
|
||||
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
|
||||
<ProfileImage autoWidth={false} pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
|
||||
{(options.showTime || options.showBookmarked) && (
|
||||
<div className="info">
|
||||
{options.showBookmarked && (
|
||||
@ -245,13 +275,29 @@ export default function Note(props: NoteProps) {
|
||||
<div className="body" onClick={e => goToEvent(e, ev.Id)}>
|
||||
{transformBody()}
|
||||
{translation()}
|
||||
{options.showReactionsLink && (
|
||||
<div className="reactions-link" onClick={() => setShowReactions(true)}>
|
||||
<FormattedMessage {...messages.ReactionsLink} values={{ n: totalReactions }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{extendable && !showMore && (
|
||||
<span className="expand-note mt10 flex f-center" onClick={() => setShowMore(true)}>
|
||||
<FormattedMessage {...messages.ShowMore} />
|
||||
</span>
|
||||
)}
|
||||
{options.showFooter && <NoteFooter ev={ev} related={related} onTranslated={t => setTranslated(t)} />}
|
||||
{options.showFooter && (
|
||||
<NoteFooter
|
||||
ev={ev}
|
||||
positive={positive}
|
||||
negative={negative}
|
||||
reposts={reposts}
|
||||
zaps={zaps}
|
||||
onTranslated={t => setTranslated(t)}
|
||||
showReactions={showReactions}
|
||||
setShowReactions={setShowReactions}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
@ -20,13 +20,13 @@ import Zap from "Icons/Zap";
|
||||
import Reply from "Icons/Reply";
|
||||
import { formatShort } from "Number";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { getReactions, dedupeByPubkey, hexToBech32, normalizeReaction, Reaction } from "Util";
|
||||
import { hexToBech32, normalizeReaction } from "Util";
|
||||
import { NoteCreator } from "Element/NoteCreator";
|
||||
import Reactions from "Element/Reactions";
|
||||
import SendSats from "Element/SendSats";
|
||||
import { parseZap, ZapsSummary } from "Element/Zap";
|
||||
import { ParsedZap, ZapsSummary } from "Element/Zap";
|
||||
import { useUserProfile } from "Feed/ProfileFeed";
|
||||
import { Event as NEvent, EventKind, TaggedRawEvent, HexKey } from "@snort/nostr";
|
||||
import { Event as NEvent, TaggedRawEvent, HexKey } from "@snort/nostr";
|
||||
import { RootState } from "State/Store";
|
||||
import { UserPreferences, setPinned, setBookmarked } from "State/Login";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
@ -41,13 +41,18 @@ export interface Translation {
|
||||
}
|
||||
|
||||
export interface NoteFooterProps {
|
||||
related: TaggedRawEvent[];
|
||||
reposts: TaggedRawEvent[];
|
||||
zaps: ParsedZap[];
|
||||
positive: TaggedRawEvent[];
|
||||
negative: TaggedRawEvent[];
|
||||
showReactions: boolean;
|
||||
setShowReactions(b: boolean): void;
|
||||
ev: NEvent;
|
||||
onTranslated?: (content: Translation) => void;
|
||||
}
|
||||
|
||||
export default function NoteFooter(props: NoteFooterProps) {
|
||||
const { related, ev } = props;
|
||||
const { ev, showReactions, setShowReactions, positive, negative, reposts, zaps } = props;
|
||||
const dispatch = useDispatch();
|
||||
const { formatMessage } = useIntl();
|
||||
const { pinned, bookmarked } = useSelector((s: RootState) => s.login);
|
||||
@ -57,49 +62,17 @@ 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;
|
||||
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
|
||||
type: "language",
|
||||
});
|
||||
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related, ev]);
|
||||
const reposts = useMemo(() => dedupeByPubkey(getReactions(related, ev.Id, EventKind.Repost)), [related, ev]);
|
||||
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(() => {
|
||||
const result = reactions?.reduce(
|
||||
(acc, reaction) => {
|
||||
const 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]: [] 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(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login);
|
||||
return positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login);
|
||||
}
|
||||
|
||||
function hasReposted() {
|
||||
|
@ -3,8 +3,6 @@ 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";
|
||||
@ -16,12 +14,10 @@ export interface NoteToSelfProps {
|
||||
link?: string;
|
||||
}
|
||||
|
||||
function NoteLabel({ pubkey }: NoteToSelfProps) {
|
||||
const user = useUserProfile(pubkey);
|
||||
function NoteLabel() {
|
||||
return (
|
||||
<div>
|
||||
<FormattedMessage {...messages.NoteToSelf} /> <FontAwesomeIcon icon={faCertificate} size="xs" />
|
||||
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -46,9 +42,9 @@ export default function NoteToSelf({ pubkey, clickable, className, link }: NoteT
|
||||
<div className="name">
|
||||
{(clickable && (
|
||||
<Link to={link ?? profileLink(pubkey)}>
|
||||
<NoteLabel pubkey={pubkey} />
|
||||
<NoteLabel />
|
||||
</Link>
|
||||
)) || <NoteLabel pubkey={pubkey} />}
|
||||
)) || <NoteLabel />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,7 +1,6 @@
|
||||
.pfp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pfp .avatar-wrapper {
|
||||
@ -30,3 +29,20 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pfp .display-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.pfp .display-name {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.pfp .subheader .about {
|
||||
max-width: calc(100vw - 140px);
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import Avatar from "Element/Avatar";
|
||||
import Nip05 from "Element/Nip05";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { MetadataCache } from "State/Users";
|
||||
import usePageWidth from "Hooks/usePageWidth";
|
||||
|
||||
export interface ProfileImageProps {
|
||||
pubkey: HexKey;
|
||||
@ -15,8 +16,10 @@ export interface ProfileImageProps {
|
||||
showUsername?: boolean;
|
||||
className?: string;
|
||||
link?: string;
|
||||
autoWidth?: boolean;
|
||||
defaultNip?: string;
|
||||
verifyNip?: boolean;
|
||||
linkToProfile?: boolean;
|
||||
}
|
||||
|
||||
export default function ProfileImage({
|
||||
@ -25,12 +28,15 @@ export default function ProfileImage({
|
||||
showUsername = true,
|
||||
className,
|
||||
link,
|
||||
autoWidth = true,
|
||||
defaultNip,
|
||||
verifyNip,
|
||||
linkToProfile = true,
|
||||
}: ProfileImageProps) {
|
||||
const navigate = useNavigate();
|
||||
const user = useUserProfile(pubkey);
|
||||
const nip05 = defaultNip ? defaultNip : user?.nip05;
|
||||
const width = usePageWidth();
|
||||
|
||||
const name = useMemo(() => {
|
||||
return getDisplayName(user, pubkey);
|
||||
@ -40,20 +46,28 @@ export default function ProfileImage({
|
||||
link = "#";
|
||||
}
|
||||
|
||||
const onAvatarClick = () => {
|
||||
if (linkToProfile) {
|
||||
navigate(link ?? profileLink(pubkey));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`pfp${className ? ` ${className}` : ""}`}>
|
||||
<div className="avatar-wrapper">
|
||||
<Avatar user={user} onClick={() => navigate(link ?? profileLink(pubkey))} />
|
||||
<Avatar user={user} onClick={onAvatarClick} />
|
||||
</div>
|
||||
{showUsername && (
|
||||
<div className="profile-name f-grow">
|
||||
<div className="profile-name">
|
||||
<div className="username">
|
||||
<Link className="display-name" key={pubkey} to={link ?? profileLink(pubkey)}>
|
||||
{name}
|
||||
<div>{name}</div>
|
||||
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} verifyNip={verifyNip} />}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="subheader">{subHeader}</div>
|
||||
<div className="subheader" style={{ width: autoWidth ? width - 190 : "" }}>
|
||||
{subHeader}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -5,11 +5,17 @@
|
||||
}
|
||||
|
||||
.profile-preview .pfp {
|
||||
flex-grow: 1;
|
||||
min-width: 200px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.profile-preview .about {
|
||||
font-size: small;
|
||||
color: var(--gray-light);
|
||||
color: var(--font-secondary-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.profile-preview button {
|
||||
min-width: 98px;
|
||||
}
|
||||
|
@ -25,20 +25,22 @@ export default function ProfilePreview(props: ProfilePreviewProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`profile-preview${props.className ? ` ${props.className}` : ""}`} ref={ref}>
|
||||
{inView && (
|
||||
<>
|
||||
<ProfileImage
|
||||
pubkey={pubkey}
|
||||
subHeader={options.about ? <div className="f-ellipsis about">{user?.about}</div> : undefined}
|
||||
/>
|
||||
{props.actions ?? (
|
||||
<div className="follow-button-container">
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div className={`profile-preview${props.className ? ` ${props.className}` : ""}`} ref={ref}>
|
||||
{inView && (
|
||||
<>
|
||||
<ProfileImage
|
||||
pubkey={pubkey}
|
||||
subHeader={options.about ? <div className="about">{user?.about}</div> : undefined}
|
||||
/>
|
||||
{props.actions ?? (
|
||||
<div className="follow-button-container">
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import useImgProxy from "Feed/ImgProxy";
|
||||
import useImgProxy from "Hooks/useImgProxy";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ProxyImgProps extends React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement> {
|
||||
|
@ -96,27 +96,4 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
@ -96,7 +96,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
|
||||
<div className="reaction-icon">
|
||||
{ev.content === "+" ? <Heart width={20} height={18} /> : ev.content}
|
||||
</div>
|
||||
<ProfileImage pubkey={ev.pubkey} />
|
||||
<ProfileImage autoWidth={false} pubkey={ev.pubkey} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -109,7 +109,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
|
||||
<ZapIcon width={17} height={20} />
|
||||
<span className="zap-amount">{formatShort(z.amount)}</span>
|
||||
</div>
|
||||
<ProfileImage pubkey={z.zapper} subHeader={<>{z.content}</>} />
|
||||
<ProfileImage autoWidth={false} pubkey={z.zapper} subHeader={<>{z.content}</>} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
@ -121,7 +121,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
|
||||
<div className="reaction-icon">
|
||||
<Heart width={20} height={18} />
|
||||
</div>
|
||||
<ProfileImage pubkey={ev.pubkey} />
|
||||
<ProfileImage autoWidth={false} pubkey={ev.pubkey} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -132,7 +132,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
|
||||
<div className="reaction-icon">
|
||||
<Dislike width={20} height={18} />
|
||||
</div>
|
||||
<ProfileImage pubkey={ev.pubkey} />
|
||||
<ProfileImage autoWidth={false} pubkey={ev.pubkey} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
@ -13,6 +13,8 @@
|
||||
|
||||
.relay-settings {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.relay-settings svg:not(:last-child) {
|
||||
@ -25,6 +27,13 @@
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.relay-settings svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.relay-url {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
@ -8,13 +8,10 @@ import Write from "Icons/Write";
|
||||
|
||||
const RelayFavicon = ({ url }: { url: string }) => {
|
||||
const cleanUrl = url
|
||||
.replace("wss://relay.", "https://")
|
||||
.replace("wss://nostr.", "https://")
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
.replace(/^wss:\/\//, "https://")
|
||||
.replace(/^ws:\/\//, "http://")
|
||||
.replace(/\/$/, "");
|
||||
const [faviconUrl, setFaviconUrl] = useState(`${cleanUrl}/favicon.ico`);
|
||||
|
||||
return <img className="favicon" src={faviconUrl} onError={() => setFaviconUrl(Nostrich)} />;
|
||||
};
|
||||
|
||||
@ -29,7 +26,7 @@ const RelaysMetadata = ({ relays }: RelaysMetadataProps) => {
|
||||
return (
|
||||
<div className="card relay-card">
|
||||
<RelayFavicon url={url} />
|
||||
<code className="relay-url">{url}</code>
|
||||
<code className="relay-url f-ellipsis">{url}</code>
|
||||
<div className="relay-settings">
|
||||
<Read className={settings.read ? "enabled" : "disabled"} />
|
||||
<Write className={settings.write ? "enabled" : "disabled"} />
|
||||
|
@ -64,17 +64,11 @@
|
||||
.amounts {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
overflow-x: scroll;
|
||||
-ms-overflow-style: none; /* for Internet Explorer, Edge */
|
||||
scrollbar-width: none; /* for Firefox */
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.amounts::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sat-amount {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
background-color: #2a2a2a;
|
||||
|
@ -14,7 +14,6 @@ import Modal from "Element/Modal";
|
||||
import QrCode from "Element/QrCode";
|
||||
import Copy from "Element/Copy";
|
||||
import useWebln from "Hooks/useWebln";
|
||||
import useHorizontalScroll from "Hooks/useHorizontalScroll";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
@ -49,6 +48,18 @@ export interface LNURLTipProps {
|
||||
author?: HexKey;
|
||||
}
|
||||
|
||||
function chunks<T>(arr: T[], length: number) {
|
||||
const result = [];
|
||||
let idx = 0;
|
||||
let n = arr.length / length;
|
||||
while (n > 0) {
|
||||
result.push(arr.slice(idx, idx + length));
|
||||
idx += length;
|
||||
n -= 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function LNURLTip(props: LNURLTipProps) {
|
||||
const onClose = props.onClose || (() => undefined);
|
||||
const service = props.svc;
|
||||
@ -74,7 +85,6 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
const webln = useWebln(show);
|
||||
const { formatMessage } = useIntl();
|
||||
const publisher = useEventPublisher();
|
||||
const horizontalScroll = useHorizontalScroll();
|
||||
const canComment = (payService?.commentAllowed ?? 0) > 0 || payService?.nostrPubkey;
|
||||
|
||||
useEffect(() => {
|
||||
@ -100,6 +110,7 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
}
|
||||
return [];
|
||||
}, [payService]);
|
||||
const amountRows = useMemo(() => chunks(serviceAmounts, 3), [serviceAmounts]);
|
||||
|
||||
const selectAmount = (a: number) => {
|
||||
setError(undefined);
|
||||
@ -204,6 +215,19 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderAmounts(amount: number, amounts: number[]) {
|
||||
return (
|
||||
<div className="amounts">
|
||||
{amounts.map(a => (
|
||||
<span className={`sat-amount ${amount === a ? "active" : ""}`} key={a} onClick={() => selectAmount(a)}>
|
||||
{emojis[a] && <>{emojis[a]} </>}
|
||||
{a === 1000 ? "1K" : formatShort(a)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function invoiceForm() {
|
||||
if (invoice) return null;
|
||||
return (
|
||||
@ -211,14 +235,7 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
<h3>
|
||||
<FormattedMessage {...messages.ZapAmount} />
|
||||
</h3>
|
||||
<div className="amounts" ref={horizontalScroll}>
|
||||
{serviceAmounts.map(a => (
|
||||
<span className={`sat-amount ${amount === a ? "active" : ""}`} key={a} onClick={() => selectAmount(a)}>
|
||||
{emojis[a] && <>{emojis[a]} </>}
|
||||
{formatShort(a)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{amountRows.map(amounts => renderAmounts(amount, amounts))}
|
||||
{payService && custom()}
|
||||
<div className="flex">
|
||||
{canComment && (
|
||||
|
@ -6,6 +6,7 @@
|
||||
-ms-overflow-style: none; /* for Internet Explorer, Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
margin-bottom: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabs::-webkit-scrollbar {
|
||||
@ -14,16 +15,17 @@
|
||||
|
||||
.tab {
|
||||
color: var(--font-tertiary-color);
|
||||
border: 1px solid var(--font-tertiary-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
line-height: 19px;
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 17px;
|
||||
margin-right: 12px;
|
||||
padding: 6px 12px;
|
||||
text-align: center;
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
.tab:not(:last-of-type) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
@ -40,3 +42,7 @@
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
border-color: var(--font-color);
|
||||
}
|
||||
|
@ -327,7 +327,15 @@ export default function Thread(props: ThreadProps) {
|
||||
function renderRoot(note: NEvent) {
|
||||
const className = `thread-root ${isSingleNote ? "thread-root-single" : ""}`;
|
||||
if (note) {
|
||||
return <Note className={className} key={note.Id} data-ev={note} related={notes} />;
|
||||
return (
|
||||
<Note
|
||||
className={className}
|
||||
key={note.Id}
|
||||
data-ev={note}
|
||||
related={notes}
|
||||
options={{ showReactionsLink: true }}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <NoteGhost className={className}>Loading thread root.. ({notes?.length} notes loaded)</NoteGhost>;
|
||||
}
|
||||
|
@ -1,5 +1,55 @@
|
||||
.latest-notes {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 6px 24px;
|
||||
gap: 8px;
|
||||
position: absolute;
|
||||
width: 261px;
|
||||
left: calc(50% - 261px / 2 + 0.5px);
|
||||
top: 0;
|
||||
color: white;
|
||||
background: var(--highlight);
|
||||
box-shadow: 0px 0px 15px rgba(78, 0, 255, 0.6);
|
||||
border-radius: 100px;
|
||||
z-index: 42;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.latest-notes-fixed {
|
||||
position: fixed;
|
||||
left: calc(50% - 261px / 2 + 0.5px);
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.latest-notes {
|
||||
width: 200px;
|
||||
left: calc(50% - 110px);
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.latest-notes-fixed {
|
||||
width: 200px;
|
||||
padding: 6px 12px;
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: calc(50% - 110px);
|
||||
}
|
||||
}
|
||||
|
||||
.latest-notes .pfp:not(:last-of-type) {
|
||||
margin: 0;
|
||||
margin-right: -26px;
|
||||
}
|
||||
.latest-notes .pfp:last-of-type {
|
||||
margin-right: -8px;
|
||||
}
|
||||
|
||||
.latest-notes .pfp .avatar-wrapper .avatar {
|
||||
margin: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
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";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
import ArrowUp from "Icons/ArrowUp";
|
||||
import { dedupeByPubkey } from "Util";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed";
|
||||
import { TaggedRawEvent } from "@snort/nostr";
|
||||
import { EventKind } from "@snort/nostr";
|
||||
@ -15,8 +17,6 @@ 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;
|
||||
@ -34,15 +34,16 @@ export default function Timeline({
|
||||
postsOnly = false,
|
||||
method,
|
||||
ignoreModeration = false,
|
||||
window,
|
||||
window: timeWindow,
|
||||
relay,
|
||||
}: TimelineProps) {
|
||||
const { muted, isMuted } = useModeration();
|
||||
const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, {
|
||||
method,
|
||||
window: window,
|
||||
window: timeWindow,
|
||||
relay,
|
||||
});
|
||||
const { ref, inView } = useInView();
|
||||
|
||||
const filterPosts = useCallback(
|
||||
(nts: TaggedRawEvent[]) => {
|
||||
@ -61,6 +62,9 @@ export default function Timeline({
|
||||
const latestFeed = useMemo(() => {
|
||||
return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id));
|
||||
}, [latest, mainFeed, filterPosts]);
|
||||
const latestAuthors = useMemo(() => {
|
||||
return dedupeByPubkey(latestFeed).map(e => e.pubkey);
|
||||
}, [latestFeed]);
|
||||
|
||||
function eventElement(e: TaggedRawEvent) {
|
||||
switch (e.kind) {
|
||||
@ -82,13 +86,40 @@ export default function Timeline({
|
||||
}
|
||||
}
|
||||
|
||||
function onShowLatest(scrollToTop = false) {
|
||||
showLatest();
|
||||
if (scrollToTop) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
{latestFeed.length > 1 && (
|
||||
<div className="card latest-notes pointer" onClick={() => showLatest()}>
|
||||
<FontAwesomeIcon icon={faForward} size="xl" />{" "}
|
||||
<FormattedMessage {...messages.ShowLatest} values={{ n: latestFeed.length - 1 }} />
|
||||
</div>
|
||||
{latestFeed.length > 0 && (
|
||||
<>
|
||||
<div className="card latest-notes pointer" onClick={() => onShowLatest()} ref={ref}>
|
||||
{latestAuthors.slice(0, 3).map(p => {
|
||||
return <ProfileImage pubkey={p} showUsername={false} linkToProfile={false} />;
|
||||
})}
|
||||
<FormattedMessage
|
||||
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
|
||||
values={{ n: latestFeed.length }}
|
||||
/>
|
||||
<ArrowUp />
|
||||
</div>
|
||||
{!inView && (
|
||||
<div className="card latest-notes latest-notes-fixed pointer" onClick={() => onShowLatest(true)}>
|
||||
{latestAuthors.slice(0, 3).map(p => {
|
||||
return <ProfileImage pubkey={p} showUsername={false} linkToProfile={false} />;
|
||||
})}
|
||||
<FormattedMessage
|
||||
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
|
||||
values={{ n: latestFeed.length }}
|
||||
/>
|
||||
<ArrowUp />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{mainFeed.map(eventElement)}
|
||||
<LoadMore onLoadMore={loadMore} shouldLoadMore={main.end}>
|
||||
|
@ -7,10 +7,6 @@
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.zap .header .pfp {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.zap .header .amount {
|
||||
font-size: 24px;
|
||||
}
|
||||
@ -69,7 +65,7 @@
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
|
||||
.top-zap .avatar {
|
||||
.top-zap .summary .pfp .avatar-wrapper .avatar {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
@ -85,9 +81,15 @@
|
||||
}
|
||||
|
||||
.amount-number {
|
||||
font-weight: bold;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.zap.note .body {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.zap .nip05 .badge {
|
||||
margin: 0 0 0 0.3em;
|
||||
}
|
||||
}
|
||||
|
@ -91,8 +91,8 @@ const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean
|
||||
return valid && zapper ? (
|
||||
<div className="zap note card">
|
||||
<div className="header">
|
||||
<ProfileImage pubkey={zapper} />
|
||||
{p !== pubKey && showZapped && <ProfileImage pubkey={p} />}
|
||||
<ProfileImage autoWidth={false} pubkey={zapper} />
|
||||
{p !== pubKey && showZapped && <ProfileImage autoWidth={false} pubkey={p} />}
|
||||
<div className="amount">
|
||||
<span className="amount-number">
|
||||
<FormattedMessage {...messages.Sats} values={{ n: formatShort(amount) }} />
|
||||
@ -132,7 +132,7 @@ export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
|
||||
{amount && (
|
||||
<div className={`top-zap`}>
|
||||
<div className="summary">
|
||||
{zapper && <ProfileImage pubkey={zapper} />}
|
||||
{zapper && <ProfileImage autoWidth={false} pubkey={zapper} />}
|
||||
{restZaps.length > 0 && <FormattedMessage {...messages.Others} values={{ n: restZaps.length }} />}{" "}
|
||||
<FormattedMessage {...messages.OthersZapped} values={{ n: restZaps.length }} />
|
||||
</div>
|
||||
|
@ -102,4 +102,5 @@ export default defineMessages({
|
||||
All: { defaultMessage: "All" },
|
||||
ConfirmUnbookmark: { defaultMessage: "Are you sure you want to remove this note from bookmarks?" },
|
||||
ConfirmUnpin: { defaultMessage: "Are you sure you want to unpin this note?" },
|
||||
ReactionsLink: { defaultMessage: "{n} Reactions" },
|
||||
});
|
||||
|
@ -13,5 +13,14 @@ export default function useFollowersFeed(pubkey: HexKey) {
|
||||
return x;
|
||||
}, [pubkey]);
|
||||
|
||||
return useSubscription(sub);
|
||||
const followersFeed = useSubscription(sub, { leaveOpen: false, cache: true });
|
||||
|
||||
const followers = useMemo(() => {
|
||||
const contactLists = followersFeed?.store.notes.filter(
|
||||
a => a.kind === EventKind.ContactList && a.tags.some(b => b[0] === "p" && b[1] === pubkey)
|
||||
);
|
||||
return [...new Set(contactLists?.map(a => a.pubkey))];
|
||||
}, [followersFeed, pubkey]);
|
||||
|
||||
return followers;
|
||||
}
|
||||
|
@ -1,23 +1,32 @@
|
||||
import { useMemo } from "react";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { EventKind, Subscriptions } from "@snort/nostr";
|
||||
import useSubscription, { NoteStore } from "Feed/Subscription";
|
||||
import { useSelector } from "react-redux";
|
||||
import { HexKey, TaggedRawEvent, EventKind, Subscriptions } from "@snort/nostr";
|
||||
|
||||
import useSubscription from "Feed/Subscription";
|
||||
import { RootState } from "State/Store";
|
||||
|
||||
export default function useFollowsFeed(pubkey: HexKey) {
|
||||
const { publicKey, follows } = useSelector((s: RootState) => s.login);
|
||||
const isMe = publicKey === pubkey;
|
||||
|
||||
const sub = useMemo(() => {
|
||||
if (isMe) return null;
|
||||
const x = new Subscriptions();
|
||||
x.Id = `follows:${pubkey.slice(0, 12)}`;
|
||||
x.Kinds = new Set([EventKind.ContactList]);
|
||||
x.Authors = new Set([pubkey]);
|
||||
|
||||
return x;
|
||||
}, [pubkey]);
|
||||
}, [isMe, pubkey]);
|
||||
|
||||
return useSubscription(sub);
|
||||
const contactFeed = useSubscription(sub, { leaveOpen: false, cache: true });
|
||||
const following = useMemo(() => {
|
||||
return getFollowing(contactFeed.store.notes ?? [], pubkey);
|
||||
}, [contactFeed.store.notes]);
|
||||
return isMe ? follows : following;
|
||||
}
|
||||
|
||||
export function getFollowers(feed: NoteStore, pubkey: HexKey) {
|
||||
const contactLists = feed?.notes.filter(a => a.kind === EventKind.ContactList && a.pubkey === pubkey);
|
||||
export function getFollowing(notes: TaggedRawEvent[], pubkey: HexKey) {
|
||||
const contactLists = notes.filter(a => a.kind === EventKind.ContactList && a.pubkey === pubkey);
|
||||
const pTags = contactLists?.map(a => a.tags.filter(b => b[0] === "p").map(c => c[1]));
|
||||
return [...new Set(pTags?.flat())];
|
||||
}
|
||||
|
@ -1,12 +1,18 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { getNewest } from "Util";
|
||||
import { HexKey, TaggedRawEvent, Lists } from "@snort/nostr";
|
||||
import { EventKind, Subscriptions } from "@snort/nostr";
|
||||
import useSubscription, { NoteStore } from "Feed/Subscription";
|
||||
import { RootState } from "State/Store";
|
||||
|
||||
export default function useMutedFeed(pubkey: HexKey) {
|
||||
const { publicKey, muted } = useSelector((s: RootState) => s.login);
|
||||
const isMe = publicKey === pubkey;
|
||||
|
||||
const sub = useMemo(() => {
|
||||
if (isMe) return null;
|
||||
const sub = new Subscriptions();
|
||||
sub.Id = `muted:${pubkey.slice(0, 12)}`;
|
||||
sub.Kinds = new Set([EventKind.PubkeyLists]);
|
||||
@ -16,7 +22,13 @@ export default function useMutedFeed(pubkey: HexKey) {
|
||||
return sub;
|
||||
}, [pubkey]);
|
||||
|
||||
return useSubscription(sub);
|
||||
const mutedFeed = useSubscription(sub, { leaveOpen: false, cache: true });
|
||||
|
||||
const mutedList = useMemo(() => {
|
||||
return getMuted(mutedFeed.store, pubkey);
|
||||
}, [mutedFeed.store]);
|
||||
|
||||
return isMe ? muted : mutedList;
|
||||
}
|
||||
|
||||
export function getMutedKeys(rawNotes: TaggedRawEvent[]): {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { EventKind, Subscriptions } from "@snort/nostr";
|
||||
import { HexKey, EventKind, Subscriptions } from "@snort/nostr";
|
||||
import { parseZap } from "Element/Zap";
|
||||
import useSubscription from "./Subscription";
|
||||
|
||||
export default function useZapsFeed(pubkey: HexKey) {
|
||||
@ -12,5 +12,15 @@ export default function useZapsFeed(pubkey: HexKey) {
|
||||
return x;
|
||||
}, [pubkey]);
|
||||
|
||||
return useSubscription(sub, { leaveOpen: true, cache: true });
|
||||
const zapsFeed = useSubscription(sub, { leaveOpen: false, cache: true });
|
||||
|
||||
const zaps = useMemo(() => {
|
||||
const profileZaps = zapsFeed.store.notes
|
||||
.map(parseZap)
|
||||
.filter(z => z.valid && z.p === pubkey && z.zapper !== pubkey && !z.e);
|
||||
profileZaps.sort((a, b) => b.amount - a.amount);
|
||||
return profileZaps;
|
||||
}, [zapsFeed]);
|
||||
|
||||
return zaps;
|
||||
}
|
||||
|
20
packages/app/src/Hooks/useClientWidth.tsx
Normal file
20
packages/app/src/Hooks/useClientWidth.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
|
||||
export default function useClientWidth() {
|
||||
const ref = useRef<HTMLDivElement | null>(document.querySelector(".page"));
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const updateSize = () => {
|
||||
if (ref.current) {
|
||||
setWidth(ref.current.offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", updateSize);
|
||||
updateSize();
|
||||
return () => window.removeEventListener("resize", updateSize);
|
||||
}, [ref]);
|
||||
|
||||
return width;
|
||||
}
|
@ -30,7 +30,7 @@ export default function useImgProxy() {
|
||||
return {
|
||||
proxy: async (url: string, resize?: number) => {
|
||||
if (!settings) return url;
|
||||
const opt = resize ? `rs:fit:${resize}:${resize}` : "";
|
||||
const opt = resize ? `rs:fit:${resize}:${resize}/dpr:${window.devicePixelRatio}` : "";
|
||||
const urlBytes = te.encode(url);
|
||||
const urlEncoded = urlSafe(base64.encode(urlBytes, 0, urlBytes.byteLength));
|
||||
const path = `/${opt}/${urlEncoded}`;
|
20
packages/app/src/Hooks/usePageWidth.tsx
Normal file
20
packages/app/src/Hooks/usePageWidth.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
|
||||
export default function usePageWidth() {
|
||||
const ref = useRef<HTMLDivElement | null>(document.querySelector(".page"));
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const updateSize = () => {
|
||||
if (ref.current) {
|
||||
setWidth(ref.current.offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", updateSize);
|
||||
updateSize();
|
||||
return () => window.removeEventListener("resize", updateSize);
|
||||
}, [ref]);
|
||||
|
||||
return width;
|
||||
}
|
15
packages/app/src/Icons/ArrowUp.tsx
Normal file
15
packages/app/src/Icons/ArrowUp.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
const ArrowUp = () => {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.99992 10.6673V1.33398M5.99992 1.33398L1.33325 6.00065M5.99992 1.33398L10.6666 6.00065"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.33333"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArrowUp;
|
17
packages/app/src/Icons/Badge.tsx
Normal file
17
packages/app/src/Icons/Badge.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import IconProps from "./IconProps";
|
||||
|
||||
const Badge = (props: IconProps) => {
|
||||
return (
|
||||
<svg width="16" height="15" viewBox="0 0 16 15" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M6.00004 7.50065L7.33337 8.83398L10.3334 5.83398M11.9342 2.83299C12.0714 3.16501 12.3349 3.42892 12.6667 3.5667L13.8302 4.04864C14.1622 4.18617 14.426 4.44998 14.5636 4.78202C14.7011 5.11407 14.7011 5.48715 14.5636 5.81919L14.082 6.98185C13.9444 7.31404 13.9442 7.6875 14.0824 8.01953L14.5632 9.18185C14.6313 9.34631 14.6664 9.52259 14.6665 9.70062C14.6665 9.87865 14.6315 10.0549 14.5633 10.2194C14.4952 10.3839 14.3953 10.5333 14.2694 10.6592C14.1435 10.7851 13.9941 10.8849 13.8296 10.953L12.6669 11.4346C12.3349 11.5718 12.071 11.8354 11.9333 12.1672L11.4513 13.3307C11.3138 13.6627 11.05 13.9265 10.718 14.0641C10.3859 14.2016 10.0129 14.2016 9.68085 14.0641L8.51823 13.5825C8.18619 13.4453 7.81326 13.4455 7.48143 13.5832L6.31797 14.0645C5.98612 14.2017 5.61338 14.2016 5.28162 14.0642C4.94986 13.9267 4.68621 13.6632 4.54858 13.3316L4.06652 12.1677C3.92924 11.8357 3.66574 11.5718 3.33394 11.434L2.17048 10.9521C1.8386 10.8146 1.57488 10.5509 1.4373 10.2191C1.29971 9.88724 1.29953 9.51434 1.43678 9.18235L1.91835 8.01968C2.05554 7.68763 2.05526 7.31469 1.91757 6.98284L1.43669 5.81851C1.36851 5.65405 1.3334 5.47777 1.33337 5.29974C1.33335 5.12171 1.3684 4.94542 1.43652 4.78094C1.50465 4.61646 1.60452 4.46702 1.73042 4.34115C1.85632 4.21529 2.00579 4.11546 2.17028 4.04739L3.33291 3.5658C3.66462 3.42863 3.92836 3.16545 4.06624 2.83402L4.54816 1.67052C4.68569 1.33848 4.94949 1.07467 5.28152 0.937137C5.61355 0.7996 5.98662 0.7996 6.31865 0.937137L7.48127 1.41873C7.81331 1.55593 8.18624 1.55565 8.51808 1.41795L9.68202 0.937884C10.014 0.800424 10.387 0.800452 10.719 0.937962C11.0509 1.07547 11.3147 1.3392 11.4522 1.67116L11.9343 2.835L11.9342 2.83299Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.33333"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Badge;
|
@ -30,13 +30,18 @@ header .pfp .avatar-wrapper {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-actions .avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.header-actions .btn-rnd {
|
||||
position: relative;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 520px) {
|
||||
.header-actions .btn-rnd {
|
||||
.header-actions .btn-rnd:last-of-type {
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
@ -47,8 +52,15 @@ header .pfp .avatar-wrapper {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.header-actions .btn-rnd .has-unread {
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
|
@ -40,12 +40,12 @@ export default function Layout() {
|
||||
useLoginFeed();
|
||||
|
||||
const shouldHideNoteCreator = useMemo(() => {
|
||||
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate"];
|
||||
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/p/"];
|
||||
return hideOn.some(a => location.pathname.startsWith(a));
|
||||
}, [location]);
|
||||
|
||||
const shouldHideHeader = useMemo(() => {
|
||||
const hideOn = ["/login"];
|
||||
const hideOn = ["/login", "/new"];
|
||||
return hideOn.some(a => location.pathname.startsWith(a));
|
||||
}, [location]);
|
||||
|
||||
|
@ -3,6 +3,14 @@
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.light .login {
|
||||
color: var(--font-tertiary-color);
|
||||
}
|
||||
|
||||
.login > div {
|
||||
@ -29,22 +37,33 @@
|
||||
background-image: var(--img-src);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50px;
|
||||
border-top-left-radius: 50px;
|
||||
border-bottom-right-radius: 50px;
|
||||
background-size: cover;
|
||||
background-position-x: center;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.login > div:nth-child(2) > div.artwork > div {
|
||||
.login > div:nth-child(2) > div.artwork > .attribution {
|
||||
margin-left: 25px;
|
||||
margin-right: 25px;
|
||||
margin-bottom: 25px;
|
||||
padding: 4px 12px;
|
||||
background-color: var(--modal-bg-color);
|
||||
border-radius: 1em;
|
||||
border-radius: 100px;
|
||||
color: var(--gray-light);
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.artwork .artist {
|
||||
background: var(--snort-gradient);
|
||||
font-weight: 600;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.login > div:nth-child(2) > div.artwork .zap-button {
|
||||
@ -59,26 +78,59 @@
|
||||
}
|
||||
}
|
||||
|
||||
.login input {
|
||||
background-color: var(--gray-secondary);
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login .login-note {
|
||||
color: var(--gray-light);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login .login-actions {
|
||||
margin-top: 24px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.login .login-actions > button {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.login .login-actions > button {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.login .login-or {
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
margin-top: 56px;
|
||||
margin-bottom: 64px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.login .login-or {
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.light .login-or {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
.login-container input[type="text"] {
|
||||
border: none;
|
||||
background-color: var(--gray-secondary);
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-container h1 {
|
||||
color: var(--font-color);
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-size: 32px;
|
||||
line-height: 39px;
|
||||
}
|
||||
|
||||
.login-container h2 {
|
||||
color: var(--font-color);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import { CSSProperties, useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import * as secp from "@noble/secp256k1";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
|
||||
import { RootState } from "State/Store";
|
||||
import { setPrivateKey, setPublicKey, setRelays, setGeneratedPrivateKey } from "State/Login";
|
||||
@ -14,6 +14,8 @@ import { HexKey } from "@snort/nostr";
|
||||
import ZapButton from "Element/ZapButton";
|
||||
// import useImgProxy from "Feed/ImgProxy";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
interface ArtworkEntry {
|
||||
name: string;
|
||||
pubkey: HexKey;
|
||||
@ -51,7 +53,8 @@ export default function LoginPage() {
|
||||
const [key, setKey] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [art, setArt] = useState<ArtworkEntry>();
|
||||
// const { proxy } = useImgProxy();
|
||||
const { formatMessage } = useIntl();
|
||||
//const { proxy } = useImgProxy();
|
||||
|
||||
useEffect(() => {
|
||||
if (publicKey) {
|
||||
@ -160,17 +163,17 @@ export default function LoginPage() {
|
||||
<p dir="auto">
|
||||
<FormattedMessage defaultMessage="Your key" description="Label for key input" />
|
||||
</p>
|
||||
<div dir="auto" className="flex">
|
||||
<div className="flex">
|
||||
<input
|
||||
dir="auto"
|
||||
type="text"
|
||||
placeholder="nsec / npub / nip-05 / hex private key..."
|
||||
placeholder={formatMessage(messages.KeyPlaceholder)}
|
||||
className="f-grow"
|
||||
onChange={e => setKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error.length > 0 ? <b className="error">{error}</b> : null}
|
||||
<p dir="auto" className="login-note">
|
||||
<p className="login-note">
|
||||
<FormattedMessage
|
||||
defaultMessage="Only the secret key can be used to publish (sign events), everything else logs you in read-only mode."
|
||||
description="Explanation for public key only login is read-only"
|
||||
@ -188,22 +191,20 @@ export default function LoginPage() {
|
||||
</button>
|
||||
{altLogins()}
|
||||
</div>
|
||||
<div dir="auto" className="flex login-or">
|
||||
<div className="login-note">
|
||||
<FormattedMessage defaultMessage="OR" description="Seperator text for Login / Generate Key" />
|
||||
</div>
|
||||
<div className="flex login-or">
|
||||
<FormattedMessage defaultMessage="OR" description="Seperator text for Login / Generate Key" />
|
||||
<div className="divider w-max"></div>
|
||||
</div>
|
||||
<h1 dir="auto">
|
||||
<FormattedMessage defaultMessage="Create an Account" description="Heading for generate key flow" />
|
||||
</h1>
|
||||
<p dir="auto" className="login-note">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="Generate a public / private key pair. Do not share your private key with anyone, this acts as your password. Once lost, it cannot be “reset” or recovered. Keep safe!"
|
||||
description="Note about key security before generating a new key"
|
||||
/>
|
||||
</p>
|
||||
<div dir="auto" className="tabs">
|
||||
<div className="login-actions">
|
||||
<button type="button" onClick={() => makeRandomKey()}>
|
||||
<FormattedMessage defaultMessage="Generate Key" description="Button: Generate a new key" />
|
||||
</button>
|
||||
@ -211,13 +212,13 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div dir="auto" className="artwork" style={{ ["--img-src"]: `url('${art?.link}')` } as CSSProperties}>
|
||||
<div>
|
||||
<div className="artwork" style={{ ["--img-src"]: `url('${art?.link}')` } as CSSProperties}>
|
||||
<div className="attribution">
|
||||
<FormattedMessage
|
||||
defaultMessage="Art by {name}"
|
||||
description="Artwork attribution label"
|
||||
values={{
|
||||
name: "Karnage",
|
||||
name: <span className="artist">Karnage</span>,
|
||||
}}
|
||||
/>
|
||||
<ZapButton pubkey={art?.pubkey ?? ""} />
|
||||
|
@ -11,6 +11,11 @@
|
||||
margin-bottom: -60px;
|
||||
z-index: 0;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.profile .banner {
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.profile .profile-actions {
|
||||
position: absolute;
|
||||
@ -231,23 +236,39 @@
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.icon-title {
|
||||
font-weight: 600;
|
||||
font-size: 19px;
|
||||
line-height: 23px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 22px;
|
||||
.banner-bg {
|
||||
width: 1438px;
|
||||
height: 758px;
|
||||
position: absolute;
|
||||
top: -120px;
|
||||
left: calc(50% - 750px);
|
||||
background: radial-gradient(circle at top center, rgba(0, 0, 0, 0.7) 0%, #000000 81.25%), var(--img-url);
|
||||
filter: blur(10px);
|
||||
z-index: -1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon-title svg {
|
||||
margin-right: 8px;
|
||||
.light .banner-bg {
|
||||
background: radial-gradient(circle at top center, rgba(241, 241, 241, 0.7) 0%, var(--bg-color) 81.25%), var(--img-url);
|
||||
}
|
||||
|
||||
.icon-title h3 {
|
||||
margin: 0;
|
||||
@media (min-width: 1420px) {
|
||||
.banner-bg {
|
||||
display: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-title select {
|
||||
margin-left: auto;
|
||||
.zaps-total {
|
||||
text-align: right;
|
||||
padding-right: 12px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.profile .nip05 .nick {
|
||||
display: unset;
|
||||
}
|
||||
.profile .nip05 .domain {
|
||||
display: unset;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import "./ProfilePage.css";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, CSSProperties } from "react";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
@ -14,12 +14,16 @@ import Link from "Icons/Link";
|
||||
import Qr from "Icons/Qr";
|
||||
import Zap from "Icons/Zap";
|
||||
import Envelope from "Icons/Envelope";
|
||||
import useMutedFeed from "Feed/MuteList";
|
||||
import useRelaysFeed from "Feed/RelaysFeed";
|
||||
import usePinnedFeed from "Feed/PinnedFeed";
|
||||
import useBookmarkFeed from "Feed/BookmarkFeed";
|
||||
import useFollowersFeed from "Feed/FollowersFeed";
|
||||
import useFollowsFeed from "Feed/FollowsFeed";
|
||||
import { useUserProfile } from "Feed/ProfileFeed";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import useZapsFeed from "Feed/ZapsFeed";
|
||||
import { default as ZapElement, parseZap } from "Element/Zap";
|
||||
import { default as ZapElement } from "Element/Zap";
|
||||
import FollowButton from "Element/FollowButton";
|
||||
import { extractLnAddress, parseId, hexToBech32 } from "Util";
|
||||
import Avatar from "Element/Avatar";
|
||||
@ -30,10 +34,9 @@ import Nip05 from "Element/Nip05";
|
||||
import Copy from "Element/Copy";
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import FollowersList from "Element/FollowersList";
|
||||
import BlockList from "Element/BlockList";
|
||||
import MutedList from "Element/MutedList";
|
||||
import FollowsList from "Element/FollowsList";
|
||||
import FollowsList from "Element/FollowListBase";
|
||||
import IconButton from "Element/IconButton";
|
||||
import { RootState } from "State/Store";
|
||||
import { HexKey, NostrPrefix } from "@snort/nostr";
|
||||
@ -62,12 +65,9 @@ export default function ProfilePage() {
|
||||
const user = useUserProfile(id);
|
||||
const loggedOut = useSelector<RootState, boolean | undefined>(s => s.login.loggedOut);
|
||||
const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
|
||||
const isMe = loginPubKey === id;
|
||||
const [showLnQr, setShowLnQr] = useState<boolean>(false);
|
||||
const [showProfileQr, setShowProfileQr] = useState<boolean>(false);
|
||||
const { notes: pinned, related: pinRelated } = usePinnedFeed(id);
|
||||
const { notes: bookmarks, related: bookmarkRelated } = useBookmarkFeed(id);
|
||||
const aboutText = user?.about || "";
|
||||
const about = Text({
|
||||
content: aboutText,
|
||||
@ -78,32 +78,36 @@ export default function ProfilePage() {
|
||||
const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || "");
|
||||
const website_url =
|
||||
user?.website && !user.website.startsWith("http") ? "https://" + user.website : user?.website || "";
|
||||
// feeds
|
||||
const { blocked } = useModeration();
|
||||
const { notes: pinned, related: pinRelated } = usePinnedFeed(id);
|
||||
const { notes: bookmarks, related: bookmarkRelated } = useBookmarkFeed(id);
|
||||
const relays = useRelaysFeed(id);
|
||||
const zapFeed = useZapsFeed(id);
|
||||
const zaps = useMemo(() => {
|
||||
const profileZaps = zapFeed.store.notes.map(parseZap).filter(z => z.valid && z.p === id && !z.e && z.zapper !== id);
|
||||
profileZaps.sort((a, b) => b.amount - a.amount);
|
||||
return profileZaps;
|
||||
}, [zapFeed.store, id]);
|
||||
const zaps = useZapsFeed(id);
|
||||
const zapsTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
|
||||
const horizontalScroll = useHorizontalScroll();
|
||||
const followers = useFollowersFeed(id);
|
||||
const follows = useFollowsFeed(id);
|
||||
const muted = useMutedFeed(id);
|
||||
// tabs
|
||||
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 },
|
||||
Relays: { text: formatMessage(messages.Relays), value: RELAYS },
|
||||
Bookmarks: { text: formatMessage(messages.Bookmarks), value: BOOKMARKS },
|
||||
Followers: { text: formatMessage(messages.FollowersCount, { n: followers.length }), value: FOLLOWERS },
|
||||
Follows: { text: formatMessage(messages.FollowsCount, { n: follows.length }), value: FOLLOWS },
|
||||
Zaps: { text: formatMessage(messages.ZapsCount, { n: zaps.length }), value: ZAPS },
|
||||
Muted: { text: formatMessage(messages.MutedCount, { n: muted.length }), value: MUTED },
|
||||
Blocked: { text: formatMessage(messages.BlockedCount, { n: blocked.length }), value: BLOCKED },
|
||||
Relays: { text: formatMessage(messages.RelaysCount, { n: relays.length }), value: RELAYS },
|
||||
Bookmarks: { text: formatMessage(messages.BookmarksCount, { n: bookmarks.length }), value: BOOKMARKS },
|
||||
};
|
||||
const [tab, setTab] = useState<Tab>(ProfileTab.Notes);
|
||||
const optionalTabs = [
|
||||
zapsTotal > 0 && ProfileTab.Zaps,
|
||||
relays.length > 0 && ProfileTab.Relays,
|
||||
bookmarks.length > 0 && ProfileTab.Bookmarks,
|
||||
muted.length > 0 && ProfileTab.Muted,
|
||||
].filter(a => unwrap(a)) as Tab[];
|
||||
const horizontalScroll = useHorizontalScroll();
|
||||
|
||||
useEffect(() => {
|
||||
setTab(ProfileTab.Notes);
|
||||
@ -114,7 +118,7 @@ export default function ProfilePage() {
|
||||
<div className="name">
|
||||
<h2>
|
||||
{user?.display_name || user?.name || "Nostrich"}
|
||||
<FollowsYou pubkey={id} />
|
||||
<FollowsYou followsMe={follows.includes(id)} />
|
||||
</h2>
|
||||
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
|
||||
<Copy text={params.id || ""} />
|
||||
@ -200,9 +204,9 @@ export default function ProfilePage() {
|
||||
case ZAPS: {
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h4 className="zaps-total">
|
||||
<div className="zaps-total">
|
||||
<FormattedMessage {...messages.Sats} values={{ n: formatShort(zapsTotal) }} />
|
||||
</h4>
|
||||
</div>
|
||||
{zaps.map(z => (
|
||||
<ZapElement showZapped={false} zap={z} />
|
||||
))}
|
||||
@ -211,29 +215,22 @@ export default function ProfilePage() {
|
||||
}
|
||||
|
||||
case FOLLOWS: {
|
||||
if (isMe) {
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h4>
|
||||
<FormattedMessage {...messages.Following} values={{ n: follows.length }} />
|
||||
</h4>
|
||||
{follows.map(a => (
|
||||
<ProfilePreview key={a} pubkey={a.toLowerCase()} options={{ about: false }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <FollowsList pubkey={id} />;
|
||||
}
|
||||
return (
|
||||
<div className="main-content">
|
||||
{follows.map(a => (
|
||||
<ProfilePreview key={a} pubkey={a.toLowerCase()} options={{ about: !isMe }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case FOLLOWERS: {
|
||||
return <FollowersList pubkey={id} />;
|
||||
return <FollowsList pubkeys={followers} />;
|
||||
}
|
||||
case MUTED: {
|
||||
return isMe ? <BlockList variant="muted" /> : <MutedList pubkey={id} />;
|
||||
return <MutedList pubkeys={muted} />;
|
||||
}
|
||||
case BLOCKED: {
|
||||
return isMe ? <BlockList variant="blocked" /> : null;
|
||||
return <BlockList />;
|
||||
}
|
||||
case RELAYS: {
|
||||
return <RelaysMetadata relays={relays} />;
|
||||
@ -308,20 +305,23 @@ export default function ProfilePage() {
|
||||
}
|
||||
|
||||
const w = window.document.querySelector(".page")?.clientWidth;
|
||||
const bannerStyle = user?.banner ? ({ "--img-url": `url(${user.banner})` } as CSSProperties) : {};
|
||||
return (
|
||||
<>
|
||||
<div className="profile flex">
|
||||
{user?.banner && <div className="banner-bg" style={bannerStyle} />}
|
||||
{user?.banner && <ProxyImg alt="banner" className="banner" src={user.banner} size={w} />}
|
||||
<div className="profile-wrapper flex">
|
||||
{avatar()}
|
||||
{userDetails()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tabs main-content" ref={horizontalScroll}>
|
||||
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(renderTab)}
|
||||
|
||||
{optionalTabs.map(renderTab)}
|
||||
{isMe && renderTab(ProfileTab.Blocked)}
|
||||
<div className="main-content">
|
||||
<div className="tabs" ref={horizontalScroll}>
|
||||
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(renderTab)}
|
||||
{optionalTabs.map(renderTab)}
|
||||
{isMe && blocked.length > 0 && renderTab(ProfileTab.Blocked)}
|
||||
</div>
|
||||
</div>
|
||||
{tabContent()}
|
||||
</>
|
||||
|
@ -10,10 +10,15 @@ export default defineMessages({
|
||||
Notes: { defaultMessage: "Notes" },
|
||||
Reactions: { defaultMessage: "Reactions" },
|
||||
Followers: { defaultMessage: "Followers" },
|
||||
Follows: { defaultMessage: "Follows" },
|
||||
FollowersCount: { defaultMessage: "{n} Followers" },
|
||||
Follows: { defaultMessage: "Following" },
|
||||
FollowsCount: { defaultMessage: "{n} Following" },
|
||||
Zaps: { defaultMessage: "Zaps" },
|
||||
ZapsCount: { defaultMessage: "{n} Zaps" },
|
||||
Muted: { defaultMessage: "Muted" },
|
||||
MutedCount: { defaultMessage: "{n} Muted" },
|
||||
Blocked: { defaultMessage: "Blocked" },
|
||||
BlockedCount: { defaultMessage: "{n} Blocked" },
|
||||
Sats: { defaultMessage: "{n} {n, plural, =1 {sat} other {sats}}" },
|
||||
Following: { defaultMessage: "Following {n}" },
|
||||
Settings: { defaultMessage: "Settings" },
|
||||
@ -36,6 +41,10 @@ export default defineMessages({
|
||||
Relays: {
|
||||
defaultMessage: "Relays",
|
||||
},
|
||||
RelaysCount: {
|
||||
defaultMessage: "{n} Relays",
|
||||
},
|
||||
Bookmarks: { defaultMessage: "Bookmarks" },
|
||||
BookmarksCount: { defaultMessage: "Bookmarks ({n})" },
|
||||
BookmarksCount: { defaultMessage: "{n} Bookmarks" },
|
||||
KeyPlaceholder: { defaultMessage: "nsec, npub, nip-05, hex" },
|
||||
});
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { RecommendedFollows } from "Const";
|
||||
import Logo from "Element/Logo";
|
||||
import FollowListBase from "Element/FollowListBase";
|
||||
import { useMemo } from "react";
|
||||
|
||||
@ -8,12 +9,14 @@ import messages from "./messages";
|
||||
|
||||
export default function DiscoverFollows() {
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const sortedReccomends = useMemo(() => {
|
||||
return RecommendedFollows.sort(() => (Math.random() >= 0.5 ? -1 : 1));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="main-content new-user" dir="auto">
|
||||
<Logo />
|
||||
<div className="progress-bar">
|
||||
<div className="progress"></div>
|
||||
</div>
|
||||
@ -23,6 +26,11 @@ export default function DiscoverFollows() {
|
||||
<p>
|
||||
<FormattedMessage {...messages.Share} values={{ link: <Link to="/">{formatMessage(messages.World)}</Link> }} />
|
||||
</p>
|
||||
<div className="next-actions continue-actions">
|
||||
<button type="button" onClick={() => navigate("/")}>
|
||||
<FormattedMessage {...messages.Done} />{" "}
|
||||
</button>
|
||||
</div>
|
||||
<h3>
|
||||
<FormattedMessage {...messages.PopularAccounts} />
|
||||
</h3>
|
||||
|
@ -3,6 +3,7 @@ import { FormattedMessage } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import Logo from "Element/Logo";
|
||||
import { services } from "Pages/Verification";
|
||||
import Nip5Service from "Element/Nip5Service";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
@ -25,6 +26,7 @@ export default function GetVerified() {
|
||||
|
||||
return (
|
||||
<div className="main-content new-user" dir="auto">
|
||||
<Logo />
|
||||
<div className="progress-bar">
|
||||
<div className="progress progress-third"></div>
|
||||
</div>
|
||||
|
@ -3,6 +3,7 @@ import { useSelector } from "react-redux";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
|
||||
import { ApiHost } from "Const";
|
||||
import Logo from "Element/Logo";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import FollowListBase from "Element/FollowListBase";
|
||||
import { RootState } from "State/Store";
|
||||
@ -50,6 +51,7 @@ export default function ImportFollows() {
|
||||
|
||||
return (
|
||||
<div className="main-content new-user" dir="auto">
|
||||
<Logo />
|
||||
<div className="progress-bar">
|
||||
<div className="progress progress-last"></div>
|
||||
</div>
|
||||
@ -68,6 +70,16 @@ export default function ImportFollows() {
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<div className="next-actions continue-actions">
|
||||
<button className="secondary" type="button" onClick={() => navigate("/new/discover")}>
|
||||
<FormattedMessage {...messages.Skip} />
|
||||
</button>
|
||||
<button type="button" onClick={() => navigate("/new/discover")}>
|
||||
<FormattedMessage {...messages.Next} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2>
|
||||
<FormattedMessage {...messages.TwitterUsername} />
|
||||
</h2>
|
||||
@ -96,15 +108,6 @@ export default function ImportFollows() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="next-actions">
|
||||
<button className="secondary" type="button" onClick={() => navigate("/new/discover")}>
|
||||
<FormattedMessage {...messages.Skip} />
|
||||
</button>
|
||||
<button type="button" onClick={() => navigate("/new/discover")}>
|
||||
<FormattedMessage {...messages.Next} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { useSelector } from "react-redux";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import Logo from "Element/Logo";
|
||||
import { CollapsedSection } from "Element/Collapsed";
|
||||
import Copy from "Element/Copy";
|
||||
import { RootState } from "State/Store";
|
||||
@ -72,6 +73,7 @@ export default function NewUserFlow() {
|
||||
|
||||
return (
|
||||
<div className="main-content new-user" dir="auto">
|
||||
<Logo />
|
||||
<div className="progress-bar">
|
||||
<div className="progress progress-first"></div>
|
||||
</div>
|
||||
|
@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import Logo from "Element/Logo";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
|
||||
import messages from "./messages";
|
||||
@ -23,6 +24,7 @@ export default function NewUserName() {
|
||||
|
||||
return (
|
||||
<div className="main-content new-user" dir="auto">
|
||||
<Logo />
|
||||
<div className="progress-bar">
|
||||
<div className="progress progress-second"></div>
|
||||
</div>
|
||||
@ -42,6 +44,9 @@ export default function NewUserName() {
|
||||
value={username}
|
||||
onChange={ev => setUsername(ev.target.value)}
|
||||
/>
|
||||
<div className="help-text">
|
||||
<FormattedMessage defaultMessage="You can change your username at any point." />
|
||||
</div>
|
||||
<div className="next-actions">
|
||||
<button type="button" className="transparent" onClick={() => navigate("/new/verify")}>
|
||||
<FormattedMessage {...messages.Skip} />
|
||||
|
@ -1,18 +1,33 @@
|
||||
.new-user p {
|
||||
.new-user {
|
||||
color: var(--font-secondary-color);
|
||||
}
|
||||
.new-user li {
|
||||
color: var(--font-secondary-color);
|
||||
|
||||
.new-user input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.new-user p {
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.new-user p > a {
|
||||
color: var(--highlight);
|
||||
}
|
||||
|
||||
.new-user li {
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.new-user li > a {
|
||||
color: var(--highlight);
|
||||
}
|
||||
|
||||
.new-user .nip-handle {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.new-user h1 {
|
||||
color: var(--font-color);
|
||||
font-weight: 700;
|
||||
@ -21,6 +36,8 @@
|
||||
}
|
||||
|
||||
.new-user h2 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--font-color);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
@ -84,6 +101,10 @@
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.new-user .next-actions.continue-actions {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.new-user > .copy {
|
||||
padding: 12px 16px;
|
||||
border: 2px dashed #222222;
|
||||
@ -149,3 +170,10 @@
|
||||
.new-user .nip-container input[type="text"] {
|
||||
width: 166px;
|
||||
}
|
||||
|
||||
.new-user .help-text {
|
||||
margin-top: 6px;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
@ -58,28 +58,28 @@ const PreferencesPage = () => {
|
||||
<small>
|
||||
<FormattedMessage {...messages.AutoloadMediaHelp} />
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
value={perf.autoLoadMedia}
|
||||
onChange={e =>
|
||||
dispatch(
|
||||
setPreferences({
|
||||
...perf,
|
||||
autoLoadMedia: e.target.value,
|
||||
} as UserPreferences)
|
||||
)
|
||||
}>
|
||||
<option value="none">
|
||||
<FormattedMessage {...messages.None} />
|
||||
</option>
|
||||
<option value="follows-only">
|
||||
<FormattedMessage {...messages.FollowsOnly} />
|
||||
</option>
|
||||
<option value="all">
|
||||
<FormattedMessage {...messages.All} />
|
||||
</option>
|
||||
</select>
|
||||
<div className="mt10">
|
||||
<select
|
||||
value={perf.autoLoadMedia}
|
||||
onChange={e =>
|
||||
dispatch(
|
||||
setPreferences({
|
||||
...perf,
|
||||
autoLoadMedia: e.target.value,
|
||||
} as UserPreferences)
|
||||
)
|
||||
}>
|
||||
<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>
|
||||
<div className="card flex f-col">
|
||||
@ -215,30 +215,30 @@ const PreferencesPage = () => {
|
||||
<small>
|
||||
<FormattedMessage {...messages.ReactionEmojiHelp} />
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
className="emoji-selector"
|
||||
value={perf.reactionEmoji}
|
||||
onChange={e =>
|
||||
dispatch(
|
||||
setPreferences({
|
||||
...perf,
|
||||
reactionEmoji: e.target.value,
|
||||
} as UserPreferences)
|
||||
)
|
||||
}>
|
||||
<option value="+">
|
||||
+ <FormattedMessage {...messages.Default} />
|
||||
</option>
|
||||
{emoji("").map(({ name, char }) => {
|
||||
return (
|
||||
<option value={char}>
|
||||
{name} {char}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<div className="mt10">
|
||||
<select
|
||||
className="emoji-selector"
|
||||
value={perf.reactionEmoji}
|
||||
onChange={e =>
|
||||
dispatch(
|
||||
setPreferences({
|
||||
...perf,
|
||||
reactionEmoji: e.target.value,
|
||||
} as UserPreferences)
|
||||
)
|
||||
}>
|
||||
<option value="+">
|
||||
+ <FormattedMessage {...messages.Default} />
|
||||
</option>
|
||||
{emoji("").map(({ name, char }) => {
|
||||
return (
|
||||
<option value={char}>
|
||||
{name} {char}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card flex">
|
||||
@ -283,24 +283,24 @@ const PreferencesPage = () => {
|
||||
<small>
|
||||
<FormattedMessage {...messages.FileUploadHelp} />
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
value={perf.fileUploader}
|
||||
onChange={e =>
|
||||
dispatch(
|
||||
setPreferences({
|
||||
...perf,
|
||||
fileUploader: e.target.value,
|
||||
} as UserPreferences)
|
||||
)
|
||||
}>
|
||||
<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>
|
||||
<div className="mt10">
|
||||
<select
|
||||
value={perf.fileUploader}
|
||||
onChange={e =>
|
||||
dispatch(
|
||||
setPreferences({
|
||||
...perf,
|
||||
fileUploader: e.target.value,
|
||||
} as UserPreferences)
|
||||
)
|
||||
}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card flex">
|
||||
|
@ -4,7 +4,7 @@ import { DefaultRelays } from "Const";
|
||||
import { HexKey, TaggedRawEvent } from "@snort/nostr";
|
||||
import { RelaySettings } from "@snort/nostr";
|
||||
import type { AppDispatch, RootState } from "State/Store";
|
||||
import { ImgProxySettings } from "Feed/ImgProxy";
|
||||
import { ImgProxySettings } from "Hooks/useImgProxy";
|
||||
|
||||
const PrivateKeyItem = "secret";
|
||||
const PublicKeyItem = "pubkey";
|
||||
|
@ -3,6 +3,7 @@
|
||||
--font-color: #fff;
|
||||
--font-secondary-color: #a7a7a7;
|
||||
--font-tertiary-color: #a3a3a3;
|
||||
--border-color: rgba(163, 163, 163, 0.3);
|
||||
--font-size: 16px;
|
||||
--font-size-small: 14px;
|
||||
--font-size-tiny: 12px;
|
||||
@ -38,15 +39,18 @@
|
||||
--strike-army-gradient: linear-gradient(to bottom right, #ccff00, #a1c900);
|
||||
}
|
||||
|
||||
html.light {
|
||||
--bg-color: #f1f1f1;
|
||||
--font-color: #57534e;
|
||||
--font-secondary-color: #7b7b7b;
|
||||
--font-tertiary-color: #a7a7a7;
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
--highlight-light: #16aac1;
|
||||
--highlight: #0284c7;
|
||||
--highlight-dark: #0a52b5;
|
||||
html.light {
|
||||
--bg-color: #f8f8f8;
|
||||
--font-color: #27272a;
|
||||
--font-secondary-color: #71717a;
|
||||
--font-tertiary-color: #52525b;
|
||||
--border-color: rgba(167, 167, 167, 0.3);
|
||||
|
||||
--highlight: #7139f1;
|
||||
--modal-bg-color: rgba(240, 240, 240, 0.8);
|
||||
|
||||
--note-bg: white;
|
||||
@ -125,6 +129,7 @@ button {
|
||||
padding: 6px 12px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
min-height: 35px;
|
||||
font-size: var(--font-size);
|
||||
background-color: var(--highlight);
|
||||
border: none;
|
||||
@ -215,9 +220,8 @@ button.icon:hover {
|
||||
cursor: pointer;
|
||||
color: var(--font-color);
|
||||
user-select: none;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--font-color);
|
||||
border: 1px solid;
|
||||
background: none;
|
||||
border: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@ -230,18 +234,16 @@ button.icon:hover {
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
border: 2px solid;
|
||||
background-color: var(--gray-secondary);
|
||||
color: var(--font-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn.disabled {
|
||||
color: var(--gray-light);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: var(--gray);
|
||||
color: var(--highlight);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@ -278,7 +280,7 @@ textarea {
|
||||
color: var(--font-color);
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@ -520,6 +522,7 @@ body.scroll-lock {
|
||||
|
||||
.main-content {
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
@ -581,3 +584,8 @@ button.tall {
|
||||
.action-heading button {
|
||||
width: 98px;
|
||||
}
|
||||
|
||||
.flex-end {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
@ -35,6 +35,9 @@
|
||||
"1Mo59U": {
|
||||
"string": "Are you sure you want to remove this note from bookmarks?"
|
||||
},
|
||||
"1nYUGC": {
|
||||
"string": "{n} Following"
|
||||
},
|
||||
"1udzha": {
|
||||
"string": "Conversations"
|
||||
},
|
||||
@ -47,6 +50,9 @@
|
||||
"2IFGap": {
|
||||
"string": "Donate"
|
||||
},
|
||||
"2a2YiP": {
|
||||
"string": "{n} Bookmarks"
|
||||
},
|
||||
"2k0Cv+": {
|
||||
"string": "Dislikes ({n})"
|
||||
},
|
||||
@ -56,6 +62,12 @@
|
||||
"3gOsZq": {
|
||||
"string": "Translators"
|
||||
},
|
||||
"3t3kok": {
|
||||
"string": "{n,plural,=1{{n} new note} other{{n} new notes}}"
|
||||
},
|
||||
"3tVy+Z": {
|
||||
"string": "{n} Followers"
|
||||
},
|
||||
"3xCwbZ": {
|
||||
"developer_comment": "Seperator text for Login / Generate Key",
|
||||
"string": "OR"
|
||||
@ -156,6 +168,9 @@
|
||||
"B6+XJy": {
|
||||
"string": "zapped"
|
||||
},
|
||||
"B6H7eJ": {
|
||||
"string": "nsec, npub, nip-05, hex"
|
||||
},
|
||||
"BOUMjw": {
|
||||
"string": "No nostr users found for {twitterUsername}"
|
||||
},
|
||||
@ -171,6 +186,9 @@
|
||||
"CHTbO3": {
|
||||
"string": "Failed to load invoice"
|
||||
},
|
||||
"CmZ9ls": {
|
||||
"string": "{n} Muted"
|
||||
},
|
||||
"Cu/K85": {
|
||||
"string": "Translated from {lang}"
|
||||
},
|
||||
@ -210,6 +228,9 @@
|
||||
"F+B3x1": {
|
||||
"string": "We have also partnered with nostrplebs.com to give you more options"
|
||||
},
|
||||
"FDguSC": {
|
||||
"string": "{n} Zaps"
|
||||
},
|
||||
"FS3b54": {
|
||||
"string": "Done!"
|
||||
},
|
||||
@ -256,9 +277,6 @@
|
||||
"IEwZvs": {
|
||||
"string": "Are you sure you want to unpin this note?"
|
||||
},
|
||||
"IKKHqV": {
|
||||
"string": "Follows"
|
||||
},
|
||||
"INSqIz": {
|
||||
"string": "Twitter username..."
|
||||
},
|
||||
@ -347,10 +365,16 @@
|
||||
"PrsIg7": {
|
||||
"string": "Reactions will be shown on every page, if disabled no reactions will be shown"
|
||||
},
|
||||
"QDFTjG": {
|
||||
"string": "{n} Relays"
|
||||
},
|
||||
"QTdJfH": {
|
||||
"developer_comment": "Heading for generate key flow",
|
||||
"string": "Create an Account"
|
||||
},
|
||||
"QawghE": {
|
||||
"string": "You can change your username at any point."
|
||||
},
|
||||
"QxCuTo": {
|
||||
"developer_comment": "Artwork attribution label",
|
||||
"string": "Art by {name}"
|
||||
@ -399,6 +423,9 @@
|
||||
"Vx7Zm2": {
|
||||
"string": "How do keys work?"
|
||||
},
|
||||
"W2PiAr": {
|
||||
"string": "{n} Blocked"
|
||||
},
|
||||
"W9355R": {
|
||||
"string": "Unmute"
|
||||
},
|
||||
@ -444,6 +471,9 @@
|
||||
"c35bj2": {
|
||||
"string": "If you have an enquiry about your NIP-05 order please DM {link}"
|
||||
},
|
||||
"cPIKU2": {
|
||||
"string": "Following"
|
||||
},
|
||||
"cQfLWb": {
|
||||
"developer_comment": "Placeholder text for imgproxy url textbox",
|
||||
"string": "URL.."
|
||||
@ -554,6 +584,9 @@
|
||||
"jvo0vs": {
|
||||
"string": "Save"
|
||||
},
|
||||
"jzgQ2z": {
|
||||
"string": "{n} Reactions"
|
||||
},
|
||||
"k2veDA": {
|
||||
"string": "Write"
|
||||
},
|
||||
@ -738,6 +771,10 @@
|
||||
"xmcVZ0": {
|
||||
"string": "Search"
|
||||
},
|
||||
"yCmnnm": {
|
||||
"developer_comment": "Label for reading global feed from specific relays",
|
||||
"string": "Read global from"
|
||||
},
|
||||
"zFegDD": {
|
||||
"string": "Contact"
|
||||
},
|
||||
|
@ -12,13 +12,17 @@ export default {
|
||||
"0yO7wF": "{n} secs",
|
||||
"1A7TZk": "What is Snort and how does it work?",
|
||||
"1Mo59U": "Are you sure you want to remove this note from bookmarks?",
|
||||
"1nYUGC": "{n} Following",
|
||||
"1udzha": "Conversations",
|
||||
"2/2yg+": "Add",
|
||||
"25V4l1": "Banner",
|
||||
"2IFGap": "Donate",
|
||||
"2a2YiP": "{n} Bookmarks",
|
||||
"2k0Cv+": "Dislikes ({n})",
|
||||
"3cc4Ct": "Light",
|
||||
"3gOsZq": "Translators",
|
||||
"3t3kok": "{n,plural,=1{{n} new note} other{{n} new notes}}",
|
||||
"3tVy+Z": "{n} Followers",
|
||||
"3xCwbZ": "OR",
|
||||
"450Fty": "None",
|
||||
"47FYwb": "Cancel",
|
||||
@ -53,11 +57,13 @@ export default {
|
||||
AyGauy: "Login",
|
||||
B4C47Y: "name too short",
|
||||
"B6+XJy": "zapped",
|
||||
B6H7eJ: "nsec, npub, nip-05, hex",
|
||||
BOUMjw: "No nostr users found for {twitterUsername}",
|
||||
"BOr9z/": "Snort is an open source project built by passionate people in their free time",
|
||||
"BcGMo+": 'Notes hold text content, the most popular usage of these notes is to store "tweet like" messages.',
|
||||
"C81/uG": "Logout",
|
||||
CHTbO3: "Failed to load invoice",
|
||||
CmZ9ls: "{n} Muted",
|
||||
"Cu/K85": "Translated from {lang}",
|
||||
D3idYv: "Settings",
|
||||
DKnriN: "Send sats",
|
||||
@ -73,6 +79,7 @@ export default {
|
||||
Eqjl5K:
|
||||
"Only Snort and our integration partner identifier gives you a colorful domain name, but you are welcome to use other services too.",
|
||||
"F+B3x1": "We have also partnered with nostrplebs.com to give you more options",
|
||||
FDguSC: "{n} Zaps",
|
||||
FS3b54: "Done!",
|
||||
FfYsOb: "An error has occured!",
|
||||
FmXUJg: "follows you",
|
||||
@ -88,7 +95,6 @@ export default {
|
||||
HOzFdo: "Muted",
|
||||
HbefNb: "Open Wallet",
|
||||
IEwZvs: "Are you sure you want to unpin this note?",
|
||||
IKKHqV: "Follows",
|
||||
INSqIz: "Twitter username...",
|
||||
"IUZC+0":
|
||||
"This means that nobody can modify notes which you have created and everybody can easily verify that the notes they are reading are created by you.",
|
||||
@ -119,7 +125,9 @@ export default {
|
||||
PCSt5T: "Preferences",
|
||||
Pe0ogR: "Theme",
|
||||
PrsIg7: "Reactions will be shown on every page, if disabled no reactions will be shown",
|
||||
QDFTjG: "{n} Relays",
|
||||
QTdJfH: "Create an Account",
|
||||
QawghE: "You can change your username at any point.",
|
||||
QxCuTo: "Art by {name}",
|
||||
RDZVQL: "Check",
|
||||
RahCRH: "Expired",
|
||||
@ -136,6 +144,7 @@ export default {
|
||||
VnXp8Z: "Avatar",
|
||||
"VtPV/B": "Login with Extension (NIP-07)",
|
||||
Vx7Zm2: "How do keys work?",
|
||||
W2PiAr: "{n} Blocked",
|
||||
W9355R: "Unmute",
|
||||
WONP5O: "Find your twitter follows on nostr (Data provided by {provider})",
|
||||
WxthCV: "e.g. Jack",
|
||||
@ -151,6 +160,7 @@ export default {
|
||||
brAXSu: "Pick a username",
|
||||
bxv59V: "Just now",
|
||||
c35bj2: "If you have an enquiry about your NIP-05 order please DM {link}",
|
||||
cPIKU2: "Following",
|
||||
cQfLWb: "URL..",
|
||||
cWx9t8: "Mute all",
|
||||
cuV2gK: "name is registered",
|
||||
@ -187,6 +197,7 @@ export default {
|
||||
jfV8Wr: "Back",
|
||||
juhqvW: "Improve login security with browser extensions",
|
||||
jvo0vs: "Save",
|
||||
jzgQ2z: "{n} Reactions",
|
||||
k2veDA: "Write",
|
||||
k7sKNy:
|
||||
"Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!",
|
||||
@ -252,6 +263,7 @@ export default {
|
||||
xKflGN: "{username}''s Follows on Nostr",
|
||||
xbVgIm: "Automatically load media",
|
||||
xmcVZ0: "Search",
|
||||
yCmnnm: "Read global from",
|
||||
zFegDD: "Contact",
|
||||
zINlao: "Owner",
|
||||
zQvVDJ: "All",
|
||||
|
Loading…
x
Reference in New Issue
Block a user