Merge remote-tracking branch 'mmalmi/main'
This commit is contained in:
48
packages/app/src/Cache/FollowListCache.ts
Normal file
48
packages/app/src/Cache/FollowListCache.ts
Normal 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));
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -448,7 +448,7 @@ export function NoteCreator() {
|
||||
className="note-creator-icon"
|
||||
link=""
|
||||
showUsername={false}
|
||||
showFollowingMark={false}
|
||||
showFollowDistance={false}
|
||||
/>
|
||||
{note.pollOptions === undefined && !note.replyTo && (
|
||||
<AsyncIcon
|
||||
|
@ -59,7 +59,7 @@ export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
|
||||
{sender && (
|
||||
<ProfileImage
|
||||
pubkey={anonZap ? "" : sender}
|
||||
showFollowingMark={false}
|
||||
showFollowDistance={false}
|
||||
overrideUsername={anonZap ? formatMessage({ defaultMessage: "Anonymous" }) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
@ -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}}"
|
||||
|
@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
@ -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}>
|
||||
|
@ -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)))}
|
||||
/>
|
||||
))}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
@ -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;
|
||||
|
@ -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}`;
|
||||
}
|
||||
|
@ -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]);
|
||||
|
@ -50,7 +50,7 @@ export interface UserPreferences {
|
||||
/**
|
||||
* Use imgproxy to optimize images
|
||||
*/
|
||||
imgProxyConfig: ImgProxySettings | null;
|
||||
imgProxyConfig?: ImgProxySettings;
|
||||
|
||||
/**
|
||||
* Default page to select on load
|
||||
|
@ -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>
|
||||
|
264
packages/app/src/Pages/NetworkGraph.tsx
Normal file
264
packages/app/src/Pages/NetworkGraph.tsx
Normal 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;
|
@ -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 = {
|
||||
|
@ -299,7 +299,7 @@ const PreferencesPage = () => {
|
||||
onChange={e =>
|
||||
updatePreferences(id, {
|
||||
...perf,
|
||||
imgProxyConfig: e.target.checked ? DefaultImgProxy : null,
|
||||
imgProxyConfig: e.target.checked ? DefaultImgProxy : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
@ -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} />
|
||||
|
@ -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" },
|
||||
});
|
||||
|
234
packages/app/src/SocialGraph/SocialGraph.ts
Normal file
234
packages/app/src/SocialGraph/SocialGraph.ts
Normal 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 () => {};
|
||||
},
|
||||
};
|
38
packages/app/src/SocialGraph/UniqueIds.ts
Normal file
38
packages/app/src/SocialGraph/UniqueIds.ts
Normal 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;
|
@ -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>,
|
||||
|
@ -368,6 +368,9 @@
|
||||
"Cu/K85": {
|
||||
"defaultMessage": "Translated from {lang}"
|
||||
},
|
||||
"CzHZoc": {
|
||||
"defaultMessage": "Social Graph"
|
||||
},
|
||||
"D+KzKd": {
|
||||
"defaultMessage": "Automatically zap every note when loaded"
|
||||
},
|
||||
|
@ -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",
|
||||
|
Reference in New Issue
Block a user