forked from Kieran/snort
Merge pull request '3-column layout' (#699) from mmalmi/snort:main into main
This commit is contained in:
commit
7dfb7ec363
@ -6,6 +6,7 @@
|
||||
"nip05Domain": "snort.social",
|
||||
"favicon": "public/favicon.ico",
|
||||
"appleTouchIconUrl": "/nostrich_512.png",
|
||||
"navLogo": null,
|
||||
"publicDir": "public/snort",
|
||||
"httpCache": "",
|
||||
"animalNamePlaceholders": false,
|
||||
|
@ -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,
|
||||
|
1
packages/app/custom.d.ts
vendored
1
packages/app/custom.d.ts
vendored
@ -48,6 +48,7 @@ declare const CONFIG: {
|
||||
nip05Domain: string;
|
||||
favicon: string;
|
||||
appleTouchIconUrl: string;
|
||||
navLogo: string | null;
|
||||
httpCache: string;
|
||||
animalNamePlaceholders: boolean;
|
||||
defaultZapPoolFee?: number;
|
||||
|
@ -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"],
|
||||
};
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
BIN
packages/app/public/iris/img/icon128.png
Normal file
BIN
packages/app/public/iris/img/icon128.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
@ -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 => {
|
||||
|
@ -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";
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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()} />
|
||||
</>
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
39
packages/app/src/Element/ErrorBoundary.tsx
Normal file
39
packages/app/src/Element/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
@ -5,10 +5,6 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.note:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note > .header .reply {
|
||||
font-size: 13px;
|
||||
color: var(--font-secondary-color);
|
||||
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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" />
|
||||
|
@ -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 (
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -15,6 +15,7 @@ export default function NoteQuote({ link, depth }: { link: NostrLink; depth?: nu
|
||||
depth={(depth ?? 0) + 1}
|
||||
options={{
|
||||
showFooter: false,
|
||||
truncate: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -103,6 +103,9 @@ export function TimelineFragment(props: TimelineFragProps) {
|
||||
depth={0}
|
||||
onClick={props.noteOnClick}
|
||||
context={props.noteContext?.(e)}
|
||||
options={{
|
||||
truncate: true,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
@ -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>
|
||||
|
@ -7,7 +7,7 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: pre-wrap;
|
||||
display: inline;
|
||||
overflow-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.text .text-frag > a {
|
||||
|
@ -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 })} />;
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -10,6 +10,7 @@
|
||||
box-sizing: border-box;
|
||||
background-position: center;
|
||||
background-color: var(--gray);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.avatar[data-domain="iris.to"],
|
||||
|
28
packages/app/src/Element/User/FollowDistanceIndicator.tsx
Normal file
28
packages/app/src/Element/User/FollowDistanceIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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">
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
43
packages/app/src/FuzzySearch.ts
Normal file
43
packages/app/src/FuzzySearch.ts
Normal 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);
|
||||
}
|
||||
});
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
45
packages/app/src/Pages/Layout/LogoHeader.tsx
Normal file
45
packages/app/src/Pages/Layout/LogoHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
121
packages/app/src/Pages/Layout/NavSidebar.tsx
Normal file
121
packages/app/src/Pages/Layout/NavSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
39
packages/app/src/Pages/Layout/RightColumn.tsx
Normal file
39
packages/app/src/Pages/Layout/RightColumn.tsx
Normal 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>
|
||||
);
|
||||
}
|
102
packages/app/src/Pages/Layout/index.tsx
Normal file
102
packages/app/src/Pages/Layout/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
115
packages/app/src/Pages/Messages/MessagesPage.tsx
Normal file
115
packages/app/src/Pages/Messages/MessagesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
145
packages/app/src/Pages/Messages/NewChatWindow.tsx
Normal file
145
packages/app/src/Pages/Messages/NewChatWindow.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
20
packages/app/src/Pages/Messages/Nip28ChatProfile.tsx
Normal file
20
packages/app/src/Pages/Messages/Nip28ChatProfile.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
@ -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}
|
||||
|
@ -39,8 +39,6 @@
|
||||
|
||||
@media (min-width: 520px) {
|
||||
.profile .banner {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -49,7 +49,7 @@ const FollowsHint = () => {
|
||||
{...messages.NoFollows}
|
||||
values={{
|
||||
newUsersPage: (
|
||||
<Link to={"/new/discover"}>
|
||||
<Link to={"/discover"}>
|
||||
<FormattedMessage {...messages.NewUsers} />
|
||||
</Link>
|
||||
),
|
||||
|
@ -49,6 +49,7 @@
|
||||
}
|
||||
|
||||
.settings-row:hover,
|
||||
.settings-row.active,
|
||||
.settings-group-header:hover {
|
||||
color: var(--highlight);
|
||||
}
|
||||
|
@ -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} />
|
||||
|
@ -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", () => {
|
||||
|
@ -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 |
@ -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 */
|
||||
}
|
||||
|
@ -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>,
|
||||
|
@ -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}"
|
||||
},
|
||||
|
@ -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!",
|
||||
|
@ -5,7 +5,7 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"neutral-999": "#090909",
|
||||
"nearly-bg-color": "var(--nearly-bg-color)",
|
||||
},
|
||||
textColor: {
|
||||
"nostr-blue": "var(--repost)",
|
||||
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
@ -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
388
yarn.lock
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user