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 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-08: Handling Mentions
- [x] NIP-09: Event Deletion - [x] NIP-09: Event Deletion
- [x] NIP-10: Conventions for clients' use of `e` and `p` tags in text events - [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 - [x] NIP-12: Generic Tag Queries
- [ ] NIP-13: Proof of Work - [ ] NIP-13: Proof of Work
- [ ] NIP-14: Subject tag in text events - [ ] NIP-14: Subject tag in text events

5
d.ts
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,5 +8,20 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: 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 ( return (
<div className="modal" onClick={(e) => { e.stopPropagation(); onClose(); }}> <div className="modal" onClick={(e) => { e.stopPropagation(); onClose(); }}>
<div className="modal-body">
{props.children} {props.children}
</div>
</div> </div>
) )
} }

View File

@ -1,7 +1,6 @@
.nip05 { .nip05 {
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
font-size: 14px;
margin: .2em; margin: .2em;
} }
@ -10,50 +9,8 @@
} }
.nip05 .nick { .nip05 .nick {
color: var(--gray-light); color: var(--font-secondary-color);
font-weight: bold; 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 { .nip05 .badge {

View File

@ -1,7 +1,7 @@
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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 './Nip05.css'
import { HexKey } from "Nostr"; import { HexKey } from "Nostr";
@ -57,16 +57,20 @@ const Nip05 = (props: Nip05Params) => {
const { isVerified, couldNotVerify } = useIsVerified(props.pubkey, props.nip05) const { isVerified, couldNotVerify } = useIsVerified(props.pubkey, props.nip05)
return ( return (
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`}> <div className={`flex nip05${couldNotVerify ? " failed" : ""}`} onClick={(ev) => ev.stopPropagation()}>
{!isDefaultUser && ( <div className="nick">
<div className="nick"> {isDefaultUser ? (
{name} `${domain}`
</div> ) : `@${name}`}
)}
<div className={`domain text-gradient`} data-domain={domain?.toLowerCase()}>
{domain}
</div> </div>
<span className="badge"> <span className="badge">
{isVerified && (
<FontAwesomeIcon
color={"var(--highlight)"}
icon={faCircleCheck}
size="xs"
/>
)}
{!isVerified && !couldNotVerify && ( {!isVerified && !couldNotVerify && (
<FontAwesomeIcon <FontAwesomeIcon
color={"var(--fg-color)"} 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 pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey);
const user = useProfile(pubkey); const user = useProfile(pubkey);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const svc = new ServiceProvider(props.service); const svc = useMemo(() => new ServiceProvider(props.service), [props.service]);
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>(); const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
const [error, setError] = useState<ServiceError>(); const [error, setError] = useState<ServiceError>();
const [handle, setHandle] = useState<string>(""); const [handle, setHandle] = useState<string>("");
@ -43,7 +43,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
const [showInvoice, setShowInvoice] = useState<boolean>(false); const [showInvoice, setShowInvoice] = useState<boolean>(false);
const [registerStatus, setRegisterStatus] = useState<CheckRegisterResponse>(); 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(() => { useEffect(() => {
svc.GetConfig() svc.GetConfig()
@ -58,7 +58,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
} }
}) })
.catch(console.error) .catch(console.error)
}, [props]); }, [props, svc]);
useEffect(() => { useEffect(() => {
setError(undefined); setError(undefined);
@ -89,7 +89,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
.catch(console.error); .catch(console.error);
}); });
} }
}, [handle, domain]); }, [handle, domain, domainConfig, svc]);
useEffect(() => { useEffect(() => {
if (registerResponse && showInvoice) { if (registerResponse && showInvoice) {
@ -111,7 +111,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
}, 2_000); }, 2_000);
return () => clearInterval(t); return () => clearInterval(t);
} }
}, [registerResponse, showInvoice]) }, [registerResponse, showInvoice, svc])
function mapError(e: ServiceErrorCode, t: string | null): string | undefined { function mapError(e: ServiceErrorCode, t: string | null): string | undefined {
let whyMap = new Map([ 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> <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>} {error && <b className="error">{error.error}</b>}
{!registerStatus && <div className="flex mb10"> {!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; &nbsp;@&nbsp;
<select value={domain} onChange={(e) => setDomain(e.target.value)}> <select value={domain} onChange={(e) => setDomain(e.target.value)}>
{serviceConfig?.domains.map(a => <option key={a.name}>{a.name}</option>)} {serviceConfig?.domains.map(a => <option key={a.name}>{a.name}</option>)}

View File

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

View File

@ -1,6 +1,6 @@
import "./Note.css"; import "./Note.css";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo, ReactNode } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, Link } from "react-router-dom";
import { default as NEvent } from "Nostr/Event"; import { default as NEvent } from "Nostr/Event";
import ProfileImage from "Element/ProfileImage"; import ProfileImage from "Element/ProfileImage";
@ -64,21 +64,57 @@ export default function Note(props: NoteProps) {
const maxMentions = 2; const maxMentions = 2;
let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event; 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) { 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) { 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 { } 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 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 ( return (
<div className="reply"> <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> </div>
) )
} }

View File

@ -13,28 +13,28 @@
.note-creator textarea { .note-creator textarea {
outline: none; outline: none;
resize: none; resize: none;
min-height: 40px;
background-color: var(--note-bg); background-color: var(--note-bg);
border-radius: 10px 10px 0 0; border-radius: 10px 10px 0 0;
max-width: -webkit-fill-available; max-width: stretch;
max-width: -moz-available; min-width: stretch;
max-width: fill-available;
min-width: 100%;
min-width: -webkit-fill-available;
min-width: -moz-available;
min-width: fill-available;
} }
.note-creator .actions { .note-creator-actions {
width: 100%; width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end; justify-content: flex-end;
margin-bottom: 5px; margin-bottom: 5px;
} }
.note-creator .attachment { .note-creator .attachment {
cursor: pointer; cursor: pointer;
padding: 5px 10px; margin-left: auto;
border-radius: 10px; }
.note-creator-actions button:not(:last-child) {
margin-right: 4px;
} }
.note-creator .attachment .error { .note-creator .attachment .error {
@ -50,3 +50,26 @@
color: var(--font-color); color: var(--font-color);
font-size: var(--font-size); 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 "./NoteCreator.css";
import Plus from "Icons/Plus";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import { openFile } from "Util"; import { openFile } from "Util";
import VoidUpload from "Feed/VoidUpload"; import VoidUpload from "Feed/VoidUpload";
import { FileExtensionRegex } from "Const"; import { FileExtensionRegex } from "Const";
import Textarea from "Element/Textarea"; 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 { export interface NoteCreatorProps {
show: boolean
setShow: (s: boolean) => void
replyTo?: NEvent, replyTo?: NEvent,
onSend?: Function, onSend?: Function,
show: boolean, onClose?(): void
autoFocus: boolean autoFocus: boolean
} }
export function NoteCreator(props: NoteCreatorProps) { export function NoteCreator(props: NoteCreatorProps) {
const { show, setShow } = props
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const [note, setNote] = useState<string>(); const [note, setNote] = useState<string>();
const [error, setError] = 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>) { function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
ev.stopPropagation(); ev.stopPropagation();
sendNote().catch(console.warn); sendNote().catch(console.warn);
} }
if (!props.show) return null;
return ( 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 note-creator ${props.replyTo ? 'note-reply' : ''}`}>
<div className="flex f-col mr10 f-grow"> <div className="flex f-col mr10 f-grow">
<Textarea <Textarea
@ -85,19 +99,22 @@ export function NoteCreator(props: NoteCreatorProps) {
value={note} value={note}
onFocus={() => setActive(true)} onFocus={() => setActive(true)}
/> />
{active && note && ( <div className="attachment">
<div className="actions flex f-row"> {(error?.length ?? 0) > 0 ? <b className="error">{error}</b> : null}
<div className="attachment flex f-row"> <FontAwesomeIcon icon={faPaperclip} size="xl" onClick={(e) => attachFile()} />
{(error?.length ?? 0) > 0 ? <b className="error">{error}</b> : null} </div>
<FontAwesomeIcon icon={faPaperclip} size="xl" onClick={(e) => attachFile()} />
</div>
<button type="button" className="btn" onClick={onSubmit}>
{props.replyTo ? 'Reply' : 'Send'}
</button>
</div>
)}
</div> </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 { useMemo, useState } from "react";
import { useSelector } from "react-redux"; 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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Menu, MenuItem } from '@szhsin/react-menu'; 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 { formatShort } from "Number";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import { getReactions, hexToBech32, normalizeReaction, Reaction } from "Util"; import { getReactions, hexToBech32, normalizeReaction, Reaction } from "Util";
@ -31,8 +36,8 @@ export default function NoteFooter(props: NoteFooterProps) {
const [reply, setReply] = useState(false); const [reply, setReply] = useState(false);
const [tip, setTip] = useState(false); const [tip, setTip] = useState(false);
const isMine = ev.RootPubKey === login; const isMine = ev.RootPubKey === login;
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related]); const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related, ev]);
const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related]); const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related, ev]);
const groupReactions = useMemo(() => { const groupReactions = useMemo(() => {
return reactions?.reduce((acc, { content }) => { return reactions?.reduce((acc, { content }) => {
let r = normalizeReaction(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" onClick={() => setTip(true)}>
<div className="reaction-pill-icon"> <div className="reaction-pill-icon">
<FontAwesomeIcon icon={faBolt} /> <Zap />
</div> </div>
</div> </div>
</> </>
@ -114,7 +119,7 @@ export default function NoteFooter(props: NoteFooterProps) {
<> <>
<div className={`reaction-pill ${hasReacted('+') ? 'reacted' : ''} `} onClick={() => react("+")}> <div className={`reaction-pill ${hasReacted('+') ? 'reacted' : ''} `} onClick={() => react("+")}>
<div className="reaction-pill-icon"> <div className="reaction-pill-icon">
<FontAwesomeIcon icon={faHeart} /> <Heart />
</div> </div>
<div className="reaction-pill-number"> <div className="reaction-pill-number">
{formatShort(groupReactions[Reaction.Positive])} {formatShort(groupReactions[Reaction.Positive])}
@ -148,14 +153,14 @@ export default function NoteFooter(props: NoteFooterProps) {
function menuItems() { function menuItems() {
return ( return (
<> <>
{prefs.enableReactions && (<MenuItem onClick={() => react("-")}> {prefs.enableReactions && (
<div> <MenuItem onClick={() => react("-")}>
<FontAwesomeIcon icon={faThumbsDown} className={hasReacted('-') ? 'reacted' : ''} /> <Dislike />
&nbsp;
{formatShort(groupReactions[Reaction.Negative])} {formatShort(groupReactions[Reaction.Negative])}
</div> &nbsp;
Dislike Dislike
</MenuItem>)} </MenuItem>
)}
<MenuItem onClick={() => share()}> <MenuItem onClick={() => share()}>
<FontAwesomeIcon icon={faShareNodes} /> <FontAwesomeIcon icon={faShareNodes} />
Share Share
@ -183,18 +188,18 @@ export default function NoteFooter(props: NoteFooterProps) {
return ( return (
<> <>
<div className="footer"> <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"> <Menu menuButton={<div className="reaction-pill">
<div className="reaction-pill-icon"> <div className="reaction-pill-icon">
<FontAwesomeIcon icon={faEllipsisVertical} /> <Dots />
</div> </div>
</div>} menuClassName="ctx-menu"> </div>} menuClassName="ctx-menu">
{menuItems()} {menuItems()}
</Menu> </Menu>
<div className={`reaction-pill ${reply ? 'reacted' : ''}`} onClick={(e) => setReply(s => !s)}>
<div className="reaction-pill-icon">
<Reply />
</div>
</div>
{reactionIcons()} {reactionIcons()}
{tipButton()} {tipButton()}
@ -204,6 +209,7 @@ export default function NoteFooter(props: NoteFooterProps) {
replyTo={ev} replyTo={ev}
onSend={() => setReply(false)} onSend={() => setReply(false)}
show={reply} show={reply}
setShow={setReply}
/> />
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={() => setTip(false)} show={tip} /> <LNURLTip svc={author?.lud16 || author?.lud06} onClose={() => setTip(false)} show={tip} />
</> </>

View File

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

View File

@ -16,7 +16,8 @@ export interface NoteReactionProps {
root?: TaggedRawEvent root?: TaggedRawEvent
} }
export default function NoteReaction(props: NoteReactionProps) { 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(() => { const refEvent = useMemo(() => {
if (ev) { if (ev) {
@ -32,19 +33,6 @@ export default function NoteReaction(props: NoteReactionProps) {
return null; 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 * Some clients embed the reposted note in the content
*/ */

View File

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

View File

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

View File

@ -1,13 +1,14 @@
import "./Relay.css" 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 useRelayState from "Feed/RelayState";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { removeRelay, setRelays } from "State/Login"; import { setRelays } from "State/Login";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { RelaySettings } from "Nostr/Connection"; import { RelaySettings } from "Nostr/Connection";
import { useNavigate } from "react-router-dom";
export interface RelayProps { export interface RelayProps {
addr: string addr: string
@ -15,11 +16,11 @@ export interface RelayProps {
export default function Relay(props: RelayProps) { export default function Relay(props: RelayProps) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigate = useNavigate();
const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays); const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
const relaySettings = allRelaySettings[props.addr]; const relaySettings = allRelaySettings[props.addr];
const state = useRelayState(props.addr); const state = useRelayState(props.addr);
const name = useMemo(() => new URL(props.addr).host, [props.addr]); const name = useMemo(() => new URL(props.addr).host, [props.addr]);
const [showExtra, setShowExtra] = useState(false);
function configure(o: RelaySettings) { function configure(o: RelaySettings) {
dispatch(setRelays({ dispatch(setRelays({
@ -62,28 +63,13 @@ export default function Relay(props: RelayProps) {
<FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects} <FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects}
</div> </div>
<div> <div>
<span className="icon-btn" onClick={() => setShowExtra(s => !s)}> <span className="icon-btn" onClick={() => navigate(name)}>
<FontAwesomeIcon icon={faEllipsisVertical} /> <FontAwesomeIcon icon={faGear} />
</span> </span>
</div> </div>
</div> </div>
</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; align-items: flex-start;
} }
.nip05 { .user-item .nip05 {
font-size: 12px; font-size: var(--font-size-tiny);
} }
.emoji-item { .emoji-item {
font-size: 12px; font-size: var(--font-size-tiny);
} }
.emoji-item .emoji { .emoji-item .emoji {

View File

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

View File

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

View File

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

View File

@ -38,7 +38,8 @@ function notesReducer(state: NoteStore, arg: ReducerArg) {
if (!Array.isArray(evs)) { if (!Array.isArray(evs)) {
evs = [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) { if (evs.length === 0) {
return state; return state;
} }
@ -83,7 +84,7 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use
setSubDebounced(sub); setSubDebounced(sub);
}); });
} }
}, [sub]); }, [sub, options]);
useEffect(() => { useEffect(() => {
if (sub) { if (sub) {
@ -115,6 +116,7 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use
}; };
} }
}, [subDebounce]); }, [subDebounce]);
useEffect(() => { useEffect(() => {
return debounce(DebounceMs, () => { return debounce(DebounceMs, () => {
setDebounceOutput(s => s += 1); setDebounceOutput(s => s += 1);

View File

@ -36,7 +36,7 @@ export default function useThreadFeed(id: u256) {
thisSub.AddSubscription(subRelated); thisSub.AddSubscription(subRelated);
return thisSub; return thisSub;
}, [trackingEvents]); }, [trackingEvents, pref, id]);
const main = useSubscription(sub, { leaveOpen: true }); 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 { u256 } from "Nostr";
import EventKind from "Nostr/EventKind"; import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions"; import { Subscriptions } from "Nostr/Subscriptions";
@ -19,15 +19,15 @@ export interface TimelineSubject {
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) { export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
const now = unixNow(); const now = unixNow();
const [window, setWindow] = useState<number>(60 * 60); const [window] = useState<number>(60 * 60);
const [until, setUntil] = useState<number>(now); const [until, setUntil] = useState<number>(now);
const [since, setSince] = useState<number>(now - window); const [since, setSince] = useState<number>(now - window);
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]); const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]); const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences); const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
function createSub() { const createSub = useCallback(() => {
if (subject.type !== "global" && subject.items.length == 0) { if (subject.type !== "global" && subject.items.length === 0) {
return null; return null;
} }
@ -49,7 +49,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
} }
} }
return sub; return sub;
} }, [subject.type, subject.items]);
const sub = useMemo(() => { const sub = useMemo(() => {
let sub = createSub(); let sub = createSub();
@ -78,7 +78,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
} }
} }
return sub; return sub;
}, [subject.type, subject.items, until, since, window]); }, [until, since, options.method, pref, createSub]);
const main = useSubscription(sub, { leaveOpen: true }); 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); subLatest.Since = Math.floor(new Date().getTime() / 1000);
} }
return subLatest; return subLatest;
}, [subject.type, subject.items]); }, [pref, createSub]);
const latest = useSubscription(subRealtime, { leaveOpen: true }); const latest = useSubscription(subRealtime, { leaveOpen: true });
@ -103,7 +103,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
sub.ETags = new Set(trackingEvents); sub.ETags = new Set(trackingEvents);
} }
return sub ?? null; return sub ?? null;
}, [trackingEvents]); }, [trackingEvents, pref, subject.type]);
const others = useSubscription(subNext, { leaveOpen: true }); const others = useSubscription(subNext, { leaveOpen: true });
@ -115,7 +115,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
return parents; return parents;
} }
return null; return null;
}, [trackingParentEvents]); }, [trackingParentEvents, subject.type]);
const parent = useSubscription(subParents); const parent = useSubscription(subParents);
@ -123,8 +123,10 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
if (main.store.notes.length > 0) { if (main.store.notes.length > 0) {
setTrackingEvent(s => { setTrackingEvent(s => {
let ids = main.store.notes.map(a => a.id); let ids = main.store.notes.map(a => a.id);
let temp = new Set([...s, ...ids]); if(ids.some(a => !s.includes(a))) {
return Array.from(temp); return Array.from(new Set([...s, ...ids]));
}
return s;
}); });
let reposts = main.store.notes let reposts = main.store.notes
.filter(a => a.kind === EventKind.Repost && a.content === "") .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 { DefaultConnectTimeout } from "Const";
import { ConnectionStats } from "Nostr/ConnectionStats"; import { ConnectionStats } from "Nostr/ConnectionStats";
import { RawEvent, TaggedRawEvent, u256 } from "Nostr"; import { RawEvent, TaggedRawEvent, u256 } from "Nostr";
import { RelayInfo } from "./RelayInfo";
export type CustomHook = (state: Readonly<StateSnapshot>) => void; export type CustomHook = (state: Readonly<StateSnapshot>) => void;
@ -27,7 +28,8 @@ export type StateSnapshot = {
events: { events: {
received: number, received: number,
send: number send: number
} },
info?: RelayInfo
}; };
export default class Connection { export default class Connection {
@ -36,6 +38,7 @@ export default class Connection {
Pending: Subscriptions[]; Pending: Subscriptions[];
Subscriptions: Map<string, Subscriptions>; Subscriptions: Map<string, Subscriptions>;
Settings: RelaySettings; Settings: RelaySettings;
Info?: RelayInfo;
ConnectTimeout: number; ConnectTimeout: number;
Stats: ConnectionStats; Stats: ConnectionStats;
StateHooks: Map<string, CustomHook>; StateHooks: Map<string, CustomHook>;
@ -56,7 +59,7 @@ export default class Connection {
this.Stats = new ConnectionStats(); this.Stats = new ConnectionStats();
this.StateHooks = new Map(); this.StateHooks = new Map();
this.HasStateChange = true; this.HasStateChange = true;
this.CurrentState = <StateSnapshot>{ this.CurrentState = {
connected: false, connected: false,
disconnects: 0, disconnects: 0,
avgLatency: 0, avgLatency: 0,
@ -64,7 +67,7 @@ export default class Connection {
received: 0, received: 0,
send: 0 send: 0
} }
}; } as StateSnapshot;
this.LastState = Object.freeze({ ...this.CurrentState }); this.LastState = Object.freeze({ ...this.CurrentState });
this.IsClosed = false; this.IsClosed = false;
this.ReconnectTimer = null; this.ReconnectTimer = null;
@ -72,7 +75,29 @@ export default class Connection {
this.Connect(); 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.IsClosed = false;
this.Socket = new WebSocket(this.Address); this.Socket = new WebSocket(this.Address);
this.Socket.onopen = (e) => this.OnOpen(e); this.Socket.onopen = (e) => this.OnOpen(e);
@ -259,6 +284,7 @@ export default class Connection {
this.CurrentState.events.send = this.Stats.EventsSent; 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.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.disconnects = this.Stats.Disconnects;
this.CurrentState.info = this.Info;
this.Stats.Latency = this.Stats.Latency.slice(-20); // trim this.Stats.Latency = this.Stats.Latency.slice(-20); // trim
this.HasStateChange = true; this.HasStateChange = true;
this._NotifyState(); this._NotifyState();

View File

@ -1,4 +1,3 @@
/** /**
* Stats class for tracking metrics per connection * 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="write-dm">
<div className="inner"> <div className="inner">
<textarea className="f-grow mr10" value={content} onChange={(e) => setContent(e.target.value)} onKeyDown={(e) => onEnter(e)}></textarea> <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>
</div> </div>
</> </>
) )
} }

View File

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

View File

@ -1,20 +1,37 @@
.logo { .logo {
cursor: pointer; cursor: pointer;
font-weight: bold;
font-size: 29px;
} }
.unread-count { header {
width: 20px; display: flex;
height: 20px; flex-direction: row;
border: 1px solid; align-items: center;
border-radius: 100%; justify-content: space-between;
position: relative; height: 72px;
padding: 3px; padding: 0 12px;
line-height: 1.5em; }
top: -10px;
left: -10px; header .pfp .avatar-wrapper {
font-size: var(--font-size-small); margin-right: 0;
background-color: var(--error); }
color: var(--note-bg);
font-weight: bold; .header-actions {
text-align: center; 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 { useEffect } from "react"
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { Outlet, useNavigate } from "react-router-dom"; import { Outlet, useNavigate } from "react-router-dom";
import { faBell, faMessage } from "@fortawesome/free-solid-svg-icons"; import Envelope from "Icons/Envelope"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Bell from "Icons/Bell"
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { init, setPreferences, UserPreferences } from "State/Login"; 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 unreadNotifications = notifications?.filter(a => (a.created_at * 1000) > readNotifications).length;
const unreadDms = key ? totalUnread(dms, key) : 0; const unreadDms = key ? totalUnread(dms, key) : 0;
return ( return (
<> <div className="header-actions">
<div className={`btn btn-rnd${unreadDms === 0 ? " mr10" : ""}`} onClick={(e) => navigate("/messages")}> <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> </div>
{unreadDms > 0 && (<span className="unread-count">
{unreadDms > 100 ? ">99" : unreadDms}
</span>)}
<div className={`btn btn-rnd${unreadNotifications === 0 ? " mr10" : ""}`} onClick={(e) => goToNotifications(e)}> <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> </div>
{unreadNotifications > 0 && (<span className="unread-count">
{unreadNotifications > 100 ? ">99" : unreadNotifications}
</span>)}
<ProfileImage pubkey={key || ""} showUsername={false} /> <ProfileImage pubkey={key || ""} showUsername={false} />
</> </div>
) )
} }
@ -108,14 +104,14 @@ export default function Layout() {
return ( return (
<div className="page"> <div className="page">
<div className="header"> <header>
<div className="logo" onClick={() => navigate("/")}>snort</div> <div className="logo" onClick={() => navigate("/")}>Snort</div>
<div> <div>
{key ? accountHeader() : {key ? accountHeader() :
<div className="btn" onClick={() => navigate("/login")}>Login</div> <button type="button" onClick={() => navigate("/login")}>Login</button>
} }
</div> </div>
</div> </header>
<Outlet /> <Outlet />
</div> </div>

View File

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

View File

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

View File

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

View File

@ -1,26 +1,29 @@
.profile { .profile {
display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start;
} }
.profile .banner { .profile .banner {
width: 100%; width: 100%;
height: 210px; height: 160px;
margin-bottom: -80px; object-fit: cover;
object-fit: cover; margin-bottom: -60px;
mask-image: linear-gradient(to bottom, var(--bg-color) 60%, rgba(0,0,0,0)); 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)); -webkit-mask-image: linear-gradient(to bottom, var(--bg-color) 60%, rgba(0,0,0,0));
z-index: 0; z-index: 0;
} }
@media (min-width: 720px) { .profile .profile-wrapper {
.profile .banner { margin: 0 16px;
width: 100%; width: calc(100% - 32px);
max-width: 720px; display: flex;
height: 300px; flex-direction: column;
margin-bottom: -120px; align-items: flex-start;
} position: relative;
} }
.profile p { .profile p {
white-space: pre-wrap; white-space: pre-wrap;
} }
@ -29,35 +32,37 @@
margin: 0; margin: 0;
} }
@media (min-width: 720px) { .profile .nip05 {
.profile .banner { display: flex;
width: 100%; margin: 4px 0 12px 0;
max-width: 720px; }
height: 300px;
margin-bottom: -120px; .profile .nip05 .nick {
} font-weight: normal;
color: var(--gray-light);
} }
.profile .avatar-wrapper { .profile .avatar-wrapper {
align-self: flex-start; z-index: 1;
z-index: 1; }
margin-left: 4px;
.profile .avatar-wrapper .avatar {
width: 120px;
height: 120px;
} }
.profile .name { .profile .name {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start;
}
.profile .name h2 {
margin: 0;
} }
.profile .details { .profile .details {
max-width: 680px;
width: 100%; width: 100%;
margin-top: 12px; margin-top: 12px;
background-color: var(--note-bg);
padding: 12px 16px;
margin: 0 auto;
border-radius: 16px;
} }
.profile .details p { .profile .details p {
@ -76,129 +81,63 @@
.profile .btn-icon { .profile .btn-icon {
color: var(--font-color); color: var(--font-color);
padding: 6px; padding: 6px;
margin-left: 4px;
} }
.profile .details-wrapper { .profile .details-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start;
justify-content: space-between; justify-content: space-between;
position: relative; width: calc(100% - 32px);
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;
} }
.tabs > div { .tabs > div {
margin-right: 0; 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 { .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 { .profile .website {
color: var(--highlight); margin: 4px 0;
margin: 6px 0; display: flex;
flex-direction: row;
align-items: center;
}
.profile .website a {
color: var(--font-color);
} }
.profile .website a { .profile .website a {
text-decoration: none; text-decoration: none;
} }
.profile .website::before { .profile .website a:hover {
content: '🔗 '; text-decoration: underline;
} }
.profile .lnurl { .profile .lnurl {
color: var(--highlight);
margin: 6px 0;
cursor: pointer; cursor: pointer;
} }
.profile .ln-address {
display: flex;
flex-direction: row;
align-items: center;
}
.profile .lnurl:hover { .profile .lnurl:hover {
text-decoration: underline; text-decoration: underline;
} }
@ -208,6 +147,42 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.profile .zap { .profile .link-icon {
margin-right: .3em; 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 { useEffect, useMemo, useState } from "react";
import { useSelector } from "react-redux"; 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 { useNavigate, useParams } from "react-router-dom";
import Link from "Icons/Link";
import Zap from "Icons/Zap";
import useProfile from "Feed/ProfileFeed"; import useProfile from "Feed/ProfileFeed";
import FollowButton from "Element/FollowButton"; import FollowButton from "Element/FollowButton";
import { extractLnAddress, parseId, hexToBech32 } from "Util"; import { extractLnAddress, parseId, hexToBech32 } from "Util";
@ -39,7 +39,9 @@ export default function ProfilePage() {
const isMe = loginPubKey === id; const isMe = loginPubKey === id;
const [showLnQr, setShowLnQr] = useState<boolean>(false); const [showLnQr, setShowLnQr] = useState<boolean>(false);
const [tab, setTab] = useState(ProfileTab.Notes); const [tab, setTab] = useState(ProfileTab.Notes);
const aboutText = user?.about || ''
const about = Text({ content: user?.about || '', tags: [], users: new Map() }) const about = Text({ content: user?.about || '', tags: [], users: new Map() })
const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || "");
useEffect(() => { useEffect(() => {
setTab(ProfileTab.Notes); setTab(ProfileTab.Notes);
@ -52,36 +54,48 @@ export default function ProfilePage() {
{user?.display_name || user?.name || 'Nostrich'} {user?.display_name || user?.name || 'Nostrich'}
<FollowsYou pubkey={id} /> <FollowsYou pubkey={id} />
</h2> </h2>
<Copy text={params.id || ""} />
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />} {user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
<Copy text={params.id || ""} />
{links()}
</div> </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() { function bio() {
const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || ""); return aboutText.length > 0 && (
return ( <>
<div className="details"> <h3>Bio</h3>
<div>{about}</div> <div className="details">
{about}
<div className="links"> </div>
{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>
) )
} }
@ -119,17 +133,20 @@ export default function ProfilePage() {
return ( return (
<div className="details-wrapper"> <div className="details-wrapper">
{username()} {username()}
{isMe ? ( <div className="profile-actions">
<div className="btn btn-icon follow-button" onClick={() => navigate("/settings")}> {isMe ? (
<FontAwesomeIcon icon={faGear} size="lg" /> <button type="button" onClick={() => navigate("/settings")}>
</div> Settings
) : <> </button>
<div className="btn message-button" onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}> ) : (
<FontAwesomeIcon icon={faEnvelope} size="lg" /> <>
</div> <button type="button" onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}>
<FollowButton pubkey={id} /> Message
</> </button>
} <FollowButton pubkey={id} />
</>
)}
</div>
{bio()} {bio()}
</div> </div>
) )
@ -138,18 +155,11 @@ export default function ProfilePage() {
return ( return (
<> <>
<div className="profile flex"> <div className="profile flex">
{user?.banner && <img alt="banner" className="banner" src={user.banner} />} {user?.banner && <img alt="banner" className="banner" src={user.banner} />}
{user?.banner ? ( <div className="profile-wrapper flex">
<> {avatar()}
{avatar()} {userDetails()}
{userDetails()} </div>
</>
) : (
<div className="no-banner">
{avatar()}
{userDetails()}
</div>
)}
</div> </div>
<div className="tabs"> <div className="tabs">
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(v => { {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(v => {

View File

@ -16,6 +16,7 @@ const RootTab = {
}; };
export default function RootPage() { 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 [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); const [tab, setTab] = useState(RootTab.Posts);
@ -32,20 +33,20 @@ export default function RootPage() {
return ( return (
<> <>
{pubKey ? <> {pubKey ? <>
<NoteCreator show={true} autoFocus={false} /> <div className="tabs">
<div className="tabs root-tabs"> <div className={`tab f-1 ${tab === RootTab.Posts ? "active" : ""}`} onClick={() => setTab(RootTab.Posts)}>
<div className={`root-tab f-1 ${tab === RootTab.Posts ? "active" : ""}`} onClick={() => setTab(RootTab.Posts)}>
Posts Posts
</div> </div>
<div className={`root-tab f-1 ${tab === RootTab.PostsAndReplies ? "active" : ""}`} onClick={() => setTab(RootTab.PostsAndReplies)}> <div className={`tab f-1 ${tab === RootTab.PostsAndReplies ? "active" : ""}`} onClick={() => setTab(RootTab.PostsAndReplies)}>
Posts &amp; Replies Conversations
</div> </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 Global
</div> </div>
</div></> : null} </div></> : null}
{followHints()} {followHints()}
<Timeline key={tab} subject={timelineSubect} postsOnly={tab === RootTab.Posts} method={"TIME_RANGE"} /> <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 Profile from "Pages/settings/Profile";
import Relay from "Pages/settings/Relays"; import Relay from "Pages/settings/Relays";
import Preferences from "Pages/settings/Preferences"; import Preferences from "Pages/settings/Preferences";
import RelayInfo from "Pages/settings/RelayInfo";
export default function SettingsPage() { export default function SettingsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<> <div className="main-content">
<h2 onClick={() => navigate("/settings")} className="pointer">Settings</h2> <h2 onClick={() => navigate("/settings")} className="pointer">Settings</h2>
<Outlet /> <Outlet />
</> </div>
); );
} }
@ -26,7 +27,11 @@ export const SettingsRoutes: RouteObject[] = [
}, },
{ {
path: "relays", path: "relays",
element: <Relay /> element: <Relay />,
},
{
path: "relays/:addr",
element: <RelayInfo />
}, },
{ {
path: "preferences", path: "preferences",

View File

@ -1,3 +1,4 @@
import { ApiHost } from "Const";
import Nip5Service from "Element/Nip5Service"; import Nip5Service from "Element/Nip5Service";
import './Verification.css' import './Verification.css'
@ -6,7 +7,7 @@ export default function VerificationPage() {
const services = [ const services = [
{ {
name: "Snort", name: "Snort",
service: "https://api.snort.social/api/v1/n5sp", service: `${ApiHost}/api/v1/n5sp`,
link: "https://snort.social/", link: "https://snort.social/",
supportLink: "https://snort.social/help", supportLink: "https://snort.social/help",
about: <>Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!</> about: <>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 ( return (
<div className="verification"> <div className="main-content verification">
<h2>Get Verified</h2> <h2>Get Verified</h2>
<p> <p>
NIP-05 is a DNS based verification spec which helps to validate you as a real user. 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 [about, setAbout] = useState<string>();
const [website, setWebsite] = useState<string>(); const [website, setWebsite] = useState<string>();
const [nip05, setNip05] = useState<string>(); const [nip05, setNip05] = useState<string>();
const [lud06, setLud06] = useState<string>();
const [lud16, setLud16] = useState<string>(); const [lud16, setLud16] = useState<string>();
const avatarPicture = (picture?.length ?? 0) === 0 ? Nostrich : picture const avatarPicture = (picture?.length ?? 0) === 0 ? Nostrich : picture
@ -45,7 +44,6 @@ export default function ProfileSettings() {
setAbout(user.about); setAbout(user.about);
setWebsite(user.website); setWebsite(user.website);
setNip05(user.nip05); setNip05(user.nip05);
setLud06(user.lud06);
setLud16(user.lud16); setLud16(user.lud16);
} }
}, [user]); }, [user]);
@ -130,11 +128,11 @@ export default function ProfileSettings() {
<div>NIP-05:</div> <div>NIP-05:</div>
<div> <div>
<input type="text" className="mr10" value={nip05} onChange={(e) => setNip05(e.target.value)} /> <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} /> <FontAwesomeIcon icon={faShop} />
&nbsp; &nbsp;
Buy Buy
</div> </button>
</div> </div>
</div> </div>
<div className="form-group"> <div className="form-group">
@ -145,10 +143,10 @@ export default function ProfileSettings() {
</div> </div>
<div className="form-group"> <div className="form-group">
<div> <div>
<div className="btn" onClick={() => { dispatch(logout()); navigate("/"); }}>Logout</div> <button className="secondary" type="button" onClick={() => { dispatch(logout()); navigate("/"); }}>Logout</button>
</div> </div>
<div> <div>
<div className="btn" onClick={() => saveProfile()}>Save</div> <button type="button" onClick={() => saveProfile()}>Save</button>
</div> </div>
</div> </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"> <div className="flex mb10">
<input type="text" className="f-grow" placeholder="wss://my-relay.com" value={newRelay} onChange={(e) => setNewRelay(e.target.value)} /> <input type="text" className="f-grow" placeholder="wss://my-relay.com" value={newRelay} onChange={(e) => setNewRelay(e.target.value)} />
</div> </div>
<div className="btn mb10" onClick={() => addNewRelay()}>Add</div> <button className="secondary mb10" onClick={() => addNewRelay()}>Add</button>
</> </>
) )
} }
@ -49,16 +49,16 @@ const RelaySettingsPage = () => {
return ( return (
<> <>
<h3>Relays</h3> <h3>Relays</h3>
<div className="flex f-col"> <div className="flex f-col mb10">
{Object.keys(relays || {}).map(a => <Relay addr={a} key={a} />)} {Object.keys(relays || {}).map(a => <Relay addr={a} key={a} />)}
</div> </div>
<div className="flex actions"> <div className="flex mt10">
<div className="f-grow"></div> <div className="f-grow"></div>
<div className="btn" onClick={() => saveRelays()}>Save</div> <button type="button" onClick={() => saveRelays()}>Save</button>
</div> </div>
{addRelay()} {addRelay()}
</> </>
) )
} }
export default RelaySettingsPage; export default RelaySettingsPage;

View File

@ -1,9 +1,8 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import * as secp from '@noble/secp256k1'; import * as secp from '@noble/secp256k1';
import { DefaultRelays } from 'Const'; import { DefaultRelays } from 'Const';
import { HexKey, RawEvent, TaggedRawEvent } from 'Nostr'; import { HexKey, TaggedRawEvent } from 'Nostr';
import { RelaySettings } from 'Nostr/Connection'; import { RelaySettings } from 'Nostr/Connection';
import { useDispatch } from 'react-redux';
const PrivateKeyItem = "secret"; const PrivateKeyItem = "secret";
const PublicKeyItem = "pubkey"; const PublicKeyItem = "pubkey";
@ -182,7 +181,7 @@ const LoginSlice = createSlice({
let filtered = new Map<string, RelaySettings>(); let filtered = new Map<string, RelaySettings>();
for (let [k, v] of Object.entries(relays)) { for (let [k, v] of Object.entries(relays)) {
if (k.startsWith("wss://") || k.startsWith("ws://")) { 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 * as secp from "@noble/secp256k1";
import { bech32 } from "bech32"; import { bech32 } from "bech32";
import { HexKey, RawEvent, TaggedRawEvent, u256 } from "Nostr"; import { HexKey, TaggedRawEvent, u256 } from "Nostr";
import EventKind from "Nostr/EventKind"; import EventKind from "Nostr/EventKind";
export async function openFile(): Promise<File | undefined> { export async function openFile(): Promise<File | undefined> {
@ -65,7 +65,7 @@ export function eventLink(hex: u256) {
* @param {string} hex * @param {string} hex
*/ */
export function hexToBech32(hrp: string, hex: string) { 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 ""; 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 { :root {
--bg-color: #000; --bg-color: #000;
@ -9,10 +9,8 @@
--font-size-small: 14px; --font-size-small: 14px;
--font-size-tiny: 12px; --font-size-tiny: 12px;
--modal-bg-color: rgba(0, 0, 0, 0.8); --modal-bg-color: rgba(0, 0, 0, 0.8);
--note-bg: #111; --note-bg: #0C0C0C;
--highlight-light: #ffd342; --highlight: #8B5CF6;
--highlight: #ffc400;
--highlight-dark: #dba800;
--error: #FF6053; --error: #FF6053;
--success: #2AD544; --success: #2AD544;
@ -22,8 +20,10 @@
--gray: #333; --gray: #333;
--gray-secondary: #222; --gray-secondary: #222;
--gray-tertiary: #444; --gray-tertiary: #444;
--gray-dark: #2B2B2B;
--gray-superdark: #171717;
--gray-gradient: linear-gradient(to bottom right, var(--gray-superlight), var(--gray), var(--gray-light)); --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); --nostrplebs-gradient: linear-gradient(to bottom right, #ff3cac, #2b86c5);
--strike-army-gradient: linear-gradient(to bottom right, #CCFF00, #a1c900); --strike-army-gradient: linear-gradient(to bottom right, #CCFF00, #a1c900);
} }
@ -46,11 +46,13 @@ html.light {
--gray-tertiary: #EEE; --gray-tertiary: #EEE;
--gray-superlight: #333; --gray-superlight: #333;
--gray-light: #555; --gray-light: #555;
--gray-dark: #2B2B2B;
--gray-superdark: #171717;
} }
body { body {
margin: 0; margin: 0;
font-family: 'Montserrat', sans-serif; font-family: 'Be Vietnam Pro', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: var(--bg-color); background-color: var(--bg-color);
@ -63,25 +65,17 @@ code {
} }
.page { .page {
width: 720px; width: 100vw;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
.page>.header { @media (min-width: 720px) {
display: flex; .page {
align-items: center; width: 720px;
margin: 10px 0; margin-left: auto;
} margin-right: auto;
}
.page>.header>div:nth-child(1) {
font-size: x-large;
flex-grow: 1;
}
.page>.header>div:nth-child(2) {
display: flex;
align-items: center;
} }
.card { .card {
@ -115,10 +109,42 @@ html.light .card {
margin-top: 12px; 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 { .btn {
padding: 10px; padding: 10px;
border-radius: 5px; border-radius: 5px;
cursor: pointer; cursor: pointer;
color: var(--font-color);
user-select: none; user-select: none;
background-color: var(--bg-color); background-color: var(--bg-color);
color: var(--font-color); color: var(--font-color);
@ -155,6 +181,17 @@ html.light .card {
.btn-rnd { .btn-rnd {
border-radius: 100%; 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 { textarea {
@ -222,18 +259,18 @@ textarea:placeholder {
align-items: flex-start !important; align-items: flex-start !important;
} }
.f-end {
justify-content: flex-end;
}
.w-max { .w-max {
width: 100%; width: 100%;
width: -moz-available; width: stretch;
width: -webkit-fill-available;
width: fill-available;
} }
.w-max-w { .w-max-w {
max-width: 100%; max-width: 100%;
max-width: -moz-available; max-width: stretch;
max-width: -webkit-fill-available;
max-width: fill-available;
} }
a { a {
@ -263,7 +300,7 @@ div.form-group>div:nth-child(1) {
div.form-group>div:nth-child(2) { div.form-group>div:nth-child(2) {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
justify-content: end; justify-content: flex-end;
} }
div.form-group>div:nth-child(2) input { div.form-group>div:nth-child(2) input {
@ -365,18 +402,23 @@ body.scroll-lock {
background-color: var(--success); background-color: var(--success);
} }
.root-tabs { .tabs {
padding: 0; padding: 0;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
margin-bottom: 16px;
} }
.root-tab { .tab {
border-bottom: 3px solid var(--gray-secondary); border-bottom: 1px solid var(--gray-secondary);
font-weight: bold;
color: var(--font-secondary-color);
padding: 8px 0;
} }
.root-tab.active { .tab.active {
border-bottom: 3px solid var(--highlight); border-bottom: 1px solid var(--highlight);
color: var(--font-color);
} }
.tweet { .tweet {
@ -398,10 +440,6 @@ body.scroll-lock {
} }
@media(max-width: 720px) { @media(max-width: 720px) {
.page {
width: calc(100vw - 8px);
}
div.form-group { div.form-group {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@ -410,4 +448,15 @@ body.scroll-lock {
.highlight { .highlight {
color: var(--highlight); color: var(--highlight);
} }
.main-content {
padding: 0 12px;
}
@media (min-width: 720px) {
.main-content {
padding: 0;
}
}