UI updates #135

Merged
verbiricha merged 12 commits from ui into main 2023-01-25 18:08:54 +00:00
62 changed files with 800 additions and 533 deletions

View File

@ -1,4 +1,4 @@
## Snort
## Snort
Snort is a nostr UI built with React, Snort intends to be fast and effecient
@ -13,7 +13,7 @@ Snort supports the following NIP's
- [x] NIP-08: Handling Mentions
- [x] NIP-09: Event Deletion
- [x] NIP-10: Conventions for clients' use of `e` and `p` tags in text events
- [ ] NIP-11: Relay Information Document
- [x] NIP-11: Relay Information Document
- [x] NIP-12: Generic Tag Queries
- [ ] NIP-13: Proof of Work
- [ ] NIP-14: Subject tag in text events

5
d.ts
View File

@ -2,3 +2,8 @@ declare module "*.jpg" {
const value: any
export default value
}
declare module "*.svg" {
const value: any
export default value
}

View File

@ -1,5 +1,10 @@
import { RelaySettings } from "Nostr/Connection";
/**
* Add-on api for snort features
*/
export const ApiHost = "https://api.snort.social";
/**
* Websocket re-connect timeout
*/
@ -93,5 +98,4 @@ export const TidalRegex = /tidal\.com\/(?:browse\/)?(\w+)\/([a-z0-9-]+)/i;
/**
* SoundCloud regex
*/
export const SoundCloudRegex = /soundcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/

View File

@ -2,8 +2,8 @@
border-radius: 50%;
height: 210px;
width: 210px;
background-image: var(--img-url), var(--gray-gradient);
border: 2px solid transparent;
background-image: var(--img-url);
border: 1px solid transparent;
background-origin: border-box;
background-clip: content-box, border-box;
background-size: cover;

View File

@ -1,15 +1,9 @@
.copy {
user-select: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
cursor: pointer;
}
.copy .body {
font-family: monospace;
font-size: 14px;
background: var(--note-bg);
font-size: var(--font-size-small);
color: var(--font-color);
padding: 2px 4px;
border-radius: 10px;
margin: 0 4px 0 0;
margin-right: 8px;
}

View File

