diff --git a/packages/app/src/Cache/FollowListCache.ts b/packages/app/src/Cache/FollowListCache.ts index 34f60dcb..ca4cdbf6 100644 --- a/packages/app/src/Cache/FollowListCache.ts +++ b/packages/app/src/Cache/FollowListCache.ts @@ -1,9 +1,8 @@ import { db } from "Db"; import { unixNowMs } from "@snort/shared"; -import { EventKind, RequestBuilder, TaggedNostrEvent } from "@snort/system"; +import { EventKind, RequestBuilder, socialGraphInstance, TaggedNostrEvent } from "@snort/system"; import { RefreshFeedCache } from "./RefreshFeedCache"; import { LoginSession } from "Login"; -import SocialGraph from "SocialGraph/SocialGraph"; export class FollowListCache extends RefreshFeedCache { constructor() { @@ -27,7 +26,7 @@ export class FollowListCache extends RefreshFeedCache { loaded: unixNowMs(), }); if (update !== "no_change") { - SocialGraph.handleFollowEvent(e); + socialGraphInstance.handleFollowEvent(e); } }), ); @@ -43,6 +42,6 @@ export class FollowListCache extends RefreshFeedCache { override async preload() { await super.preload(); - this.snapshot().forEach(e => SocialGraph.handleFollowEvent(e)); + this.snapshot().forEach(e => socialGraphInstance.handleFollowEvent(e)); } } diff --git a/packages/app/src/Element/User/ProfileImage.tsx b/packages/app/src/Element/User/ProfileImage.tsx index 9be02460..13d689a7 100644 --- a/packages/app/src/Element/User/ProfileImage.tsx +++ b/packages/app/src/Element/User/ProfileImage.tsx @@ -1,7 +1,7 @@ import "./ProfileImage.css"; import React, { ReactNode } from "react"; -import { HexKey, UserMetadata } from "@snort/system"; +import { HexKey, socialGraphInstance, UserMetadata } from "@snort/system"; import { useUserProfile } from "@snort/system-react"; import { useHover } from "@uidotdev/usehooks"; import classNames from "classnames"; @@ -12,7 +12,6 @@ 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; @@ -51,7 +50,7 @@ export default function ProfileImage({ }: ProfileImageProps) { const user = useUserProfile(profile ? "" : pubkey) ?? profile; const nip05 = defaultNip ? defaultNip : user?.nip05; - const followDistance = SocialGraph.getFollowDistance(pubkey); + const followDistance = socialGraphInstance.getFollowDistance(pubkey); const [ref, hovering] = useHover(); function handleClick(e: React.MouseEvent) { @@ -65,7 +64,7 @@ export default function ProfileImage({ let followDistanceColor = ""; if (followDistance <= 1) { followDistanceColor = "success"; - } else if (followDistance === 2 && SocialGraph.followedByFriendsCount(pubkey) >= 10) { + } else if (followDistance === 2 && socialGraphInstance.followedByFriendsCount(pubkey) >= 10) { followDistanceColor = "text-nostr-orange"; } return ( diff --git a/packages/app/src/Login/MultiAccountStore.ts b/packages/app/src/Login/MultiAccountStore.ts index a26f5e25..6764ffb9 100644 --- a/packages/app/src/Login/MultiAccountStore.ts +++ b/packages/app/src/Login/MultiAccountStore.ts @@ -2,7 +2,7 @@ import * as secp from "@noble/curves/secp256k1"; import * as utils from "@noble/curves/abstract/utils"; import { v4 as uuid } from "uuid"; -import { HexKey, RelaySettings, EventPublisher, KeyStorage, NotEncrypted } from "@snort/system"; +import { HexKey, RelaySettings, EventPublisher, KeyStorage, NotEncrypted, socialGraphInstance } from "@snort/system"; import { deepClone, sanitizeRelayUrl, unwrap, ExternalStore } from "@snort/shared"; import { DefaultRelays } from "Const"; @@ -74,6 +74,10 @@ export class MultiAccountStore extends ExternalStore { if (!this.#activeAccount) { this.#activeAccount = this.#accounts.keys().next().value; } + if (this.#activeAccount) { + const pubKey = this.#accounts.get(this.#activeAccount)?.publicKey; + socialGraphInstance.setRoot(pubKey || ""); + } for (const [, v] of this.#accounts) { // reset readonly on load if (v.type === LoginSessionType.PrivateKey && v.readonly) { @@ -116,6 +120,8 @@ export class MultiAccountStore extends ExternalStore { switchAccount(id: string) { if (this.#accounts.has(id)) { this.#activeAccount = id; + const pubKey = this.#accounts.get(id)?.publicKey || ""; + socialGraphInstance.setRoot(pubKey); this.#save(); } } @@ -140,6 +146,7 @@ export class MultiAccountStore extends ExternalStore { if (this.#accounts.has(key)) { throw new Error("Already logged in with this pubkey"); } + socialGraphInstance.setRoot(key); const initRelays = this.decideInitRelays(relays); const newSession = { ...LoggedOut, @@ -180,6 +187,7 @@ export class MultiAccountStore extends ExternalStore { if (this.#accounts.has(pubKey)) { throw new Error("Already logged in with this pubkey"); } + socialGraphInstance.setRoot(pubKey); const initRelays = this.decideInitRelays(relays); const newSession = { ...LoggedOut, diff --git a/packages/app/src/Pages/NetworkGraph.tsx b/packages/app/src/Pages/NetworkGraph.tsx index d5e40272..fdd5bd5a 100644 --- a/packages/app/src/Pages/NetworkGraph.tsx +++ b/packages/app/src/Pages/NetworkGraph.tsx @@ -1,8 +1,6 @@ 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 { MetadataCache, socialGraphInstance, STR, UID } from "@snort/system"; import { SnortContext } from "@snort/system-react"; import * as THREE from "three"; import { defaultAvatar } from "../SnortUtils"; @@ -96,17 +94,17 @@ const NetworkGraph = () => { const nodesVisited = new Set(); const userCountByDistance = Array.from( { length: 6 }, - (_, i) => SocialGraph.usersByFollowDistance.get(i)?.size || 0, + (_, i) => socialGraphInstance.usersByFollowDistance.get(i)?.size || 0, ); // Go through all the nodes for (let distance = 0; distance <= showDistance; ++distance) { - const users = SocialGraph.usersByFollowDistance.get(distance); + const users = socialGraphInstance.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 inboundCount = socialGraphInstance.followersByUser.get(UID)?.size || 0; + const outboundCount = socialGraphInstance.followedByUser.get(UID)?.size || 0; const pubkey = STR(UID); const node = { id: UID, @@ -132,7 +130,7 @@ const NetworkGraph = () => { // Add links for (const node of nodes.values()) { if (direction === Direction.OUTBOUND || direction === Direction.BOTH) { - for (const followedID of SocialGraph.followedByUser.get(node.id) ?? []) { + for (const followedID of socialGraphInstance.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({ @@ -144,7 +142,7 @@ const NetworkGraph = () => { } // TODO: Fix filtering /* if (direction === Direction.INBOUND || direction === Direction.BOTH) { - for (const followerID of SocialGraph.followersByUser.get(node.id) ?? []) { + for (const followerID of socialGraphInstance.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 diff --git a/packages/app/src/SocialGraph/SocialGraph.ts b/packages/system/src/SocialGraph/SocialGraph.ts similarity index 65% rename from packages/app/src/SocialGraph/SocialGraph.ts rename to packages/system/src/SocialGraph/SocialGraph.ts index 13051901..c3264aea 100644 --- a/packages/app/src/SocialGraph/SocialGraph.ts +++ b/packages/system/src/SocialGraph/SocialGraph.ts @@ -1,27 +1,53 @@ import { ID, STR, UID } from "./UniqueIds"; -import { LoginStore } from "../Login"; -import { unwrap } from "../SnortUtils"; -import { HexKey, MetadataCache, NostrEvent } from "@snort/system"; +import { HexKey, NostrEvent } from ".."; -type Unsubscribe = () => void; +export default class SocialGraph { + root: UID; + followDistanceByUser = new Map(); + usersByFollowDistance = new Map>(); + followedByUser = new Map>(); + followersByUser = new Map>(); + latestFollowEventTimestamps = new Map(); -const Key = { - pubKey: null as HexKey | null, - getPubKey: () => { - return unwrap(LoginStore.snapshot().publicKey); - }, - isMine: (user: HexKey) => user === Key.getPubKey(), -}; + constructor(root: HexKey) { + this.root = ID(root); + this.followDistanceByUser.set(this.root, 0); + this.usersByFollowDistance.set(0, new Set([this.root])); + } -export default { - followDistanceByUser: new Map(), - usersByFollowDistance: new Map>(), - profiles: new Map(), // JSON.parsed event.content of profile events - followedByUser: new Map>(), - followersByUser: new Map>(), - latestFollowEventTimestamps: new Map(), + setRoot(root: HexKey) { + const rootId = ID(root); + if (rootId === this.root) { + return; + } + this.root = rootId; + this.followDistanceByUser.clear(); + this.usersByFollowDistance.clear(); + this.followDistanceByUser.set(this.root, 0); + this.usersByFollowDistance.set(0, new Set([this.root])); - handleFollowEvent: function (event: NostrEvent) { + const queue = [this.root]; + + while (queue.length > 0) { + const user = queue.shift()!; + const distance = this.followDistanceByUser.get(user)!; + + const followers = this.followersByUser.get(user) || new Set(); + for (const follower of followers) { + if (!this.followDistanceByUser.has(follower)) { + const newFollowDistance = distance + 1; + this.followDistanceByUser.set(follower, newFollowDistance); + if (!this.usersByFollowDistance.has(newFollowDistance)) { + this.usersByFollowDistance.set(newFollowDistance, new Set()); + } + this.usersByFollowDistance.get(newFollowDistance)!.add(follower); + queue.push(follower); + } + } + } + } + + handleFollowEvent(event: NostrEvent) { try { const author = ID(event.pubkey); const timestamp = event.created_at; @@ -59,27 +85,27 @@ export default { } catch (e) { // might not be logged in or sth } - }, + } - isFollowing: function (follower: HexKey, followedUser: HexKey): boolean { + isFollowing(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 { + getFollowDistance(user: HexKey): number { try { - if (Key.isMine(user)) { + const userId = ID(user); + if (userId === this.root) { 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)) { @@ -101,21 +127,12 @@ export default { 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) { + addFollower(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()); } @@ -124,11 +141,10 @@ export default { if (!this.followedByUser.has(follower)) { this.followedByUser.set(follower, new Set()); } - const myId = ID(Key.getPubKey()); - if (followedUser !== myId) { + if (followedUser !== this.root) { let newFollowDistance; - if (follower === myId) { + if (follower === this.root) { // basically same as the next "else" block, but faster newFollowDistance = 1; this.addUserByFollowDistance(newFollowDistance, followedUser); @@ -145,19 +161,20 @@ export default { } this.followedByUser.get(follower)?.add(followedUser); - if (this.followedByUser.get(myId)?.has(follower)) { + if (this.followedByUser.get(this.root)?.has(follower)) { /* setTimeout(() => { PubSub.subscribe({ authors: [STR(followedUser)], kinds: [0, 3] }, undefined, true); }, 0); */ } - }, - removeFollower: function (unfollowedUser: UID, follower: UID) { + } + + removeFollower(unfollowedUser: UID, follower: UID) { this.followersByUser.get(unfollowedUser)?.delete(follower); this.followedByUser.get(follower)?.delete(unfollowedUser); - if (unfollowedUser === ID(Key.getPubKey())) { + if (unfollowedUser === this.root) { return; } @@ -175,60 +192,47 @@ export default { } else { this.followDistanceByUser.set(unfollowedUser, smallest); } - }, + } + // TODO subscription methods for followersByUser and followedByUser. and maybe messagesByTime. and replies - followerCount: function (address: HexKey) { + followerCount(address: HexKey) { const id = ID(address); return this.followersByUser.get(id)?.size ?? 0; - }, - followedByFriendsCount: function (address: HexKey) { + } + + followedByFriendsCount(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)) { + if (this.followedByUser.get(this.root)?.has(follower)) { count++; // should we stop at 10? } } return count; - }, - getFollowedByUser: function ( - user: HexKey, - cb?: (followedUsers: Set) => void, - includeSelf = false, - ): Unsubscribe { + } + + getFollowedByUser(user: HexKey, includeSelf = false): Set { const userId = ID(user); - const callback = () => { - if (cb) { - const set = new Set(); - 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(); + const set = new Set(); + for (const id of this.followedByUser.get(userId) || []) { + set.add(STR(id)); + } + if (includeSelf) { + set.add(user); } //return PubSub.subscribe({ kinds: [3], authors: [user] }, callback); - return () => {}; - }, - getFollowersByUser: function (address: HexKey, cb?: (followers: Set) => void): Unsubscribe { + return set; + } + + getFollowersByUser(address: HexKey): Set { const userId = ID(address); - const callback = () => { - if (cb) { - const set = new Set(); - for (const id of this.followersByUser.get(userId) || []) { - set.add(STR(id)); - } - cb(set); - } - }; - this.followersByUser.has(userId) && callback(); + const set = new Set(); + for (const id of this.followersByUser.get(userId) || []) { + set.add(STR(id)); + } //return PubSub.subscribe({ kinds: [3], '#p': [address] }, callback); // TODO this doesn't fire when a user is unfollowed - return () => {}; - }, -}; + return set; + } +} + +export const socialGraphInstance = new SocialGraph(""); diff --git a/packages/app/src/SocialGraph/UniqueIds.ts b/packages/system/src/SocialGraph/UniqueIds.ts similarity index 100% rename from packages/app/src/SocialGraph/UniqueIds.ts rename to packages/system/src/SocialGraph/UniqueIds.ts diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts index c226a857..768bd5a2 100644 --- a/packages/system/src/index.ts +++ b/packages/system/src/index.ts @@ -10,6 +10,8 @@ import { base64 } from "@scure/base"; export * from "./nostr-system"; export { default as EventKind } from "./event-kind"; +export { default as SocialGraph, socialGraphInstance } from "./SocialGraph/SocialGraph"; +export * from "./SocialGraph/UniqueIds"; export * from "./nostr"; export * from "./links"; export * from "./nips";