Merge pull request '3-column layout' (#699) from mmalmi/snort:main into main
Some checks are pending
continuous-integration/drone/push Build is running

This commit is contained in:
mmalmi 2023-11-27 15:22:02 +00:00
commit 7dfb7ec363
67 changed files with 1707 additions and 1059 deletions

View File

@ -6,6 +6,7 @@
"nip05Domain": "snort.social",
"favicon": "public/favicon.ico",
"appleTouchIconUrl": "/nostrich_512.png",
"navLogo": null,
"publicDir": "public/snort",
"httpCache": "",
"animalNamePlaceholders": false,

View File

@ -6,6 +6,7 @@
"nip05Domain": "iris.to",
"favicon": "public/iris/favicon.ico",
"appleTouchIconUrl": "/img/apple-touch-icon.png",
"navLogo": "/img/icon128.png",
"publicDir": "public/iris",
"httpCache": "https://api.iris.to",
"animalNamePlaceholders": true,

View File

@ -48,6 +48,7 @@ declare const CONFIG: {
nip05Domain: string;
favicon: string;
appleTouchIconUrl: string;
navLogo: string | null;
httpCache: string;
animalNamePlaceholders: boolean;
defaultZapPoolFee?: number;

View File

@ -1,9 +0,0 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
bail: true,
preset: "ts-jest",
testEnvironment: "jsdom",
roots: ["src"],
moduleDirectories: ["src", "node_modules"],
setupFiles: ["./src/setupTests.ts"],
};

View File

@ -15,12 +15,13 @@
"@snort/system-wasm": "workspace:*",
"@snort/system-web": "workspace:*",
"@szhsin/react-menu": "^3.3.1",
"@uidotdev/usehooks": "^2.3.1",
"@uidotdev/usehooks": "^2.4.1",
"@void-cat/api": "^1.0.10",
"classnames": "^2.3.2",
"debug": "^4.3.4",
"dexie": "^3.2.4",
"emojilib": "^3.0.10",
"fuse.js": "^7.0.0",
"highlight.js": "^11.8.0",
"light-bolt11-decoder": "^2.1.0",
"marked": "^9.1.0",
@ -49,7 +50,8 @@
"start": "vite",
"build": "yarn eslint --fix && vite build",
"serve": "vite preview",
"test": "jest --runInBand",
"test": "vitest run",
"test:watch": "vitest watch",
"intl-extract": "formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file src/lang.json --flatten true",
"intl-compile": "formatjs compile src/lang.json --out-file src/translations/en.json",
"eslint": "eslint ."
@ -75,10 +77,8 @@
},
"devDependencies": {
"@formatjs/cli": "^6.1.3",
"@jest/globals": "^29.6.1",
"@types/config": "^3.3.3",
"@types/debug": "^4.1.8",
"@types/jest": "^29.5.1",
"@types/node": "^20.4.1",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
@ -96,8 +96,6 @@
"config": "^3.3.9",
"eslint": "^8.48.0",
"eslint-plugin-formatjs": "^4.11.3",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"postcss": "^8.4.31",
"postcss-preset-env": "^9.2.0",
"prettier": "2.8.3",
@ -105,10 +103,10 @@
"rollup-plugin-visualizer": "^5.9.2",
"tailwindcss": "^3.3.3",
"tinybench": "^2.5.1",
"ts-jest": "^29.1.1",
"typescript": "^5.2.2",
"vite": "^5.0.0",
"vite-plugin-pwa": "^0.17.0",
"vite-plugin-version-mark": "^0.0.10"
"vite-plugin-version-mark": "^0.0.10",
"vitest": "^0.34.6"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -27,10 +27,12 @@ export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
}
buildSub(session: LoginSession, rb: RequestBuilder): void {
const authors = session.follows.item;
authors.push(session.publicKey);
const since = this.newest();
rb.withFilter()
.kinds(this.#kinds)
.authors(session.follows.item)
.authors(authors)
.since(since === 0 ? unixNow() - WindowSize : since);
}
@ -67,9 +69,11 @@ export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
async loadMore(system: SystemInterface, session: LoginSession, before: number) {
if (this.#oldest && before <= this.#oldest) {
const rb = new RequestBuilder(`${this.name}-loadmore`);
const authors = session.follows.item;
authors.push(session.publicKey);
rb.withFilter()
.kinds(this.#kinds)
.authors(session.follows.item)
.authors(authors)
.until(before)
.since(before - WindowSize);
await system.Fetch(rb, async evs => {

View File

@ -3,7 +3,7 @@ import { useReactions } from "@snort/system-react";
import { useArticles } from "@/Feed/ArticlesFeed";
import { orderDescending } from "@/SnortUtils";
import Note from "../Event/Note";
import Note from "./Event/Note";
import { useContext } from "react";
import { DeckContext } from "@/Pages/DeckLayout";

View File

@ -10,5 +10,12 @@ export function ChatParticipantProfile({ participant }: { participant: ChatParti
if (participant.id === publicKey) {
return <NoteToSelf className="grow" />;
}
return <ProfileImage pubkey={participant.id} className="grow" profile={participant.profile as MetadataCache} />;
return (
<ProfileImage
showNip05={false}
pubkey={participant.id}
className="grow"
profile={participant.profile as MetadataCache}
/>
);
}

View File

@ -1,37 +1,7 @@
.dm {
margin-top: 16px;
min-width: 100px;
max-width: 90%;
white-space: pre-wrap;
color: var(--font-color);
}
.dm a {
color: var(--font-color) !important;
}
.dm > div:last-child {
color: var(--gray-light);
font-size: small;
margin-top: 3px;
}
.dm.other > div:first-child {
padding: 12px 16px;
background: var(--gray-secondary);
border-radius: 16px 16px 16px 0px;
}
.dm.me {
align-self: flex-end;
}
.dm.me > div:first-child {
padding: 12px 16px;
.dm-gradient {
background: var(--dm-gradient);
border-radius: 16px 16px 0px 16px;
}
.dm.me > div:last-child {
text-align: end;
.other {
background: var(--gray-superdark);
}

View File

@ -1,4 +1,5 @@
import "./DM.css";
import { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useInView } from "react-intersection-observer";
@ -55,8 +56,19 @@ export default function DM(props: DMProps) {
}, [inView]);
return (
<div className={isMe ? "dm me" : "dm other"} ref={ref}>
<div>
<div
className={
isMe
? "self-end mt-4 min-w-[100px] max-w-[90%] whitespace-pre-wrap align-self-end"
: "mt-4 min-w-[100px] max-w-[90%] whitespace-pre-wrap"
}
ref={ref}>
<div
className={
isMe
? "p-3 dm-gradient rounded-tl-lg rounded-tr-lg rounded-bl-lg rounded-br-none"
: "p-3 bg-gray-300 rounded-tl-lg rounded-tr-lg rounded-br-lg rounded-bl-none other"
}>
{sender()}
{content ? (
<Text id={msg.id} content={content} tags={[]} creator={otherPubkey} />
@ -64,7 +76,7 @@ export default function DM(props: DMProps) {
<FormattedMessage defaultMessage="Loading..." id="gjBiyj" />
)}
</div>
<div>
<div className={isMe ? "text-end text-gray-400 text-sm mt-1" : "text-gray-400 text-sm mt-1"}>
<NoteTime from={msg.created_at * 1000} fallback={formatMessage(messages.JustNow)} />
</div>
</div>

View File

@ -1,32 +0,0 @@
.dm-window {
display: flex;
flex-direction: column;
height: 100%;
}
.dm-window > div:nth-child(1) {
padding: 12px 0;
}
.dm-window > div:nth-child(2) {
overflow-y: auto;
padding: 0 10px 10px 10px;
flex-grow: 1;
display: flex;
flex-direction: column-reverse;
}
.dm-window > div:nth-child(3) {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 10px;
}
.pfp-overlap .pfp:not(:last-of-type) {
margin-right: -20px;
}
.pfp-overlap .avatar {
width: 32px;
height: 32px;
}

View File

@ -1,6 +1,4 @@
import "./DmWindow.css";
import { useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import ProfileImage from "@/Element/User/ProfileImage";
import DM from "@/Element/Chat/DM";
import useLogin from "@/Hooks/useLogin";
@ -18,7 +16,7 @@ export default function DmWindow({ id }: { id: string }) {
return <ChatParticipantProfile participant={chat.participants[0]} />;
} else {
return (
<div className="flex pfp-overlap mb10">
<div className="flex -space-x-5 mb-2.5">
{chat.participants.map(v => (
<ProfileImage pubkey={v.id} showUsername={false} />
))}
@ -29,12 +27,12 @@ export default function DmWindow({ id }: { id: string }) {
}
return (
<div className="dm-window">
<div>{sender()}</div>
<div>
<div className="flex flex-1 flex-col h-[calc(100vh-62px)] md:h-screen">
<div className="p-3">{sender()}</div>
<div className="overflow-y-auto hide-scrollbar p-2.5 flex-grow">
<div className="flex flex-col">{chat && <DmChatSelected chat={chat} />}</div>
</div>
<div className="flex g8">
<div className="flex items-center gap-2.5 p-2.5">
<WriteMessage chat={chat} />
</div>
</div>
@ -43,6 +41,9 @@ export default function DmWindow({ id }: { id: string }) {
function DmChatSelected({ chat }: { chat: Chat }) {
const { publicKey: myPubKey } = useLogin(s => ({ publicKey: s.publicKey }));
const messagesContainerRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const sortedDms = useMemo(() => {
const myDms = chat?.messages;
if (myPubKey && myDms) {
@ -52,11 +53,37 @@ function DmChatSelected({ chat }: { chat: Chat }) {
return [];
}, [chat, myPubKey]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "instant" });
};
useEffect(() => {
const observer = new ResizeObserver(() => {
scrollToBottom();
});
// Start observing the element that you want to keep in view
if (messagesContainerRef.current) {
observer.observe(messagesContainerRef.current);
}
// Make sure to scroll to bottom on initial load
scrollToBottom();
// Clean up the observer on component unmount
return () => {
if (messagesContainerRef.current) {
observer.unobserve(messagesContainerRef.current);
}
};
}, [sortedDms]);
return (
<>
<div className="flex flex-col" ref={messagesContainerRef}>
{sortedDms.map(a => (
<DM data={a} key={a.id} chat={chat} />
))}
</>
<div ref={messagesEndRef} />
</div>
);
}

View File

@ -1,54 +1,12 @@
import { useState } from "react";
import { NostrEvent, NostrLink, NostrPrefix } from "@snort/system";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useFileUpload from "@/Upload";
import { openFile } from "@/SnortUtils";
import Textarea from "../Textarea";
import { Chat } from "@/chat";
import { AsyncIcon } from "@/Element/AsyncIcon";
export default function WriteMessage({ chat }: { chat: Chat }) {
const [msg, setMsg] = useState("");
const [otherEvents, setOtherEvents] = useState<Array<NostrEvent>>([]);
const [error, setError] = useState("");
const { publisher, system } = useEventPublisher();
const uploader = useFileUpload();
async function attachFile() {
try {
const file = await openFile();
if (file) {
uploadFile(file);
}
} catch (e) {
if (e instanceof Error) {
setError(e.message);
}
}
}
async function uploadFile(file: File | Blob) {
try {
if (file) {
const rx = await uploader.upload(file, file.name);
if (rx.header) {
const link = `nostr:${new NostrLink(NostrPrefix.Event, rx.header.id, rx.header.kind).encode(
CONFIG.eventLinkPrefix,
)}`;
setMsg(`${msg ? `${msg}\n` : ""}${link}`);
setOtherEvents([...otherEvents, rx.header]);
} else if (rx.url) {
setMsg(`${msg ? `${msg}\n` : ""}${rx.url}`);
} else if (rx?.error) {
setError(rx.error);
}
}
} catch (e) {
if (e instanceof Error) {
setError(e.message);
}
}
}
async function sendMessage() {
if (msg && publisher && chat) {
@ -72,7 +30,6 @@ export default function WriteMessage({ chat }: { chat: Chat }) {
return (
<>
<AsyncIcon className="circle flex items-center button" iconName="attachment" onClick={() => attachFile()} />
<div className="grow">
<Textarea
autoFocus={true}
@ -85,7 +42,6 @@ export default function WriteMessage({ chat }: { chat: Chat }) {
// ignored
}}
/>
{error && <b className="error">{error}</b>}
</div>
<AsyncIcon className="circle flex items-center button" iconName="arrow-right" onClick={() => sendMessage()} />
</>

View File

@ -1,12 +0,0 @@
nav.deck {
width: 48px;
height: calc(100vh - 20px);
padding: 10px 8px;
border-right: 1px solid var(--border-color);
text-align: center;
}
nav.deck .avatar {
width: 40px;
height: 40px;
}

View File

@ -1,38 +0,0 @@
import { useUserProfile } from "@snort/system-react";
import Avatar from "@/Element/User/Avatar";
import useLogin from "@/Hooks/useLogin";
import "./Nav.css";
import Icon from "@/Icons/Icon";
import { Link } from "react-router-dom";
import { NoteCreatorButton } from "@/Element/Event/NoteCreatorButton";
import { ProfileLink } from "@/Element/User/ProfileLink";
export function DeckNav() {
const { publicKey } = useLogin();
const profile = useUserProfile(publicKey);
const unreadDms = 0;
return (
<nav className="deck flex flex-col justify-between">
<div className="flex flex-col items-center g24">
<Link className="btn" to="/messages">
<Icon name="mail" size={24} />
{unreadDms > 0 && <span className="has-unread"></span>}
</Link>
<NoteCreatorButton />
</div>
<div className="flex flex-col items-center g16">
{/*<Link className="btn" to="/">
<Icon name="grid-01" size={24} />
</Link>*/}
<Link className="btn" to="/settings">
<Icon name="settings-02" size={24} />
</Link>
<ProfileLink pubkey={publicKey ?? ""} user={profile}>
<Avatar pubkey={publicKey ?? ""} user={profile} />
</ProfileLink>
</div>
</nav>
);
}

View File

@ -1,25 +1,35 @@
import { NostrLink, NostrPrefix } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { useHover } from "@uidotdev/usehooks";
import DisplayName from "@/Element/User/DisplayName";
import { ProfileCard } from "@/Element/User/ProfileCard";
import { ProfileLink } from "@/Element/User/ProfileLink";
import { useCallback, useRef, useState } from "react";
export default function Mention({ link }: { link: NostrLink }) {
const [ref, hovering] = useHover<HTMLAnchorElement>();
const profile = useUserProfile(link.id);
const [isHovering, setIsHovering] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleMouseEnter = useCallback(() => {
hoverTimeoutRef.current && clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = setTimeout(() => setIsHovering(true), 100); // Adjust timeout as needed
}, []);
const handleMouseLeave = useCallback(() => {
hoverTimeoutRef.current && clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = setTimeout(() => setIsHovering(false), 300); // Adjust timeout as needed
}, []);
if (link.type !== NostrPrefix.Profile && link.type !== NostrPrefix.PublicKey) return;
return (
<>
<span className="highlight" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<ProfileLink pubkey={link.id} link={link} user={profile} onClick={e => e.stopPropagation()}>
<span ref={ref}>
@<DisplayName user={profile} pubkey={link.id} />
</span>
@<DisplayName user={profile} pubkey={link.id} />
</ProfileLink>
<ProfileCard pubkey={link.id} user={profile} show={hovering} ref={ref} />
</>
{isHovering && <ProfileCard pubkey={link.id} user={profile} show={true} />}
</span>
);
}

View File

@ -0,0 +1,39 @@
import React from "react";
interface ErrorBoundaryState {
hasError: boolean;
errorMessage?: string;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
}
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, errorMessage: error.message };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("Caught an error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
// Render any custom fallback UI with the error message
return (
<div className="p-2">
<h1>Something went wrong.</h1>
<p>Error: {this.state.errorMessage}</p>
</div>
);
}
return this.props.children;
}
}

View File

@ -5,10 +5,6 @@
gap: 16px;
}
.note:hover {
cursor: pointer;
}
.note > .header .reply {
font-size: 13px;
color: var(--font-secondary-color);

View File

@ -23,6 +23,7 @@ export interface NoteProps {
threadChains?: Map<string, Array<NostrEvent>>;
context?: ReactNode;
options?: {
isRoot?: boolean;
showHeader?: boolean;
showContextMenu?: boolean;
showTime?: boolean;
@ -36,6 +37,7 @@ export interface NoteProps {
canClick?: boolean;
showMediaSpotlight?: boolean;
longFormPreview?: boolean;
truncate?: boolean;
};
}

View File

@ -200,7 +200,7 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
<>
<Menu
menuButton={
<div className="reaction-pill">
<div className="reaction-pill cursor-pointer">
<Icon name="dots" size={15} />
</div>
}

View File

@ -9,8 +9,17 @@ import useLogin from "@/Hooks/useLogin";
import Icon from "@/Icons/Icon";
import { useNoteCreator } from "@/State/NoteCreator";
import { NoteCreator } from "./NoteCreator";
import { FormattedMessage } from "react-intl";
export const NoteCreatorButton = ({ className }: { className?: string }) => {
export const NoteCreatorButton = ({
className,
alwaysShow,
showText,
}: {
className?: string;
alwaysShow?: boolean;
showText?: boolean;
}) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const location = useLocation();
const { readonly } = useLogin(s => ({ readonly: s.readonly }));
@ -27,6 +36,9 @@ export const NoteCreatorButton = ({ className }: { className?: string }) => {
});
const shouldHideNoteCreator = useMemo(() => {
if (alwaysShow) {
return false;
}
const isReply = replyTo && show;
const hideOn = [
"/settings",
@ -48,7 +60,7 @@ export const NoteCreatorButton = ({ className }: { className?: string }) => {
{!shouldHideNoteCreator && (
<button
ref={buttonRef}
className={classNames("primary circle", className)}
className={classNames("flex flex-row items-center primary rounded-full", { circle: !showText }, className)}
onClick={() =>
update(v => {
v.replyTo = undefined;
@ -56,6 +68,11 @@ export const NoteCreatorButton = ({ className }: { className?: string }) => {
})
}>
<Icon name="plus" size={16} />
{showText && (
<span className="ml-2 hidden xl:inline">
<FormattedMessage defaultMessage="New Note" id="2mcwT8" />
</span>
)}
</button>
)}
<NoteCreator key="global-note-creator" />

View File

@ -316,7 +316,7 @@ const AsyncFooterIcon = forwardRef((props: AsyncIconProps & { value: number }, r
const mergedProps = {
...props,
iconSize: 18,
className: classNames("transition duration-200 ease-in-out reaction-pill", props.className),
className: classNames("transition duration-200 ease-in-out reaction-pill cursor-pointer", props.className),
};
return (

View File

@ -27,6 +27,8 @@ import { NoteProps } from "./Note";
import { chainKey } from "@/Hooks/useThreadContext";
import { ProfileLink } from "@/Element/User/ProfileLink";
const TEXT_TRUNCATE_LENGTH = 400;
export function NoteInner(props: NoteProps) {
const { data: ev, related, highlight, options: opt, ignoreModeration = false, className } = props;
@ -43,6 +45,7 @@ export function NoteInner(props: NoteProps) {
const [translated, setTranslated] = useState<NoteTranslation>();
const [showTranslation, setShowTranslation] = useState(true);
const { formatMessage } = useIntl();
const [showMore, setShowMore] = useState(false);
const totalReactions = reactions.positive.length + reactions.negative.length + reposts.length + zaps.length;
@ -78,22 +81,46 @@ export function NoteInner(props: NoteProps) {
}
}
const ToggleShowMore = () => (
<a
className="highlight"
onClick={e => {
e.preventDefault();
e.stopPropagation();
setShowMore(!showMore);
}}>
{showMore ? (
<FormattedMessage defaultMessage="Show less" id="qyJtWy" />
) : (
<FormattedMessage defaultMessage="Show more" id="aWpBzj" />
)}
</a>
);
const innerContent = useMemo(() => {
const body = translated && showTranslation ? translated.text : ev?.content ?? "";
const id = translated && showTranslation ? `${ev.id}-translated` : ev.id;
const shouldTruncate = opt?.truncate && body.length > TEXT_TRUNCATE_LENGTH;
return (
<Text
id={id}
highlighText={props.searchedValue}
content={body}
tags={ev.tags}
creator={ev.pubkey}
depth={props.depth}
disableMedia={!(options.showMedia ?? true)}
disableMediaSpotlight={!(props.options?.showMediaSpotlight ?? true)}
/>
<>
{shouldTruncate && showMore && <ToggleShowMore />}
<Text
id={id}
highlighText={props.searchedValue}
content={body}
tags={ev.tags}
creator={ev.pubkey}
depth={props.depth}
disableMedia={!(options.showMedia ?? true)}
disableMediaSpotlight={!(props.options?.showMediaSpotlight ?? true)}
truncate={shouldTruncate && !showMore ? TEXT_TRUNCATE_LENGTH : undefined}
/>
{shouldTruncate && !showMore && <ToggleShowMore />}
</>
);
}, [
showMore,
ev,
translated,
showTranslation,
@ -101,6 +128,8 @@ export function NoteInner(props: NoteProps) {
props.depth,
options.showMedia,
props.options?.showMediaSpotlight,
opt?.truncate,
TEXT_TRUNCATE_LENGTH,
]);
const transformBody = () => {
@ -148,15 +177,24 @@ export function NoteInner(props: NoteProps) {
return innerContent;
};
function goToEvent(
e: React.MouseEvent,
eTarget: TaggedNostrEvent,
isTargetAllowed: boolean = e.target === e.currentTarget,
) {
if (!isTargetAllowed || opt?.canClick === false) {
function goToEvent(e: React.MouseEvent, eTarget: TaggedNostrEvent) {
if (opt?.canClick === false) {
return;
}
let target = e.target as HTMLElement | null;
while (target) {
if (
target.tagName === "A" ||
target.tagName === "BUTTON" ||
target.classList.contains("reaction-pill") ||
target.classList.contains("szh-menu-container")
) {
return; // is there a better way to do this?
}
target = target.parentElement;
}
e.stopPropagation();
if (props.onClick) {
props.onClick(eTarget);
@ -326,7 +364,7 @@ export function NoteInner(props: NoteProps) {
{translation()}
{pollOptions()}
{options.showReactionsLink && (
<div className="reactions-link" onClick={() => setShowReactions(true)}>
<div className="reactions-link cursor-pointer" onClick={() => setShowReactions(true)}>
<FormattedMessage {...messages.ReactionsLink} values={{ n: totalReactions }} />
</div>
)}
@ -353,7 +391,13 @@ export function NoteInner(props: NoteProps) {
}
const note = (
<div className={classNames(baseClassName, { active: highlight })} onClick={e => goToEvent(e, ev)} ref={ref}>
<div
className={classNames(baseClassName, {
active: highlight,
"hover:bg-nearly-bg-color cursor-pointer": !opt?.isRoot,
})}
onClick={e => goToEvent(e, ev)}
ref={ref}>
{content()}
</div>
);

View File

@ -15,6 +15,7 @@ export default function NoteQuote({ link, depth }: { link: NostrLink; depth?: nu
depth={(depth ?? 0) + 1}
options={{
showFooter: false,
truncate: true,
}}
/>
);

View File

@ -1,44 +1,50 @@
import { useEffect, useState } from "react";
const MinuteInMs = 1_000 * 60;
const HourInMs = MinuteInMs * 60;
const DayInMs = HourInMs * 24;
import { FormattedMessage } from "react-intl";
export interface NoteTimeProps {
from: number;
fallback?: string;
}
const secondsInAMinute = 60;
const secondsInAnHour = secondsInAMinute * 60;
const secondsInADay = secondsInAnHour * 24;
export default function NoteTime(props: NoteTimeProps) {
const [time, setTime] = useState<string>();
const [time, setTime] = useState<string | JSX.Element>();
const { from, fallback } = props;
const absoluteTime = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "long",
}).format(from);
const fromDate = new Date(from);
const isoDate = fromDate.toISOString();
const isoDate = new Date(from).toISOString();
function calcTime() {
const fromDate = new Date(from);
const ago = new Date().getTime() - from;
const absAgo = Math.abs(ago);
if (absAgo > DayInMs) {
return fromDate.toLocaleDateString(undefined, {
year: "2-digit",
month: "short",
day: "2-digit",
});
} else if (absAgo > HourInMs) {
return `${fromDate.getHours().toString().padStart(2, "0")}:${fromDate.getMinutes().toString().padStart(2, "0")}`;
} else if (absAgo < MinuteInMs) {
return fallback;
const currentTime = new Date();
const timeDifference = Math.floor((currentTime.getTime() - fromDate.getTime()) / 1000);
if (timeDifference < secondsInAMinute) {
return <FormattedMessage defaultMessage="now" id="kaaf1E" />;
} else if (timeDifference < secondsInAnHour) {
return `${Math.floor(timeDifference / secondsInAMinute)}m`;
} else if (timeDifference < secondsInADay) {
return `${Math.floor(timeDifference / secondsInAnHour)}h`;
} else {
const mins = Math.floor(absAgo / MinuteInMs);
if (ago < 0) {
return `in ${mins}m`;
if (fromDate.getFullYear() === currentTime.getFullYear()) {
return fromDate.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
} else {
return fromDate.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
return `${mins}m`;
}
}
@ -52,13 +58,13 @@ export default function NoteTime(props: NoteTimeProps) {
}
return s;
});
}, MinuteInMs);
}, 60_000); // update every minute
return () => clearInterval(t);
}, [from]);
return (
<time dateTime={isoDate} title={absoluteTime}>
{time}
{time || fallback}
</time>
);
}

View File

@ -261,7 +261,7 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
key={note.id}
data={note}
related={getLinkReactions(thread.reactions, NostrLink.fromEvent(note))}
options={{ showReactionsLink: true, showMediaSpotlight: !props.disableSpotlight }}
options={{ showReactionsLink: true, showMediaSpotlight: !props.disableSpotlight, isRoot: true }}
onClick={navigateThread}
threadChains={thread.chains}
/>

View File

@ -103,6 +103,9 @@ export function TimelineFragment(props: TimelineFragProps) {
depth={0}
onClick={props.noteOnClick}
context={props.noteContext?.(e)}
options={{
truncate: true,
}}
/>
),
)}

View File

@ -8,7 +8,9 @@ import { NostrLink, tryParseNostrLink } from "@snort/system";
import { useLocation, useNavigate } from "react-router-dom";
import { unixNow } from "@snort/shared";
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "../Feed/TimelineFeed";
import Note from "./Event/Note";
import { fuzzySearch, FuzzySearchResult } from "@/index";
import ProfileImage from "@/Element/User/ProfileImage";
import { socialGraphInstance } from "@snort/system";
const MAX_RESULTS = 3;
@ -39,6 +41,38 @@ export default function SearchBox() {
const { main } = useTimelineFeed(subject, options);
const [results, setResults] = useState<FuzzySearchResult[]>([]);
useEffect(() => {
const searchString = search.trim();
const fuseResults = fuzzySearch.search(searchString);
const followDistanceNormalizationFactor = 3;
const combinedResults = fuseResults.map(result => {
const fuseScore = result.score === undefined ? 1 : result.score;
const followDistance =
socialGraphInstance.getFollowDistance(result.item.pubkey) / followDistanceNormalizationFactor;
const startsWithSearchString = [result.item.name, result.item.display_name, result.item.nip05].some(
field => field && field.toLowerCase?.().startsWith(searchString.toLowerCase()),
);
const boostFactor = startsWithSearchString ? 0.25 : 1;
const weightForFuseScore = 0.8;
const weightForFollowDistance = 0.2;
const combinedScore = (fuseScore * weightForFuseScore + followDistance * weightForFollowDistance) * boostFactor;
return { ...result, combinedScore };
});
// Sort by combined score, lower is better
combinedResults.sort((a, b) => a.combinedScore - b.combinedScore);
setResults(combinedResults.map(r => r.item));
}, [search, main]);
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
@ -92,8 +126,8 @@ export default function SearchBox() {
case "Enter":
if (activeIndex === 0) {
navigate(`/search/${encodeURIComponent(search)}`);
} else if (activeIndex > 0 && main) {
const selectedResult = main[activeIndex - 1];
} else if (activeIndex > 0 && results) {
const selectedResult = results[activeIndex - 1];
navigate(`/${new NostrLink(CONFIG.profileLinkPrefix, selectedResult.pubkey).encode()}`);
} else {
executeSearch();
@ -101,7 +135,7 @@ export default function SearchBox() {
break;
case "ArrowDown":
e.preventDefault();
setActiveIndex(prev => Math.min(prev + 1, Math.min(MAX_RESULTS, main ? main.length : 0)));
setActiveIndex(prev => Math.min(prev + 1, Math.min(MAX_RESULTS, results ? results.length : 0)));
break;
case "ArrowUp":
e.preventDefault();
@ -143,7 +177,7 @@ export default function SearchBox() {
onClick={() => navigate(`/search/${encodeURIComponent(search)}`, { state: { forceRefresh: true } })}>
<FormattedMessage defaultMessage="Search notes" id="EJbFi7" />: <b>{search}</b>
</div>
{main?.slice(0, MAX_RESULTS).map((result, idx) => (
{results?.slice(0, MAX_RESULTS).map((result, idx) => (
<div
key={idx}
className={`p-2 cursor-pointer ${
@ -152,7 +186,7 @@ export default function SearchBox() {
: "hover:bg-neutral-200 dark:hover:bg-neutral-800"
}`}
onMouseEnter={() => setActiveIndex(idx + 1)}>
<Note data={result} depth={0} related={[]} />
<ProfileImage pubkey={result.pubkey} />
</div>
))}
</div>

View File

@ -7,7 +7,7 @@
text-overflow: ellipsis;
white-space: pre-wrap;
display: inline;
overflow-wrap: break-word;
overflow-wrap: anywhere;
}
.text .text-frag > a {

View File

@ -5,8 +5,18 @@ import NostrBandApi from "@/External/NostrBand";
import { ErrorOrOffline } from "./ErrorOrOffline";
import { HashTagHeader } from "@/Pages/HashTagsPage";
import { useLocale } from "@/IntlProvider";
import classNames from "classnames";
import { Link } from "react-router-dom";
export default function TrendingHashtags({ title }: { title?: ReactNode }) {
export default function TrendingHashtags({
title,
count = Infinity,
short,
}: {
title?: ReactNode;
count?: number;
short?: boolean;
}) {
const [hashtags, setHashtags] = useState<Array<{ hashtag: string; posts: number }>>();
const [error, setError] = useState<Error>();
const { lang } = useLocale();
@ -14,7 +24,7 @@ export default function TrendingHashtags({ title }: { title?: ReactNode }) {
async function loadTrendingHashtags() {
const api = new NostrBandApi();
const rsp = await api.trendingHashtags(lang);
setHashtags(rsp.hashtags);
setHashtags(rsp.hashtags.slice(0, count)); // Limit the number of hashtags to the count
}
useEffect(() => {
@ -31,9 +41,20 @@ export default function TrendingHashtags({ title }: { title?: ReactNode }) {
return (
<>
{title}
{hashtags.map(a => (
<HashTagHeader tag={a.hashtag} events={a.posts} className="bb p" />
))}
{hashtags.map(a => {
if (short) {
// return just the hashtag (not HashTagHeader) and post count
return (
<div className="my-1 font-bold" key={a.hashtag}>
<Link to={`/t/${a.hashtag}`} key={a.hashtag}>
#{a.hashtag}
</Link>
</div>
);
} else {
return <HashTagHeader tag={a.hashtag} events={a.posts} className={classNames("bb", { p: !short })} />;
}
})}
</>
);
}

View File

@ -9,7 +9,8 @@ import { ErrorOrOffline } from "@/Element/ErrorOrOffline";
import { useLocale } from "@/IntlProvider";
import useModeration from "@/Hooks/useModeration";
export default function TrendingNotes() {
export default function TrendingNotes({ count = Infinity, small = false }) {
// Added count prop with a default value
const [posts, setPosts] = useState<Array<NostrEvent>>();
const [error, setError] = useState<Error>();
const { lang } = useLocale();
@ -33,13 +34,24 @@ export default function TrendingNotes() {
if (error) return <ErrorOrOffline error={error} onRetry={loadTrendingNotes} className="p" />;
if (!posts) return <PageSpinner />;
// if small, render less stuff
const options = {
showFooter: !small,
showReactionsLink: !small,
showMedia: !small,
longFormPreview: !small,
truncate: small,
showContextMenu: !small,
};
return (
<>
<div className="flex flex-col gap-4">
{posts
.filter(a => !isEventMuted(a))
.slice(0, count) // Limit the number of posts displayed
.map(e => (
<Note key={e.id} data={e as TaggedNostrEvent} related={related?.data ?? []} depth={0} />
<Note key={e.id} data={e as TaggedNostrEvent} related={related?.data ?? []} depth={0} options={options} />
))}
</>
</div>
);
}

View File

@ -6,14 +6,14 @@ import PageSpinner from "@/Element/PageSpinner";
import NostrBandApi from "@/External/NostrBand";
import { ErrorOrOffline } from "./ErrorOrOffline";
export default function TrendingUsers({ title }: { title?: ReactNode }) {
export default function TrendingUsers({ title, count = Infinity }: { title?: ReactNode; count?: number }) {
const [userList, setUserList] = useState<HexKey[]>();
const [error, setError] = useState<Error>();
async function loadTrendingUsers() {
const api = new NostrBandApi();
const users = await api.trendingProfiles();
const keys = users.profiles.map(a => a.pubkey);
const keys = users.profiles.map(a => a.pubkey).slice(0, count); // Limit the user list to the count
setUserList(keys);
}

View File

@ -10,6 +10,7 @@
box-sizing: border-box;
background-position: center;
background-color: var(--gray);
z-index: 2;
}
.avatar[data-domain="iris.to"],

View File

@ -0,0 +1,28 @@
import React from "react";
import { HexKey, socialGraphInstance } from "@snort/system";
import Icon from "@/Icons/Icon";
import classNames from "classnames";
interface FollowDistanceIndicatorProps {
pubkey: HexKey;
className?: string;
}
export default function FollowDistanceIndicator({ pubkey, className }: FollowDistanceIndicatorProps) {
const followDistance = socialGraphInstance.getFollowDistance(pubkey);
let followDistanceColor = "";
if (followDistance <= 1) {
followDistanceColor = "success";
} else if (followDistance === 2 && socialGraphInstance.followedByFriendsCount(pubkey) >= 10) {
followDistanceColor = "text-nostr-orange";
} else if (followDistance > 2) {
return null;
}
return (
<span className={classNames("icon-circle", className)}>
<Icon name="check" className={followDistanceColor} size={10} />
</span>
);
}

View File

@ -10,28 +10,15 @@ import Text from "@/Element/Text";
import { useEffect, useState } from "react";
import useLogin from "../../Hooks/useLogin";
interface RectElement {
getBoundingClientRect(): {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
};
}
export function ProfileCard({
pubkey,
user,
show,
ref,
delay,
}: {
pubkey: string;
user?: UserMetadata;
show: boolean;
ref: React.RefObject<Element | RectElement>;
delay?: number;
}) {
const [showProfileMenu, setShowProfileMenu] = useState(false);
@ -56,7 +43,6 @@ export function ProfileCard({
return (
<ControlledMenu
state={showProfileMenu ? "open" : "closed"}
anchorRef={ref}
menuClassName="profile-card"
onClose={() => setShowProfileMenu(false)}
align="end">

View File

@ -1,17 +1,16 @@
import "./ProfileImage.css";
import React, { ReactNode } from "react";
import { HexKey, socialGraphInstance, UserMetadata } from "@snort/system";
import React, { ReactNode, useCallback, useRef, useState } from "react";
import { HexKey, UserMetadata } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { useHover } from "@uidotdev/usehooks";
import classNames from "classnames";
import Avatar from "@/Element/User/Avatar";
import Nip05 from "@/Element/User/Nip05";
import Icon from "@/Icons/Icon";
import DisplayName from "./DisplayName";
import { ProfileLink } from "./ProfileLink";
import { ProfileCard } from "./ProfileCard";
import FollowDistanceIndicator from "@/Element/User/FollowDistanceIndicator";
export interface ProfileImageProps {
pubkey: HexKey;
@ -21,6 +20,7 @@ export interface ProfileImageProps {
link?: string;
defaultNip?: string;
verifyNip?: boolean;
showNip05?: boolean;
overrideUsername?: string;
profile?: UserMetadata;
size?: number;
@ -39,6 +39,7 @@ export default function ProfileImage({
link,
defaultNip,
verifyNip,
showNip05 = true,
overrideUsername,
profile,
size,
@ -50,8 +51,19 @@ export default function ProfileImage({
}: ProfileImageProps) {
const user = useUserProfile(profile ? "" : pubkey) ?? profile;
const nip05 = defaultNip ? defaultNip : user?.nip05;
const followDistance = socialGraphInstance.getFollowDistance(pubkey);
const [ref, hovering] = useHover<HTMLDivElement>();
const [isHovering, setIsHovering] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleMouseEnter = useCallback(() => {
hoverTimeoutRef.current && clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = setTimeout(() => setIsHovering(true), 100); // Adjust timeout as needed
}, []);
const handleMouseLeave = useCallback(() => {
hoverTimeoutRef.current && clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = setTimeout(() => setIsHovering(false), 300); // Adjust timeout as needed
}, []);
function handleClick(e: React.MouseEvent) {
if (link === "") {
@ -61,29 +73,19 @@ export default function ProfileImage({
}
function inner() {
let followDistanceColor = "";
if (followDistance <= 1) {
followDistanceColor = "success";
} else if (followDistance === 2 && socialGraphInstance.followedByFriendsCount(pubkey) >= 10) {
followDistanceColor = "text-nostr-orange";
}
return (
<>
<div className="avatar-wrapper" ref={ref}>
<div className="avatar-wrapper" onMouseEnter={handleMouseEnter}>
<Avatar
pubkey={pubkey}
user={user}
size={size}
imageOverlay={imageOverlay}
icons={
(followDistance <= 2 && showFollowDistance) || icons ? (
showFollowDistance || icons ? (
<>
{icons}
{showFollowDistance && (
<div className="icon-circle">
<Icon name="check" className={followDistanceColor} size={10} />
</div>
)}
{showFollowDistance && <FollowDistanceIndicator pubkey={pubkey} />}
</>
) : undefined
}
@ -93,7 +95,7 @@ export default function ProfileImage({
<div className="f-ellipsis">
<div className="flex g4 username">
{overrideUsername ? overrideUsername : <DisplayName pubkey={pubkey} user={user} />}
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} verifyNip={verifyNip} />}
{showNip05 && nip05 && <Nip05 nip05={nip05} pubkey={pubkey} verifyNip={verifyNip} />}
</div>
<div className="subheader">{subHeader}</div>
</div>
@ -103,8 +105,12 @@ export default function ProfileImage({
}
function profileCard() {
if ((showProfileCard ?? true) && user) {
return <ProfileCard pubkey={pubkey} user={user} show={hovering} ref={ref} />;
if ((showProfileCard ?? true) && user && isHovering) {
return (
<div className="absolute shadow-lg z-10">
<ProfileCard pubkey={pubkey} user={user} show={true} />
</div>
);
}
return null;
}
@ -120,7 +126,7 @@ export default function ProfileImage({
);
} else {
return (
<>
<div className="relative" onMouseLeave={handleMouseLeave}>
<ProfileLink
pubkey={pubkey}
className={classNames("pfp", className)}
@ -130,7 +136,7 @@ export default function ProfileImage({
{inner()}
</ProfileLink>
{profileCard()}
</>
</div>
);
}
}

View File

@ -38,7 +38,10 @@ export default function ProfilePreview(props: ProfilePreviewProps) {
return (
<>
<div className={`profile-preview${props.className ? ` ${props.className}` : ""}`} ref={ref} onClick={handleClick}>
<div
className={`justify-between profile-preview${props.className ? ` ${props.className}` : ""}`}
ref={ref}
onClick={handleClick}>
{inView && (
<>
<ProfileImage

View File

@ -0,0 +1,43 @@
import Fuse from "fuse.js";
import { socialGraphInstance } from "@snort/system";
import { System } from ".";
export type FuzzySearchResult = {
pubkey: string;
name?: string;
username?: string;
nip05?: string;
};
export const fuzzySearch = new Fuse<FuzzySearchResult>([], {
keys: ["name", "username", { name: "nip05", weight: 0.5 }],
threshold: 0.3,
// sortFn here?
});
const profileTimestamps = new Map<string, number>(); // is this somewhere in cache?
System.on("event", ev => {
if (ev.kind === 0) {
const existing = profileTimestamps.get(ev.pubkey);
if (existing) {
if (existing > ev.created_at) {
return;
}
fuzzySearch.remove(doc => doc.pubkey === ev.pubkey);
}
profileTimestamps.set(ev.pubkey, ev.created_at);
try {
const data = JSON.parse(ev.content);
if (ev.pubkey && (data.name || data.username || data.nip05)) {
data.pubkey = ev.pubkey;
fuzzySearch.add(data);
}
} catch (e) {
console.error(e);
}
}
if (ev.kind === 3) {
socialGraphInstance.handleFollowEvent(ev);
}
});

View File

@ -4,11 +4,10 @@ import { Outlet, useNavigate } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { DeckNav } from "@/Element/Deck/Nav";
import useLoginFeed from "@/Feed/LoginFeed";
import { useLoginRelays } from "@/Hooks/useLoginRelays";
import { useTheme } from "@/Hooks/useTheme";
import Articles from "@/Element/Deck/Articles";
import Articles from "@/Element/Articles";
import TimelineFollows from "@/Element/Feed/TimelineFollows";
import { transformTextCached } from "@/Hooks/useTextTransformCache";
import Icon from "@/Icons/Icon";
@ -22,6 +21,8 @@ import { ThreadContext, ThreadContextWrapper } from "@/Hooks/useThreadContext";
import Toaster from "@/Toaster";
import useLogin from "@/Hooks/useLogin";
import { LongFormText } from "@/Element/Event/LongFormText";
import NavSidebar from "@/Pages/Layout/NavSidebar";
import ErrorBoundary from "@/Element/ErrorBoundary";
type Cols = "notes" | "articles" | "media" | "streams" | "notifications";
@ -67,47 +68,49 @@ export function SnortDeckLayout() {
setArticle: (e?: TaggedNostrEvent) => setDeckState({ article: e }),
reset: () => setDeckState({}),
}}>
<DeckNav />
<div className="deck-cols">
{cols.map(c => {
switch (c) {
case "notes":
return <NotesCol />;
case "media":
return <MediaCol setThread={t => setDeckState({ thread: t })} />;
case "articles":
return <ArticlesCol />;
case "notifications":
return <NotificationsCol setThread={t => setDeckState({ thread: t })} />;
}
})}
</div>
{deckState.thread && (
<>
<Modal id="thread-overlay" onClose={() => setDeckState({})} className="thread-overlay thread">
<ThreadContextWrapper link={deckState.thread}>
<SpotlightFromThread onClose={() => setDeckState({})} />
<div>
<Thread onBack={() => setDeckState({})} disableSpotlight={true} />
<NavSidebar narrow={true} />
<ErrorBoundary>
<div className="deck-cols">
{cols.map(c => {
switch (c) {
case "notes":
return <NotesCol />;
case "media":
return <MediaCol setThread={t => setDeckState({ thread: t })} />;
case "articles":
return <ArticlesCol />;
case "notifications":
return <NotificationsCol setThread={t => setDeckState({ thread: t })} />;
}
})}
</div>
{deckState.thread && (
<>
<Modal id="thread-overlay" onClose={() => setDeckState({})} className="thread-overlay thread">
<ThreadContextWrapper link={deckState.thread}>
<SpotlightFromThread onClose={() => setDeckState({})} />
<div>
<Thread onBack={() => setDeckState({})} disableSpotlight={true} />
</div>
</ThreadContextWrapper>
</Modal>
</>
)}
{deckState.article && (
<>
<Modal
id="thread-overlay-article"
onClose={() => setDeckState({})}
className="thread-overlay long-form"
onClick={() => setDeckState({})}>
<div onClick={e => e.stopPropagation()}>
<LongFormText ev={deckState.article} isPreview={false} related={[]} />
</div>
</ThreadContextWrapper>
</Modal>
</>
)}
{deckState.article && (
<>
<Modal
id="thread-overlay-article"
onClose={() => setDeckState({})}
className="thread-overlay long-form"
onClick={() => setDeckState({})}>
<div onClick={e => e.stopPropagation()}>
<LongFormText ev={deckState.article} isPreview={false} related={[]} />
</div>
</Modal>
</>
)}
<Toaster />
</Modal>
</>
)}
<Toaster />
</ErrorBoundary>
</DeckContext.Provider>
</div>
);

View File

@ -1,94 +1,20 @@
import "./Layout.css";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { Link, useNavigate } from "react-router-dom";
import { useUserProfile } from "@snort/system-react";
import { useMemo, useSyncExternalStore } from "react";
import { base64 } from "@scure/base";
import { unwrap } from "@snort/shared";
import Icon from "@/Icons/Icon";
import useLoginFeed from "@/Feed/LoginFeed";
import { mapPlanName } from "./subscribe";
import useLogin from "@/Hooks/useLogin";
import Avatar from "@/Element/User/Avatar";
import { isHalloween, isFormElement, isStPatricksDay, isChristmas } from "@/SnortUtils";
import { getCurrentSubscription } from "@/Subscription";
import Toaster from "@/Toaster";
import { useTheme } from "@/Hooks/useTheme";
import { useLoginRelays } from "@/Hooks/useLoginRelays";
import { LoginUnlock } from "@/Element/PinPrompt";
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
import { LoginStore } from "@/Login";
import { NoteCreatorButton } from "@/Element/Event/NoteCreatorButton";
import { FormattedMessage } from "react-intl";
import SearchBox from "@/Element/SearchBox";
import { ProfileLink } from "@/Element/User/ProfileLink";
import SearchBox from "../Element/SearchBox";
import SnortApi from "@/External/SnortApi";
import Avatar from "@/Element/User/Avatar";
import Icon from "@/Icons/Icon";
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
import { isFormElement } from "@/SnortUtils";
import useLogin from "@/Hooks/useLogin";
import useEventPublisher from "@/Hooks/useEventPublisher";
import SnortApi from "@/External/SnortApi";
import { Notifications } from "@/Cache";
export default function Layout() {
const location = useLocation();
const [pageClass, setPageClass] = useState("page");
const { id, stalker } = useLogin(s => ({ id: s.id, stalker: s.stalker ?? false }));
useLoginFeed();
useTheme();
useLoginRelays();
useKeyboardShortcut(".", event => {
// if event happened in a form element, do nothing, otherwise focus on search input
if (event.target && !isFormElement(event.target as HTMLElement)) {
event.preventDefault();
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
});
const shouldHideHeader = useMemo(() => {
const hideOn = ["/login", "/new"];
return hideOn.some(a => location.pathname.startsWith(a));
}, [location]);
useEffect(() => {
const widePage = ["/login", "/messages"];
const noScroll = ["/messages"];
if (widePage.some(a => location.pathname.startsWith(a))) {
setPageClass(noScroll.some(a => location.pathname.startsWith(a)) ? "scroll-lock" : "");
} else {
setPageClass("page");
}
}, [location]);
return (
<>
<div className={pageClass}>
{!shouldHideHeader && (
<header className="main-content">
<LogoHeader />
<AccountHeader />
</header>
)}
<Outlet />
<NoteCreatorButton className="note-create-button" />
<Toaster />
</div>
<LoginUnlock />
{stalker && (
<div
className="stalker"
onClick={() => {
LoginStore.removeSession(id);
}}>
<button type="button" className="circle flex items-center">
<Icon name="close" />
</button>
</div>
)}
</>
);
}
const AccountHeader = () => {
const navigate = useNavigate();
@ -170,32 +96,6 @@ const AccountHeader = () => {
);
};
function LogoHeader() {
const { subscriptions } = useLogin();
const currentSubscription = getCurrentSubscription(subscriptions);
const extra = () => {
if (isHalloween()) return "🎃";
if (isStPatricksDay()) return "🍀";
if (isChristmas()) return "🎄";
};
return (
<Link to="/" className="logo">
<h1>
{extra()}
{CONFIG.appName}
</h1>
{currentSubscription && (
<div className="flex items-center g4 text-sm font-semibold tracking-wider">
<Icon name="diamond" size={16} className="text-pro" />
{mapPlanName(currentSubscription.type)}
</div>
)}
</Link>
);
}
function HasNotificationsMarker() {
const readNotifications = useLogin(s => s.readNotifications);
const notifications = useSyncExternalStore(
@ -215,3 +115,5 @@ function HasNotificationsMarker() {
return <span className="has-unread"></span>;
}
}
export default AccountHeader;

View File

@ -0,0 +1,45 @@
import useLogin from "../../Hooks/useLogin";
import { getCurrentSubscription } from "../../Subscription";
import { isChristmas, isHalloween, isStPatricksDay } from "../../SnortUtils";
import { Link } from "react-router-dom";
import { mapPlanName } from "../subscribe";
import Icon from "@/Icons/Icon";
export function LogoHeader({ showText = false }) {
const { subscriptions } = useLogin();
const currentSubscription = getCurrentSubscription(subscriptions);
const extra = () => {
if (isHalloween()) return "🎃";
if (isStPatricksDay()) return "🍀";
if (isChristmas()) return "🎄";
};
const handleLogoClick = () => {
window.scrollTo({ top: 0, behavior: "instant" });
};
return (
<Link to="/" className="logo" onClick={handleLogoClick}>
<h1 className="flex flex-row items-center">
{CONFIG.navLogo && <img src={CONFIG.navLogo} className="w-8" />}
{!CONFIG.navLogo && (
<span className="text-2xl p-5 hidden md:flex xl:hidden w-8 h-8 rounded-xl bg-dark text-xl font-bold flex items-center justify-center">
{CONFIG.appName[0]}
</span>
)}
{showText && (
<div className="md:hidden xl:inline ml-2">
{extra()}
{CONFIG.appName}
</div>
)}
</h1>
{currentSubscription && (
<div className="flex items-center g4 text-sm font-semibold tracking-wider">
<Icon name="diamond" size={16} className="text-pro" />
{mapPlanName(currentSubscription.type)}
</div>
)}
</Link>
);
}

View File

@ -0,0 +1,121 @@
import { LogoHeader } from "./LogoHeader";
import { NavLink, useNavigate } from "react-router-dom";
import Icon from "@/Icons/Icon";
import { ProfileLink } from "../../Element/User/ProfileLink";
import Avatar from "../../Element/User/Avatar";
import useLogin from "../../Hooks/useLogin";
import { useUserProfile } from "@snort/system-react";
import { NoteCreatorButton } from "../../Element/Event/NoteCreatorButton";
import { FormattedMessage } from "react-intl";
import classNames from "classnames";
const MENU_ITEMS = [
{
label: "Home",
icon: "home",
link: "/",
nonLoggedIn: true,
},
{
label: "Search",
icon: "search",
link: "/search",
},
{
label: "Notifications",
icon: "bell-02",
link: "/notifications",
},
{
label: "Messages",
icon: "mail",
link: "/messages",
},
{
label: "Deck",
icon: "deck",
link: "/deck",
},
{
label: "Social Graph",
icon: "graph",
link: "/graph",
},
{
label: "Settings",
icon: "settings",
link: "/settings",
},
];
const getNavLinkClass = (isActive: boolean, narrow: boolean) => {
const c = isActive
? "py-4 hover:no-underline flex flex-row items-center text-nostr-purple"
: "py-4 hover:no-underline hover:text-nostr-purple flex flex-row items-center";
return classNames(c, { "xl:ml-1": !narrow });
};
export default function NavSidebar({ narrow = false }) {
const { publicKey } = useLogin(s => ({
publicKey: s.publicKey,
latestNotification: s.latestNotification,
readNotifications: s.readNotifications,
readonly: s.readonly,
}));
const profile = useUserProfile(publicKey);
const navigate = useNavigate();
const className = classNames(
{ "xl:w-56 xl:gap-3 xl:items-start": !narrow },
"overflow-y-auto hide-scrollbar sticky items-center border-r border-neutral-900 top-0 z-20 h-screen max-h-screen hidden md:flex flex-col px-2 py-4 flex-shrink-0 gap-2",
);
return (
<div className={className}>
<LogoHeader showText={!narrow} />
<div className="flex-grow flex flex-col justify-between">
<div className={classNames({ "xl:items-start": !narrow }, "flex flex-col items-center font-bold text-lg")}>
{MENU_ITEMS.map(item => {
if (!item.nonLoggedIn && !publicKey) {
return "";
}
return (
<NavLink key={item.link} to={item.link} className={({ isActive }) => getNavLinkClass(isActive, narrow)}>
<Icon name={item.icon} size={24} />
{!narrow && <span className="hidden xl:inline ml-3">{item.label}</span>}
</NavLink>
);
})}
{publicKey ? (
<div className="mt-2">
<NoteCreatorButton alwaysShow={true} showText={!narrow} />
</div>
) : (
<div className="mt-2">
<button onClick={() => navigate("/login/sign-up")} className="flex flex-row items-center primary">
<Icon name="sign-in" size={24} />
{!narrow && (
<span className="hidden xl:inline ml-3">
<FormattedMessage defaultMessage="Sign up" id="8HJxXG" />
</span>
)}
</button>
</div>
)}
</div>
</div>
{publicKey ? (
<>
<ProfileLink pubkey={publicKey} user={profile}>
<div className="flex flex-row items-center font-bold text-md">
<Avatar pubkey={publicKey} user={profile} size={40} />
{!narrow && <span className="hidden xl:inline ml-3">{profile?.name}</span>}
</div>
</ProfileLink>
</>
) : (
""
)}
</div>
);
}

View File

@ -0,0 +1,39 @@
import SearchBox from "@/Element/SearchBox";
import TrendingUsers from "@/Element/TrendingUsers";
import TrendingHashtags from "@/Element/TrendingHashtags";
import TrendingNotes from "@/Element/TrendingPosts";
import { FormattedMessage } from "react-intl";
import classNames from "classnames";
export default function RightColumn({ show = true }) {
return (
<div
className={classNames("flex-col hidden lg:w-1/3 sticky top-0 h-screen p-2 border-l border-neutral-900", {
"lg:flex": show,
})}>
<div>
<SearchBox />
</div>
<div className="overflow-y-auto hide-scrollbar">
<div className="bg-superdark rounded-lg p-2 mt-8">
<div className="font-bold text-lg">
<FormattedMessage defaultMessage="Trending hashtags" id="CbM2hK" />
</div>
<TrendingHashtags short={true} count={5} />
</div>
<div className="bg-superdark rounded-lg p-2 mt-8">
<div className="font-bold text-lg">
<FormattedMessage defaultMessage="Trending notes" id="6k7xfM" />
</div>
<TrendingNotes small={true} count={5} />
</div>
<div className="bg-superdark rounded-lg p-2 mt-8">
<div className="font-bold text-lg">
<FormattedMessage defaultMessage="Trending users" id="arZnG2" />
</div>
<TrendingUsers count={5} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,102 @@
import "./Layout.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Outlet, useLocation } from "react-router-dom";
import Icon from "@/Icons/Icon";
import useLogin from "@/Hooks/useLogin";
import { isFormElement } from "@/SnortUtils";
import Toaster from "@/Toaster";
import { useTheme } from "@/Hooks/useTheme";
import { useLoginRelays } from "@/Hooks/useLoginRelays";
import { LoginUnlock } from "@/Element/PinPrompt";
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
import { LoginStore } from "@/Login";
import { NoteCreatorButton } from "@/Element/Event/NoteCreatorButton";
import NavSidebar from "./NavSidebar";
import AccountHeader from "./AccountHeader";
import RightColumn from "./RightColumn";
import { LogoHeader } from "./LogoHeader";
import useLoginFeed from "@/Feed/LoginFeed";
import ErrorBoundary from "@/Element/ErrorBoundary";
export default function Index() {
const location = useLocation();
const [pageClass, setPageClass] = useState("page");
const { id, stalker } = useLogin(s => ({ id: s.id, stalker: s.stalker ?? false }));
useTheme();
useLoginRelays();
useLoginFeed();
const hideHeaderPaths = ["/login", "/new"];
const hideRightColumnPaths = ["/login", "/new", "/messages", "/settings"];
const shouldHideHeader = hideHeaderPaths.some(path => location.pathname.startsWith(path));
const shouldHideRightColumn = hideRightColumnPaths.some(path => location.pathname.startsWith(path));
const pageClassPaths = useMemo(
() => ({
widePage: ["/login", "/messages"],
noScroll: ["/messages"],
}),
[],
);
useEffect(() => {
const isWidePage = pageClassPaths.widePage.some(path => location.pathname.startsWith(path));
const isNoScroll = pageClassPaths.noScroll.some(path => location.pathname.startsWith(path));
setPageClass(isWidePage ? (isNoScroll ? "scroll-lock" : "") : "page");
}, [location, pageClassPaths]);
const handleKeyboardShortcut = useCallback(event => {
if (event.target && !isFormElement(event.target as HTMLElement)) {
event.preventDefault();
window.scrollTo({ top: 0, behavior: "instant" });
}
}, []);
useKeyboardShortcut(".", handleKeyboardShortcut);
const isStalker = !!stalker;
return (
<div className="flex justify-center">
<div className={`${pageClass} w-full max-w-screen-xl`}>
{!shouldHideHeader && <Header />}
<div className="flex flex-row w-full">
<NavSidebar />
<div className="flex flex-1 flex-col overflow-x-hidden">
<ErrorBoundary>
<Outlet />
</ErrorBoundary>
</div>
<RightColumn show={!shouldHideRightColumn} />
</div>
<div className="md:hidden">
<NoteCreatorButton className="note-create-button" />
</div>
<Toaster />
</div>
<LoginUnlock />
{isStalker && <StalkerModal id={id} />}
</div>
);
}
function Header() {
return (
<header className="sticky top-0 md:hidden z-10 backdrop-blur-lg">
<LogoHeader />
<AccountHeader />
</header>
);
}
function StalkerModal({ id }) {
return (
<div className="stalker" onClick={() => LoginStore.removeSession(id)}>
<button type="button" className="circle flex items-center">
<Icon name="close" />
</button>
</div>
);
}

View File

@ -0,0 +1,115 @@
import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate, useParams } from "react-router-dom";
import UnreadCount from "@/Element/UnreadCount";
import ProfileImage from "@/Element/User/ProfileImage";
import { parseId } from "@/SnortUtils";
import NoteToSelf from "@/Element/User/NoteToSelf";
import useLogin from "@/Hooks/useLogin";
import usePageWidth from "@/Hooks/usePageWidth";
import NoteTime from "@/Element/Event/NoteTime";
import DmWindow from "@/Element/Chat/DmWindow";
import { Chat, ChatType, useChatSystem } from "@/chat";
import { ChatParticipantProfile } from "@/Element/Chat/ChatParticipant";
import classNames from "classnames";
import NewChatWindow from "@/Pages/Messages/NewChatWindow";
const TwoCol = 768;
export default function MessagesPage() {
const login = useLogin();
const { formatMessage } = useIntl();
const navigate = useNavigate();
const { id } = useParams();
const [chat, setChat] = useState<string>();
const pageWidth = usePageWidth();
useEffect(() => {
const parsedId = parseId(id ?? "");
setChat(id ? parsedId : undefined);
}, [id]);
const chats = useChatSystem();
const unreadCount = useMemo(() => chats.reduce((p, c) => p + c.unread, 0), [chats]);
function openChat(e: React.MouseEvent<HTMLDivElement>, type: ChatType, id: string) {
e.stopPropagation();
e.preventDefault();
navigate(`/messages/${encodeURIComponent(id)}`);
}
function noteToSelf(chat: Chat) {
return (
<div className="flex p" key={chat.id} onClick={e => openChat(e, chat.type, chat.id)}>
<NoteToSelf className="grow" />
</div>
);
}
function conversationIdent(cx: Chat) {
if (cx.participants.length === 1) {
return <ChatParticipantProfile participant={cx.participants[0]} />;
} else {
return (
<div className="flex items-center grow pfp-overlap">
{cx.participants.map(v => (
<ProfileImage pubkey={v.id} link="" showUsername={false} profile={v.profile} />
))}
{cx.title ?? <FormattedMessage defaultMessage="Group Chat" id="eXT2QQ" />}
</div>
);
}
}
function conversation(cx: Chat) {
if (!login.publicKey) return null;
const participants = cx.participants.map(a => a.id);
if (participants.length === 1 && participants[0] === login.publicKey) return noteToSelf(cx);
const isActive = cx.id === chat;
return (
<div
className={classNames("flex items-center p cursor-pointer justify-between", { active: isActive })}
key={cx.id}
onClick={e => openChat(e, cx.type, cx.id)}>
{conversationIdent(cx)}
<div className="nowrap">
<small>
<NoteTime
from={cx.lastMessage * 1000}
fallback={formatMessage({ defaultMessage: "Just now", id: "bxv59V" })}
/>
</small>
{cx.unread > 0 && <UnreadCount unread={cx.unread} />}
</div>
</div>
);
}
return (
<div className="flex flex-1 h-screen overflow-hidden">
{(pageWidth >= TwoCol || !chat) && (
<div className="overflow-y-auto h-screen p-1 w-full md:w-1/3 flex-shrink-0">
<div className="flex items-center justify-between p-2">
<button disabled={unreadCount <= 0} type="button" className="text-sm font-semibold">
<FormattedMessage defaultMessage="Mark all read" id="ShdEie" />
</button>
<NewChatWindow />
</div>
{chats
.sort((a, b) => {
const aSelf = a.participants.length === 1 && a.participants[0].id === login.publicKey;
const bSelf = b.participants.length === 1 && b.participants[0].id === login.publicKey;
if (aSelf || bSelf) {
return aSelf ? -1 : 1;
}
return b.lastMessage > a.lastMessage ? 1 : -1;
})
.map(conversation)}
</div>
)}
{chat ? <DmWindow id={chat} /> : pageWidth >= TwoCol && <div className="flex-1"></div>}
</div>
);
}

View File

@ -0,0 +1,145 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useUserSearch } from "@snort/system-react";
import useLogin from "@/Hooks/useLogin";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { appendDedupe, debounce } from "@/SnortUtils";
import { ChatType, createChatLink } from "@/chat";
import Icon from "@/Icons/Icon";
import Modal from "@/Element/Modal";
import { FormattedMessage } from "react-intl";
import ProfileImage from "@/Element/User/ProfileImage";
import ProfilePreview from "@/Element/User/ProfilePreview";
import { Nip28ChatSystem } from "@/chat/nip28";
import { LoginSession, LoginStore } from "@/Login";
import { decodeTLV, EventKind } from "@snort/system";
import Nip28ChatProfile from "@/Pages/Messages/Nip28ChatProfile";
export default function NewChatWindow() {
const [show, setShow] = useState(false);
const [newChat, setNewChat] = useState<Array<string>>([]);
const [results, setResults] = useState<Array<string>>([]);
const [term, setSearchTerm] = useState("");
const navigate = useNavigate();
const search = useUserSearch();
const login = useLogin();
const { system, publisher } = useEventPublisher();
useEffect(() => {
setNewChat([]);
setSearchTerm("");
setResults(login.follows.item);
}, [show]);
useEffect(() => {
return debounce(500, () => {
if (term) {
search(term).then(setResults);
} else {
setResults(login.follows.item);
}
});
}, [term]);
function togglePubkey(a: string) {
setNewChat(c => (c.includes(a) ? c.filter(v => v !== a) : appendDedupe(c, [a])));
}
function startChat() {
setShow(false);
if (newChat.length === 1) {
navigate(createChatLink(ChatType.DirectMessage, newChat[0]));
} else {
navigate(createChatLink(ChatType.PrivateGroupChat, ...newChat));
}
}
return (
<>
<button type="button" className="flex justify-center new-chat" onClick={() => setShow(true)}>
<Icon name="plus" size={16} />
</button>
{show && (
<Modal id="new-chat" onClose={() => setShow(false)} className="new-chat-modal">
<div className="flex flex-col g16">
<div className="flex justify-between">
<h2>
<FormattedMessage defaultMessage="New Chat" id="UT7Nkj" />
</h2>
<button onClick={startChat}>
<FormattedMessage defaultMessage="Start chat" id="v8lolG" />
</button>
</div>
<div className="flex flex-col g8">
<h3>
<FormattedMessage defaultMessage="Search users" id="JjGgXI" />
</h3>
<input
type="text"
placeholder="npub/nprofile/nostr address"
value={term}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex">
{newChat.map(a => (
<ProfileImage
key={`selected-${a}`}
pubkey={a}
showUsername={false}
link=""
onClick={() => togglePubkey(a)}
/>
))}
</div>
<div>
<p>
<FormattedMessage defaultMessage="People you follow" id="R81upa" />
</p>
<div className="user-list flex flex-col g2">
{results.map(a => {
return (
<ProfilePreview
pubkey={a}
key={`option-${a}`}
options={{ about: false, linkToProfile: false }}
actions={<></>}
onClick={() => togglePubkey(a)}
className={newChat.includes(a) ? "active" : undefined}
/>
);
})}
{results.length === 1 && (
<Nip28ChatProfile
id={results[0]}
onClick={async id => {
setShow(false);
const chats = appendDedupe(login.extraChats, [Nip28ChatSystem.chatId(id)]);
LoginStore.updateSession({
...login,
extraChats: chats,
} as LoginSession);
const evList = await publisher?.generic(eb => {
eb.kind(EventKind.PublicChatsList);
chats.forEach(c => {
if (c.startsWith("chat281")) {
eb.tag(["e", decodeTLV(c)[0].value as string]);
}
});
return eb;
});
if (evList) {
await system.BroadcastEvent(evList);
}
navigate(createChatLink(ChatType.PublicGroupChat, id));
}}
/>
)}
</div>
</div>
</div>
</Modal>
)}
</>
);
}

View File

@ -0,0 +1,20 @@
import { useEventFeed } from "@snort/system-react";
import { NostrLink, UserMetadata } from "@snort/system";
import ProfilePreview from "@/Element/User/ProfilePreview";
import React from "react";
export default function Nip28ChatProfile({ id, onClick }: { id: string; onClick: (id: string) => void }) {
const channel = useEventFeed(new NostrLink(CONFIG.eventLinkPrefix, id, 40));
if (channel?.data) {
const meta = JSON.parse(channel.data.content) as UserMetadata;
return (
<ProfilePreview
pubkey=""
profile={meta}
options={{ about: false, linkToProfile: false }}
actions={<></>}
onClick={() => onClick(id)}
/>
);
}
}

View File

@ -1,109 +0,0 @@
.dm-page {
--full-height: calc(100vh - 42px - var(--header-padding-tb) - var(--header-padding-tb) - 16px);
display: grid;
grid-template-columns: 350px auto;
height: var(--full-height);
/* 100vh - header - padding */
overflow: hidden;
padding: 4px;
}
.dm-page > div:nth-child(1)::-webkit-scrollbar-track {
background: transparent !important;
}
/* These should match what is in code too */
@media (max-width: 768px) {
.dm-page {
grid-template-columns: 100vw;
}
.dm-page > div:nth-child(1) {
margin: 0 !important;
}
}
@media (min-width: 1500px) {
.dm-page {
grid-template-columns: 400px auto 400px;
}
}
/* User list */
.dm-page > div:nth-child(1) {
overflow-y: auto;
padding: 0 5px;
}
/* Chat window */
.dm-page > div:nth-child(2) {
padding: 0 12px;
margin: 0 4px;
height: var(--full-height);
background-color: var(--gray-superdark);
border-radius: 16px;
}
/* Profile pannel */
.dm-page > div:nth-child(3) {
margin: 16px;
}
.dm-page > div:nth-child(3) .avatar {
margin-left: auto;
margin-right: auto;
}
.dm-page > div:nth-child(3) .card {
cursor: pointer;
}
.dm-page .new-chat {
min-width: 100px;
}
.dm-page .chat-list > div.active {
background-color: var(--gray-superdark);
border-radius: 16px;
}
.new-chat-modal .user-list {
max-height: 50vh;
overflow-y: auto;
}
.new-chat-modal .user-list > div {
padding: 8px 12px;
cursor: pointer;
}
/* user in list selected */
.new-chat-modal .user-list > div.active {
background-color: var(--gray-dark);
border-radius: 16px;
}
.new-chat-modal .modal-body {
padding: 24px 32px;
}
.new-chat-modal h2,
.new-chat-modal h3,
.new-chat-modal p {
font-weight: 600;
margin: 0;
}
.new-chat-modal h2 {
font-size: 21px;
}
.new-chat-modal h3 {
font-size: 16px;
}
.new-chat-modal p {
font-size: 11px;
letter-spacing: 1.21px;
text-transform: uppercase;
}

View File

@ -1,321 +0,0 @@
import "./MessagesPage.css";
import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate, useParams } from "react-router-dom";
import { EventKind, NostrLink, TLVEntryType, UserMetadata, decodeTLV } from "@snort/system";
import { useEventFeed, useUserProfile, useUserSearch } from "@snort/system-react";
import UnreadCount from "@/Element/UnreadCount";
import ProfileImage from "@/Element/User/ProfileImage";
import { appendDedupe, debounce, parseId, getDisplayName } from "@/SnortUtils";
import NoteToSelf from "@/Element/User/NoteToSelf";
import useModeration from "@/Hooks/useModeration";
import useLogin from "@/Hooks/useLogin";
import usePageWidth from "@/Hooks/usePageWidth";
import NoteTime from "@/Element/Event/NoteTime";
import DmWindow from "@/Element/Chat/DmWindow";
import Avatar from "@/Element/User/Avatar";
import Icon from "@/Icons/Icon";
import Text from "@/Element/Text";
import { Chat, ChatType, createChatLink, useChatSystem } from "@/chat";
import Modal from "@/Element/Modal";
import ProfilePreview from "@/Element/User/ProfilePreview";
import { LoginSession, LoginStore } from "@/Login";
import { Nip28ChatSystem } from "@/chat/nip28";
import { ChatParticipantProfile } from "@/Element/Chat/ChatParticipant";
import classNames from "classnames";
import useEventPublisher from "@/Hooks/useEventPublisher";
const TwoCol = 768;
const ThreeCol = 1500;
export default function MessagesPage() {
const login = useLogin();
const { formatMessage } = useIntl();
const navigate = useNavigate();
const { id } = useParams();
const [chat, setChat] = useState<string>();
const pageWidth = usePageWidth();
useEffect(() => {
const parsedId = parseId(id ?? "");
setChat(id ? parsedId : undefined);
}, [id]);
const chats = useChatSystem();
const unreadCount = useMemo(() => chats.reduce((p, c) => p + c.unread, 0), [chats]);
function openChat(e: React.MouseEvent<HTMLDivElement>, type: ChatType, id: string) {
e.stopPropagation();
e.preventDefault();
navigate(`/messages/${encodeURIComponent(id)}`);
}
function noteToSelf(chat: Chat) {
return (
<div className="flex p" key={chat.id} onClick={e => openChat(e, chat.type, chat.id)}>
<NoteToSelf className="grow" />
</div>
);
}
function conversationIdent(cx: Chat) {
if (cx.participants.length === 1) {
return <ChatParticipantProfile participant={cx.participants[0]} />;
} else {
return (
<div className="flex items-center grow pfp-overlap">
{cx.participants.map(v => (
<ProfileImage pubkey={v.id} link="" showUsername={false} profile={v.profile} />
))}
{cx.title ?? <FormattedMessage defaultMessage="Group Chat" id="eXT2QQ" />}
</div>
);
}
}
function conversation(cx: Chat) {
if (!login.publicKey) return null;
const participants = cx.participants.map(a => a.id);
if (participants.length === 1 && participants[0] === login.publicKey) return noteToSelf(cx);
const isActive = cx.id === chat;
return (
<div
className={classNames("flex items-center p", { active: isActive })}
key={cx.id}
onClick={e => openChat(e, cx.type, cx.id)}>
{conversationIdent(cx)}
<div className="nowrap">
<small>
<NoteTime
from={cx.lastMessage * 1000}
fallback={formatMessage({ defaultMessage: "Just now", id: "bxv59V" })}
/>
</small>
{cx.unread > 0 && <UnreadCount unread={cx.unread} />}
</div>
</div>
);
}
return (
<div className="dm-page">
{(pageWidth >= TwoCol || !chat) && (
<div className="chat-list">
<div className="flex items-center p justify-between">
<button disabled={unreadCount <= 0} type="button">
<FormattedMessage defaultMessage="Mark all read" id="ShdEie" />
</button>
<NewChatWindow />
</div>
{chats
.sort((a, b) => {
const aSelf = a.participants.length === 1 && a.participants[0].id === login.publicKey;
const bSelf = b.participants.length === 1 && b.participants[0].id === login.publicKey;
if (aSelf || bSelf) {
return aSelf ? -1 : 1;
}
return b.lastMessage > a.lastMessage ? 1 : -1;
})
.map(conversation)}
</div>
)}
{chat ? <DmWindow id={chat} /> : pageWidth >= TwoCol && <div></div>}
{pageWidth >= ThreeCol && chat && (
<div>
<ProfileDmActions id={chat} />
</div>
)}
</div>
);
}
function ProfileDmActions({ id }: { id: string }) {
const authors = decodeTLV(id)
.filter(a => a.type === TLVEntryType.Author)
.map(a => a.value as string);
const pubkey = authors[0];
const profile = useUserProfile(pubkey);
const { block, unblock, isBlocked } = useModeration();
function truncAbout(s?: string) {
if ((s?.length ?? 0) > 200) {
return `${s?.slice(0, 200)}...`;
}
return s;
}
const blocked = isBlocked(pubkey);
return (
<>
<Avatar pubkey={pubkey} user={profile} size={210} />
<h2>{getDisplayName(profile, pubkey)}</h2>
<p>
<Text
id={pubkey}
content={truncAbout(profile?.about) ?? ""}
tags={[]}
creator={pubkey}
disableMedia={true}
depth={0}
/>
</p>
<div className="settings-row" onClick={() => (blocked ? unblock(pubkey) : block(pubkey))}>
<Icon name="block" />
{blocked ? (
<FormattedMessage defaultMessage="Unblock" id="nDejmx" />
) : (
<FormattedMessage defaultMessage="Block" id="Up5U7K" />
)}
</div>
</>
);
}
function NewChatWindow() {
const [show, setShow] = useState(false);
const [newChat, setNewChat] = useState<Array<string>>([]);
const [results, setResults] = useState<Array<string>>([]);
const [term, setSearchTerm] = useState("");
const navigate = useNavigate();
const search = useUserSearch();
const login = useLogin();
const { system, publisher } = useEventPublisher();
useEffect(() => {
setNewChat([]);
setSearchTerm("");
setResults(login.follows.item);
}, [show]);
useEffect(() => {
return debounce(500, () => {
if (term) {
search(term).then(setResults);
} else {
setResults(login.follows.item);
}
});
}, [term]);
function togglePubkey(a: string) {
setNewChat(c => (c.includes(a) ? c.filter(v => v !== a) : appendDedupe(c, [a])));
}
function startChat() {
setShow(false);
if (newChat.length === 1) {
navigate(createChatLink(ChatType.DirectMessage, newChat[0]));
} else {
navigate(createChatLink(ChatType.PrivateGroupChat, ...newChat));
}
}
return (
<>
<button type="button" className="flex justify-center new-chat" onClick={() => setShow(true)}>
<Icon name="plus" size={16} />
</button>
{show && (
<Modal id="new-chat" onClose={() => setShow(false)} className="new-chat-modal">
<div className="flex flex-col g16">
<div className="flex justify-between">
<h2>
<FormattedMessage defaultMessage="New Chat" id="UT7Nkj" />
</h2>
<button onClick={startChat}>
<FormattedMessage defaultMessage="Start chat" id="v8lolG" />
</button>
</div>
<div className="flex flex-col g8">
<h3>
<FormattedMessage defaultMessage="Search users" id="JjGgXI" />
</h3>
<input
type="text"
placeholder="npub/nprofile/nostr address"
value={term}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex">
{newChat.map(a => (
<ProfileImage
key={`selected-${a}`}
pubkey={a}
showUsername={false}
link=""
onClick={() => togglePubkey(a)}
/>
))}
</div>
<div>
<p>
<FormattedMessage defaultMessage="People you follow" id="R81upa" />
</p>
<div className="user-list flex flex-col g2">
{results.map(a => {
return (
<ProfilePreview
pubkey={a}
key={`option-${a}`}
options={{ about: false, linkToProfile: false }}
actions={<></>}
onClick={() => togglePubkey(a)}
className={newChat.includes(a) ? "active" : undefined}
/>
);
})}
{results.length === 1 && (
<Nip28ChatProfile
id={results[0]}
onClick={async id => {
setShow(false);
const chats = appendDedupe(login.extraChats, [Nip28ChatSystem.chatId(id)]);
LoginStore.updateSession({
...login,
extraChats: chats,
} as LoginSession);
const evList = await publisher?.generic(eb => {
eb.kind(EventKind.PublicChatsList);
chats.forEach(c => {
if (c.startsWith("chat281")) {
eb.tag(["e", decodeTLV(c)[0].value as string]);
}
});
return eb;
});
if (evList) {
await system.BroadcastEvent(evList);
}
navigate(createChatLink(ChatType.PublicGroupChat, id));
}}
/>
)}
</div>
</div>
</div>
</Modal>
)}
</>
);
}
export function Nip28ChatProfile({ id, onClick }: { id: string; onClick: (id: string) => void }) {
const channel = useEventFeed(new NostrLink(CONFIG.eventLinkPrefix, id, 40));
if (channel?.data) {
const meta = JSON.parse(channel.data.content) as UserMetadata;
return (
<ProfilePreview
pubkey=""
profile={meta}
options={{ about: false, linkToProfile: false }}
actions={<></>}
onClick={() => onClick(id)}
/>
);
}
}

View File

@ -1,11 +1,12 @@
import ForceGraph3D, { NodeObject } from "react-force-graph-3d";
import { NodeObject } from "react-force-graph-3d";
import { useContext, useEffect, useState } from "react";
import { MetadataCache, socialGraphInstance, STR, UID } from "@snort/system";
import { SnortContext } from "@snort/system-react";
import * as THREE from "three";
import { defaultAvatar } from "../SnortUtils";
import { proxyImg } from "@/Hooks/useImgProxy";
import { LoginStore } from "@/Login";
import { FormattedMessage } from "react-intl";
import Icon from "@/Icons/Icon";
interface GraphNode {
id: UID;
@ -71,6 +72,40 @@ const NetworkGraph = () => {
// const [direction, setDirection] = useState(Direction.OUTBOUND);
// const [renderLimit, setRenderLimit] = useState(NODE_LIMIT);
const [ForceGraph3D, setForceGraph3D] = useState(null);
const [THREE, setTHREE] = useState(null);
useEffect(() => {
// Dynamically import the modules
import("react-force-graph-3d").then(module => {
setForceGraph3D(module.default);
});
import("three").then(module => {
setTHREE(module);
});
}, []);
const handleCloseGraph = () => {
setOpen(false);
};
const handleKeyDown = (event: { key: string }) => {
if (event.key === "Escape") {
handleCloseGraph();
}
};
useEffect(() => {
if (open) {
window.addEventListener("keydown", handleKeyDown);
}
// Cleanup function to remove the event listener
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
const updateConfig = async (changes: Partial<GraphConfig>) => {
setGraphConfig(old => {
const newConfig = Object.assign({}, old, changes);
@ -188,6 +223,8 @@ const NetworkGraph = () => {
refreshData();
}, []);
if (!ForceGraph3D || !THREE) return null;
return (
<div>
{!open && (
@ -197,13 +234,13 @@ const NetworkGraph = () => {
setOpen(true);
refreshData();
}}>
Show graph
<FormattedMessage defaultMessage="Show graph" id="ha8JKG" />
</button>
)}
{open && graphData && (
<div className="fixed top-0 left-0 right-0 bottom-0 z-20">
<button className="absolute top-6 right-6 z-30 btn hover:bg-gray-900" onClick={() => setOpen(false)}>
X
<button className="absolute top-6 right-6 z-30 btn hover:bg-gray-900" onClick={handleCloseGraph}>
<Icon name="x" size={24} />
</button>
<div className="absolute top-6 right-0 left-0 z-20 flex flex-col content-center justify-center text-center">
<div className="text-center pb-2">Degrees of separation</div>
@ -239,7 +276,7 @@ const NetworkGraph = () => {
nodeLabel={node => `${node.profile?.name || node.address}`}
nodeAutoColorBy="distance"
linkAutoColorBy="distance"
linkDirectionalParticles={1}
linkDirectionalParticles={0}
nodeVisibility="visible"
numDimensions={3}
linkDirectionalArrowLength={0}

View File

@ -39,8 +39,6 @@
@media (min-width: 520px) {
.profile .banner {
width: 100%;
max-width: 720px;
height: 280px;
}

View File

@ -9,6 +9,7 @@ import {
MetadataCache,
NostrLink,
NostrPrefix,
socialGraphInstance,
TLVEntryType,
tryParseNostrLink,
} from "@snort/system";
@ -59,6 +60,7 @@ import { UserWebsiteLink } from "@/Element/User/UserWebsiteLink";
import { useMuteList, usePinList } from "@/Hooks/useLists";
import messages from "../messages";
import FollowDistanceIndicator from "@/Element/User/FollowDistanceIndicator";
interface ProfilePageProps {
id?: string;
@ -152,6 +154,8 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
}
function username() {
const followedByFriends = user?.pubkey ? socialGraphInstance.followedByFriends(user.pubkey) : new Set<string>();
const MAX_FOLLOWED_BY_FRIENDS = 3;
return (
<>
<div className="flex flex-col g4">
@ -160,6 +164,34 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
<FollowsYou followsMe={follows.includes(loginPubKey ?? "")} />
</h2>
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
<div className="flex flex-row items-center">
{user?.pubkey && <FollowDistanceIndicator className="p-2" pubkey={user.pubkey} />}
{followedByFriends.size > 0 && (
<div className="text-gray-light">
<span className="mr-1">
<FormattedMessage defaultMessage="Followed by" id="6mr8WU" />
</span>
{Array.from(followedByFriends)
.slice(0, MAX_FOLLOWED_BY_FRIENDS)
.map(a => {
return (
<span className="inline-block" key={a}>
<ProfileImage showFollowDistance={false} pubkey={a} size={24} showUsername={false} />
</span>
);
})}
{followedByFriends.size > MAX_FOLLOWED_BY_FRIENDS && (
<span>
<FormattedMessage
defaultMessage="and {count} others you follow"
id="CYkOCI"
values={{ count: followedByFriends.size - MAX_FOLLOWED_BY_FRIENDS }}
/>
</span>
)}
</div>
)}
</div>
</div>
{showBadges && <BadgeList badges={badges} />}
{showStatus && <>{musicStatus()}</>}
@ -314,7 +346,7 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
)}
{isMe ? (
<>
<button type="button" onClick={() => navigate("/settings")}>
<button className="md:hidden" type="button" onClick={() => navigate("/settings")}>
<FormattedMessage {...messages.Settings} />
</button>
</>

View File

@ -49,7 +49,7 @@ const FollowsHint = () => {
{...messages.NoFollows}
values={{
newUsersPage: (
<Link to={"/new/discover"}>
<Link to={"/discover"}>
<FormattedMessage {...messages.NewUsers} />
</Link>
),

View File

@ -49,6 +49,7 @@
}
.settings-row:hover,
.settings-row.active,
.settings-group-header:hover {
color: var(--highlight);
}

View File

@ -1,13 +1,12 @@
import "./Root.css";
import { useEffect, useMemo } from "react";
import { useCallback, useEffect } from "react";
import { FormattedMessage } from "react-intl";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { Outlet, NavLink, useNavigate, useLocation } from "react-router-dom";
import Icon from "@/Icons/Icon";
import { LoginStore, logout } from "@/Login";
import useLogin from "@/Hooks/useLogin";
import { getCurrentSubscription } from "@/Subscription";
import usePageWidth from "@/Hooks/usePageWidth";
import messages from "./messages";
const SettingsIndex = () => {
@ -17,98 +16,61 @@ const SettingsIndex = () => {
const pageWidth = usePageWidth();
const sub = getCurrentSubscription(LoginStore.allSubscriptions());
function handleLogout() {
const handleLogout = useCallback(() => {
logout(login.id);
navigate("/");
}
}, [login.id, navigate]);
useEffect(() => {
if (location.pathname === "/settings" && pageWidth >= 768) {
navigate("/settings/profile", { replace: true });
}
}, [location, pageWidth]);
}, [location, navigate, pageWidth]);
const [hideMenu, hideContent] = useMemo(() => {
return [location.pathname !== "/settings" && pageWidth < 768, location.pathname === "/settings" && pageWidth < 768];
}, [location, pageWidth]);
const [hideMenu, hideContent] = [
location.pathname !== "/settings" && pageWidth < 768,
location.pathname === "/settings" && pageWidth < 768,
];
const menuItems = [
{ icon: "profile", message: messages.Profile, path: "profile" },
{ icon: "relay", message: messages.Relays, path: "relays" },
{ icon: "key", message: "Export Keys", id: "08zn6O", path: "keys" },
{ icon: "shield-tick", message: "Moderation", id: "wofVHy", path: "moderation" },
{ icon: "badge", message: "Nostr Address", id: "9pMqYs", path: "handle" },
{ icon: "gear", message: messages.Preferences, path: "preferences" },
{ icon: "wallet", message: "Wallet", id: "3yk8fB", path: "wallet" },
{ icon: "heart", message: messages.Donate, path: "/donate" },
{ icon: "hard-drive", message: "Cache", id: "DBiVK1", path: "cache" },
];
if (CONFIG.features.subscriptions) {
menuItems.push({ icon: "diamond", message: "Subscription", id: "R/6nsx", path: "/subscribe/manage" });
}
if (CONFIG.features.zapPool) {
menuItems.push({ icon: "piggy-bank", message: "Zap Pool", id: "i/dBAR", path: "/zap-pool" });
}
if (sub) {
menuItems.push({ icon: "code-circle", message: "Account Switcher", id: "7BX/yC", path: "accounts" });
}
const getNavLinkClass = ({ isActive }: { isActive: boolean }) => {
return isActive ? "settings-row active" : "settings-row";
};
return (
<div className="settings-nav">
{!hideMenu && (
<div>
<div className="settings-row" onClick={() => navigate("profile")}>
<Icon name="profile" size={24} />
<FormattedMessage {...messages.Profile} />
<Icon name="arrowFront" size={16} />
</div>
<div className="settings-row" onClick={() => navigate("relays")}>
<Icon name="relay" size={24} />
<FormattedMessage {...messages.Relays} />
<Icon name="arrowFront" size={16} />
</div>
<div className="settings-row" onClick={() => navigate("keys")}>
<Icon name="key" size={24} />
<FormattedMessage defaultMessage="Export Keys" id="08zn6O" />
<Icon name="arrowFront" size={16} />
</div>
<div className="settings-row" onClick={() => navigate("moderation")}>
<Icon name="shield-tick" size={24} />
<FormattedMessage defaultMessage="Moderation" id="wofVHy" />
<Icon name="arrowFront" size={16} />
</div>
<div className="settings-row" onClick={() => navigate("handle")}>
<Icon name="badge" size={24} />
<FormattedMessage defaultMessage="Nostr Address" id="9pMqYs" />
<Icon name="arrowFront" size={16} />
</div>
{CONFIG.features.subscriptions && (
<div className="settings-row" onClick={() => navigate("/subscribe/manage")}>
<Icon name="diamond" size={24} />
<FormattedMessage defaultMessage="Subscription" id="R/6nsx" />
{menuItems.map(({ icon, message, id, path }) => (
<NavLink to={path} key={path} className={getNavLinkClass} end>
<Icon name={icon} size={24} />
<FormattedMessage {...(id ? { defaultMessage: message, id } : message)} />
<Icon name="arrowFront" size={16} />
</div>
)}
{sub && (
<div className="settings-row" onClick={() => navigate("accounts")}>
<Icon name="code-circle" size={24} />
<FormattedMessage defaultMessage="Account Switcher" id="7BX/yC" />
<Icon name="arrowFront" size={16} />
</div>
)}
<div className="settings-row" onClick={() => navigate("preferences")}>
<Icon name="gear" size={24} />
<FormattedMessage {...messages.Preferences} />
<Icon name="arrowFront" size={16} />
</div>
<div className="settings-row" onClick={() => navigate("wallet")}>
<Icon name="wallet" size={24} />
<FormattedMessage defaultMessage="Wallet" id="3yk8fB" />
<Icon name="arrowFront" size={16} />
</div>
<div className="settings-row" onClick={() => navigate("/donate")}>
<Icon name="heart" size={24} />
<FormattedMessage {...messages.Donate} />
<Icon name="arrowFront" size={16} />
</div>
{CONFIG.features.zapPool && (
<div className="settings-row" onClick={() => navigate("/zap-pool")}>
<Icon name="piggy-bank" size={24} />
<FormattedMessage defaultMessage="Zap Pool" id="i/dBAR" />
<Icon name="arrowFront" size={16} />
</div>
)}
<div className="settings-row" onClick={() => navigate("cache")}>
<Icon name="hard-drive" size={24} />
<FormattedMessage defaultMessage="Cache" id="DBiVK1" />
<Icon name="arrowFront" size={16} />
</div>
<div className="settings-row" onClick={() => navigate("/graph")}>
<Icon name="profile" size={24} />
<FormattedMessage {...messages.SocialGraph} />
<Icon name="arrowFront" size={16} />
</div>
</NavLink>
))}
<div className="settings-row" onClick={handleLogout}>
<Icon name="logout" size={24} />
<FormattedMessage {...messages.LogOut} />

View File

@ -1,5 +1,5 @@
import { magnetURIDecode, getRelayName } from ".";
import { describe, expect } from "@jest/globals";
import { describe, it, expect } from "vitest";
describe("magnet", () => {
it("should parse magnet link", () => {

View File

@ -382,5 +382,30 @@
<path d="M15.6095 7.04529C15.0822 6.88101 14.5216 7.17528 14.3573 7.70256C14.193 8.22985 14.4873 8.79048 15.0146 8.95476C16.2585 9.34233 17.4241 9.97212 18.4401 10.8184C18.8645 11.1719 19.495 11.1144 19.8485 10.69C20.2019 10.2657 20.1445 9.6351 19.7201 9.28164C18.5009 8.26611 17.1022 7.51034 15.6095 7.04529Z" fill="currentColor"/>
<path d="M12 16.5C11.4477 16.5 11 16.9477 11 17.5C11 18.0523 11.4477 18.5 12 18.5H12.01C12.5623 18.5 13.01 18.0523 13.01 17.5C13.01 16.9477 12.5623 16.5 12.01 16.5H12Z" fill="currentColor"/>
</symbol>
<symbol id="notes" viewBox="0 0 24 24" fill="none">
<path d="M11.9998 4.33268C11.9998 4.09933 11.9998 3.98265 11.9544 3.89352C11.9145 3.81512 11.8507 3.75138 11.7723 3.71143C11.6832 3.66602 11.5667 3.66602 11.3338 3.66602C10.5464 3.66602 9.24474 3.66601 8.46542 3.66602C7.79461 3.66601 7.24098 3.666 6.78998 3.70285C6.32157 3.74112 5.89098 3.82326 5.48654 4.02933C4.85933 4.34891 4.34939 4.85885 4.02982 5.48605C3.82374 5.8905 3.7416 6.32108 3.70333 6.7895C3.66649 7.24049 3.66649 7.79412 3.6665 8.46492V15.5338C3.66649 16.2046 3.66649 16.7582 3.70333 17.2092C3.7416 17.6776 3.82374 18.1082 4.02982 18.5127C4.34939 19.1399 4.85933 19.6498 5.48654 19.9694C5.89098 20.1755 6.32157 20.2576 6.78998 20.2959C7.24097 20.3327 7.7946 20.3327 8.4654 20.3327H15.5343C16.2051 20.3327 16.7587 20.3327 17.2097 20.2959C17.6781 20.2576 18.1087 20.1755 18.5131 19.9694C19.1403 19.6498 19.6503 19.1399 19.9699 18.5127C20.1759 18.1082 20.2581 17.6776 20.2963 17.2092C20.3332 16.7582 20.3332 16.2046 20.3332 15.5338C20.3332 14.7545 20.3332 13.4528 20.3332 12.6653C20.3332 12.4324 20.3332 12.3159 20.2878 12.2268C20.2478 12.1484 20.1841 12.0847 20.1057 12.0447C20.0165 11.9993 19.8999 11.9993 19.6665 11.9993H16.7988C16.128 11.9993 15.5743 11.9993 15.1233 11.9625C14.6549 11.9242 14.2243 11.8421 13.8199 11.636C13.1927 11.3164 12.6827 10.8065 12.3632 10.1793C12.1571 9.77482 12.0749 9.34424 12.0367 8.87582C11.9998 8.42483 11.9998 7.8712 11.9998 7.2004V4.33268Z" fill="currentColor"/>
<path d="M18.5898 10.3326C18.8344 10.3326 18.9568 10.3326 19.057 10.2712C19.1985 10.1844 19.2832 9.97998 19.2443 9.81854C19.2169 9.70427 19.1371 9.62458 18.9776 9.46521L14.534 5.02162C14.3746 4.86204 14.2949 4.78226 14.1805 4.75478C14.0191 4.71599 13.8147 4.8006 13.728 4.94213C13.6665 5.04235 13.6665 5.1647 13.6665 5.40939V7.16597C13.6665 7.87978 13.6672 8.36503 13.6978 8.7401C13.7276 9.10545 13.7818 9.29229 13.8482 9.42262C14.008 9.73622 14.2629 9.99119 14.5765 10.151C14.7069 10.2174 14.8937 10.2715 15.259 10.3013C15.6341 10.332 16.1194 10.3326 16.8332 10.3326H18.5898Z" fill="currentColor"/>
</symbol>
<symbol id="settings" viewBox="0 0 20 20" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.11838 17.9624L7.57461 16.7575C7.38087 16.3275 7.05367 15.9691 6.64008 15.7339C6.22626 15.4987 5.74753 15.3985 5.27241 15.4477L3.94194 15.5872C3.54593 15.6282 3.14634 15.5553 2.79152 15.3773C2.4367 15.1992 2.14184 14.9237 1.94263 14.584C1.74336 14.2443 1.64843 13.855 1.66935 13.4632C1.69026 13.0714 1.82614 12.694 2.06048 12.3769L2.84821 11.3105C3.12914 10.9298 3.28042 10.4713 3.28033 10.0007C3.28042 9.53002 3.12914 9.07147 2.84821 8.69084L2.06048 7.62445C1.82614 7.30726 1.69026 6.92988 1.66935 6.5381C1.64843 6.14632 1.74336 5.75698 1.94263 5.41732C2.14165 5.07745 2.43647 4.80177 2.79134 4.62369C3.14622 4.44562 3.5459 4.3728 3.94194 4.41408L5.27654 4.55361C5.75166 4.60278 6.23039 4.5026 6.64422 4.26741C7.05627 4.03155 7.38195 3.67323 7.57461 3.2438L8.11838 2.03889C8.28002 1.68022 8.54382 1.37547 8.87779 1.1616C9.21175 0.947727 9.60152 0.83392 9.99984 0.833984C10.3982 0.83392 10.7879 0.947727 11.1219 1.1616C11.4559 1.37547 11.7197 1.68022 11.8813 2.03889L12.4292 3.2438C12.6219 3.67323 12.9475 4.03155 13.3596 4.26741C13.7734 4.5026 14.2521 4.60278 14.7273 4.55361L16.0577 4.41408C16.4538 4.3728 16.8535 4.44562 17.2083 4.62369C17.5632 4.80177 17.858 5.07745 18.057 5.41732C18.2563 5.75698 18.3512 6.14632 18.3303 6.5381C18.3094 6.92988 18.1735 7.30726 17.9392 7.62445L17.1515 8.69084C16.8705 9.07147 16.7193 9.53002 16.7193 10.0007C16.717 10.4726 16.8668 10.933 17.1473 11.3156L17.9351 12.3819C18.1694 12.6991 18.3053 13.0765 18.3262 13.4683C18.3471 13.8601 18.2522 14.2494 18.0529 14.5891C17.8539 14.9289 17.5591 15.2046 17.2042 15.3827C16.8493 15.5608 16.4496 15.6336 16.0536 15.5923L14.7231 15.4528C14.248 15.4036 13.7693 15.5038 13.3555 15.739C12.9442 15.9735 12.6186 16.33 12.4251 16.7575L11.8813 17.9624C11.7197 18.3211 11.4559 18.6258 11.1219 18.8397C10.7879 19.0536 10.3982 19.1674 9.99984 19.1673C9.60152 19.1674 9.21175 19.0536 8.87779 18.8397C8.54382 18.6258 8.28002 18.3211 8.11838 17.9624ZM12.4998 10.0007C12.4998 11.3814 11.3805 12.5006 9.99984 12.5006C8.61913 12.5006 7.49984 11.3814 7.49984 10.0007C7.49984 8.61994 8.61913 7.50065 9.99984 7.50065C11.3805 7.50065 12.4998 8.61994 12.4998 10.0007Z" fill="currentColor"/>
</symbol>
<symbol id="home" fill="currentColor" viewBox="0 0 495.398 495.398">
<path d="M487.083,225.514l-75.08-75.08V63.704c0-15.682-12.708-28.391-28.413-28.391c-15.669,0-28.377,12.709-28.377,28.391 v29.941L299.31,37.74c-27.639-27.624-75.694-27.575-103.27,0.05L8.312,225.514c-11.082,11.104-11.082,29.071,0,40.158 c11.087,11.101,29.089,11.101,40.172,0l187.71-187.729c6.115-6.083,16.893-6.083,22.976-0.018l187.742,187.747 c5.567,5.551,12.825,8.312,20.081,8.312c7.271,0,14.541-2.764,20.091-8.312C498.17,254.586,498.17,236.619,487.083,225.514z"></path> <path d="M257.561,131.836c-5.454-5.451-14.285-5.451-19.723,0L72.712,296.913c-2.607,2.606-4.085,6.164-4.085,9.877v120.401 c0,28.253,22.908,51.16,51.16,51.16h81.754v-126.61h92.299v126.61h81.755c28.251,0,51.159-22.907,51.159-51.159V306.79 c0-3.713-1.465-7.271-4.085-9.877L257.561,131.836z"></path>
</symbol>
<symbol id="sign-in" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 3C6.44772 3 6 3.44772 6 4C6 4.55228 6.44772 5 7 5H18C18.5523 5 19 5.44772 19 6V18C19 18.5523 18.5523 19 18 19H7C6.44772 19 6 19.4477 6 20C6 20.5523 6.44772 21 7 21H18C19.6569 21 21 19.6569 21 18V6C21 4.34315 19.6569 3 18 3H7ZM12.7071 7.29289C12.3166 6.90237 11.6834 6.90237 11.2929 7.29289C10.9024 7.68342 10.9024 8.31658 11.2929 8.70711L13.5858 11H4C3.44772 11 3 11.4477 3 12C3 12.5523 3.44772 13 4 13H13.5858L11.2929 15.2929C10.9024 15.6834 10.9024 16.3166 11.2929 16.7071C11.6834 17.0976 12.3166 17.0976 12.7071 16.7071L16.7071 12.7071C17.0976 12.3166 17.0976 11.6834 16.7071 11.2929L12.7071 7.29289Z" fill="currentColor"/>
</symbol>
<symbol id="deck" viewBox="0 0 20 20" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.3011 1.66602H14.8653C15.3046 1.666 15.6836 1.66599 15.9957 1.69149C16.3251 1.71841 16.6528 1.77784 16.9681 1.9385C17.4386 2.17818 17.821 2.56064 18.0607 3.03104C18.2213 3.34636 18.2808 3.67404 18.3077 4.00349C18.3332 4.31564 18.3332 4.69462 18.3332 5.13392V14.8648C18.3332 15.3041 18.3332 15.6831 18.3077 15.9952C18.2808 16.3247 18.2213 16.6523 18.0607 16.9677C17.821 17.4381 17.4386 17.8205 16.9681 18.0602C16.6528 18.2209 16.3251 18.2803 15.9957 18.3072C15.6836 18.3327 15.3046 18.3327 14.8653 18.3327H14.301C13.8618 18.3327 13.4828 18.3327 13.1706 18.3072C12.8412 18.2803 12.5135 18.2209 12.1982 18.0602C11.7278 17.8205 11.3453 17.4381 11.1057 16.9677C10.945 16.6523 10.8856 16.3247 10.8586 15.9952C10.8331 15.6831 10.8332 15.3041 10.8332 14.8648V5.1339C10.8332 4.69461 10.8331 4.31564 10.8586 4.00349C10.8856 3.67404 10.945 3.34636 11.1057 3.03104C11.3453 2.56064 11.7278 2.17818 12.1982 1.9385C12.5135 1.77784 12.8412 1.71841 13.1706 1.69149C13.4828 1.66599 13.8618 1.666 14.3011 1.66602Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.13439 1.66602H5.69863C6.13792 1.666 6.51689 1.66599 6.82903 1.69149C7.15848 1.71841 7.48617 1.77784 7.80148 1.9385C8.27189 2.17818 8.65434 2.56064 8.89402 3.03104C9.05468 3.34636 9.11411 3.67404 9.14103 4.00349C9.16653 4.31564 9.16652 4.69462 9.16651 5.13392V14.8648C9.16652 15.3041 9.16653 15.6831 9.14103 15.9952C9.11411 16.3247 9.05468 16.6523 8.89402 16.9677C8.65434 17.4381 8.27189 17.8205 7.80148 18.0602C7.48617 18.2209 7.15848 18.2803 6.82903 18.3072C6.51689 18.3327 6.13793 18.3327 5.69864 18.3327H5.13437C4.69508 18.3327 4.31612 18.3327 4.00398 18.3072C3.67453 18.2803 3.34685 18.2209 3.03153 18.0602C2.56112 17.8205 2.17867 17.4381 1.93899 16.9677C1.77833 16.6523 1.7189 16.3247 1.69198 15.9952C1.66648 15.6831 1.66649 15.3041 1.6665 14.8648V5.1339C1.66649 4.69461 1.66648 4.31564 1.69198 4.00349C1.7189 3.67404 1.77833 3.34636 1.93899 3.03104C2.17867 2.56064 2.56112 2.17818 3.03153 1.9385C3.34685 1.77784 3.67453 1.71841 4.00398 1.69149C4.31613 1.66599 4.69509 1.666 5.13439 1.66602Z" fill="currentColor"/>
</symbol>
<symbol id="graph" viewBox="0 0 512 512" fill="currentColor">
<path d="m256 150.5c-41.353 0-75-33.647-75-75s33.647-75 75-75 75 33.647 75 75-33.647 75-75 75z" />
<path d="m10.026 429c-20.669-35.815-8.35-81.768 27.466-102.451 36.551-21.085 82.083-7.806 102.451 27.451 20.722 35.87 8.44 81.717-27.451 102.451-35.96 20.737-81.757 8.396-102.466-27.451z" />
<path d="m399.508 456.451c-35.867-20.721-48.185-66.561-27.451-102.451 20.367-35.256 65.898-48.537 102.451-27.451 35.815 20.684 48.135 66.636 27.466 102.451-20.683 35.802-66.455 48.218-102.466 27.451z" />
<path d="m61.293 275.587-29.941-1.641c3.896-70.957 41.807-136.641 101.396-175.723l16.465 25.078c-51.665 33.883-84.522 90.821-87.92 152.286z" />
<path d="m450.707 275.587c-3.398-61.465-36.255-118.403-87.92-152.285l16.465-25.078c59.59 39.082 97.5 104.766 101.396 175.723z" />
<path d="m256 511.5c-35.684 0-69.8-8.115-101.426-24.097l13.535-26.777c54.785 27.715 120.996 27.715 175.781 0l13.535 26.777c-31.625 15.982-65.741 24.097-101.425 24.097z" />
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

@ -4,6 +4,7 @@
:root {
--bg-color: #000;
--nearly-bg-color: #090909;
--font-color: #fff;
--font-secondary-color: #a7a7a7;
--font-tertiary-color: #a3a3a3;
@ -80,6 +81,7 @@ html {
html.light {
--bg-color: #fff;
--nearly-bg-color: #f9f9f9;
--font-color: #2f3f64;
--font-secondary-color: #5c6c92;
--font-tertiary-color: #52525b;
@ -132,28 +134,6 @@ a.ext {
overflow-wrap: break-word;
}
#root {
overflow-x: hidden;
}
.page {
width: 100vw;
margin-left: auto;
margin-right: auto;
}
@media (min-width: 768px) {
.page {
width: 640px;
margin-left: auto;
margin-right: auto;
}
.main-content {
border: 1px solid var(--border-color);
}
}
.b {
border: 1px solid var(--border-color);
}
@ -940,3 +920,12 @@ svg.zap-solid {
.light .modal button.secondary:hover {
background: #fff !important;
}
.hide-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}

View File

@ -25,10 +25,6 @@ import PowWorkerURL from "@snort/system/src/pow-worker.ts?worker&url";
import { SnortContext } from "@snort/system-react";
import { removeUndefined, throwIfOffline } from "@snort/shared";
import { lazy, Suspense } from "react";
const NetworkGraph = lazy(() => import("@/Pages/NetworkGraph"));
import * as serviceWorkerRegistration from "@/serviceWorkerRegistration";
import { IntlProvider } from "@/IntlProvider";
import { getCountry, unwrap } from "@/SnortUtils";
@ -39,7 +35,7 @@ import NotificationsPage from "@/Pages/Notifications";
import SettingsPage, { SettingsRoutes } from "@/Pages/SettingsPage";
import ErrorPage from "@/Pages/ErrorPage";
import NostrAddressPage from "@/Pages/NostrAddressPage";
import MessagesPage from "@/Pages/MessagesPage";
import MessagesPage from "@/Pages/Messages/MessagesPage";
import DonatePage from "@/Pages/DonatePage";
import SearchPage from "@/Pages/SearchPage";
import HelpPage from "@/Pages/HelpPage";
@ -59,6 +55,8 @@ import { AboutPage } from "@/Pages/About";
import { OnboardingRoutes } from "@/Pages/onboarding";
import { setupWebLNWalletConfig } from "@/Wallet/WebLN";
import { Wallets } from "@/Wallet";
import Fuse from "fuse.js";
import NetworkGraph from "@/Pages/NetworkGraph";
declare global {
interface Window {
@ -111,7 +109,42 @@ System.on("auth", async (c, r, cb) => {
}
});
export type FuzzySearchResult = {
pubkey: string;
name?: string;
display_name?: string;
nip05?: string;
};
export const fuzzySearch = new Fuse<FuzzySearchResult>([], {
keys: ["name", "display_name", { name: "nip05", weight: 0.5 }],
threshold: 0.3,
// sortFn here?
});
const profileTimestamps = new Map<string, number>();
// how to also add entries from ProfileCache?
System.on("event", ev => {
if (ev.kind === 0) {
const existing = profileTimestamps.get(ev.pubkey);
if (existing) {
if (existing > ev.created_at) {
return;
}
fuzzySearch.remove(doc => doc.pubkey === ev.pubkey);
}
profileTimestamps.set(ev.pubkey, ev.created_at);
try {
const data = JSON.parse(ev.content);
if (ev.pubkey && (data.name || data.display_name || data.nip05)) {
data.pubkey = ev.pubkey;
fuzzySearch.add(data);
}
} catch (e) {
console.error(e);
}
}
if (ev.kind === 3) {
socialGraphInstance.handleFollowEvent(ev);
}
@ -298,9 +331,7 @@ root.render(
<StrictMode>
<IntlProvider>
<SnortContext.Provider value={System}>
<Suspense fallback={<div>Loading...</div>}>
<RouterProvider router={router} />
</Suspense>
<RouterProvider router={router} />
</SnortContext.Provider>
</IntlProvider>
</StrictMode>,

View File

@ -57,9 +57,6 @@
"00LcfG": {
"defaultMessage": "Load more"
},
"08zn6O": {
"defaultMessage": "Export Keys"
},
"0Azlrb": {
"defaultMessage": "Manage"
},
@ -135,6 +132,9 @@
"2k0Cv+": {
"defaultMessage": "Dislikes ({n})"
},
"2mcwT8": {
"defaultMessage": "New Note"
},
"2ukA4d": {
"defaultMessage": "{n} hours"
},
@ -162,9 +162,6 @@
"3tVy+Z": {
"defaultMessage": "{n} Followers"
},
"3yk8fB": {
"defaultMessage": "Wallet"
},
"450Fty": {
"defaultMessage": "None"
},
@ -237,6 +234,12 @@
"6ewQqw": {
"defaultMessage": "Likes ({n})"
},
"6k7xfM": {
"defaultMessage": "Trending notes"
},
"6mr8WU": {
"defaultMessage": "Followed by"
},
"6uMqL1": {
"defaultMessage": "Unpaid"
},
@ -246,9 +249,6 @@
"712i26": {
"defaultMessage": "Proxy uses HODL invoices to forward the payment, which hides the pubkey of your node"
},
"7BX/yC": {
"defaultMessage": "Account Switcher"
},
"7UOvbT": {
"defaultMessage": "Offline"
},
@ -264,6 +264,9 @@
"8ED/4u": {
"defaultMessage": "Reply To"
},
"8HJxXG": {
"defaultMessage": "Sign up"
},
"8QDesP": {
"defaultMessage": "Zap {n} sats"
},
@ -371,6 +374,12 @@
"CVWeJ6": {
"defaultMessage": "Trending People"
},
"CYkOCI": {
"defaultMessage": "and {count} others you follow"
},
"CbM2hK": {
"defaultMessage": "Trending hashtags"
},
"CmZ9ls": {
"defaultMessage": "{n} Muted"
},
@ -732,9 +741,6 @@
"Qxv0B2": {
"defaultMessage": "You currently have {number} sats in your zap pool."
},
"R/6nsx": {
"defaultMessage": "Subscription"
},
"R81upa": {
"defaultMessage": "People you follow"
},
@ -946,6 +952,9 @@
"aWpBzj": {
"defaultMessage": "Show more"
},
"arZnG2": {
"defaultMessage": "Trending users"
},
"b12Goz": {
"defaultMessage": "Mnemonic"
},
@ -1125,6 +1134,9 @@
"hY4lzx": {
"defaultMessage": "Supports"
},
"ha8JKG": {
"defaultMessage": "Show graph"
},
"hicxcO": {
"defaultMessage": "Show replies"
},
@ -1340,6 +1352,9 @@
"qtWLmt": {
"defaultMessage": "Like"
},
"qyJtWy": {
"defaultMessage": "Show less"
},
"qydxOd": {
"defaultMessage": "Science"
},
@ -1470,9 +1485,6 @@
"wih7iJ": {
"defaultMessage": "name is blocked"
},
"wofVHy": {
"defaultMessage": "Moderation"
},
"wqyN/i": {
"defaultMessage": "Find out more info about {service} at {link}"
},

View File

@ -18,7 +18,6 @@
"/d6vEc": "Make your profile easier to find and share",
"/n5KSF": "{n} ms",
"00LcfG": "Load more",
"08zn6O": "Export Keys",
"0Azlrb": "Manage",
"0BUTMv": "Search...",
"0HFX0T": "Use Exact Location",
@ -44,6 +43,7 @@
"2O2sfp": "Finish",
"2a2YiP": "{n} Bookmarks",
"2k0Cv+": "Dislikes ({n})",
"2mcwT8": "New Note",
"2ukA4d": "{n} hours",
"2zJXeA": "Profiles",
"39AHJm": "Sign Up",
@ -53,7 +53,6 @@
"3qnJlS": "You are voting with {amount} sats",
"3t3kok": "{n,plural,=1{{n} new note} other{{n} new notes}}",
"3tVy+Z": "{n} Followers",
"3yk8fB": "Wallet",
"450Fty": "None",
"47FYwb": "Cancel",
"4IPzdn": "Primary Developers",
@ -78,15 +77,17 @@
"6TfgXX": "{site} is an open source project built by passionate people in their free time",
"6bgpn+": "Not all clients support this, you may still receive some zaps as if zap splits was not configured",
"6ewQqw": "Likes ({n})",
"6k7xfM": "Trending notes",
"6mr8WU": "Followed by",
"6uMqL1": "Unpaid",
"7+Domh": "Notes",
"712i26": "Proxy uses HODL invoices to forward the payment, which hides the pubkey of your node",
"7BX/yC": "Account Switcher",
"7UOvbT": "Offline",
"7hp70g": "NIP-05",
"8/vBbP": "Reposts ({n})",
"89q5wc": "Confirm Reposts",
"8ED/4u": "Reply To",
"8HJxXG": "Sign up",
"8QDesP": "Zap {n} sats",
"8Rkoyb": "Recipient",
"8Y6bZQ": "Invalid zap split: {input}",
@ -122,6 +123,8 @@
"C8HhVE": "Suggested Follows",
"CHTbO3": "Failed to load invoice",
"CVWeJ6": "Trending People",
"CYkOCI": "and {count} others you follow",
"CbM2hK": "Trending hashtags",
"CmZ9ls": "{n} Muted",
"CsCUYo": "{n} sats",
"Cu/K85": "Translated from {lang}",
@ -241,7 +244,6 @@
"QDFTjG": "{n} Relays",
"QWhotP": "Zap Pool only works if you use one of the supported wallet connections (WebLN, LNC, LNDHub or Nostr Wallet Connect)",
"Qxv0B2": "You currently have {number} sats in your zap pool.",
"R/6nsx": "Subscription",
"R81upa": "People you follow",
"RSr2uB": "Username must only contain lowercase letters and numbers",
"RahCRH": "Expired",
@ -311,6 +313,7 @@
"aHje0o": "Name or nym",
"aMaLBK": "Supported Extensions",
"aWpBzj": "Show more",
"arZnG2": "Trending users",
"b12Goz": "Mnemonic",
"b5vAk0": "Your handle will act like a lightning address and will redirect to your chosen LNURL or Lightning address",
"bLZL5a": "Get Address",
@ -370,6 +373,7 @@
"hMzcSq": "Messages",
"hRTfTR": "PRO",
"hY4lzx": "Supports",
"ha8JKG": "Show graph",
"hicxcO": "Show replies",
"hmZ3Bz": "Media",
"hniz8Z": "here",
@ -441,6 +445,7 @@
"qkvYUb": "Add to Profile",
"qmJ8kD": "Translation failed",
"qtWLmt": "Like",
"qyJtWy": "Show less",
"qydxOd": "Science",
"qz9fty": "Incorrect pin",
"r3C4x/": "Software",
@ -484,7 +489,6 @@
"wSZR47": "Submit",
"wWLwvh": "Anon",
"wih7iJ": "name is blocked",
"wofVHy": "Moderation",
"wqyN/i": "Find out more info about {service} at {link}",
"wtLjP6": "Copy ID",
"x/Fx2P": "Fund the services that you use by splitting a portion of all your zaps into a pool of funds!",

View File

@ -5,7 +5,7 @@ module.exports = {
theme: {
extend: {
colors: {
"neutral-999": "#090909",
"nearly-bg-color": "var(--nearly-bg-color)",
},
textColor: {
"nostr-blue": "var(--repost)",

View File

@ -20,7 +20,7 @@ export default defineConfig({
visualizer({
open: true,
gzipSize: true,
filename: "dist/stats.html",
filename: "build/stats.html",
}),
vitePluginVersionMark({
name: "snort",
@ -44,4 +44,8 @@ export default defineConfig({
global: {}, // needed for custom-event lib
SINGLE_RELAY: JSON.stringify(process.env.SINGLE_RELAY),
},
test: {
globals: true,
environment: "jsdom",
},
});

View File

@ -211,6 +211,17 @@ export default class SocialGraph {
return count;
}
followedByFriends(address: HexKey) {
const id = ID(address);
const set = new Set<HexKey>();
for (const follower of this.followersByUser.get(id) ?? []) {
if (this.followedByUser.get(this.root)?.has(follower)) {
set.add(STR(follower));
}
}
return set;
}
getFollowedByUser(user: HexKey, includeSelf = false): Set<HexKey> {
const userId = ID(user);
const set = new Set<HexKey>();

388
yarn.lock
View File

@ -2348,7 +2348,7 @@ __metadata:
languageName: node
linkType: hard
"@jest/globals@npm:^29.5.0, @jest/globals@npm:^29.6.1, @jest/globals@npm:^29.7.0":
"@jest/globals@npm:^29.5.0, @jest/globals@npm:^29.7.0":
version: 29.7.0
resolution: "@jest/globals@npm:29.7.0"
dependencies:
@ -2884,7 +2884,6 @@ __metadata:
dependencies:
"@cashu/cashu-ts": ^0.6.1
"@formatjs/cli": ^6.1.3
"@jest/globals": ^29.6.1
"@lightninglabs/lnc-web": ^0.2.3-alpha
"@noble/curves": ^1.0.0
"@noble/hashes": ^1.2.0
@ -2899,7 +2898,6 @@ __metadata:
"@szhsin/react-menu": ^3.3.1
"@types/config": ^3.3.3
"@types/debug": ^4.1.8
"@types/jest": ^29.5.1
"@types/node": ^20.4.1
"@types/react": ^18.0.26
"@types/react-dom": ^18.0.10
@ -2910,7 +2908,7 @@ __metadata:
"@types/webtorrent": ^0.109.3
"@typescript-eslint/eslint-plugin": ^6.1.0
"@typescript-eslint/parser": ^6.1.0
"@uidotdev/usehooks": ^2.3.1
"@uidotdev/usehooks": ^2.4.1
"@vitejs/plugin-react": ^4.2.0
"@void-cat/api": ^1.0.10
"@webbtc/webln-types": ^1.0.10
@ -2923,9 +2921,8 @@ __metadata:
emojilib: ^3.0.10
eslint: ^8.48.0
eslint-plugin-formatjs: ^4.11.3
fuse.js: ^7.0.0
highlight.js: ^11.8.0
jest: ^29.5.0
jest-environment-jsdom: ^29.5.0
light-bolt11-decoder: ^2.1.0
marked: ^9.1.0
marked-footnote: ^1.0.0
@ -2948,7 +2945,6 @@ __metadata:
tailwindcss: ^3.3.3
three: ^0.157.0
tinybench: ^2.5.1
ts-jest: ^29.1.1
typescript: ^5.2.2
use-long-press: ^3.2.0
use-sync-external-store: ^1.2.0
@ -2956,6 +2952,7 @@ __metadata:
vite: ^5.0.0
vite-plugin-pwa: ^0.17.0
vite-plugin-version-mark: ^0.0.10
vitest: ^0.34.6
workbox-core: ^6.4.2
workbox-precaching: ^7.0.0
workbox-routing: ^6.4.2
@ -2963,7 +2960,7 @@ __metadata:
languageName: unknown
linkType: soft
"@snort/shared@^1.0.6, @snort/shared@^1.0.9, @snort/shared@workspace:*, @snort/shared@workspace:packages/shared":
"@snort/shared@^1.0.10, @snort/shared@^1.0.6, @snort/shared@workspace:*, @snort/shared@workspace:packages/shared":
version: 0.0.0-use.local
resolution: "@snort/shared@workspace:packages/shared"
dependencies:
@ -2981,8 +2978,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "@snort/system-react@workspace:packages/system-react"
dependencies:
"@snort/shared": ^1.0.9
"@snort/system": ^1.1.4
"@snort/shared": ^1.0.10
"@snort/system": ^1.1.5
"@types/react": ^18.2.14
react: ^18.2.0
typescript: ^5.2.2
@ -3010,14 +3007,14 @@ __metadata:
version: 0.0.0-use.local
resolution: "@snort/system-web@workspace:packages/system-web"
dependencies:
"@snort/shared": ^1.0.9
"@snort/system": ^1.1.1
"@snort/shared": ^1.0.10
"@snort/system": ^1.1.5
dexie: ^3.2.4
typescript: ^5.2.2
languageName: unknown
linkType: soft
"@snort/system@^1.0.21, @snort/system@^1.1.1, @snort/system@^1.1.4, @snort/system@workspace:*, @snort/system@workspace:packages/system":
"@snort/system@^1.0.21, @snort/system@^1.1.5, @snort/system@workspace:*, @snort/system@workspace:packages/system":
version: 0.0.0-use.local
resolution: "@snort/system@workspace:packages/system"
dependencies:
@ -3026,7 +3023,7 @@ __metadata:
"@noble/hashes": ^1.3.2
"@peculiar/webcrypto": ^1.4.3
"@scure/base": ^1.1.2
"@snort/shared": ^1.0.9
"@snort/shared": ^1.0.10
"@stablelib/xchacha20": ^1.0.1
"@types/debug": ^4.1.8
"@types/jest": ^29.5.1
@ -3318,6 +3315,22 @@ __metadata:
languageName: node
linkType: hard
"@types/chai-subset@npm:^1.3.3":
version: 1.3.5
resolution: "@types/chai-subset@npm:1.3.5"
dependencies:
"@types/chai": "*"
checksum: 715c46d3e90f87482c2769389d560456bb257b225716ff44c275c231bdb62c8a30629f355f412bac0ecab07ebc036c1806d9ed9dde9792254f8ef4f07f76033b
languageName: node
linkType: hard
"@types/chai@npm:*, @types/chai@npm:^4.3.5":
version: 4.3.11
resolution: "@types/chai@npm:4.3.11"
checksum: d0c05fe5d02b2e6bbca2bd4866a2ab20a59cf729bc04af0060e7a3277eaf2fb65651b90d4c74b0ebf1d152b4b1d49fa8e44143acef276a2bbaa7785fbe5642d3
languageName: node
linkType: hard
"@types/config@npm:^3.3.3":
version: 3.3.3
resolution: "@types/config@npm:3.3.3"
@ -3853,7 +3866,7 @@ __metadata:
languageName: node
linkType: hard
"@uidotdev/usehooks@npm:^2.3.1":
"@uidotdev/usehooks@npm:^2.4.1":
version: 2.4.1
resolution: "@uidotdev/usehooks@npm:2.4.1"
peerDependencies:
@ -3885,6 +3898,59 @@ __metadata:
languageName: node
linkType: hard
"@vitest/expect@npm:0.34.6":
version: 0.34.6
resolution: "@vitest/expect@npm:0.34.6"
dependencies:
"@vitest/spy": 0.34.6
"@vitest/utils": 0.34.6
chai: ^4.3.10
checksum: 37a526f4af7e73fc56b71ba1139d6d93ff1972315d0e0691de967179298d2ad086e8803d2b28defe0e97a1326d808cd886e4b802d1691d8894cb234e35ed5185
languageName: node
linkType: hard
"@vitest/runner@npm:0.34.6":
version: 0.34.6
resolution: "@vitest/runner@npm:0.34.6"
dependencies:
"@vitest/utils": 0.34.6
p-limit: ^4.0.0
pathe: ^1.1.1
checksum: 0357f0a11f4e1e170099f9125e379bbe8049a59faa7b34b919b3e5ee8927f30824c2b3ebb814b6a77c75ec35a30bf9adb8ec2b5e051525b4edd0d17be15725cc
languageName: node
linkType: hard
"@vitest/snapshot@npm:0.34.6":
version: 0.34.6
resolution: "@vitest/snapshot@npm:0.34.6"
dependencies:
magic-string: ^0.30.1
pathe: ^1.1.1
pretty-format: ^29.5.0
checksum: c2f164b23741cdf10f449575a0f9996cf385675d0f76d2eb696f53b614743811f2fbefdc5eb0fd3f9544ccfbb566d57a5c50a70595167458579d56429b09151f
languageName: node
linkType: hard
"@vitest/spy@npm:0.34.6":
version: 0.34.6
resolution: "@vitest/spy@npm:0.34.6"
dependencies:
tinyspy: ^2.1.1
checksum: b05e5906f2f489a3234a0380a21cb48635915aa7f28eac92a595e78e9ceefb95340311635e39684b32fff20f9c58fdc33488eeddee39a660cd94c9c6bc2febf7
languageName: node
linkType: hard
"@vitest/utils@npm:0.34.6":
version: 0.34.6
resolution: "@vitest/utils@npm:0.34.6"
dependencies:
diff-sequences: ^29.4.3
loupe: ^2.3.6
pretty-format: ^29.5.0
checksum: acf716af2bab66037e49bd6d3e8bae40b605b9bff515d4926c46d6f8cc2366decfac5a1756ea55029968e71fba1da1f992764c3a57c9b46eccce3f6db7197bd6
languageName: node
linkType: hard
"@void-cat/api@npm:^1.0.10":
version: 1.0.10
resolution: "@void-cat/api@npm:1.0.10"
@ -3955,7 +4021,7 @@ __metadata:
languageName: node
linkType: hard
"acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1":
"acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1, acorn-walk@npm:^8.2.0":
version: 8.3.0
resolution: "acorn-walk@npm:8.3.0"
checksum: 15ea56ab6529135be05e7d018f935ca80a572355dd3f6d3cd717e36df3346e0f635a93ae781b1c7942607693e2e5f3ef81af5c6fc697bbadcc377ebda7b7f5f6
@ -4177,6 +4243,13 @@ __metadata:
languageName: node
linkType: hard
"assertion-error@npm:^1.1.0":
version: 1.1.0
resolution: "assertion-error@npm:1.1.0"
checksum: fd9429d3a3d4fd61782eb3962ae76b6d08aa7383123fca0596020013b3ebd6647891a85b05ce821c47d1471ed1271f00b0545cf6a4326cf2fc91efcc3b0fbecf
languageName: node
linkType: hard
"async@npm:^3.2.3":
version: 3.2.5
resolution: "async@npm:3.2.5"
@ -4474,6 +4547,13 @@ __metadata:
languageName: node
linkType: hard
"cac@npm:^6.7.14":
version: 6.7.14
resolution: "cac@npm:6.7.14"
checksum: 45a2496a9443abbe7f52a49b22fbe51b1905eff46e03fd5e6c98e3f85077be3f8949685a1849b1a9cd2bc3e5567dfebcf64f01ce01847baf918f1b37c839791a
languageName: node
linkType: hard
"cacache@npm:^18.0.0":
version: 18.0.0
resolution: "cacache@npm:18.0.0"
@ -4540,6 +4620,21 @@ __metadata:
languageName: node
linkType: hard
"chai@npm:^4.3.10":
version: 4.3.10
resolution: "chai@npm:4.3.10"
dependencies:
assertion-error: ^1.1.0
check-error: ^1.0.3
deep-eql: ^4.1.3
get-func-name: ^2.0.2
loupe: ^2.3.6
pathval: ^1.1.1
type-detect: ^4.0.8
checksum: 536668c60a0d985a0fbd94418028e388d243a925d7c5e858c7443e334753511614a3b6a124bac9ca077dfc4c37acc367d62f8c294960f440749536dc181dfc6d
languageName: node
linkType: hard
"chalk@npm:^2.4.2":
version: 2.4.2
resolution: "chalk@npm:2.4.2"
@ -4568,6 +4663,15 @@ __metadata:
languageName: node
linkType: hard
"check-error@npm:^1.0.3":
version: 1.0.3
resolution: "check-error@npm:1.0.3"
dependencies:
get-func-name: ^2.0.2
checksum: e2131025cf059b21080f4813e55b3c480419256914601750b0fee3bd9b2b8315b531e551ef12560419b8b6d92a3636511322752b1ce905703239e7cc451b6399
languageName: node
linkType: hard
"chokidar@npm:^3.5.3":
version: 3.5.3
resolution: "chokidar@npm:3.5.3"
@ -5101,6 +5205,15 @@ __metadata:
languageName: node
linkType: hard
"deep-eql@npm:^4.1.3":
version: 4.1.3
resolution: "deep-eql@npm:4.1.3"
dependencies:
type-detect: ^4.0.0
checksum: 7f6d30cb41c713973dc07eaadded848b2ab0b835e518a88b91bea72f34e08c4c71d167a722a6f302d3a6108f05afd8e6d7650689a84d5d29ec7fe6220420397f
languageName: node
linkType: hard
"deep-is@npm:^0.1.3":
version: 0.1.4
resolution: "deep-is@npm:0.1.4"
@ -5179,7 +5292,7 @@ __metadata:
languageName: node
linkType: hard
"diff-sequences@npm:^29.6.3":
"diff-sequences@npm:^29.4.3, diff-sequences@npm:^29.6.3":
version: 29.6.3
resolution: "diff-sequences@npm:29.6.3"
checksum: f4914158e1f2276343d98ff5b31fc004e7304f5470bf0f1adb2ac6955d85a531a6458d33e87667f98f6ae52ebd3891bb47d420bb48a5bd8b7a27ee25b20e33aa
@ -6007,6 +6120,13 @@ __metadata:
languageName: node
linkType: hard
"fuse.js@npm:^7.0.0":
version: 7.0.0
resolution: "fuse.js@npm:7.0.0"
checksum: d15750efec1808370c0cae92ec9473aa7261c59bca1f15f1cf60039ba6f804b8f95340b5cabd83a4ef55839c1034764856e0128e443921f072aa0d8a20e4cacf
languageName: node
linkType: hard
"gensync@npm:^1.0.0-beta.2":
version: 1.0.0-beta.2
resolution: "gensync@npm:1.0.0-beta.2"
@ -6021,6 +6141,13 @@ __metadata:
languageName: node
linkType: hard
"get-func-name@npm:^2.0.1, get-func-name@npm:^2.0.2":
version: 2.0.2
resolution: "get-func-name@npm:2.0.2"
checksum: 3f62f4c23647de9d46e6f76d2b3eafe58933a9b3830c60669e4180d6c601ce1b4aa310ba8366143f55e52b139f992087a9f0647274e8745621fa2af7e0acf13b
languageName: node
linkType: hard
"get-intrinsic@npm:^1.0.2, get-intrinsic@npm:^1.1.1, get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.0, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.2":
version: 1.2.2
resolution: "get-intrinsic@npm:1.2.2"
@ -7477,6 +7604,13 @@ __metadata:
languageName: node
linkType: hard
"jsonc-parser@npm:^3.2.0":
version: 3.2.0
resolution: "jsonc-parser@npm:3.2.0"
checksum: 946dd9a5f326b745aa326d48a7257e3f4a4b62c5e98ec8e49fa2bdd8d96cef7e6febf1399f5c7016114fd1f68a1c62c6138826d5d90bc650448e3cf0951c53c7
languageName: node
linkType: hard
"jsonfile@npm:^6.0.1":
version: 6.1.0
resolution: "jsonfile@npm:6.1.0"
@ -7580,6 +7714,13 @@ __metadata:
languageName: node
linkType: hard
"local-pkg@npm:^0.4.3":
version: 0.4.3
resolution: "local-pkg@npm:0.4.3"
checksum: 7825aca531dd6afa3a3712a0208697aa4a5cd009065f32e3fb732aafcc42ed11f277b5ac67229222e96f4def55197171cdf3d5522d0381b489d2e5547b407d55
languageName: node
linkType: hard
"locate-character@npm:^3.0.0":
version: 3.0.0
resolution: "locate-character@npm:3.0.0"
@ -7658,6 +7799,15 @@ __metadata:
languageName: node
linkType: hard
"loupe@npm:^2.3.6":
version: 2.3.7
resolution: "loupe@npm:2.3.7"
dependencies:
get-func-name: ^2.0.1
checksum: 96c058ec7167598e238bb7fb9def2f9339215e97d6685d9c1e3e4bdb33d14600e11fe7a812cf0c003dfb73ca2df374f146280b2287cae9e8d989e9d7a69a203b
languageName: node
linkType: hard
"lru-cache@npm:^10.0.1, lru-cache@npm:^9.1.1 || ^10.0.0":
version: 10.0.2
resolution: "lru-cache@npm:10.0.2"
@ -7694,7 +7844,7 @@ __metadata:
languageName: node
linkType: hard
"magic-string@npm:^0.30.0, magic-string@npm:^0.30.4":
"magic-string@npm:^0.30.0, magic-string@npm:^0.30.1, magic-string@npm:^0.30.4":
version: 0.30.5
resolution: "magic-string@npm:0.30.5"
dependencies:
@ -7956,6 +8106,18 @@ __metadata:
languageName: node
linkType: hard
"mlly@npm:^1.2.0, mlly@npm:^1.4.0":
version: 1.4.2
resolution: "mlly@npm:1.4.2"
dependencies:
acorn: ^8.10.0
pathe: ^1.1.1
pkg-types: ^1.0.3
ufo: ^1.3.0
checksum: ad0813eca133e59ac03b356b87deea57da96083dce7dda58a8eeb2dce92b7cc2315bedd9268f3ff8e98effe1867ddb1307486d4c5cd8be162daa8e0fa0a98ed4
languageName: node
linkType: hard
"ms@npm:2.1.2":
version: 2.1.2
resolution: "ms@npm:2.1.2"
@ -8214,6 +8376,15 @@ __metadata:
languageName: node
linkType: hard
"p-limit@npm:^4.0.0":
version: 4.0.0
resolution: "p-limit@npm:4.0.0"
dependencies:
yocto-queue: ^1.0.0
checksum: 01d9d70695187788f984226e16c903475ec6a947ee7b21948d6f597bed788e3112cc7ec2e171c1d37125057a5f45f3da21d8653e04a3a793589e12e9e80e756b
languageName: node
linkType: hard
"p-locate@npm:^4.1.0":
version: 4.1.0
resolution: "p-locate@npm:4.1.0"
@ -8323,6 +8494,20 @@ __metadata:
languageName: node
linkType: hard
"pathe@npm:^1.1.0, pathe@npm:^1.1.1":
version: 1.1.1
resolution: "pathe@npm:1.1.1"
checksum: 34ab3da2e5aa832ebc6a330ffe3f73d7ba8aec6e899b53b8ec4f4018de08e40742802deb12cf5add9c73b7bf719b62c0778246bd376ca62b0fb23e0dde44b759
languageName: node
linkType: hard
"pathval@npm:^1.1.1":
version: 1.1.1
resolution: "pathval@npm:1.1.1"
checksum: 090e3147716647fb7fb5b4b8c8e5b55e5d0a6086d085b6cd23f3d3c01fcf0ff56fd3cc22f2f4a033bd2e46ed55d61ed8379e123b42afe7d531a2a5fc8bb556d6
languageName: node
linkType: hard
"periscopic@npm:^3.1.0":
version: 3.1.0
resolution: "periscopic@npm:3.1.0"
@ -8371,6 +8556,17 @@ __metadata:
languageName: node
linkType: hard
"pkg-types@npm:^1.0.3":
version: 1.0.3
resolution: "pkg-types@npm:1.0.3"
dependencies:
jsonc-parser: ^3.2.0
mlly: ^1.2.0
pathe: ^1.1.0
checksum: 4b305c834b912ddcc8a0fe77530c0b0321fe340396f84cbb87aecdbc126606f47f2178f23b8639e71a4870f9631c7217aef52ffed0ae17ea2dbbe7e43d116a6e
languageName: node
linkType: hard
"polished@npm:4":
version: 4.2.2
resolution: "polished@npm:4.2.2"
@ -8859,7 +9055,7 @@ __metadata:
languageName: node
linkType: hard
"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0":
"pretty-format@npm:^29.0.0, pretty-format@npm:^29.5.0, pretty-format@npm:^29.7.0":
version: 29.7.0
resolution: "pretty-format@npm:29.7.0"
dependencies:
@ -9673,6 +9869,13 @@ __metadata:
languageName: node
linkType: hard
"siginfo@npm:^2.0.0":
version: 2.0.0
resolution: "siginfo@npm:2.0.0"
checksum: 8aa5a98640ca09fe00d74416eca97551b3e42991614a3d1b824b115fc1401543650914f651ab1311518177e4d297e80b953f4cd4cd7ea1eabe824e8f2091de01
languageName: node
linkType: hard
"signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7":
version: 3.0.7
resolution: "signal-exit@npm:3.0.7"
@ -9818,6 +10021,20 @@ __metadata:
languageName: node
linkType: hard
"stackback@npm:0.0.2":
version: 0.0.2
resolution: "stackback@npm:0.0.2"
checksum: 2d4dc4e64e2db796de4a3c856d5943daccdfa3dd092e452a1ce059c81e9a9c29e0b9badba91b43ef0d5ff5c04ee62feb3bcc559a804e16faf447bac2d883aa99
languageName: node
linkType: hard
"std-env@npm:^3.3.3":
version: 3.5.0
resolution: "std-env@npm:3.5.0"
checksum: 8eba87eab2d6933e0575f13a65a359952a2e3e8c4d24eb55beac5500fe0403b3482c7b59a5de8d035ae13d390c76dd6c677772f9d2a89ea7cf39ae267b71bdd3
languageName: node
linkType: hard
"string-length@npm:^4.0.1":
version: 4.0.2
resolution: "string-length@npm:4.0.2"
@ -9957,6 +10174,15 @@ __metadata:
languageName: node
linkType: hard
"strip-literal@npm:^1.0.1":
version: 1.3.0
resolution: "strip-literal@npm:1.3.0"
dependencies:
acorn: ^8.10.0
checksum: f5fa7e289df8ebe82e90091fd393974faf8871be087ca50114327506519323cf15f2f8fee6ebe68b5e58bfc795269cae8bdc7cb5a83e27b02b3fe953f37b0a89
languageName: node
linkType: hard
"sucrase@npm:^3.32.0":
version: 3.34.0
resolution: "sucrase@npm:3.34.0"
@ -10215,7 +10441,7 @@ __metadata:
languageName: node
linkType: hard
"tinybench@npm:^2.5.1":
"tinybench@npm:^2.5.0, tinybench@npm:^2.5.1":
version: 2.5.1
resolution: "tinybench@npm:2.5.1"
checksum: 6d98526c00b68b50ab0a37590b3cc6713b96fee7dd6756a2a77bab071ed1b4a4fc54e7b11e28b35ec2f761c6a806c2befa95f10acf2fee111c49327b6fc3386f
@ -10229,6 +10455,20 @@ __metadata:
languageName: node
linkType: hard
"tinypool@npm:^0.7.0":
version: 0.7.0
resolution: "tinypool@npm:0.7.0"
checksum: fdcccd5c750574fce51f8801a877f8284e145d12b79cd5f2d72bfbddfe20c895e915555bc848e122bb6aa968098e7ac4fe1e8e88104904d518dc01cccd18a510
languageName: node
linkType: hard
"tinyspy@npm:^2.1.1":
version: 2.2.0
resolution: "tinyspy@npm:2.2.0"
checksum: 36431acaa648054406147a92b9bde494b7548d0f9f3ffbcc02113c25a6e59f3310cbe924353d7f4c51436299150bec2dbb3dc595748f58c4ddffea22d5baaadb
languageName: node
linkType: hard
"tmpl@npm:1.0.5":
version: 1.0.5
resolution: "tmpl@npm:1.0.5"
@ -10298,7 +10538,7 @@ __metadata:
languageName: node
linkType: hard
"ts-jest@npm:^29.1.0, ts-jest@npm:^29.1.1":
"ts-jest@npm:^29.1.0":
version: 29.1.1
resolution: "ts-jest@npm:29.1.1"
dependencies:
@ -10385,7 +10625,7 @@ __metadata:
languageName: node
linkType: hard
"type-detect@npm:4.0.8":
"type-detect@npm:4.0.8, type-detect@npm:^4.0.0, type-detect@npm:^4.0.8":
version: 4.0.8
resolution: "type-detect@npm:4.0.8"
checksum: 62b5628bff67c0eb0b66afa371bd73e230399a8d2ad30d852716efcc4656a7516904570cd8631a49a3ce57c10225adf5d0cbdcb47f6b0255fe6557c453925a15
@ -10480,6 +10720,13 @@ __metadata:
languageName: node
linkType: hard
"ufo@npm:^1.3.0":
version: 1.3.2
resolution: "ufo@npm:1.3.2"
checksum: f1180bb715ff4dd46152fd4dec41c731e84d7b9eaf1432548a0210b2f7e0cd29de125ac88e582c6a079d8ae5bc9ab04ef2bdbafe125086480b10c1006b81bfce
languageName: node
linkType: hard
"unbox-primitive@npm:^1.0.2":
version: 1.0.2
resolution: "unbox-primitive@npm:1.0.2"
@ -10729,6 +10976,22 @@ __metadata:
languageName: node
linkType: hard
"vite-node@npm:0.34.6":
version: 0.34.6
resolution: "vite-node@npm:0.34.6"
dependencies:
cac: ^6.7.14
debug: ^4.3.4
mlly: ^1.4.0
pathe: ^1.1.1
picocolors: ^1.0.0
vite: ^3.0.0 || ^4.0.0 || ^5.0.0-0
bin:
vite-node: vite-node.mjs
checksum: 46eba82bf8b69c7dfeed901502533b172cc6303212f0f49f82c2f64758fa4b60acd1b1e37cb96aff944e36b510b0d1beedb50d9cb25ef39e0159b2b9d1136b1f
languageName: node
linkType: hard
"vite-plugin-pwa@npm:^0.17.0":
version: 0.17.0
resolution: "vite-plugin-pwa@npm:0.17.0"
@ -10753,7 +11016,7 @@ __metadata:
languageName: node
linkType: hard
"vite@npm:^5.0.0":
"vite@npm:^3.0.0 || ^4.0.0 || ^5.0.0-0, vite@npm:^3.1.0 || ^4.0.0 || ^5.0.0-0, vite@npm:^5.0.0":
version: 5.0.0
resolution: "vite@npm:5.0.0"
dependencies:
@ -10793,6 +11056,66 @@ __metadata:
languageName: node
linkType: hard
"vitest@npm:^0.34.6":
version: 0.34.6
resolution: "vitest@npm:0.34.6"
dependencies:
"@types/chai": ^4.3.5
"@types/chai-subset": ^1.3.3
"@types/node": "*"
"@vitest/expect": 0.34.6
"@vitest/runner": 0.34.6
"@vitest/snapshot": 0.34.6
"@vitest/spy": 0.34.6
"@vitest/utils": 0.34.6
acorn: ^8.9.0
acorn-walk: ^8.2.0
cac: ^6.7.14
chai: ^4.3.10
debug: ^4.3.4
local-pkg: ^0.4.3
magic-string: ^0.30.1
pathe: ^1.1.1
picocolors: ^1.0.0
std-env: ^3.3.3
strip-literal: ^1.0.1
tinybench: ^2.5.0
tinypool: ^0.7.0
vite: ^3.1.0 || ^4.0.0 || ^5.0.0-0
vite-node: 0.34.6
why-is-node-running: ^2.2.2
peerDependencies:
"@edge-runtime/vm": "*"
"@vitest/browser": "*"
"@vitest/ui": "*"
happy-dom: "*"
jsdom: "*"
playwright: "*"
safaridriver: "*"
webdriverio: "*"
peerDependenciesMeta:
"@edge-runtime/vm":
optional: true
"@vitest/browser":
optional: true
"@vitest/ui":
optional: true
happy-dom:
optional: true
jsdom:
optional: true
playwright:
optional: true
safaridriver:
optional: true
webdriverio:
optional: true
bin:
vitest: vitest.mjs
checksum: 45f5c1987fa8c76dbaf5db379bbdb4f6e3713c484e850149af38247b627e70016c1863286fd7fcfab08a1d98430f66ba1f45af6f14f5c467ded4b1ea6f26afa3
languageName: node
linkType: hard
"w3c-xmlserializer@npm:^4.0.0":
version: 4.0.0
resolution: "w3c-xmlserializer@npm:4.0.0"
@ -10923,6 +11246,18 @@ __metadata:
languageName: node
linkType: hard
"why-is-node-running@npm:^2.2.2":
version: 2.2.2
resolution: "why-is-node-running@npm:2.2.2"
dependencies:
siginfo: ^2.0.0
stackback: 0.0.2
bin:
why-is-node-running: cli.js
checksum: 50820428f6a82dfc3cbce661570bcae9b658723217359b6037b67e495255409b4c8bc7931745f5c175df71210450464517cab32b2f7458ac9c40b4925065200a
languageName: node
linkType: hard
"workbox-background-sync@npm:7.0.0":
version: 7.0.0
resolution: "workbox-background-sync@npm:7.0.0"
@ -11269,3 +11604,10 @@ __metadata:
checksum: f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700
languageName: node
linkType: hard
"yocto-queue@npm:^1.0.0":
version: 1.0.0
resolution: "yocto-queue@npm:1.0.0"
checksum: 2cac84540f65c64ccc1683c267edce396b26b1e931aa429660aefac8fbe0188167b7aee815a3c22fa59a28a58d898d1a2b1825048f834d8d629f4c2a5d443801
languageName: node
linkType: hard