@ -10,7 +10,7 @@ export interface CopyProps {
export default function Copy({ text, maxSize = 32 }: CopyProps) {
const { copy, copied, error } = useCopy();
const sliceLength = maxSize / 2
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}:${text.slice(-sliceLength)}` : text
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text
return (
<div className="flex flex-row copy" onClick={() => copy(text)}>
@ -20,7 +20,7 @@ export default function Copy({ text, maxSize = 32 }: CopyProps) {
<FontAwesomeIcon
icon={copied ? faCheck : faCopy}
size="xs"
style={{ color: copied ? 'var(--success)' : 'currentColor', marginRight: '2px' }}
style={{ color: copied ? 'var(--success)' : 'var(--highlight)', marginRight: '2px' }}
/>
</div>
)

View File

@ -7,14 +7,12 @@ import { RootState } from "State/Store";
export interface FollowButtonProps {
pubkey: HexKey,
className?: string,
className?: string
}
export default function FollowButton(props: FollowButtonProps) {
const pubkey = props.pubkey;
const publiser = useEventPublisher();
const isFollowing = useSelector<RootState, boolean>(s => s.login.follows?.includes(pubkey) ?? false);
const baseClassName = isFollowing ? `btn btn-warn follow-button` : `btn btn-success follow-button`
const className = props.className ? `${baseClassName} ${props.className}` : `${baseClassName}`;
async function follow(pubkey: HexKey) {
let ev = await publiser.addFollow(pubkey);
@ -27,8 +25,8 @@ export default function FollowButton(props: FollowButtonProps) {
}
return (
<div className={className} onClick={() => isFollowing ? unfollow(pubkey) : follow(pubkey)}>
<FontAwesomeIcon icon={isFollowing ? faUserMinus : faUserPlus} size="lg" />
</div>
<button type="button" className={isFollowing ? `${props.className ?? ''} secondary` : props.className} onClick={() => isFollowing ? unfollow(pubkey) : follow(pubkey)}>
{isFollowing ? 'Unfollow' : 'Follow'}
</button>
)
}
}

View File

@ -15,12 +15,12 @@ export default function FollowListBase({ pubkeys, title }: FollowListBaseProps)
}
return (
<>
<div className="main-content">
<div className="flex mt10">
<div className="f-grow">{title}</div>
<div className="btn" onClick={() => followAll()}>Follow All</div>
<button type="button" onClick={() => followAll()}>Follow All</button>
</div>
{pubkeys?.map(a => <ProfilePreview pubkey={a} key={a} />)}
</>
</div>
)
}
}

View File

@ -1,5 +1,5 @@
.follows-you {
color: var(--font-color);
color: var(--gray-light);
font-size: var(--font-size-tiny);
margin-left: .2em;
font-weight: normal

View File

@ -1,10 +1,5 @@
.lnurl-tip {
background-color: var(--note-bg);
padding: 10px;
border-radius: 10px;
width: 500px;
text-align: center;
min-height: 10vh;
}
.lnurl-tip .btn {
@ -62,10 +57,3 @@
align-items: center;
justify-content: center;
}
@media(max-width: 720px) {
.lnurl-tip {
width: 100vw;
margin: 0 10px;
}
}

View File

@ -8,5 +8,20 @@
display: flex;
justify-content: center;
align-items: center;
z-index: 9999999;
z-index: 42;
}
.modal-body {
background-color: var(--note-bg);
padding: 10px;
border-radius: 10px;
width: 500px;
min-height: 10vh;
}
@media(max-width: 720px) {
.modal-body {
width: 100vw;
margin: 0 10px;
}
}

View File

@ -17,7 +17,9 @@ export default function Modal(props: ModalProps) {
return (
<div className="modal" onClick={(e) => { e.stopPropagation(); onClose(); }}>
<div className="modal-body">
{props.children}
</div>
</div>
)
}
}

View File

@ -1,7 +1,6 @@
.nip05 {
justify-content: flex-start;
align-items: center;
font-size: 14px;
margin: .2em;
}
@ -10,50 +9,8 @@
}
.nip05 .nick {
color: var(--gray-light);
color: var(--font-secondary-color);
font-weight: bold;
margin-right: .2em;
}
.nip05 .domain {
color: var(--gray-superlight);
font-weight: bold;
}
.nip05 .text-gradient {
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-fill-color: transparent;
background-color: var(--gray-superlight);
}
.nip05 .domain[data-domain="snort.social"] {
background-image: var(--snort-gradient);
}
.nip05 .domain[data-domain="nostrplebs.com"] {
background-image: var(--nostrplebs-gradient);
}
.nip05 .domain[data-domain="nostrpurple.com"] {
background-image: var(--nostrplebs-gradient);
}
.nip05 .domain[data-domain="nostr.fan"] {
background-image: var(--nostrplebs-gradient);
}
.nip05 .domain[data-domain="nostrich.zone"] {
background-image: var(--nostrplebs-gradient);
}
.nip05 .domain[data-domain="nostriches.net"] {
background-image: var(--nostrplebs-gradient);
}
.nip05 .domain[data-domain="strike.army"] {
background-image: var(--strike-army-gradient);
}
.nip05 .badge {

View File

@ -1,7 +1,7 @@
import { useQuery } from "react-query";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinner, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { faCircleCheck, faSpinner, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import './Nip05.css'
import { HexKey } from "Nostr";
@ -57,16 +57,20 @@ const Nip05 = (props: Nip05Params) => {
const { isVerified, couldNotVerify } = useIsVerified(props.pubkey, props.nip05)
return (
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`}>
{!isDefaultUser && (
<div className="nick">
{name}
</div>
)}
<div className={`domain text-gradient`} data-domain={domain?.toLowerCase()}>
{domain}
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`} onClick={(ev) => ev.stopPropagation()}>
<div className="nick">
{isDefaultUser ? (
`${domain}`
) : `@${name}`}
</div>
<span className="badge">
{isVerified && (
<FontAwesomeIcon
color={"var(--highlight)"}
icon={faCircleCheck}
size="xs"
/>
)}
{!isVerified && !couldNotVerify && (
<FontAwesomeIcon
color={"var(--fg-color)"}

View File

@ -33,7 +33,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
const pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey);
const user = useProfile(pubkey);
const publisher = useEventPublisher();
const svc = new ServiceProvider(props.service);
const svc = useMemo(() => new ServiceProvider(props.service), [props.service]);
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
const [error, setError] = useState<ServiceError>();
const [handle, setHandle] = useState<string>("");
@ -43,7 +43,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
const [showInvoice, setShowInvoice] = useState<boolean>(false);
const [registerStatus, setRegisterStatus] = useState<CheckRegisterResponse>();
const domainConfig = useMemo(() => serviceConfig?.domains.find(a => a.name === domain), [domain]);
const domainConfig = useMemo(() => serviceConfig?.domains.find(a => a.name === domain), [domain, serviceConfig]);
useEffect(() => {
svc.GetConfig()
@ -58,7 +58,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
}
})
.catch(console.error)
}, [props]);
}, [props, svc]);
useEffect(() => {
setError(undefined);
@ -89,7 +89,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
.catch(console.error);
});
}
}, [handle, domain]);
}, [handle, domain, domainConfig, svc]);
useEffect(() => {
if (registerResponse && showInvoice) {
@ -111,7 +111,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
}, 2_000);
return () => clearInterval(t);
}
}, [registerResponse, showInvoice])
}, [registerResponse, showInvoice, svc])
function mapError(e: ServiceErrorCode, t: string | null): string | undefined {
let whyMap = new Map([
@ -159,7 +159,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
<p>Find out more info about {props.name} at <a href={props.link} target="_blank" rel="noreferrer">{props.link}</a></p>
{error && <b className="error">{error.error}</b>}
{!registerStatus && <div className="flex mb10">
<input type="text" placeholder="Handle" value={handle} onChange={(e) => setHandle(e.target.value)} />
<input type="text" placeholder="Handle" value={handle} onChange={(e) => setHandle(e.target.value.toLowerCase())} />
&nbsp;@&nbsp;
<select value={domain} onChange={(e) => setDomain(e.target.value)}>
{serviceConfig?.domains.map(a => <option key={a.name}>{a.name}</option>)}

View File

@ -11,6 +11,14 @@
color: var(--font-secondary-color);
}
.note>.header .reply a {
color: var(--highlight);
}
.note>.header .reply a:hover {
text-decoration-color: var(--highlight);
}
.note>.header>.info {
font-size: var(--font-size);
white-space: nowrap;
@ -18,7 +26,7 @@
}
.note>.body {
margin-top: 12px;
margin-top: 4px;
padding-left: 56px;
text-overflow: ellipsis;
white-space: pre-wrap;

View File

@ -1,6 +1,6 @@
import "./Note.css";
import { useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useCallback, useMemo, ReactNode } from "react";
import { useNavigate, Link } from "react-router-dom";
import { default as NEvent } from "Nostr/Event";
import ProfileImage from "Element/ProfileImage";
@ -64,21 +64,57 @@ export default function Note(props: NoteProps) {
const maxMentions = 2;
let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
let mentions: string[] = [];
let mentions: {pk: string, name: string, link: ReactNode}[] = [];
for (let pk of ev.Thread?.PubKeys) {
let u = users?.get(pk);
const u = users?.get(pk);
const npub = hexToBech32("npub", pk)
const shortNpub = npub.substring(0, 12);
if (u) {
mentions.push(u.name ?? hexToBech32("npub", pk).substring(0, 12));
mentions.push({
pk,
name: u.name ?? shortNpub,
link: (
<Link to={`/p/${npub}`}>
{u.name ? `@${u.name}` : shortNpub}
</Link>
)
});
} else {
mentions.push(hexToBech32("npub", pk).substring(0, 12));
mentions.push({
pk,
name: shortNpub,
link: (
<Link to={`/p/${npub}`}>
{shortNpub}
</Link>
)
});
}
}
mentions.sort((a, b) => a.startsWith("npub") ? 1 : -1);
mentions.sort((a, b) => a.name.startsWith("npub") ? 1 : -1);
let othersLength = mentions.length - maxMentions
let pubMentions = mentions.length > maxMentions ? `${mentions?.slice(0, maxMentions).join(", ")} & ${othersLength} other${othersLength > 1 ? 's' : ''}` : mentions?.join(", ");
const renderMention = (m: any, idx: number) => {
return (
<>
{idx > 0 && ", "}
{m.link}
</>
)
}
const pubMentions = mentions.length > maxMentions ? (
mentions?.slice(0, maxMentions).map(renderMention)
) : mentions?.map(renderMention);
const others = mentions.length > maxMentions ? ` & ${othersLength} other${othersLength > 1 ? 's' : ''}` : ''
return (
<div className="reply">
{(pubMentions?.length ?? 0) > 0 ? pubMentions : replyId ? hexToBech32("note", replyId)?.substring(0, 12) : ""}
{(mentions?.length ?? 0) > 0 ? (
<>
{pubMentions}
{others}
</>
) : replyId ? (
hexToBech32("note", replyId)?.substring(0, 12) // todo: link
) : ""}
</div>
)
}

View File

@ -13,28 +13,28 @@
.note-creator textarea {
outline: none;
resize: none;
min-height: 40px;
background-color: var(--note-bg);
border-radius: 10px 10px 0 0;
max-width: -webkit-fill-available;
max-width: -moz-available;
max-width: fill-available;
min-width: 100%;
min-width: -webkit-fill-available;
min-width: -moz-available;
min-width: fill-available;
max-width: stretch;
min-width: stretch;
}
.note-creator .actions {
.note-creator-actions {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
margin-bottom: 5px;
}
.note-creator .attachment {
cursor: pointer;
padding: 5px 10px;
border-radius: 10px;
margin-left: auto;
}
.note-creator-actions button:not(:last-child) {
margin-right: 4px;
}
.note-creator .attachment .error {
@ -50,3 +50,26 @@
color: var(--font-color);
font-size: var(--font-size);
}
.note-create-button {
width: 48px;
height: 48px;
background-color: var(--highlight);
border: none;
border-radius: 100%;
position: fixed;
bottom: 50px;
right: 16px;
}
@media (min-width: 520px) {
.note-create-button {
right: 10vw;
}
}
@media (min-width: 1020px) {
.note-create-button {
right: 25vw;
}
}

View File

@ -4,21 +4,26 @@ import { faPaperclip } from "@fortawesome/free-solid-svg-icons";
import "./NoteCreator.css";
import Plus from "Icons/Plus";
import useEventPublisher from "Feed/EventPublisher";
import { openFile } from "Util";
import VoidUpload from "Feed/VoidUpload";
import { FileExtensionRegex } from "Const";
import Textarea from "Element/Textarea";
import Event, { default as NEvent } from "Nostr/Event";
import Modal from "Element/Modal";
import { default as NEvent } from "Nostr/Event";
export interface NoteCreatorProps {
show: boolean
setShow: (s: boolean) => void
replyTo?: NEvent,
onSend?: Function,
show: boolean,
onClose?(): void
autoFocus: boolean
}
export function NoteCreator(props: NoteCreatorProps) {
const { show, setShow } = props
const publisher = useEventPublisher();
const [note, setNote] = useState<string>();
const [error, setError] = useState<string>();
@ -68,14 +73,23 @@ export function NoteCreator(props: NoteCreatorProps) {
}
}
function cancel(ev: any) {
setShow(false)
setNote("")
}
function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
ev.stopPropagation();
sendNote().catch(console.warn);
}
if (!props.show) return null;
return (
<>
<button className="note-create-button" type="button" onClick={() => setShow(!show)}>
<Plus />
</button>
{show && (
<Modal onClose={props.onClose}>
<div className={`flex note-creator ${props.replyTo ? 'note-reply' : ''}`}>
<div className="flex f-col mr10 f-grow">
<Textarea
@ -85,19 +99,22 @@ export function NoteCreator(props: NoteCreatorProps) {
value={note}
onFocus={() => setActive(true)}
/>
{active && note && (
<div className="actions flex f-row">
<div className="attachment flex f-row">
{(error?.length ?? 0) > 0 ? <b className="error">{error}</b> : null}
<FontAwesomeIcon icon={faPaperclip} size="xl" onClick={(e) => attachFile()} />
</div>
<button type="button" className="btn" onClick={onSubmit}>
{props.replyTo ? 'Reply' : 'Send'}
</button>
</div>
)}
<div className="attachment">
{(error?.length ?? 0) > 0 ? <b className="error">{error}</b> : null}
<FontAwesomeIcon icon={faPaperclip} size="xl" onClick={(e) => attachFile()} />
</div>
</div>
</div>
<div className="note-creator-actions">
<button className="secondary" type="button" onClick={cancel}>
Cancel
</button>
<button type="button" onClick={onSubmit}>
{props.replyTo ? 'Reply' : 'Send'}
</button>
</div>
</Modal>
)}
</>
);
}

View File

@ -1,9 +1,14 @@
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { faHeart, faReply, faThumbsDown, faTrash, faBolt, faRepeat, faEllipsisVertical, faShareNodes, faCopy } from "@fortawesome/free-solid-svg-icons";
import { faTrash, faRepeat, faShareNodes, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Menu, MenuItem } from '@szhsin/react-menu';
import Dislike from "Icons/Dislike";
import Heart from "Icons/Heart";
import Dots from "Icons/Dots";
import Zap from "Icons/Zap";
import Reply from "Icons/Reply";
import { formatShort } from "Number";
import useEventPublisher from "Feed/EventPublisher";
import { getReactions, hexToBech32, normalizeReaction, Reaction } from "Util";
@ -31,8 +36,8 @@ export default function NoteFooter(props: NoteFooterProps) {
const [reply, setReply] = useState(false);
const [tip, setTip] = useState(false);
const isMine = ev.RootPubKey === login;
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related]);
const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related]);
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related, ev]);
const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related, ev]);
const groupReactions = useMemo(() => {
return reactions?.reduce((acc, { content }) => {
let r = normalizeReaction(content);
@ -82,7 +87,7 @@ export default function NoteFooter(props: NoteFooterProps) {
<>
<div className="reaction-pill" onClick={() => setTip(true)}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faBolt} />
<Zap />
</div>
</div>
</>
@ -114,7 +119,7 @@ export default function NoteFooter(props: NoteFooterProps) {
<>
<div className={`reaction-pill ${hasReacted('+') ? 'reacted' : ''} `} onClick={() => react("+")}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faHeart} />
<Heart />
</div>
<div className="reaction-pill-number">
{formatShort(groupReactions[Reaction.Positive])}
@ -148,14 +153,14 @@ export default function NoteFooter(props: NoteFooterProps) {
function menuItems() {
return (
<>
{prefs.enableReactions && (<MenuItem onClick={() => react("-")}>
<div>
<FontAwesomeIcon icon={faThumbsDown} className={hasReacted('-') ? 'reacted' : ''} />
&nbsp;
{prefs.enableReactions && (
<MenuItem onClick={() => react("-")}>
<Dislike />
{formatShort(groupReactions[Reaction.Negative])}
</div>
Dislike
</MenuItem>)}
&nbsp;
Dislike
</MenuItem>
)}
<MenuItem onClick={() => share()}>
<FontAwesomeIcon icon={faShareNodes} />
Share
@ -183,18 +188,18 @@ export default function NoteFooter(props: NoteFooterProps) {
return (
<>
<div className="footer">
<div className={`reaction-pill ${reply ? 'reacted' : ''}`} onClick={(e) => setReply(s => !s)}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faReply} />
</div>
</div>
<Menu menuButton={<div className="reaction-pill">
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faEllipsisVertical} />
<Dots />
</div>
</div>} menuClassName="ctx-menu">
{menuItems()}
</Menu>
<div className={`reaction-pill ${reply ? 'reacted' : ''}`} onClick={(e) => setReply(s => !s)}>
<div className="reaction-pill-icon">
<Reply />
</div>
</div>
{reactionIcons()}
{tipButton()}
@ -204,6 +209,7 @@ export default function NoteFooter(props: NoteFooterProps) {
replyTo={ev}
onSend={() => setReply(false)}
show={reply}
setShow={setReply}
/>
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={() => setTip(false)} show={tip} />
</>

View File

@ -3,7 +3,7 @@
}
.reaction > .note {
margin: 10px 20px;
margin: 10px 0;
}
.reaction > .header {

View File

@ -16,7 +16,8 @@ export interface NoteReactionProps {
root?: TaggedRawEvent
}
export default function NoteReaction(props: NoteReactionProps) {
const ev = useMemo(() => props["data-ev"] || new NEvent(props.data), [props.data, props["data-ev"]])
const { ["data-ev"]: dataEv, data } = props;
const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv])
const refEvent = useMemo(() => {
if (ev) {
@ -32,19 +33,6 @@ export default function NoteReaction(props: NoteReactionProps) {
return null;
}
function mapReaction(c: string) {
switch (c) {
case "+": return "❤️";
case "-": return "👎";
default: {
if (c.length === 0) {
return "❤️";
}
return c;
}
}
}
/**
* Some clients embed the reposted note in the content
*/

View File

@ -1,6 +1,6 @@
.pfp {
display: flex;
align-items: center;
display: flex;
align-items: center;
}
.pfp .avatar-wrapper {
@ -8,7 +8,6 @@
}
.pfp .avatar {
border-width: 1px;
width: 48px;
height: 48px;
cursor: pointer;
@ -23,13 +22,15 @@
text-decoration-color: var(--gray-superlight);
}
.pfp .profile-name {
.pfp .username {
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
font-weight: bold;
}
.pfp .nip05 {
margin: 0;
margin-top: -.2em;
.pfp .profile-name {
display: flex;
flex-direction: column;
}

View File

@ -1,7 +1,7 @@
import "./ProfileImage.css";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, Link } from "react-router-dom";
import useProfile from "Feed/ProfileFeed";
import { hexToBech32, profileLink } from "Util";
import Avatar from "Element/Avatar"
@ -30,13 +30,18 @@ export default function ProfileImage({ pubkey, subHeader, showUsername = true, c
<div className="avatar-wrapper">
<Avatar user={user} onClick={() => navigate(link ?? profileLink(pubkey))} />
</div>
{showUsername && (<div className="f-grow pointer" onClick={e => { e.stopPropagation(); navigate(link ?? profileLink(pubkey)) }}>
<div className="profile-name">
<div>{name}</div>
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
{showUsername && (
<div className="profile-name f-grow">
<div className="username">
<Link className="display-name" key={pubkey} to={link ?? profileLink(pubkey)}>
{name}
</Link>
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
</div>
{subHeader ? <>{subHeader}</> : null}
</div>
<div className="subheader">
{subHeader}
</div>
</div>
)}
</div>
)

View File

@ -1,13 +1,14 @@
import "./Relay.css"
import { faPlug, faTrash, faSquareCheck, faSquareXmark, faWifi, faUpload, faDownload, faPlugCircleXmark, faEllipsisVertical } from "@fortawesome/free-solid-svg-icons";
import { faPlug, faSquareCheck, faSquareXmark, faWifi, faPlugCircleXmark, faGear } from "@fortawesome/free-solid-svg-icons";
import useRelayState from "Feed/RelayState";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { removeRelay, setRelays } from "State/Login";
import { setRelays } from "State/Login";
import { RootState } from "State/Store";
import { RelaySettings } from "Nostr/Connection";
import { useNavigate } from "react-router-dom";
export interface RelayProps {
addr: string
@ -15,11 +16,11 @@ export interface RelayProps {
export default function Relay(props: RelayProps) {
const dispatch = useDispatch();
const navigate = useNavigate();
const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
const relaySettings = allRelaySettings[props.addr];
const state = useRelayState(props.addr);
const name = useMemo(() => new URL(props.addr).host, [props.addr]);
const [showExtra, setShowExtra] = useState(false);
function configure(o: RelaySettings) {
dispatch(setRelays({
@ -62,28 +63,13 @@ export default function Relay(props: RelayProps) {
<FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects}
</div>
<div>
<span className="icon-btn" onClick={() => setShowExtra(s => !s)}>
<FontAwesomeIcon icon={faEllipsisVertical} />
<span className="icon-btn" onClick={() => navigate(name)}>
<FontAwesomeIcon icon={faGear} />
</span>
</div>
</div>
</div>
</div>
{showExtra ? <div className="flex relay-extra w-max">
<div className="f-1">
<FontAwesomeIcon icon={faUpload} /> {state?.events.send}
</div>
<div className="f-1">
<FontAwesomeIcon icon={faDownload} /> {state?.events.received}
</div>
<div className="f-1">
Delete
<span className="icon-btn" onClick={() => dispatch(removeRelay(props.addr))}>
<FontAwesomeIcon icon={faTrash} />
</span>
</div>
</div> : null}
</>
)
}

View File

@ -48,12 +48,12 @@
align-items: flex-start;
}
.nip05 {
font-size: 12px;
.user-item .nip05 {
font-size: var(--font-size-tiny);
}
.emoji-item {
font-size: 12px;
font-size: var(--font-size-tiny);
}
.emoji-item .emoji {

View File

@ -2,4 +2,4 @@
cursor: pointer;
font-weight: bold;
user-select: none;
}
}

View File

@ -1,5 +1,5 @@
import "./Timeline.css";
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed";
import { TaggedRawEvent } from "Nostr";
import EventKind from "Nostr/EventKind";
@ -23,17 +23,17 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin
method
});
const filterPosts = (notes: TaggedRawEvent[]) => {
return [...notes].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true);
}
const filterPosts = useCallback((nts: TaggedRawEvent[]) => {
return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true);
}, [postsOnly]);
const mainFeed = useMemo(() => {
return filterPosts(main.notes);
}, [main]);
}, [main, filterPosts]);
const latestFeed = useMemo(() => {
return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id));
}, [latest]);
}, [latest, mainFeed, filterPosts]);
function eventElement(e: TaggedRawEvent) {
switch (e.kind) {
@ -49,7 +49,7 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin
}
return (
<>
<div className="main-content">
{latestFeed.length > 1 && (<div className="card latest-notes pointer" onClick={() => showLatest()}>
<FontAwesomeIcon icon={faForward} size="xl"/>
&nbsp;
@ -57,6 +57,6 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin
</div>)}
{mainFeed.map(eventElement)}
<LoadMore onLoadMore={loadMore} shouldLoadMore={main.end}/>
</>
</div>
);
}
}

View File

@ -1,5 +1,5 @@
import { useLiveQuery } from "dexie-react-hooks";
import { useEffect, useMemo } from "react";
import { useEffect } from "react";
import { db } from "Db";
import { MetadataCache } from "Db/User";
import { HexKey } from "Nostr";

View File

@ -38,7 +38,8 @@ function notesReducer(state: NoteStore, arg: ReducerArg) {
if (!Array.isArray(evs)) {
evs = [evs];
}
evs = evs.filter(a => !state.notes.some(b => b.id === a.id));
let existingIds = new Set(state.notes.map(a => a.id));
evs = evs.filter(a => !existingIds.has(a.id));
if (evs.length === 0) {
return state;
}
@ -83,7 +84,7 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use
setSubDebounced(sub);
});
}
}, [sub]);
}, [sub, options]);
useEffect(() => {
if (sub) {
@ -115,6 +116,7 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use
};
}
}, [subDebounce]);
useEffect(() => {
return debounce(DebounceMs, () => {
setDebounceOutput(s => s += 1);

View File

@ -36,7 +36,7 @@ export default function useThreadFeed(id: u256) {
thisSub.AddSubscription(subRelated);
return thisSub;
}, [trackingEvents]);
}, [trackingEvents, pref, id]);
const main = useSubscription(sub, { leaveOpen: true });

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { u256 } from "Nostr";
import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions";
@ -19,15 +19,15 @@ export interface TimelineSubject {
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
const now = unixNow();
const [window, setWindow] = useState<number>(60 * 60);
const [window] = useState<number>(60 * 60);
const [until, setUntil] = useState<number>(now);
const [since, setSince] = useState<number>(now - window);
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
function createSub() {
if (subject.type !== "global" && subject.items.length == 0) {
const createSub = useCallback(() => {
if (subject.type !== "global" && subject.items.length === 0) {
return null;
}
@ -49,7 +49,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
}
}
return sub;
}
}, [subject.type, subject.items]);
const sub = useMemo(() => {
let sub = createSub();
@ -78,7 +78,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
}
}
return sub;
}, [subject.type, subject.items, until, since, window]);
}, [until, since, options.method, pref, createSub]);
const main = useSubscription(sub, { leaveOpen: true });
@ -90,7 +90,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
subLatest.Since = Math.floor(new Date().getTime() / 1000);
}
return subLatest;
}, [subject.type, subject.items]);
}, [pref, createSub]);
const latest = useSubscription(subRealtime, { leaveOpen: true });
@ -103,7 +103,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
sub.ETags = new Set(trackingEvents);
}
return sub ?? null;
}, [trackingEvents]);
}, [trackingEvents, pref, subject.type]);
const others = useSubscription(subNext, { leaveOpen: true });
@ -115,7 +115,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
return parents;
}
return null;
}, [trackingParentEvents]);
}, [trackingParentEvents, subject.type]);
const parent = useSubscription(subParents);
@ -123,8 +123,10 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
if (main.store.notes.length > 0) {
setTrackingEvent(s => {
let ids = main.store.notes.map(a => a.id);
let temp = new Set([...s, ...ids]);
return Array.from(temp);
if(ids.some(a => !s.includes(a))) {
return Array.from(new Set([...s, ...ids]));
}
return s;
});
let reposts = main.store.notes
.filter(a => a.kind === EventKind.Repost && a.content === "")

9
src/Icons/Bell.tsx Normal file
View File

@ -0,0 +1,9 @@
const Bell = () => {
return (
<svg width="20" height="23" viewBox="0 0 20 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.35419 20.5C8.05933 21.1224 8.98557 21.5 10 21.5C11.0145 21.5 11.9407 21.1224 12.6458 20.5M16 7.5C16 5.9087 15.3679 4.38258 14.2427 3.25736C13.1174 2.13214 11.5913 1.5 10 1.5C8.40872 1.5 6.8826 2.13214 5.75738 3.25736C4.63216 4.38258 4.00002 5.9087 4.00002 7.5C4.00002 10.5902 3.22049 12.706 2.34968 14.1054C1.61515 15.2859 1.24788 15.8761 1.26134 16.0408C1.27626 16.2231 1.31488 16.2926 1.46179 16.4016C1.59448 16.5 2.19261 16.5 3.38887 16.5H16.6112C17.8074 16.5 18.4056 16.5 18.5382 16.4016C18.6852 16.2926 18.7238 16.2231 18.7387 16.0408C18.7522 15.8761 18.3849 15.2859 17.6504 14.1054C16.7795 12.706 16 10.5902 16 7.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Bell

9
src/Icons/Dislike.tsx Normal file
View File

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

11
src/Icons/Dots.tsx Normal file
View File

@ -0,0 +1,11 @@
const Dots = () => {
return (
<svg width="4" height="16" viewBox="0 0 4 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.99996 8.86865C2.4602 8.86865 2.83329 8.49556 2.83329 8.03532C2.83329 7.57508 2.4602 7.20199 1.99996 7.20199C1.53972 7.20199 1.16663 7.57508 1.16663 8.03532C1.16663 8.49556 1.53972 8.86865 1.99996 8.86865Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.99996 3.03532C2.4602 3.03532 2.83329 2.66222 2.83329 2.20199C2.83329 1.74175 2.4602 1.36865 1.99996 1.36865C1.53972 1.36865 1.16663 1.74175 1.16663 2.20199C1.16663 2.66222 1.53972 3.03532 1.99996 3.03532Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.99996 14.702C2.4602 14.702 2.83329 14.3289 2.83329 13.8687C2.83329 13.4084 2.4602 13.0353 1.99996 13.0353C1.53972 13.0353 1.16663 13.4084 1.16663 13.8687C1.16663 14.3289 1.53972 14.702 1.99996 14.702Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Dots

9
src/Icons/Envelope.tsx Normal file
View File

@ -0,0 +1,9 @@
const Envelope = () => {
return (
<svg width="22" height="19" viewBox="0 0 22 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 4.5L9.16492 10.2154C9.82609 10.6783 10.1567 10.9097 10.5163 10.9993C10.8339 11.0785 11.1661 11.0785 11.4837 10.9993C11.8433 10.9097 12.1739 10.6783 12.8351 10.2154L21 4.5M5.8 17.5H16.2C17.8802 17.5 18.7202 17.5 19.362 17.173C19.9265 16.8854 20.3854 16.4265 20.673 15.862C21 15.2202 21 14.3802 21 12.7V6.3C21 4.61984 21 3.77976 20.673 3.13803C20.3854 2.57354 19.9265 2.1146 19.362 1.82698C18.7202 1.5 17.8802 1.5 16.2 1.5H5.8C4.11984 1.5 3.27976 1.5 2.63803 1.82698C2.07354 2.1146 1.6146 2.57354 1.32698 3.13803C1 3.77976 1 4.61984 1 6.3V12.7C1 14.3802 1 15.2202 1.32698 15.862C1.6146 16.4265 2.07354 16.8854 2.63803 17.173C3.27976 17.5 4.11984 17.5 5.8 17.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Envelope

9
src/Icons/Heart.tsx Normal file
View File

@ -0,0 +1,9 @@
const Heart = () => {
return (
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99425 3.315C8.32813 1.36716 5.54975 0.843192 3.4622 2.62683C1.37466 4.41048 1.08077 7.39264 2.72012 9.50216C4.08314 11.2561 8.2081 14.9552 9.56004 16.1525C9.7113 16.2865 9.78692 16.3534 9.87514 16.3798C9.95213 16.4027 10.0364 16.4027 10.1134 16.3798C10.2016 16.3534 10.2772 16.2865 10.4285 16.1525C11.7804 14.9552 15.9054 11.2561 17.2684 9.50216C18.9077 7.39264 18.6497 4.39171 16.5263 2.62683C14.4029 0.861954 11.6604 1.36716 9.99425 3.315Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Heart

9
src/Icons/Link.tsx Normal file
View File

@ -0,0 +1,9 @@
const Link = () => {
return (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.99996 12C9.42941 12.5742 9.97731 13.0492 10.6065 13.393C11.2357 13.7367 11.9315 13.9411 12.6466 13.9924C13.3617 14.0436 14.0795 13.9404 14.7513 13.6898C15.4231 13.4392 16.0331 13.0471 16.54 12.54L19.54 9.54003C20.4507 8.59702 20.9547 7.334 20.9433 6.02302C20.9319 4.71204 20.4061 3.45797 19.479 2.53093C18.552 1.60389 17.2979 1.07805 15.987 1.06666C14.676 1.05526 13.413 1.55924 12.47 2.47003L10.75 4.18003M13 10C12.5705 9.4259 12.0226 8.95084 11.3934 8.60709C10.7642 8.26333 10.0684 8.05891 9.3533 8.00769C8.63816 7.95648 7.92037 8.05966 7.24861 8.31025C6.57685 8.56083 5.96684 8.95296 5.45996 9.46003L2.45996 12.46C1.54917 13.403 1.04519 14.666 1.05659 15.977C1.06798 17.288 1.59382 18.5421 2.52086 19.4691C3.4479 20.3962 4.70197 20.922 6.01295 20.9334C7.32393 20.9448 8.58694 20.4408 9.52995 19.53L11.24 17.82" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Link

9
src/Icons/Plus.tsx Normal file
View File

@ -0,0 +1,9 @@
const Plus = () => {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 1V15M1 8H15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Plus

9
src/Icons/Reply.tsx Normal file
View File

@ -0,0 +1,9 @@
const Reply = () => {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.50004 9.70199L1.33337 5.53532M1.33337 5.53532L5.50004 1.36865M1.33337 5.53532H6.66671C9.46697 5.53532 10.8671 5.53532 11.9367 6.08029C12.8775 6.55965 13.6424 7.32456 14.1217 8.26537C14.6667 9.33493 14.6667 10.7351 14.6667 13.5353V14.702" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Reply

9
src/Icons/Zap.tsx Normal file
View File

@ -0,0 +1,9 @@
const Zap = () => {
return (
<svg width="16" height="20" viewBox="0 0 16 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.8333 1.70166L1.41118 10.6082C1.12051 10.957 0.975169 11.1314 0.972948 11.2787C0.971017 11.4068 1.02808 11.5286 1.12768 11.6091C1.24226 11.7017 1.46928 11.7017 1.92333 11.7017H7.99997L7.16663 18.3683L14.5888 9.46178C14.8794 9.11297 15.0248 8.93857 15.027 8.79128C15.0289 8.66323 14.9719 8.54141 14.8723 8.46092C14.7577 8.36833 14.5307 8.36833 14.0766 8.36833H7.99997L8.8333 1.70166Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Zap

View File

@ -6,6 +6,7 @@ import { default as NEvent } from "Nostr/Event";
import { DefaultConnectTimeout } from "Const";
import { ConnectionStats } from "Nostr/ConnectionStats";
import { RawEvent, TaggedRawEvent, u256 } from "Nostr";
import { RelayInfo } from "./RelayInfo";
export type CustomHook = (state: Readonly<StateSnapshot>) => void;
@ -27,7 +28,8 @@ export type StateSnapshot = {
events: {
received: number,
send: number
}
},
info?: RelayInfo
};
export default class Connection {
@ -36,6 +38,7 @@ export default class Connection {
Pending: Subscriptions[];
Subscriptions: Map<string, Subscriptions>;
Settings: RelaySettings;
Info?: RelayInfo;
ConnectTimeout: number;
Stats: ConnectionStats;
StateHooks: Map<string, CustomHook>;
@ -56,7 +59,7 @@ export default class Connection {
this.Stats = new ConnectionStats();
this.StateHooks = new Map();
this.HasStateChange = true;
this.CurrentState = <StateSnapshot>{
this.CurrentState = {
connected: false,
disconnects: 0,
avgLatency: 0,
@ -64,7 +67,7 @@ export default class Connection {
received: 0,
send: 0
}
};
} as StateSnapshot;
this.LastState = Object.freeze({ ...this.CurrentState });
this.IsClosed = false;
this.ReconnectTimer = null;
@ -72,7 +75,29 @@ export default class Connection {
this.Connect();
}
Connect() {
async Connect() {
try {
if (this.Info === undefined) {
let u = new URL(this.Address);
let rsp = await fetch(`https://${u.host}`, {
headers: {
"accept": "application/nostr+json"
}
});
if (rsp.ok) {
let data = await rsp.json();
for (let [k, v] of Object.entries(data)) {
if (v === "unset" || v === "") {
data[k] = undefined;
}
}
this.Info = data;
}
}
} catch (e) {
console.warn("Could not load relay information", e);
}
this.IsClosed = false;
this.Socket = new WebSocket(this.Address);
this.Socket.onopen = (e) => this.OnOpen(e);
@ -259,6 +284,7 @@ export default class Connection {
this.CurrentState.events.send = this.Stats.EventsSent;
this.CurrentState.avgLatency = this.Stats.Latency.length > 0 ? (this.Stats.Latency.reduce((acc, v) => acc + v, 0) / this.Stats.Latency.length) : 0;
this.CurrentState.disconnects = this.Stats.Disconnects;
this.CurrentState.info = this.Info;
this.Stats.Latency = this.Stats.Latency.slice(-20); // trim
this.HasStateChange = true;
this._NotifyState();

View File

@ -1,4 +1,3 @@
/**
* Stats class for tracking metrics per connection
*/

9
src/Nostr/RelayInfo.ts Normal file
View File

@ -0,0 +1,9 @@
export interface RelayInfo {
name?: string,
description?: string,
pubkey?: string,
contact?: string,
supported_nips?: number[],
software?: string,
version?: string
}

View File

@ -73,9 +73,9 @@ export default function ChatPage() {
<div className="write-dm">
<div className="inner">
<textarea className="f-grow mr10" value={content} onChange={(e) => setContent(e.target.value)} onKeyDown={(e) => onEnter(e)}></textarea>
<div className="btn" onClick={() => sendDm()}>Send</div>
<button type="button" onClick={() => sendDm()}>Send</button>
</div>
</div>
</>
)
}
}

