Merge pull request #282 from v0l/ui-fixes

UI fixes + counts on profile page tabs
This commit is contained in:
Kieran 2023-02-14 16:11:38 +00:00 committed by GitHub
commit 0e673cab8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 893 additions and 493 deletions

View File

@ -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 .",

View File

@ -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" />

View File

@ -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);

View File

@ -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>
);
}

View File

@ -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}

View File

@ -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>
);

View File

@ -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 })} />;
}

View File

@ -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 })} />;
}

View File

@ -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;
}

View 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;

View File

@ -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 (

View File

@ -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;
}
}

View File

@ -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>
);
};

View File

@ -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}
/>
&nbsp;@&nbsp;
<select value={domain} onChange={onDomainChange}>
{serviceConfig?.domains.map(a => (

View File

@ -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;
}

View File

@ -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:&nbsp;
{(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}
/>
)}
</>
);
}

View File

@ -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() {

View File

@ -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>

View File

@ -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);
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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>
</>
);
}

View File

@ -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> {

View File

@ -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;
}
}

View File

@ -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>
);
})}

View File

@ -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;
}

View File

@ -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"} />

View File

@ -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;

View File

@ -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]}&nbsp;</>}
{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]}&nbsp;</>}
{formatShort(a)}
</span>
))}
</div>
{amountRows.map(amounts => renderAmounts(amount, amounts))}
{payService && custom()}
<div className="flex">
{canComment && (

View File

@ -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);
}

View File

@ -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>;
}

View File

@ -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;
}

View File

@ -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}>

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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" },
});

View File

@ -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;
}

View File

@ -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())];
}

View File

@ -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[]): {

View File

@ -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;
}

View 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;
}

View File

@ -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}`;

View 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;
}

View 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;

View 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;

View File

@ -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 {

View File

@ -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]);

View File

@ -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;
}

View File

@ -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 ?? ""} />

View File

@ -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;
}

View File

@ -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()}
</>

View File

@ -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" },
});

View File

@ -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>

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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} />

View File

@ -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;
}

View File

@ -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">

View File

@ -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";

View File

@ -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;
}

View File

@ -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"
},

View File

@ -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",