Merge remote-tracking branch 'mmalmi/main'

This commit is contained in:
Kieran 2023-11-16 12:37:48 +00:00
parent 83ffe746b1
commit 3611af9dce
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
28 changed files with 997 additions and 51 deletions

View File

@ -30,12 +30,14 @@
"qr-code-styling": "^1.6.0-rc.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-force-graph-3d": "^1.24.0",
"react-intersection-observer": "^9.4.1",
"react-intl": "^6.4.4",
"react-router-dom": "^6.5.0",
"react-textarea-autosize": "^8.4.0",
"react-twitter-embed": "^4.0.4",
"recharts": "^2.8.0",
"three": "^0.157.0",
"use-long-press": "^3.2.0",
"use-sync-external-store": "^1.2.0",
"uuid": "^9.0.0",
@ -85,6 +87,7 @@
"@types/node": "^20.4.1",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/three": "^0.157.2",
"@types/uuid": "^9.0.2",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
"@types/webtorrent": "^0.109.3",

View File

@ -0,0 +1,48 @@
import { db } from "Db";
import { unixNowMs } from "@snort/shared";
import { EventKind, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { RefreshFeedCache } from "./RefreshFeedCache";
import { LoginSession } from "Login";
import SocialGraph from "SocialGraph/SocialGraph";
export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
constructor() {
super("FollowListCache", db.followLists);
}
buildSub(session: LoginSession, rb: RequestBuilder): void {
const since = this.newest();
rb.withFilter()
.kinds([EventKind.ContactList])
.authors(session.follows.item)
.since(since === 0 ? undefined : since);
}
async onEvent(evs: readonly TaggedNostrEvent[]) {
await Promise.all(
evs.map(async e => {
const update = await super.update({
...e,
created: e.created_at,
loaded: unixNowMs(),
});
if (update !== "no_change") {
SocialGraph.handleFollowEvent(e);
}
}),
);
}
key(of: TaggedNostrEvent): string {
return of.pubkey;
}
takeSnapshot() {
return [...this.cache.values()];
}
override async preload() {
await super.preload();
this.snapshot().forEach(e => SocialGraph.handleFollowEvent(e));
}
}

View File