View File

@ -1,3 +1,4 @@
import { ApiHost } from "Const";
import ProfilePreview from "Element/ProfilePreview";
import ZapButton from "Element/ZapButton";
import { HexKey } from "Nostr";
@ -24,7 +25,7 @@ const DonatePage = () => {
const [splits, setSplits] = useState<Splits[]>([]);
async function loadSplits() {
let rsp = await fetch("https://api.snort.social/api/v1/revenue/splits");
let rsp = await fetch(`${ApiHost}/api/v1/revenue/splits`);
if(rsp.ok) {
setSplits(await rsp.json());
}
@ -43,7 +44,7 @@ const DonatePage = () => {
}
return (
<div className="m5">
<div className="main-content m5">
<h2>Help fund the development of Snort</h2>
<p>
Snort is an open source project built by passionate people in their free time

View File

@ -1,20 +1,37 @@
.logo {
cursor: pointer;
font-weight: bold;
font-size: 29px;
}
.unread-count {
width: 20px;
height: 20px;
border: 1px solid;
border-radius: 100%;
position: relative;
padding: 3px;
line-height: 1.5em;
top: -10px;
left: -10px;
font-size: var(--font-size-small);
background-color: var(--error);
color: var(--note-bg);
font-weight: bold;
text-align: center;
header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: 72px;
padding: 0 12px;
}
header .pfp .avatar-wrapper {
margin-right: 0;
}
.header-actions {
display: flex;
flex-direction: row;
}
.header-actions .btn-rnd {
position: relative;
}
.header-actions .btn-rnd .has-unread {
background: var(--highlight);
border-radius: 100%;
width: 9px;
height: 9px;
position: absolute;
top: 0;
right: 0;
}

View File

@ -2,8 +2,8 @@ import "./Layout.css";
import { useEffect } from "react"
import { useDispatch, useSelector } from "react-redux";
import { Outlet, useNavigate } from "react-router-dom";
import { faBell, faMessage } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Envelope from "Icons/Envelope"
import Bell from "Icons/Bell"
import { RootState } from "State/Store";
import { init, setPreferences, UserPreferences } from "State/Login";
@ -84,21 +84,17 @@ export default function Layout() {
const unreadNotifications = notifications?.filter(a => (a.created_at * 1000) > readNotifications).length;
const unreadDms = key ? totalUnread(dms, key) : 0;
return (
<>
<div className="header-actions">
<div className={`btn btn-rnd${unreadDms === 0 ? " mr10" : ""}`} onClick={(e) => navigate("/messages")}>
<FontAwesomeIcon icon={faMessage} size="xl" />
<Envelope />
{unreadDms > 0 && (<span className="has-unread"></span>)}
</div>
{unreadDms > 0 && (<span className="unread-count">
{unreadDms > 100 ? ">99" : unreadDms}
</span>)}
<div className={`btn btn-rnd${unreadNotifications === 0 ? " mr10" : ""}`} onClick={(e) => goToNotifications(e)}>
<FontAwesomeIcon icon={faBell} size="xl" />
<Bell />
{unreadNotifications > 0 && (<span className="has-unreads"></span>)}
</div>
{unreadNotifications > 0 && (<span className="unread-count">
{unreadNotifications > 100 ? ">99" : unreadNotifications}
</span>)}
<ProfileImage pubkey={key || ""} showUsername={false} />
</>
</div>
)
}
@ -108,14 +104,14 @@ export default function Layout() {
return (
<div className="page">
<div className="header">
<div className="logo" onClick={() => navigate("/")}>snort</div>
<header>
<div className="logo" onClick={() => navigate("/")}>Snort</div>
<div>
{key ? accountHeader() :
<div className="btn" onClick={() => navigate("/login")}>Login</div>
<button type="button" onClick={() => navigate("/login")}>Login</button>
}
</div>
</div>
</header>
<Outlet />
</div>

View File

@ -85,24 +85,24 @@ export default function LoginPage() {
<>
<h2>Other Login Methods</h2>
<div className="flex">
<div className="btn" onClick={(e) => doNip07Login()}>Login with Extension (NIP-07)</div>
<button type="button" onClick={(e) => doNip07Login()}>Login with Extension (NIP-07)</button>
</div>
</>
)
}
return (
<>
<div className="main-content">
<h1>Login</h1>
<div className="flex">
<input type="text" placeholder="nsec / npub / nip-05 / hex private key..." className="f-grow" onChange={e => setKey(e.target.value)} />
</div>
{error.length > 0 ? <b className="error">{error}</b> : null}
<div className="tabs">
<div className="btn" onClick={(e) => doLogin()}>Login</div>
<div className="btn" onClick={() => makeRandomKey()}>Generate Key</div>
<button type="button" onClick={(e) => doLogin()}>Login</button>
<button type="button" onClick={() => makeRandomKey()}>Generate Key</button>
</div>
{altLogins()}
</>
</div>
);
}
}

View File

@ -51,16 +51,16 @@ export default function MessagesPage() {
}
return (
<>
<div className="main-content">
<div className="flex">
<h3 className="f-grow">Messages</h3>
<div className="btn" onClick={() => markAllRead()}>Mark All Read</div>
<button type="button" onClick={() => markAllRead()}>Mark All Read</button>
</div>
{chats.sort((a, b) => {
if(b.pubkey === myPubKey) return 1
return b.newestMessage - a.newestMessage
}).map(person)}
</>
</div>
)
}
@ -121,4 +121,4 @@ export function extractChats(dms: RawEvent[], myPubKey: HexKey) {
newestMessage: newestMessage(dms, myPubKey, a)
} as DmChat;
})
}
}