@ -7,6 +7,7 @@ import { Payments } from "./PaymentsCache";
import { GiftWrapCache } from "./GiftWrapCache";
import { NotificationsCache } from "./Notifications";
import { FollowsFeedCache } from "./FollowsFeed";
import { FollowListCache } from "./FollowListCache";
export const SystemDb = new SnortSystemDb();
export const UserCache = new UserProfileCache(SystemDb.users);
@ -19,6 +20,7 @@ export const InteractionCache = new EventInteractionCache();
export const GiftsCache = new GiftWrapCache();
export const Notifications = new NotificationsCache();
export const FollowsFeed = new FollowsFeedCache();
export const FollowLists = new FollowListCache();
export async function preload(follows?: Array<string>) {
const preloads = [
@ -30,6 +32,7 @@ export async function preload(follows?: Array<string>) {
GiftsCache.preload(),
Notifications.preload(),
FollowsFeed.preload(),
FollowLists.preload(),
];
await Promise.all(preloads);
}

View File

@ -2,7 +2,7 @@ import Dexie, { Table } from "dexie";
import { HexKey, NostrEvent, TaggedNostrEvent, u256 } from "@snort/system";
export const NAME = "snortDB";
export const VERSION = 15;
export const VERSION = 16;
export interface SubCache {
id: string;
@ -46,6 +46,7 @@ const STORES = {
gifts: "++id",
notifications: "++id",
followsFeed: "++id, created_at, kind",
followLists: "++pubkey",
};
export class SnortDB extends Dexie {
@ -56,6 +57,7 @@ export class SnortDB extends Dexie {
gifts!: Table<UnwrappedGift>;
notifications!: Table<NostrEventForSession>;
followsFeed!: Table<TaggedNostrEvent>;
followLists!: Table<TaggedNostrEvent>;
constructor() {
super(NAME);

View File

@ -448,7 +448,7 @@ export function NoteCreator() {
className="note-creator-icon"
link=""
showUsername={false}
showFollowingMark={false}
showFollowDistance={false}
/>
{note.pollOptions === undefined && !note.replyTo && (
<AsyncIcon

View File

@ -59,7 +59,7 @@ export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
{sender && (
<ProfileImage
pubkey={anonZap ? "" : sender}
showFollowingMark={false}
showFollowDistance={false}
overrideUsername={anonZap ? formatMessage({ defaultMessage: "Anonymous" }) : undefined}
/>
)}

View File

@ -36,7 +36,7 @@ export function TimelineRenderer(props: TimelineRendererProps) {
<>
<div className="card latest-notes" onClick={() => props.showLatest(false)} ref={ref}>
{props.latest.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} showFollowingMark={false} />;
return <ProfileImage pubkey={p} showUsername={false} link={""} showFollowDistance={false} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
@ -49,7 +49,7 @@ export function TimelineRenderer(props: TimelineRendererProps) {
className="card latest-notes latest-notes-fixed pointer fade-in"
onClick={() => props.showLatest(true)}>
{props.latest.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} showFollowingMark={false} />;
return <ProfileImage pubkey={p} showUsername={false} link={""} showFollowDistance={false} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"

View File

@ -10,6 +10,34 @@ export interface ModalProps {
children: ReactNode;
}
let scrollbarWidth: number | null = null;
const getScrollbarWidth = () => {
if (scrollbarWidth !== null) {
return scrollbarWidth;
}
const outer = document.createElement("div");
outer.style.visibility = "hidden";
outer.style.width = "100px";
document.body.appendChild(outer);
const widthNoScroll = outer.offsetWidth;
outer.style.overflow = "scroll";
const inner = document.createElement("div");
inner.style.width = "100%";
outer.appendChild(inner);
const widthWithScroll = inner.offsetWidth;
outer.parentNode?.removeChild(outer);
scrollbarWidth = widthNoScroll - widthWithScroll;
return scrollbarWidth;
};
export default function Modal(props: ModalProps) {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && props.onClose) {
@ -19,10 +47,13 @@ export default function Modal(props: ModalProps) {
useEffect(() => {
document.body.classList.add("scroll-lock");
document.body.style.paddingRight = `${getScrollbarWidth()}px`;
document.addEventListener("keydown", handleKeyDown);
return () => {
document.body.classList.remove("scroll-lock");
document.body.style.paddingRight = "";
document.removeEventListener("keydown", handleKeyDown);
};
}, []);

View File

@ -16,6 +16,7 @@ export default function SearchBox() {
const { formatMessage } = useIntl();
const [search, setSearch] = useState("");
const [searching, setSearching] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const navigate = useNavigate();
const location = useLocation();
@ -120,13 +121,15 @@ export default function SearchBox() {
value={search}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setTimeout(() => setIsFocused(false), 150)}
/>
{searching ? (
<Spinner width={24} height={24} />
) : (
<Icon name="search" size={24} onClick={() => navigate("/search")} />
)}
{search && !searching && (
{search && !searching && isFocused && (
<div
className="absolute top-full mt-2 w-full border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-black shadow-lg rounded-lg z-10 overflow-hidden"
ref={resultListRef}>

View File

@ -166,7 +166,7 @@ export default function SendSats(props: SendSatsProps) {
<ProfileImage
pubkey={v.value}
showUsername={false}
showFollowingMark={false}
showFollowDistance={false}
imageOverlay={formatShort(Math.floor((amount?.amount ?? 0) * (v.weight / total)))}
/>
))}

View File

@ -8,6 +8,7 @@ import ProfileImage from "./ProfileImage";
import { UserWebsiteLink } from "./UserWebsiteLink";
import Text from "Element/Text";
import { useEffect, useState } from "react";
import useLogin from "../../Hooks/useLogin";
interface RectElement {
getBoundingClientRect(): {
@ -35,6 +36,7 @@ export function ProfileCard({
}) {
const [showProfileMenu, setShowProfileMenu] = useState(false);
const [t, setT] = useState<ReturnType<typeof setTimeout>>();
const { publicKey: myPublicKey } = useLogin(s => ({ publicKey: s.publicKey }));
useEffect(() => {
if (show) {
@ -60,14 +62,14 @@ export function ProfileCard({
align="end">
<div className="flex flex-col g8">
<div className="flex justify-between">
<ProfileImage pubkey={""} profile={user} showProfileCard={false} link="" />
<ProfileImage pubkey={pubkey} profile={user} showProfileCard={false} link="" />
<div className="flex g8">
{/*<button type="button" onClick={() => {
LoginStore.loginWithPubkey(pubkey, LoginSessionType.PublicKey, undefined, undefined, undefined, true);
}}>
<FormattedMessage defaultMessage="Stalk" />
</button>*/}
<FollowButton pubkey={pubkey} />
{myPublicKey !== pubkey && <FollowButton pubkey={pubkey} />}
</div>
</div>
<Text

View File

@ -8,11 +8,11 @@ import classNames from "classnames";
import Avatar from "Element/User/Avatar";
import Nip05 from "Element/User/Nip05";
import useLogin from "Hooks/useLogin";
import Icon from "Icons/Icon";
import DisplayName from "./DisplayName";
import { ProfileLink } from "./ProfileLink";
import { ProfileCard } from "./ProfileCard";
import SocialGraph from "../../SocialGraph/SocialGraph";
export interface ProfileImageProps {
pubkey: HexKey;
@ -27,7 +27,7 @@ export interface ProfileImageProps {
size?: number;
onClick?: (e: React.MouseEvent) => void;
imageOverlay?: ReactNode;
showFollowingMark?: boolean;
showFollowDistance?: boolean;
icons?: ReactNode;
showProfileCard?: boolean;
}
@ -45,14 +45,13 @@ export default function ProfileImage({
size,
imageOverlay,
onClick,
showFollowingMark = true,
showFollowDistance = true,
icons,
showProfileCard,
}: ProfileImageProps) {
const user = useUserProfile(profile ? "" : pubkey) ?? profile;
const nip05 = defaultNip ? defaultNip : user?.nip05;
const { follows } = useLogin();
const doesFollow = follows.item.includes(pubkey);
const followDistance = SocialGraph.getFollowDistance(pubkey);
const [ref, hovering] = useHover<HTMLDivElement>();
function handleClick(e: React.MouseEvent) {
@ -63,6 +62,12 @@ export default function ProfileImage({
}
function inner() {
let followDistanceColor = "";
if (followDistance <= 1) {
followDistanceColor = "success";
} else if (followDistance === 2 && SocialGraph.followedByFriendsCount(pubkey) >= 10) {
followDistanceColor = "text-nostr-orange";
}
return (
<>
<div className="avatar-wrapper" ref={ref}>
@ -72,12 +77,12 @@ export default function ProfileImage({
size={size}
imageOverlay={imageOverlay}
icons={
(doesFollow && showFollowingMark) || icons ? (
(followDistance <= 2 && showFollowDistance) || icons ? (
<>
{icons}
{showFollowingMark && (
{showFollowDistance && (
<div className="icon-circle">
<Icon name="check" className="success" size={10} />
<Icon name="check" className={followDistanceColor} size={10} />
</div>
)}
</>

View File

@ -22,7 +22,7 @@ import {
import { SnortPubKey } from "Const";
import { SubscriptionEvent } from "Subscription";
import useRelaysFeedFollows from "./RelaysFeedFollows";
import { FollowsFeed, GiftsCache, Notifications, UserRelays } from "Cache";
import { FollowLists, FollowsFeed, GiftsCache, Notifications, UserRelays } from "Cache";
import { Nip28Chats, Nip4Chats } from "chat";
import { useRefreshFeedCache } from "Hooks/useRefreshFeedcache";
@ -38,6 +38,7 @@ export default function useLoginFeed() {
useRefreshFeedCache(Notifications, true);
useRefreshFeedCache(FollowsFeed, true);
useRefreshFeedCache(GiftsCache, true);
useRefreshFeedCache(FollowLists, false);
useEffect(() => {
system.checkSigs = login.appData.item.preferences.checkSigs;

View File

@ -11,8 +11,14 @@ export interface ImgProxySettings {
export default function useImgProxy() {
const settings = useLogin(s => s.appData.item.preferences.imgProxyConfig);
const te = new TextEncoder();
return {
proxy: (url: string, resize?: number) => proxyImg(url, settings, resize),
};
}
export function proxyImg(url: string, settings?: ImgProxySettings, resize?: number) {
const te = new TextEncoder();
function urlSafe(s: string) {
return s.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
@ -25,17 +31,12 @@ export default function useImgProxy() {
);
return urlSafe(base64.encode(result));
}
return {
proxy: (url: string, resize?: number) => {
if (!settings) return url;
if (url.startsWith("data:") || url.startsWith("blob:")) return url;
const opt = resize ? `rs:fit:${resize}:${resize}/dpr:${window.devicePixelRatio}` : "";
const urlBytes = te.encode(url);
const urlEncoded = urlSafe(base64.encode(urlBytes));
const path = `/${opt}/${urlEncoded}`;
const sig = signUrl(path);
return `${new URL(settings.url).toString()}${sig}${path}`;
},
};
if (!settings) return url;
if (url.startsWith("data:") || url.startsWith("blob:")) return url;
const opt = resize ? `rs:fit:${resize}:${resize}/dpr:${window.devicePixelRatio}` : "";
const urlBytes = te.encode(url);
const urlEncoded = urlSafe(base64.encode(urlBytes));
const path = `/${opt}/${urlEncoded}`;
const sig = signUrl(path);
return `${new URL(settings.url).toString()}${sig}${path}`;
}

View File

@ -15,9 +15,7 @@ export function useLinkList(id: string, fn: (rb: RequestBuilder) => void) {
const listStore = useRequestBuilder(NoteCollection, sub);
return useMemo(() => {
if (listStore.data && listStore.data.length > 0) {
return listStore.data
.map(e => NostrLink.fromTags(e.tags))
.flat();
return listStore.data.map(e => NostrLink.fromTags(e.tags)).flat();
}
return [];
}, [listStore.data]);

View File

@ -50,7 +50,7 @@ export interface UserPreferences {
/**
* Use imgproxy to optimize images
*/
imgProxyConfig: ImgProxySettings | null;
imgProxyConfig?: ImgProxySettings;
/**
* Default page to select on load

View File

@ -65,7 +65,7 @@ export function HashTagHeader({ tag }: { tag: string }) {
<h2>#{tag}</h2>
<div className="flex">
{pubkeys.slice(0, 5).map(a => (
<ProfileImage pubkey={a} showUsername={false} link={""} showFollowingMark={false} size={40} />
<ProfileImage pubkey={a} showUsername={false} link={""} showFollowDistance={false} size={40} />
))}
{pubkeys.length > 5 && (
<span>

View File

@ -0,0 +1,264 @@
import ForceGraph3D, { NodeObject } from "react-force-graph-3d";
import { useContext, useEffect, useState } from "react";
import { STR, UID } from "../SocialGraph/UniqueIds";
import SocialGraph from "../SocialGraph/SocialGraph";
import { MetadataCache } 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";
interface GraphNode {
id: UID;
profile?: MetadataCache;
distance: number;
val: number;
inboundCount: number;
outboundCount: number;
color?: string;
visible: boolean;
// curvature?: number;
}
interface GraphLink {
source: UID;
target: UID;
distance: number;
}
interface GraphMetadata {
// usersByFollowDistance?: Map<number, Set<UID>>;
userCountByDistance: number[];
nodes?: Map<number, GraphNode>;
}
interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
meta?: GraphMetadata;
}
enum Direction {
INBOUND,
OUTBOUND,
BOTH,
}
const avatar = (node: NodeObject<NodeObject<GraphNode>>) => {
const login = LoginStore.snapshot();
return node.profile?.picture
? proxyImg(node.profile?.picture, login.appData.item.preferences.imgProxyConfig)
: defaultAvatar(node.address);
};
const NODE_LIMIT = 500;
interface GraphConfig {
direction: Direction;
renderLimit: number | null;
showDistance: number;
}
const NetworkGraph = () => {
const [graphData, setGraphData] = useState(null as GraphData | null);
const [graphConfig, setGraphConfig] = useState({
direction: Direction.OUTBOUND,
renderLimit: NODE_LIMIT,
showDistance: 2,
});
const [open, setOpen] = useState(false);
const system = useContext(SnortContext);
// const [showDistance, setShowDistance] = useState(2);
// const [direction, setDirection] = useState(Direction.OUTBOUND);
// const [renderLimit, setRenderLimit] = useState(NODE_LIMIT);
const updateConfig = async (changes: Partial<GraphConfig>) => {
setGraphConfig(old => {
const newConfig = Object.assign({}, old, changes);
updateGraph(newConfig).then(graph => setGraphData(graph));
return newConfig;
});
};
const toggleConnections = () => {
if (graphConfig.direction === Direction.OUTBOUND) {
updateConfig({ direction: Direction.BOTH });
} else {
updateConfig({ direction: Direction.OUTBOUND });
}
};
const updateGraph = async (newConfig?: GraphConfig) => {
const { direction, renderLimit, showDistance } = newConfig ?? graphConfig;
const nodes = new Map<number, GraphNode>();
const links: GraphLink[] = [];
const nodesVisited = new Set<UID>();
const userCountByDistance = Array.from(
{ length: 6 },
(_, i) => SocialGraph.usersByFollowDistance.get(i)?.size || 0,
);
// Go through all the nodes
for (let distance = 0; distance <= showDistance; ++distance) {
const users = SocialGraph.usersByFollowDistance.get(distance);
if (!users) break;
for (const UID of users) {
if (renderLimit && nodes.size >= renderLimit) break; // Temporary hack
const inboundCount = SocialGraph.followersByUser.get(UID)?.size || 0;
const outboundCount = SocialGraph.followedByUser.get(UID)?.size || 0;
const pubkey = STR(UID);
const node = {
id: UID,
address: pubkey,
profile: system.ProfileLoader.Cache.getFromCache(pubkey),
distance,
inboundCount,
outboundCount,
visible: true, // Setting to false only hides the rendered element, does not prevent calculations
// curvature: 0.6,
// Node size is based on the follower count
val: Math.log10(inboundCount) + 1, // 1 followers -> 1, 10 followers -> 2, 100 followers -> 3, etc.,
} as GraphNode;
// A visibility boost for the origin user:
if (node.distance === 0) {
node.val = 10; // they're always larger than life
node.color = "#603285";
}
nodes.set(UID, node);
}
}
// Add links
for (const node of nodes.values()) {
if (direction === Direction.OUTBOUND || direction === Direction.BOTH) {
for (const followedID of SocialGraph.followedByUser.get(node.id) ?? []) {
if (!nodes.has(followedID)) continue; // Skip links to nodes that we're not rendering
if (nodesVisited.has(followedID)) continue;
links.push({
source: node.id,
target: followedID,
distance: node.distance,
});
}
}
// TODO: Fix filtering
/* if (direction === Direction.INBOUND || direction === Direction.BOTH) {
for (const followerID of SocialGraph.followersByUser.get(node.id) ?? []) {
if (nodesVisited.has(followerID)) continue;
const follower = nodes.get(followerID);
if (!follower) continue; // Skip links to nodes that we're not rendering
links.push({
source: followerID,
target: node.id,
distance: follower.distance,
});
}
}*/
nodesVisited.add(node.id);
}
// Squash cases, where there are a lot of nodes
const graph: GraphData = {
nodes: [...nodes.values()],
links,
meta: {
nodes,
userCountByDistance,
},
};
// console.log('!!', graph);
// for (const l of links) {
// if (!nodes.has(l.source)) {
// console.log('source missing:', l.source);
// }
// if (!nodes.has(l.target)) {
// console.log('target missing:', l.target);
// }
// }
return graph;
};
const refreshData = async () => {
updateGraph().then(setGraphData);
};
useEffect(() => {
refreshData();
}, []);
return (
<div>
{!open && (
<button
className="btn btn-primary"
onClick={() => {
setOpen(true);
refreshData();
}}>
Show graph
</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>
<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>
<div className="flex flex-row justify-center space-x-4">
{graphData.meta?.userCountByDistance?.map((value, i) => {
if (i === 0 || value <= 0) return null;
const isSelected = graphConfig.showDistance === i;
return (
<button
key={i}
className={`btn bg-gray-900 py-4 h-auto flex-col ${
isSelected ? "bg-gray-600 hover:bg-gray-600" : "hover:bg-gray-800"
}`}
onClick={() => isSelected || updateConfig({ showDistance: i })}>
<div className="text-lg block leading-none">{i}</div>
<div className="text-xs">({value})</div>
</button>
);
})}
</div>
</div>
<ForceGraph3D
graphData={graphData}
nodeThreeObject={(node: NodeObject<NodeObject<GraphNode>>) => {
const imgTexture = new THREE.TextureLoader().load(avatar(node));
imgTexture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({ map: imgTexture });
const sprite = new THREE.Sprite(material);
sprite.scale.set(12, 12, 1);
return sprite;
}}
nodeLabel={node => `${node.profile?.name || node.address}`}
nodeAutoColorBy="distance"
linkAutoColorBy="distance"
linkDirectionalParticles={1}
nodeVisibility="visible"
numDimensions={3}
linkDirectionalArrowLength={0}
nodeOpacity={0.9}
/>
<div className="absolute bottom-6 right-6">
<button className="text-lg" onClick={() => toggleConnections()}>
Showing: {graphConfig.direction === Direction.OUTBOUND ? "Outbound" : "All"} connections
</button>
</div>
<div className="absolute bottom-6 left-6">
<span className="text-lg">Render limit: {graphConfig.renderLimit} nodes</span>
</div>
</div>
)}
</div>
);
};
export default NetworkGraph;

View File

@ -62,13 +62,7 @@ export function RelaysTab({ id }: { id: HexKey }) {
export function BookMarksTab({ id }: { id: HexKey }) {
const bookmarks = useCategorizedBookmarks(id, "bookmark");
const reactions = useReactions(`bookmark:reactions:{id}`, bookmarks.map(NostrLink.fromEvent));
return (
<Bookmarks
pubkey={id}
bookmarks={bookmarks}
related={reactions.data ?? []}
/>
);
return <Bookmarks pubkey={id} bookmarks={bookmarks} related={reactions.data ?? []} />;
}
const ProfileTab = {

View File

@ -299,7 +299,7 @@ const PreferencesPage = () => {
onChange={e =>
updatePreferences(id, {
...perf,
imgProxyConfig: e.target.checked ? DefaultImgProxy : null,
imgProxyConfig: e.target.checked ? DefaultImgProxy : undefined,
})
}
/>

View File

@ -104,6 +104,11 @@ const SettingsIndex = () => {
<FormattedMessage defaultMessage="Cache" />
<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>
<div className="settings-row" onClick={handleLogout}>
<Icon name="logout" size={24} />
<FormattedMessage {...messages.LogOut} />

View File

@ -60,4 +60,5 @@ export default defineMessages({
Nip05: { defaultMessage: "NIP-05" },
ReactionEmoji: { defaultMessage: "Reaction emoji" },
ReactionEmojiHelp: { defaultMessage: "Emoji to send when reactiong to a note" },
SocialGraph: { defaultMessage: "Social Graph" },
});

View File

@ -0,0 +1,234 @@
import { ID, STR, UID } from "./UniqueIds";
import { LoginStore } from "../Login";
import { unwrap } from "../SnortUtils";
import { HexKey, MetadataCache, NostrEvent } from "@snort/system";
type Unsubscribe = () => void;
const Key = {
pubKey: null as HexKey | null,
getPubKey: () => {
return unwrap(LoginStore.snapshot().publicKey);
},
isMine: (user: HexKey) => user === Key.getPubKey(),
};
export default {
followDistanceByUser: new Map<UID, number>(),
usersByFollowDistance: new Map<number, Set<UID>>(),
profiles: new Map<UID, MetadataCache>(), // JSON.parsed event.content of profile events
followedByUser: new Map<UID, Set<UID>>(),
followersByUser: new Map<UID, Set<UID>>(),
latestFollowEventTimestamps: new Map<UID, number>(),
handleFollowEvent: function (event: NostrEvent) {
try {
const author = ID(event.pubkey);
const timestamp = event.created_at;
const existingTimestamp = this.latestFollowEventTimestamps.get(author);
if (existingTimestamp && timestamp <= existingTimestamp) {
return;
}
this.latestFollowEventTimestamps.set(author, timestamp);
// Collect all users followed in the new event.
const followedInEvent = new Set<UID>();
for (const tag of event.tags) {
if (tag[0] === "p") {
const followedUser = ID(tag[1]);
if (followedUser !== author) {
followedInEvent.add(followedUser);
}
}
}
// Get the set of users currently followed by the author.
const currentlyFollowed = this.followedByUser.get(author) || new Set<UID>();
// Find users that need to be removed.
for (const user of currentlyFollowed) {
if (!followedInEvent.has(user)) {
this.removeFollower(user, author);
}
}
// Add or update the followers based on the new event.
for (const user of followedInEvent) {
this.addFollower(user, author);
}
} catch (e) {
// might not be logged in or sth
}
},
isFollowing: function (follower: HexKey, followedUser: HexKey): boolean {
const followedUserId = ID(followedUser);
const followerId = ID(follower);
return !!this.followedByUser.get(followerId)?.has(followedUserId);
},
getFollowDistance: function (user: HexKey): number {
try {
if (Key.isMine(user)) {
return 0;
}
const userId = ID(user);
const distance = this.followDistanceByUser.get(userId);
return distance === undefined ? 1000 : distance;
} catch (e) {
// might not be logged in or sth
return 1000;
}
},
addUserByFollowDistance(distance: number, user: UID) {
if (!this.usersByFollowDistance.has(distance)) {
this.usersByFollowDistance.set(distance, new Set());
}
if (distance <= 2) {
/*
let unsub;
// get also profile events for profile search indexing
// eslint-disable-next-line prefer-const
unsub = PubSub.subscribe({ authors: [STR(user)], kinds: [0] }, () => unsub?.(), true);
// TODO subscribe once param?
*/
}
this.usersByFollowDistance.get(distance)?.add(user);
// remove from higher distances
for (const d of this.usersByFollowDistance.keys()) {
if (d > distance) {
this.usersByFollowDistance.get(d)?.delete(user);
}
}
},
ensureRootUser: function () {
const myId = ID(Key.getPubKey());
if (myId && !this.followDistanceByUser.has(myId)) {
this.followDistanceByUser.set(myId, 0);
this.addUserByFollowDistance(0, myId);
}
},
addFollower: function (followedUser: UID, follower: UID) {
if (typeof followedUser !== "number" || typeof follower !== "number") {
throw new Error("Invalid user id");
}
this.ensureRootUser();
if (!this.followersByUser.has(followedUser)) {
this.followersByUser.set(followedUser, new Set<UID>());
}
this.followersByUser.get(followedUser)?.add(follower);
if (!this.followedByUser.has(follower)) {
this.followedByUser.set(follower, new Set<UID>());
}
const myId = ID(Key.getPubKey());
if (followedUser !== myId) {
let newFollowDistance;
if (follower === myId) {
// basically same as the next "else" block, but faster
newFollowDistance = 1;
this.addUserByFollowDistance(newFollowDistance, followedUser);
this.followDistanceByUser.set(followedUser, newFollowDistance);
} else {
const existingFollowDistance = this.followDistanceByUser.get(followedUser);
const followerDistance = this.followDistanceByUser.get(follower);
newFollowDistance = followerDistance && followerDistance + 1;
if (existingFollowDistance === undefined || (newFollowDistance && newFollowDistance < existingFollowDistance)) {
this.followDistanceByUser.set(followedUser, newFollowDistance!);
this.addUserByFollowDistance(newFollowDistance!, followedUser);
}
}
}
this.followedByUser.get(follower)?.add(followedUser);
if (this.followedByUser.get(myId)?.has(follower)) {
/*
setTimeout(() => {
PubSub.subscribe({ authors: [STR(followedUser)], kinds: [0, 3] }, undefined, true);
}, 0);
*/
}
},
removeFollower: function (unfollowedUser: UID, follower: UID) {
this.followersByUser.get(unfollowedUser)?.delete(follower);
this.followedByUser.get(follower)?.delete(unfollowedUser);
if (unfollowedUser === ID(Key.getPubKey())) {
return;
}
// iterate over remaining followers and set the smallest follow distance
let smallest = Infinity;
for (const follower of this.followersByUser.get(unfollowedUser) || []) {
const followerDistance = this.followDistanceByUser.get(follower);
if (followerDistance !== undefined && followerDistance + 1 < smallest) {
smallest = followerDistance + 1;
}
}
if (smallest === Infinity) {
this.followDistanceByUser.delete(unfollowedUser);
} else {
this.followDistanceByUser.set(unfollowedUser, smallest);
}
},
// TODO subscription methods for followersByUser and followedByUser. and maybe messagesByTime. and replies
followerCount: function (address: HexKey) {
const id = ID(address);
return this.followersByUser.get(id)?.size ?? 0;
},
followedByFriendsCount: function (address: HexKey) {
let count = 0;
const myId = ID(Key.getPubKey());
const id = ID(address);
for (const follower of this.followersByUser.get(id) ?? []) {
if (this.followedByUser.get(myId)?.has(follower)) {
count++; // should we stop at 10?
}
}
return count;
},
getFollowedByUser: function (
user: HexKey,
cb?: (followedUsers: Set<HexKey>) => void,
includeSelf = false,
): Unsubscribe {
const userId = ID(user);
const callback = () => {
if (cb) {
const set = new Set<HexKey>();
for (const id of this.followedByUser.get(userId) || []) {
set.add(STR(id));
}
if (includeSelf) {
set.add(user);
}
cb(set);
}
};
if (this.followedByUser.has(userId) || includeSelf) {
callback();
}
//return PubSub.subscribe({ kinds: [3], authors: [user] }, callback);
return () => {};
},
getFollowersByUser: function (address: HexKey, cb?: (followers: Set<HexKey>) => void): Unsubscribe {
const userId = ID(address);
const callback = () => {
if (cb) {
const set = new Set<HexKey>();
for (const id of this.followersByUser.get(userId) || []) {
set.add(STR(id));
}
cb(set);
}
};
this.followersByUser.has(userId) && callback();
//return PubSub.subscribe({ kinds: [3], '#p': [address] }, callback); // TODO this doesn't fire when a user is unfollowed
return () => {};
},
};

View File

@ -0,0 +1,38 @@
// should this be a class instead? convert all strings to internal representation, enable comparison
export type UID = number;
// save space by mapping strs to internal unique ids
export class UniqueIds {
static strToUniqueId = new Map<string, UID>();
static uniqueIdToStr = new Map<UID, string>();
static currentUniqueId = 0;
static id(str: string): UID {
if (str.startsWith("npub")) {
throw new Error("use hex instead of npub " + str);
}
const existing = UniqueIds.strToUniqueId.get(str);
if (existing) {
return existing;
}
const newId = UniqueIds.currentUniqueId++;
UniqueIds.strToUniqueId.set(str, newId);
UniqueIds.uniqueIdToStr.set(newId, str);
return newId;
}
static str(id: UID): string {
const pub = UniqueIds.uniqueIdToStr.get(id);
if (!pub) {
throw new Error("pub: invalid id " + id);
}
return pub;
}
static has(str: string): boolean {
return UniqueIds.strToUniqueId.has(str);
}
}
export const STR = UniqueIds.str;
export const ID = UniqueIds.id;

View File

@ -23,6 +23,10 @@ import {
import { SnortContext } from "@snort/system-react";
import { removeUndefined, throwIfOffline } from "@snort/shared";
import React, { lazy, Suspense } from "react";
const NetworkGraph = lazy(() => import("Pages/NetworkGraph"));
import * as serviceWorkerRegistration from "serviceWorkerRegistration";
import { IntlProvider } from "IntlProvider";
import { getCountry, unwrap } from "SnortUtils";
@ -224,6 +228,10 @@ const mainRoutes = [
path: "/about",
element: <AboutPage />,
},
{
path: "/graph",
element: <NetworkGraph />,
},
...OnboardingRoutes,
...WalletRoutes,
] as Array<RouteObject>;
@ -282,7 +290,9 @@ root.render(
<StrictMode>
<IntlProvider>
<SnortContext.Provider value={System}>
<RouterProvider router={router} />
<Suspense fallback={<div>Loading...</div>}>
<RouterProvider router={router} />
</Suspense>
</SnortContext.Provider>
</IntlProvider>
</StrictMode>,

View File

@ -368,6 +368,9 @@
"Cu/K85": {
"defaultMessage": "Translated from {lang}"
},
"CzHZoc": {
"defaultMessage": "Social Graph"
},
"D+KzKd": {
"defaultMessage": "Automatically zap every note when loaded"
},

View File

@ -121,6 +121,7 @@
"CmZ9ls": "{n} Muted",
"CsCUYo": "{n} sats",
"Cu/K85": "Translated from {lang}",
"CzHZoc": "Social Graph",
"D+KzKd": "Automatically zap every note when loaded",
"D3idYv": "Settings",
"DBiVK1": "Cache",

309
yarn.lock
View File

@ -5,6 +5,19 @@ __metadata:
version: 6
cacheKey: 8
"3d-force-graph@npm:^1.73":
version: 1.73.0
resolution: "3d-force-graph@npm:1.73.0"
dependencies:
accessor-fn: 1
kapsule: 1
three: ">=0.118 <1"
three-forcegraph: 1
three-render-objects: ^1.29
checksum: be9057ced895131b8788f2f20f6fc587d027e697a353bb69df2ddd6a64e91670202b13e9ea44b794491a562f20551c779d18f85c35b6f8604dbfc27a9a3f77bc
languageName: node
linkType: hard
"@aashutoshrathi/word-wrap@npm:^1.2.3":
version: 1.2.6
resolution: "@aashutoshrathi/word-wrap@npm:1.2.6"
@ -1401,6 +1414,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.17.8":
version: 7.23.2
resolution: "@babel/runtime@npm:7.23.2"
dependencies:
regenerator-runtime: ^0.14.0
checksum: 6c4df4839ec75ca10175f636d6362f91df8a3137f86b38f6cd3a4c90668a0fe8e9281d320958f4fbd43b394988958585a17c3aab2a4ea6bf7316b22916a371fb
languageName: node
linkType: hard
"@babel/template@npm:^7.22.5, @babel/template@npm:^7.3.3":
version: 7.22.5
resolution: "@babel/template@npm:7.22.5"
@ -3104,6 +3126,7 @@ __metadata:
"@types/node": ^20.4.1
"@types/react": ^18.0.26
"@types/react-dom": ^18.0.10
"@types/three": ^0.157.2
"@types/use-sync-external-store": ^0.0.4
"@types/uuid": ^9.0.2
"@types/webscopeio__react-textarea-autocomplete": ^4.7.2
@ -3144,6 +3167,7 @@ __metadata:
qr-code-styling: ^1.6.0-rc.1
react: ^18.2.0
react-dom: ^18.2.0
react-force-graph-3d: ^1.24.0
react-intersection-observer: ^9.4.1
react-intl: ^6.4.4
react-router-dom: ^6.5.0
@ -3153,6 +3177,7 @@ __metadata:
source-map-loader: ^4.0.1
tailwindcss: ^3.3.3
terser-webpack-plugin: ^5.3.9
three: ^0.157.0
tinybench: ^2.5.1
ts-jest: ^29.1.1
ts-loader: ^9.4.4
@ -3501,6 +3526,13 @@ __metadata:
languageName: node
linkType: hard
"@tweenjs/tween.js@npm:18 - 21":
version: 21.0.0
resolution: "@tweenjs/tween.js@npm:21.0.0"
checksum: 3c2498e9916a599a54d9f8e2bd2dfe0ceb249e3453d29c7b4287310f0fbe12f37d82e986263b411a672d50544dafdda7827b1707a7250023a6440351a741dddc
languageName: node
linkType: hard
"@types/babel__core@npm:^7.1.14":
version: 7.20.1
resolution: "@types/babel__core@npm:7.20.1"
@ -4052,6 +4084,25 @@ __metadata:
languageName: node
linkType: hard
"@types/stats.js@npm:*":
version: 0.17.2
resolution: "@types/stats.js@npm:0.17.2"
checksum: cbe300f1a548051c353f5a17a9bde406f468cb171fad1b6ec3d50b8af539389ecbd4a99765bd67c9f7acd7608ed8c5a275b19a60c1545341c3cac58abf0f487e
languageName: node
linkType: hard
"@types/three@npm:^0.157.2":
version: 0.157.2
resolution: "@types/three@npm:0.157.2"
dependencies:
"@types/stats.js": "*"
"@types/webxr": "*"
fflate: ~0.6.10
meshoptimizer: ~0.18.1
checksum: c839492a2c09f73877fc53ffc5d1860762921bee608fdb742a5be59077d4300685346ac9ea7ac3070e41900a0a9c96dc74a452c4ad7f1eac1dcdc935abb09444
languageName: node
linkType: hard
"@types/tough-cookie@npm:*":
version: 4.0.2
resolution: "@types/tough-cookie@npm:4.0.2"
@ -4111,6 +4162,13 @@ __metadata:
languageName: node
linkType: hard
"@types/webxr@npm:*":
version: 0.5.6
resolution: "@types/webxr@npm:0.5.6"
checksum: fbf79f471382a8649f04d9109a6d67a4b2c691bed3d4a9940f850cf288ab335230a67919425c412594eca80b8a69f4b40916375586c1e0a4947455398d57dd9d
languageName: node
linkType: hard
"@types/ws@npm:^8.5.5":
version: 8.5.5
resolution: "@types/ws@npm:8.5.5"
@ -4546,6 +4604,13 @@ __metadata:
languageName: node
linkType: hard
"accessor-fn@npm:1":
version: 1.5.0
resolution: "accessor-fn@npm:1.5.0"
checksum: b24398a79c1f2f6808cc7116fa2033c52bd5c5d49ac552dbb7f5a41618c515d7b04e6ebf3a06342d54df101c175b8e5583b1b900e768f93492ca9ed2a87b386e
languageName: node
linkType: hard
"acorn-globals@npm:^7.0.0":
version: 7.0.1
resolution: "acorn-globals@npm:7.0.1"
@ -6320,7 +6385,7 @@ __metadata:
languageName: node
linkType: hard
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6":
"d3-array@npm:1 - 3, d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6":
version: 3.2.4
resolution: "d3-array@npm:3.2.4"
dependencies:
@ -6329,6 +6394,13 @@ __metadata:
languageName: node
linkType: hard
"d3-binarytree@npm:1":
version: 1.0.2
resolution: "d3-binarytree@npm:1.0.2"
checksum: 6768077e82844373d78fcf5202a098217f8e534ec132231dd6aefbe49f5f04ffb1a6acb36c032e03426495a5018d291d61e0a6edf987dfb628ac447d88cd72c9
languageName: node
linkType: hard
"d3-color@npm:1 - 3":
version: 3.1.0
resolution: "d3-color@npm:3.1.0"
@ -6336,6 +6408,13 @@ __metadata:
languageName: node
linkType: hard
"d3-dispatch@npm:1 - 3":
version: 3.0.1
resolution: "d3-dispatch@npm:3.0.1"
checksum: fdfd4a230f46463e28e5b22a45dd76d03be9345b605e1b5dc7d18bd7ebf504e6c00ae123fd6d03e23d9e2711e01f0e14ea89cd0632545b9f0c00b924ba4be223
languageName: node
linkType: hard
"d3-ease@npm:^3.0.1":
version: 3.0.1
resolution: "d3-ease@npm:3.0.1"
@ -6343,6 +6422,19 @@ __metadata:
languageName: node
linkType: hard
"d3-force-3d@npm:2 - 3":
version: 3.0.5
resolution: "d3-force-3d@npm:3.0.5"
dependencies:
d3-binarytree: 1
d3-dispatch: 1 - 3
d3-octree: 1
d3-quadtree: 1 - 3
d3-timer: 1 - 3
checksum: 36426a752187236c1f8b901f2244f49e19955c6f281936f2d1508779081ef856b867510a1e0ecf9a62ba5f6e17caed21a20ed4fedbbdc1887e745261723569a4
languageName: node
linkType: hard
"d3-format@npm:1 - 3":
version: 3.1.0
resolution: "d3-format@npm:3.1.0"
@ -6350,7 +6442,7 @@ __metadata:
languageName: node
linkType: hard
"d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1":
"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1":
version: 3.0.1
resolution: "d3-interpolate@npm:3.0.1"
dependencies:
@ -6359,6 +6451,13 @@ __metadata:
languageName: node
linkType: hard
"d3-octree@npm:1":
version: 1.0.2
resolution: "d3-octree@npm:1.0.2"
checksum: 73161dda8f15c58db58ea1abc0a5fbffe5628f29bb3358933c696c378cee3de70226108fe0923963bbbc6a02dfee79be1e8772e86a9e15bc4e0dbc9f739b31bb
languageName: node
linkType: hard
"d3-path@npm:^3.1.0":
version: 3.1.0
resolution: "d3-path@npm:3.1.0"
@ -6366,7 +6465,24 @@ __metadata:
languageName: node
linkType: hard
"d3-scale@npm:^4.0.2":
"d3-quadtree@npm:1 - 3":
version: 3.0.1
resolution: "d3-quadtree@npm:3.0.1"
checksum: 5469d462763811475f34a7294d984f3eb100515b0585ca5b249656f6b1a6e99b20056a2d2e463cc9944b888896d2b1d07859c50f9c0cf23438df9cd2e3146066
languageName: node
linkType: hard
"d3-scale-chromatic@npm:1 - 3":
version: 3.0.0
resolution: "d3-scale-chromatic@npm:3.0.0"
dependencies:
d3-color: 1 - 3
d3-interpolate: 1 - 3
checksum: a8ce4cb0267a17b28ebbb929f5e3071d985908a9c13b6fcaa2a198e1e018f275804d691c5794b970df0049725b7944f32297b31603d235af6414004f0c7f82c0
languageName: node
linkType: hard
"d3-scale@npm:1 - 4, d3-scale@npm:^4.0.2":
version: 4.0.2
resolution: "d3-scale@npm:4.0.2"
dependencies:
@ -6406,7 +6522,7 @@ __metadata:
languageName: node
linkType: hard
"d3-timer@npm:^3.0.1":
"d3-timer@npm:1 - 3, d3-timer@npm:^3.0.1":
version: 3.0.1
resolution: "d3-timer@npm:3.0.1"
checksum: 1cfddf86d7bca22f73f2c427f52dfa35c49f50d64e187eb788dcad6e927625c636aa18ae4edd44d084eb9d1f81d8ca4ec305dae7f733c15846a824575b789d73
@ -6420,6 +6536,15 @@ __metadata:
languageName: node
linkType: hard
"data-joint@npm:1":
version: 1.3.1
resolution: "data-joint@npm:1.3.1"
dependencies:
index-array-by: ^1.4.0
checksum: 9becfaccd59ca02a4caffe6b8b9b52d4e9df8ed93dc01cd3109ef67dc406e8fc678b539f8fd62662ce9b7a1061ec3b4865ca3b4d0edf69656c0cf97f9a2c1b1a
languageName: node
linkType: hard
"data-urls@npm:^3.0.2":
version: 3.0.2
resolution: "data-urls@npm:3.0.2"
@ -7455,6 +7580,13 @@ __metadata:
languageName: node
linkType: hard
"fflate@npm:~0.6.10":
version: 0.6.10
resolution: "fflate@npm:0.6.10"
checksum: 96384bc4090987fe565c0de8204e3830f538144ec950576fea50aee1b42adbe9fc3ed5e7905dfa7979faaa20979def330dbebce548f3dcafc3e118cc9838526d
languageName: node
linkType: hard
"figures@npm:^3.0.0":
version: 3.2.0
resolution: "figures@npm:3.2.0"
@ -7644,6 +7776,13 @@ __metadata:
languageName: node
linkType: hard
"fromentries@npm:^1.3.2":
version: 1.3.2
resolution: "fromentries@npm:1.3.2"
checksum: 33729c529ce19f5494f846f0dd4945078f4e37f4e8955f4ae8cc7385c218f600e9d93a7d225d17636c20d1889106fd87061f911550861b7072f53bf891e6b341
languageName: node
linkType: hard
"fs-extra@npm:^9.0.1":
version: 9.1.0
resolution: "fs-extra@npm:9.1.0"
@ -8434,6 +8573,13 @@ __metadata:
languageName: node
linkType: hard
"index-array-by@npm:^1.4.0":
version: 1.4.1
resolution: "index-array-by@npm:1.4.1"
checksum: 030ca9ddbb50b6b525ef6c4da026968df75e728915ed574a28c4437dd4b8154217b21f519136abc8c198796d9ee609ee7279c02e6f6db72d248422e460656c09
languageName: node
linkType: hard
"infer-owner@npm:^1.0.4":
version: 1.0.4
resolution: "infer-owner@npm:1.0.4"
@ -9011,6 +9157,13 @@ __metadata:
languageName: node
linkType: hard
"jerrypick@npm:^1.1.1":
version: 1.1.1
resolution: "jerrypick@npm:1.1.1"
checksum: 8c247a4a7b5a6ece961cbc1ec0a0c76ca8009f5873a0fe26c4c367e4f3cf1cd7aa994c50442338b6d5d4394b1ba725f0acbe88293e0f9e11659dea86260139bf
languageName: node
linkType: hard
"jest-changed-files@npm:^29.6.3":
version: 29.6.3
resolution: "jest-changed-files@npm:29.6.3"
@ -9712,6 +9865,15 @@ __metadata:
languageName: node
linkType: hard
"kapsule@npm:1":
version: 1.14.5
resolution: "kapsule@npm:1.14.5"
dependencies:
lodash-es: 4
checksum: 1208d7a172aa1b46ac35bb993be808b3cc8f5bc52967a6593ac34582a08ef83cedd669ca95a1ce2d1e29053234b20b4d98ba547288866244bdd9a128df7384e8
languageName: node
linkType: hard
"keyv@npm:^4.5.3":
version: 4.5.3
resolution: "keyv@npm:4.5.3"
@ -9849,6 +10011,13 @@ __metadata:
languageName: node
linkType: hard
"lodash-es@npm:4":
version: 4.17.21
resolution: "lodash-es@npm:4.17.21"
checksum: 05cbffad6e2adbb331a4e16fbd826e7faee403a1a04873b82b42c0f22090f280839f85b95393f487c1303c8a3d2a010048bf06151a6cbe03eee4d388fb0a12d2
languageName: node
linkType: hard
"lodash.debounce@npm:^4.0.8":
version: 4.0.8
resolution: "lodash.debounce@npm:4.0.8"
@ -10181,6 +10350,13 @@ __metadata:
languageName: node
linkType: hard
"meshoptimizer@npm:~0.18.1":
version: 0.18.1
resolution: "meshoptimizer@npm:0.18.1"
checksum: 101dbed8abd4cf167cdb7a0bc13db90dd0743332c689e43b18cc5254d238f0766750752432401fa63dc7e9e32399ef68daacf48f0d89db1484042c1761c7362d
languageName: node
linkType: hard
"methods@npm:~1.1.2":
version: 1.1.2
resolution: "methods@npm:1.1.2"
@ -10536,6 +10712,47 @@ __metadata:
languageName: node
linkType: hard
"ngraph.events@npm:^1.0.0, ngraph.events@npm:^1.2.1":
version: 1.2.2
resolution: "ngraph.events@npm:1.2.2"
checksum: 358d312ebdf8eeddfac9741938f38cb7fe488b402641900613f632fe81fe63da92543db050c659e5a7dc7c28750b1109ce4d78cc9d68e66d732fba7128efbbc2
languageName: node
linkType: hard
"ngraph.forcelayout@npm:3":
version: 3.3.1
resolution: "ngraph.forcelayout@npm:3.3.1"
dependencies:
ngraph.events: ^1.0.0
ngraph.merge: ^1.0.0
ngraph.random: ^1.0.0
checksum: 580ab50debd7cf03bae7752337496f45158a8808a6494df71080a28c5f047fa146d53daf5635ed22bb06cc95b06b217e3c104fe2071d8e5beb036c7697871b7d
languageName: node
linkType: hard
"ngraph.graph@npm:20":
version: 20.0.1
resolution: "ngraph.graph@npm:20.0.1"
dependencies:
ngraph.events: ^1.2.1
checksum: 16e42c4c4fdabf8243b42291c8c5bf5baa0614773d62edf911b86ef2aa9f60a38e107a9d1bb97ab6feac2ac514c8e04a0aa24d1992502b21420fdd5f563c96cb
languageName: node
linkType: hard
"ngraph.merge@npm:^1.0.0":
version: 1.0.0
resolution: "ngraph.merge@npm:1.0.0"
checksum: e4ad9e55acec7704229f56e7d6157b17a9d87736efdbd2cee6c3fab92cffb99e94b3d8e2e5e75f863f4fe972192a2bff1381e58334115d7c9c08b55b1673801f
languageName: node
linkType: hard
"ngraph.random@npm:^1.0.0":
version: 1.1.0
resolution: "ngraph.random@npm:1.1.0"
checksum: 926ed86450d2fc983aea92e22f595bf064c45d90e5efbf4bd85f14e825253c9e01417ef1357da0875691ccb61ec411443a7c8e1ec10885f2eb841366b3bd7960
languageName: node
linkType: hard
"no-case@npm:^3.0.4":
version: 3.0.4
resolution: "no-case@npm:3.0.4"
@ -11390,6 +11607,15 @@ __metadata:
languageName: node
linkType: hard
"polished@npm:4":
version: 4.2.2
resolution: "polished@npm:4.2.2"
dependencies:
"@babel/runtime": ^7.17.8
checksum: 97fb927dc55cd34aeb11b31ae2a3332463f114351c86e8aa6580d7755864a0120164fdc3770e6160c8b1775052f0eda14db9a6e34402cd4b08ab2d658a593725
languageName: node
linkType: hard
"postcss-attribute-case-insensitive@npm:^6.0.2":
version: 6.0.2
resolution: "postcss-attribute-case-insensitive@npm:6.0.2"
@ -12342,7 +12568,7 @@ __metadata:
languageName: node
linkType: hard
"prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
"prop-types@npm:15, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
version: 15.8.1
resolution: "prop-types@npm:15.8.1"
dependencies:
@ -12486,6 +12712,19 @@ __metadata:
languageName: node
linkType: hard
"react-force-graph-3d@npm:^1.24.0":
version: 1.24.0
resolution: "react-force-graph-3d@npm:1.24.0"
dependencies:
3d-force-graph: ^1.73
prop-types: 15
react-kapsule: 2
peerDependencies:
react: "*"
checksum: 5084491aac5629a38176c4fef52513e423cddb4d98a41f445851bedb1895f1800e3ab04e908b5ad39544bb13610a214518348465c0baf7d6d7c709fa0024e179
languageName: node
linkType: hard
"react-intersection-observer@npm:^9.4.1":
version: 9.5.2
resolution: "react-intersection-observer@npm:9.5.2"
@ -12533,6 +12772,18 @@ __metadata:
languageName: node
linkType: hard
"react-kapsule@npm:2":
version: 2.4.0
resolution: "react-kapsule@npm:2.4.0"
dependencies:
fromentries: ^1.3.2
jerrypick: ^1.1.1
peerDependencies:
react: ">=16.13.1"
checksum: 8d9af1c8e3329991a90cb5f2a6e64ee0085016e00de7783a58c6a95089ff710a07c0acaf6b2b6b4d393802e9448e7263f3324486f1358926706e8075e7d5cd3a
languageName: node
linkType: hard
"react-lifecycles-compat@npm:^3.0.4":
version: 3.0.4
resolution: "react-lifecycles-compat@npm:3.0.4"
@ -14217,6 +14468,47 @@ __metadata:
languageName: node
linkType: hard
"three-forcegraph@npm:1":
version: 1.41.10
resolution: "three-forcegraph@npm:1.41.10"
dependencies:
accessor-fn: 1
d3-array: 1 - 3
d3-force-3d: 2 - 3
d3-scale: 1 - 4
d3-scale-chromatic: 1 - 3
data-joint: 1
kapsule: 1
ngraph.forcelayout: 3
ngraph.graph: 20
tinycolor2: 1
peerDependencies:
three: ">=0.118.3"
checksum: 921f3f7abc60816738aefde97946b51b00d06dba4e0f04b5fd9678d3dfa375579048008fe6f2994208dc09e359f0c17431f65cf5f7569b6ff7907919a7c61d41
languageName: node
linkType: hard
"three-render-objects@npm:^1.29":
version: 1.29.0
resolution: "three-render-objects@npm:1.29.0"
dependencies:
"@tweenjs/tween.js": 18 - 21
accessor-fn: 1
kapsule: 1
polished: 4
peerDependencies:
three: "*"
checksum: 90824e427aa4605d9b52844a6a021ac323cd5890a02e5f9ed1651c23304366e57c1670d3cd607ac18a867a852be977127b4303729aabd586791e73c8b39b089c
languageName: node
linkType: hard
"three@npm:>=0.118 <1, three@npm:^0.157.0":
version: 0.157.0
resolution: "three@npm:0.157.0"
checksum: 444797461c9db09d8a4cad886e494c2e6dd5754f09ac7ac4af75a3bf1143ae79641388db31999edcc76c5dba9677639f09da2df8d0128396a1dd59e41226c85c
languageName: node
linkType: hard
"through@npm:^2.3.6":
version: 2.3.8
resolution: "through@npm:2.3.8"
@ -14238,6 +14530,13 @@ __metadata:
languageName: node
linkType: hard
"tinycolor2@npm:1":
version: 1.6.0
resolution: "tinycolor2@npm:1.6.0"
checksum: 6df4d07fceeedc0a878d7bac47e2cd47c1ceeb1078340a9eb8a295bc0651e17c750f73d47b3028d829f30b85c15e0572c0fd4142083e4c21a30a597e47f47230
languageName: node
linkType: hard
"tmp@npm:^0.0.33":
version: 0.0.33
resolution: "tmp@npm:0.0.33"