View File

@ -1,4 +1,4 @@
import { RecommendedFollows } from "Const";
import { ApiHost, RecommendedFollows } from "Const";
import AsyncButton from "Element/AsyncButton";
import FollowListBase from "Element/FollowListBase";
import ProfilePreview from "Element/ProfilePreview";
@ -8,7 +8,7 @@ import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import { bech32ToHex } from "Util";
const TwitterFollowsApi = "https://api.snort.social/api/v1/twitter/follows-for-nostr";
const TwitterFollowsApi = `${ApiHost}/api/v1/twitter/follows-for-nostr`;
export default function NewUserPage() {
const [twitterUsername, setTwitterUsername] = useState<string>("");
@ -24,7 +24,7 @@ export default function NewUserPage() {
const sortedTwitterFollows = useMemo(() => {
return follows.map(a => bech32ToHex(a))
.sort((a, b) => currentFollows.includes(a) ? 1 : -1);
}, [follows]);
}, [follows, currentFollows]);
async function loadFollows() {
setFollows([]);
@ -67,9 +67,9 @@ export default function NewUserPage() {
}
return (
<>
<div className="main-content">
{importTwitterFollows()}
{followSomebody()}
</>
</div>
);
}

View File

@ -1,26 +1,29 @@
.profile {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.profile .banner {
width: 100%;
height: 210px;
margin-bottom: -80px;
object-fit: cover;
mask-image: linear-gradient(to bottom, var(--bg-color) 60%, rgba(0,0,0,0));
-webkit-mask-image: linear-gradient(to bottom, var(--bg-color) 60%, rgba(0,0,0,0));
z-index: 0;
width: 100%;
height: 160px;
object-fit: cover;
margin-bottom: -60px;
mask-image: linear-gradient(to bottom, var(--bg-color) 60%, rgba(0,0,0,0));
-webkit-mask-image: linear-gradient(to bottom, var(--bg-color) 60%, rgba(0,0,0,0));
z-index: 0;
}
@media (min-width: 720px) {
.profile .banner {
width: 100%;
max-width: 720px;
height: 300px;
margin-bottom: -120px;
}
.profile .profile-wrapper {
margin: 0 16px;
width: calc(100% - 32px);
display: flex;
flex-direction: column;
align-items: flex-start;
position: relative;
}
.profile p {
white-space: pre-wrap;
}
@ -29,35 +32,37 @@
margin: 0;
}
@media (min-width: 720px) {
.profile .banner {
width: 100%;
max-width: 720px;
height: 300px;
margin-bottom: -120px;
}
.profile .nip05 {
display: flex;
margin: 4px 0 12px 0;
}
.profile .nip05 .nick {
font-weight: normal;
color: var(--gray-light);
}
.profile .avatar-wrapper {
align-self: flex-start;
z-index: 1;
margin-left: 4px;
z-index: 1;
}
.profile .avatar-wrapper .avatar {
width: 120px;
height: 120px;
}
.profile .name {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.profile .name h2 {
margin: 0;
display: flex;
flex-direction: column;
}
.profile .details {
max-width: 680px;
width: 100%;
margin-top: 12px;
background-color: var(--note-bg);
padding: 12px 16px;
margin: 0 auto;
border-radius: 16px;
}
.profile .details p {
@ -76,129 +81,63 @@
.profile .btn-icon {
color: var(--font-color);
padding: 6px;
margin-left: 4px;
}
.profile .details-wrapper {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
position: relative;
width: 100%;
margin-left: 4px;
}
.profile .copy .body { font-size: 12px }
@media (min-width: 360px) {
.profile .copy .body { font-size: 14px }
.profile .details-wrapper, .profile .avatar-wrapper { margin-left: 21px; }
.profile .details { width: calc(100% - 21px); }
}
@media (min-width: 720px) {
.profile .details-wrapper, .profile .avatar-wrapper { margin-left: 30px; }
.profile .details { width: calc(100% - 30px); }
}
.profile .follow-button {
position: absolute;
top: -30px;
right: 20px;
}
.profile .message-button {
position: absolute;
top: -30px;
right: 74px;
}
.profile .no-banner .follow-button {
right: 0px;
}
.profile .no-banner .message-button {
right: 54px;
}
.tabs {
display: flex;
justify-content: flex-start;
width: 100%;
margin: 10px 0;
width: calc(100% - 32px);
}
.tabs > div {
margin-right: 0;
}
.tab {
margin: 0;
padding: 8px 0;
border-bottom: 3px solid var(--gray-secondary);
}
.tab.active {
border-bottom: 3px solid var(--highlight);
}
.profile .no-banner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
.profile .no-banner .avatar {
height: 256px;
width: 256px;
margin-bottom: 30px;
}
.profile .no-banner .avatar-wrapper, .profile .no-banner .details-wrapper {
margin: 0 auto;
}
@media (min-width: 720px) {
.profile .no-banner {
width: 100%;
flex-direction: row;
justify-content: space-around;
margin-top: 21px;
}
.profile .no-banner .avatar-wrapper {
margin: auto 10px;
}
.profile .no-banner .details-wrapper {
margin-left: 10px;
margin-top: 21px;
max-width: 420px;
}
}
.profile .links {
margin: 8px 12px;
margin-top: 4px;
margin-left: 2px;
}
.profile h3 {
color: var(--font-secondary-color);
font-size: 10px;
letter-spacing: .11em;
font-weight: 600;
line-height: 12px;
text-transform: uppercase;
margin-left: 16px;
}
.profile .website {
color: var(--highlight);
margin: 6px 0;
margin: 4px 0;
display: flex;
flex-direction: row;
align-items: center;
}
.profile .website a {
color: var(--font-color);
}
.profile .website a {
text-decoration: none;
}
.profile .website::before {
content: '🔗 ';
.profile .website a:hover {
text-decoration: underline;
}
.profile .lnurl {
color: var(--highlight);
margin: 6px 0;
cursor: pointer;
}
.profile .ln-address {
display: flex;
flex-direction: row;
align-items: center;
}
.profile .lnurl:hover {
text-decoration: underline;
}
@ -208,6 +147,42 @@
text-overflow: ellipsis;
}
.profile .zap {
margin-right: .3em;
.profile .link-icon {
color: var(--highlight);
margin-right: 8px;
}
.profile .link-icon svg {
width: 12px;
height: 12px;
}
.profile .profile-actions {
position: absolute;
top: 80px;
right: 0;
}
@media (min-width: 520px) {
.profile .profile-actions {
top: 120px;
}
}
.profile .profile-actions button:not(:last-child) {
margin-right: 8px;
}
@media (min-width: 520px) {
.profile .banner {
width: 100%;
max-width: 720px;
height: 300px;
margin-bottom: -100px;
}
.profile .avatar-wrapper .avatar {
width: 210px;
height: 210px;
}
}

View File

@ -2,10 +2,10 @@ import "./ProfilePage.css";
import { useEffect, useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGear, faEnvelope } from "@fortawesome/free-solid-svg-icons";
import { useNavigate, useParams } from "react-router-dom";
import Link from "Icons/Link";
import Zap from "Icons/Zap";
import useProfile from "Feed/ProfileFeed";
import FollowButton from "Element/FollowButton";
import { extractLnAddress, parseId, hexToBech32 } from "Util";
@ -39,7 +39,9 @@ export default function ProfilePage() {
const isMe = loginPubKey === id;
const [showLnQr, setShowLnQr] = useState<boolean>(false);
const [tab, setTab] = useState(ProfileTab.Notes);
const aboutText = user?.about || ''
const about = Text({ content: user?.about || '', tags: [], users: new Map() })
const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || "");
useEffect(() => {
setTab(ProfileTab.Notes);
@ -52,36 +54,48 @@ export default function ProfilePage() {
{user?.display_name || user?.name || 'Nostrich'}
<FollowsYou pubkey={id} />
</h2>
<Copy text={params.id || ""} />
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
<Copy text={params.id || ""} />
{links()}
</div>
)
}
function links() {
return (
<div className="links">
{user?.website && (
<div className="website f-ellipsis">
<span className="link-icon">
<Link />
</span>
<a href={user.website} target="_blank" rel="noreferrer">{user.website}</a>
</div>
)}
{lnurl && (
<div className="ln-address" onClick={(e) => setShowLnQr(true)}>
<span className="link-icon">
<Zap />
</span>
<span className="lnurl f-ellipsis" >
{lnurl}
</span>
</div>
)}
<LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} />
</div>
)
}
function bio() {
const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || "");
return (
<div className="details">
<div>{about}</div>
<div className="links">
{user?.website && (
<div className="website f-ellipsis">
<a href={user.website} target="_blank" rel="noreferrer">{user.website}</a>
</div>
)}
{lnurl && (
<div className="f-ellipsis" onClick={(e) => setShowLnQr(true)}>
<span className="zap"></span>
<span className="lnurl" >
{lnurl}
</span>
</div>
)}
</div>
<LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} />
</div>
return aboutText.length > 0 && (
<>
<h3>Bio</h3>
<div className="details">
{about}
</div>
</>
)
}
@ -119,17 +133,20 @@ export default function ProfilePage() {
return (
<div className="details-wrapper">
{username()}
{isMe ? (
<div className="btn btn-icon follow-button" onClick={() => navigate("/settings")}>
<FontAwesomeIcon icon={faGear} size="lg" />
</div>
) : <>
<div className="btn message-button" onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}>
<FontAwesomeIcon icon={faEnvelope} size="lg" />
</div>
<FollowButton pubkey={id} />
</>
}
<div className="profile-actions">
{isMe ? (
<button type="button" onClick={() => navigate("/settings")}>
Settings
</button>
) : (
<>
<button type="button" onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}>
Message
</button>
<FollowButton pubkey={id} />
</>
)}
</div>
{bio()}
</div>
)
@ -138,18 +155,11 @@ export default function ProfilePage() {
return (
<>
<div className="profile flex">
{user?.banner && <img alt="banner" className="banner" src={user.banner} />}
{user?.banner ? (
<>
{avatar()}
{userDetails()}
</>
) : (
<div className="no-banner">
{avatar()}
{userDetails()}
</div>
)}
{user?.banner && <img alt="banner" className="banner" src={user.banner} />}
<div className="profile-wrapper flex">
{avatar()}
{userDetails()}
</div>
</div>
<div className="tabs">
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(v => {

View File

@ -16,6 +16,7 @@ const RootTab = {
};
export default function RootPage() {
const [show, setShow] = useState(false)
const [loggedOut, pubKey, follows] = useSelector<RootState, [boolean | undefined, HexKey | undefined, HexKey[]]>(s => [s.login.loggedOut, s.login.publicKey, s.login.follows]);
const [tab, setTab] = useState(RootTab.Posts);
@ -32,20 +33,20 @@ export default function RootPage() {
return (
<>
{pubKey ? <>
<NoteCreator show={true} autoFocus={false} />
<div className="tabs root-tabs">
<div className={`root-tab f-1 ${tab === RootTab.Posts ? "active" : ""}`} onClick={() => setTab(RootTab.Posts)}>
<div className="tabs">
<div className={`tab f-1 ${tab === RootTab.Posts ? "active" : ""}`} onClick={() => setTab(RootTab.Posts)}>
Posts
</div>
<div className={`root-tab f-1 ${tab === RootTab.PostsAndReplies ? "active" : ""}`} onClick={() => setTab(RootTab.PostsAndReplies)}>
Posts &amp; Replies
<div className={`tab f-1 ${tab === RootTab.PostsAndReplies ? "active" : ""}`} onClick={() => setTab(RootTab.PostsAndReplies)}>
Conversations
</div>
<div className={`root-tab f-1 ${tab === RootTab.Global ? "active" : ""}`} onClick={() => setTab(RootTab.Global)}>
<div className={`tab f-1 ${tab === RootTab.Global ? "active" : ""}`} onClick={() => setTab(RootTab.Global)}>
Global
</div>
</div></> : null}
{followHints()}
<Timeline key={tab} subject={timelineSubect} postsOnly={tab === RootTab.Posts} method={"TIME_RANGE"} />
<NoteCreator autoFocus={true} show={show} setShow={setShow} />
</>
);
}
}

View File

@ -3,15 +3,16 @@ import SettingsIndex from "Pages/settings/Index";
import Profile from "Pages/settings/Profile";
import Relay from "Pages/settings/Relays";
import Preferences from "Pages/settings/Preferences";
import RelayInfo from "Pages/settings/RelayInfo";
export default function SettingsPage() {
const navigate = useNavigate();
return (
<>
<div className="main-content">
<h2 onClick={() => navigate("/settings")} className="pointer">Settings</h2>
<Outlet />
</>
</div>
);
}
@ -26,7 +27,11 @@ export const SettingsRoutes: RouteObject[] = [
},
{
path: "relays",
element: <Relay />
element: <Relay />,
},
{
path: "relays/:addr",
element: <RelayInfo />
},
{
path: "preferences",

View File

@ -1,3 +1,4 @@
import { ApiHost } from "Const";
import Nip5Service from "Element/Nip5Service";
import './Verification.css'
@ -6,7 +7,7 @@ export default function VerificationPage() {
const services = [
{
name: "Snort",
service: "https://api.snort.social/api/v1/n5sp",
service: `${ApiHost}/api/v1/n5sp`,
link: "https://snort.social/",
supportLink: "https://snort.social/help",
about: <>Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!</>
@ -23,7 +24,7 @@ export default function VerificationPage() {
];
return (
<div className="verification">
<div className="main-content verification">
<h2>Get Verified</h2>
<p>
NIP-05 is a DNS based verification spec which helps to validate you as a real user.

View File

@ -31,7 +31,6 @@ export default function ProfileSettings() {
const [about, setAbout] = useState<string>();
const [website, setWebsite] = useState<string>();
const [nip05, setNip05] = useState<string>();
const [lud06, setLud06] = useState<string>();
const [lud16, setLud16] = useState<string>();
const avatarPicture = (picture?.length ?? 0) === 0 ? Nostrich : picture
@ -45,7 +44,6 @@ export default function ProfileSettings() {
setAbout(user.about);
setWebsite(user.website);
setNip05(user.nip05);
setLud06(user.lud06);
setLud16(user.lud16);
}
}, [user]);
@ -130,11 +128,11 @@ export default function ProfileSettings() {
<div>NIP-05:</div>
<div>
<input type="text" className="mr10" value={nip05} onChange={(e) => setNip05(e.target.value)} />
<div className="btn" onClick={() => navigate("/verification")}>
<button type="button" onClick={() => navigate("/verification")}>
<FontAwesomeIcon icon={faShop} />
&nbsp;
Buy
</div>
</button>
</div>
</div>
<div className="form-group">
@ -145,10 +143,10 @@ export default function ProfileSettings() {
</div>
<div className="form-group">
<div>
<div className="btn" onClick={() => { dispatch(logout()); navigate("/"); }}>Logout</div>
<button className="secondary" type="button" onClick={() => { dispatch(logout()); navigate("/"); }}>Logout</button>
</div>
<div>
<div className="btn" onClick={() => saveProfile()}>Save</div>
<button type="button" onClick={() => saveProfile()}>Save</button>
</div>
</div>
</div>

View File

@ -0,0 +1,57 @@
import ProfilePreview from "Element/ProfilePreview";
import useRelayState from "Feed/RelayState";
import { System } from "Nostr/System";
import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import { removeRelay } from "State/Login";
import { parseId } from "Util";
const RelayInfo = () => {
const params = useParams();
const navigate = useNavigate();
const dispatch = useDispatch();
const addr: string = `wss://${params.addr}`;
const con = System.Sockets.get(addr) ?? System.Sockets.get(`${addr}/`);
const stats = useRelayState(con?.Address ?? addr);
return (
<>
<h3 className="pointer" onClick={() => navigate("/settings/relays")}>Relays</h3>
<div className="card">
<h3>{stats?.info?.name ?? addr}</h3>
<p>{stats?.info?.description}</p>
{stats?.info?.pubkey && (<>
<h4>Owner</h4>
<ProfilePreview pubkey={parseId(stats.info.pubkey)} />
</>)}
{stats?.info?.software && (<div className="flex">
<h4 className="f-grow">Software</h4>
<div className="flex f-col">
{stats.info.software.startsWith("http") ? <a href={stats.info.software} target="_blank" rel="noreferrer">{stats.info.software}</a> : <>{stats.info.software}</>}
<small>{!stats.info.version?.startsWith("v") && "v"}{stats.info.version}</small>
</div>
</div>)}
{stats?.info?.contact && (<div className="flex">
<h4 className="f-grow">Contact</h4>
<a href={`${stats.info.contact.startsWith("mailto:") ? "" : "mailto:"}${stats.info.contact}`} target="_blank" rel="noreferrer">{stats.info.contact}</a>
</div>)}
{stats?.info?.supported_nips && (<>
<h4>Supports</h4>
<div className="f-grow">
{stats.info.supported_nips.map(a => <span className="pill" onClick={() => navigate(`https://github.com/nostr-protocol/nips/blob/master/${a.toString().padStart(2, "0")}.md`)}>NIP-{a.toString().padStart(2, "0")}</span>)}
</div>
</>)}
<div className="flex mt10 f-end">
<div className="btn error" onClick={() => {
dispatch(removeRelay(con!.Address));
navigate("/settings/relays")
}}>Remove</div>
</div>
</div>
</>
)
}
export default RelayInfo;

View File

@ -27,7 +27,7 @@ const RelaySettingsPage = () => {
<div className="flex mb10">
<input type="text" className="f-grow" placeholder="wss://my-relay.com" value={newRelay} onChange={(e) => setNewRelay(e.target.value)} />
</div>
<div className="btn mb10" onClick={() => addNewRelay()}>Add</div>
<button className="secondary mb10" onClick={() => addNewRelay()}>Add</button>
</>
)
}
@ -49,16 +49,16 @@ const RelaySettingsPage = () => {
return (
<>
<h3>Relays</h3>
<div className="flex f-col">
<div className="flex f-col mb10">
{Object.keys(relays || {}).map(a => <Relay addr={a} key={a} />)}
</div>
<div className="flex actions">
<div className="flex mt10">
<div className="f-grow"></div>
<div className="btn" onClick={() => saveRelays()}>Save</div>
<button type="button" onClick={() => saveRelays()}>Save</button>
</div>
{addRelay()}
</>
)
}
export default RelaySettingsPage;
export default RelaySettingsPage;

View File

@ -1,9 +1,8 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import * as secp from '@noble/secp256k1';
import { DefaultRelays } from 'Const';
import { HexKey, RawEvent, TaggedRawEvent } from 'Nostr';
import { HexKey, TaggedRawEvent } from 'Nostr';
import { RelaySettings } from 'Nostr/Connection';
import { useDispatch } from 'react-redux';
const PrivateKeyItem = "secret";
const PublicKeyItem = "pubkey";
@ -182,7 +181,7 @@ const LoginSlice = createSlice({
let filtered = new Map<string, RelaySettings>();
for (let [k, v] of Object.entries(relays)) {
if (k.startsWith("wss://") || k.startsWith("ws://")) {
filtered.set(k, <RelaySettings>v);
filtered.set(k, v as RelaySettings);
}
}

View File

@ -1,6 +1,6 @@
import * as secp from "@noble/secp256k1";
import { bech32 } from "bech32";
import { HexKey, RawEvent, TaggedRawEvent, u256 } from "Nostr";
import { HexKey, TaggedRawEvent, u256 } from "Nostr";
import EventKind from "Nostr/EventKind";
export async function openFile(): Promise<File | undefined> {
@ -65,7 +65,7 @@ export function eventLink(hex: u256) {
* @param {string} hex
*/
export function hexToBech32(hrp: string, hex: string) {
if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 != 0) {
if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0) {
return "";
}

View File

@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Be+Bietnam+Pro:wght@400;600;700&display=swap');
:root {
--bg-color: #000;
@ -9,10 +9,8 @@
--font-size-small: 14px;
--font-size-tiny: 12px;
--modal-bg-color: rgba(0, 0, 0, 0.8);
--note-bg: #111;
--highlight-light: #ffd342;
--highlight: #ffc400;
--highlight-dark: #dba800;
--note-bg: #0C0C0C;
--highlight: #8B5CF6;
--error: #FF6053;
--success: #2AD544;
@ -22,8 +20,10 @@
--gray: #333;
--gray-secondary: #222;
--gray-tertiary: #444;
--gray-dark: #2B2B2B;
--gray-superdark: #171717;
--gray-gradient: linear-gradient(to bottom right, var(--gray-superlight), var(--gray), var(--gray-light));
--snort-gradient: linear-gradient(to bottom right, var(--highlight-light), var(--highlight), var(--highlight-dark));
--snort-gradient: linear-gradient(180deg, #FFC7B7 0%, #4F1B73 100%);
--nostrplebs-gradient: linear-gradient(to bottom right, #ff3cac, #2b86c5);
--strike-army-gradient: linear-gradient(to bottom right, #CCFF00, #a1c900);
}
@ -46,11 +46,13 @@ html.light {
--gray-tertiary: #EEE;
--gray-superlight: #333;
--gray-light: #555;
--gray-dark: #2B2B2B;
--gray-superdark: #171717;
}
body {
margin: 0;
font-family: 'Montserrat', sans-serif;
font-family: 'Be Vietnam Pro', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-color);
@ -63,25 +65,17 @@ code {
}
.page {
width: 720px;
width: 100vw;
margin-left: auto;
margin-right: auto;
}
.page>.header {
display: flex;
align-items: center;
margin: 10px 0;
}
.page>.header>div:nth-child(1) {
font-size: x-large;
flex-grow: 1;
}
.page>.header>div:nth-child(2) {
display: flex;
align-items: center;
@media (min-width: 720px) {
.page {
width: 720px;
margin-left: auto;
margin-right: auto;
}
}
.card {
@ -115,10 +109,42 @@ html.light .card {
margin-top: 12px;
}
button {
cursor: pointer;
padding: 6px 12px;
font-weight: bold;
color: white;
font-size: var(--font-size);
background-color: var(--highlight);
border: none;
border-radius: 16px;
outline: none;
}
button:hover {
background-color: var(--font-color);
color: var(--bg-color);
}
button.secondary {
color: var(--font-color);
background-color: var(--gray-dark);
}
.light button.secondary {
background-color: var(--gray);
}
button.secondary:hover {
color: var(--font-color);
background-color: var(--gray-superdark);
}
.light button.secondary:hover {
background-color: var(--gray-secondary);
}
.btn {
padding: 10px;
border-radius: 5px;
cursor: pointer;
color: var(--font-color);
user-select: none;
background-color: var(--bg-color);
color: var(--font-color);
@ -155,6 +181,17 @@ html.light .card {
.btn-rnd {
border-radius: 100%;
border-color: var(--gray-superdark);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
}
.light .btn-rnd {
border-color: var(--gray);
}
textarea {
@ -222,18 +259,18 @@ textarea:placeholder {
align-items: flex-start !important;
}
.f-end {
justify-content: flex-end;
}
.w-max {
width: 100%;
width: -moz-available;
width: -webkit-fill-available;
width: fill-available;
width: stretch;
}
.w-max-w {
max-width: 100%;
max-width: -moz-available;
max-width: -webkit-fill-available;
max-width: fill-available;
max-width: stretch;
}
a {
@ -263,7 +300,7 @@ div.form-group>div:nth-child(1) {
div.form-group>div:nth-child(2) {
display: flex;
flex-grow: 1;
justify-content: end;
justify-content: flex-end;
}
div.form-group>div:nth-child(2) input {
@ -365,18 +402,23 @@ body.scroll-lock {
background-color: var(--success);
}
.root-tabs {
.tabs {
padding: 0;
align-items: center;
justify-content: flex-start;
margin-bottom: 16px;
}
.root-tab {
border-bottom: 3px solid var(--gray-secondary);
.tab {
border-bottom: 1px solid var(--gray-secondary);
font-weight: bold;
color: var(--font-secondary-color);
padding: 8px 0;
}
.root-tab.active {
border-bottom: 3px solid var(--highlight);
.tab.active {
border-bottom: 1px solid var(--highlight);
color: var(--font-color);
}
.tweet {
@ -398,10 +440,6 @@ body.scroll-lock {
}
@media(max-width: 720px) {
.page {
width: calc(100vw - 8px);
}
div.form-group {
flex-direction: column;
align-items: flex-start;
@ -410,4 +448,15 @@ body.scroll-lock {
.highlight {
color: var(--highlight);
}
}
.main-content {
padding: 0 12px;
}
@media (min-width: 720px) {
.main-content {
padding: 0;
}
}