system/SocialGraph, socialGraphInstance

This commit is contained in:
Martti Malmi 2023-11-17 10:07:16 +02:00
parent 55c938961f
commit 4fbe92baa2
7 changed files with 114 additions and 104 deletions

View File

@ -1,9 +1,8 @@
import { db } from "Db"; import { db } from "Db";
import { unixNowMs } from "@snort/shared"; 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 { RefreshFeedCache } from "./RefreshFeedCache";
import { LoginSession } from "Login"; import { LoginSession } from "Login";
import SocialGraph from "SocialGraph/SocialGraph";
export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> { export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
constructor() { constructor() {
@ -27,7 +26,7 @@ export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
loaded: unixNowMs(), loaded: unixNowMs(),
}); });
if (update !== "no_change") { if (update !== "no_change") {
SocialGraph.handleFollowEvent(e); socialGraphInstance.handleFollowEvent(e);
} }
}), }),
); );
@ -43,6 +42,6 @@ export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
override async preload() { override async preload() {
await super.preload(); await super.preload();
this.snapshot().forEach(e => SocialGraph.handleFollowEvent(e)); this.snapshot().forEach(e => socialGraphInstance.handleFollowEvent(e));
} }
} }

View File

@ -1,7 +1,7 @@
import "./ProfileImage.css"; import "./ProfileImage.css";
import React, { ReactNode } from "react"; 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 { useUserProfile } from "@snort/system-react";
import { useHover } from "@uidotdev/usehooks"; import { useHover } from "@uidotdev/usehooks";
import classNames from "classnames"; import classNames from "classnames";
@ -12,7 +12,6 @@ import Icon from "Icons/Icon";
import DisplayName from "./DisplayName"; import DisplayName from "./DisplayName";
import { ProfileLink } from "./ProfileLink"; import { ProfileLink } from "./ProfileLink";
import { ProfileCard } from "./ProfileCard"; import { ProfileCard } from "./ProfileCard";
import SocialGraph from "../../SocialGraph/SocialGraph";
export interface ProfileImageProps { export interface ProfileImageProps {
pubkey: HexKey; pubkey: HexKey;
@ -51,7 +50,7 @@ export default function ProfileImage({
}: ProfileImageProps) { }: ProfileImageProps) {
const user = useUserProfile(profile ? "" : pubkey) ?? profile; const user = useUserProfile(profile ? "" : pubkey) ?? profile;
const nip05 = defaultNip ? defaultNip : user?.nip05; const nip05 = defaultNip ? defaultNip : user?.nip05;
const followDistance = SocialGraph.getFollowDistance(pubkey); const followDistance = socialGraphInstance.getFollowDistance(pubkey);
const [ref, hovering] = useHover<HTMLDivElement>(); const [ref, hovering] = useHover<HTMLDivElement>();
function handleClick(e: React.MouseEvent) { function handleClick(e: React.MouseEvent) {
@ -65,7 +64,7 @@ export default function ProfileImage({
let followDistanceColor = ""; let followDistanceColor = "";
if (followDistance <= 1) { if (followDistance <= 1) {
followDistanceColor = "success"; followDistanceColor = "success";
} else if (followDistance === 2 && SocialGraph.followedByFriendsCount(pubkey) >= 10) { } else if (followDistance === 2 && socialGraphInstance.followedByFriendsCount(pubkey) >= 10) {
followDistanceColor = "text-nostr-orange"; followDistanceColor = "text-nostr-orange";
} }
return ( return (

View File

@ -2,7 +2,7 @@ import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils"; import * as utils from "@noble/curves/abstract/utils";
import { v4 as uuid } from "uuid"; 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 { deepClone, sanitizeRelayUrl, unwrap, ExternalStore } from "@snort/shared";
import { DefaultRelays } from "Const"; import { DefaultRelays } from "Const";
@ -74,6 +74,10 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
if (!this.#activeAccount) { if (!this.#activeAccount) {
this.#activeAccount = this.#accounts.keys().next().value; 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) { for (const [, v] of this.#accounts) {
// reset readonly on load // reset readonly on load
if (v.type === LoginSessionType.PrivateKey && v.readonly) { if (v.type === LoginSessionType.PrivateKey && v.readonly) {
@ -116,6 +120,8 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
switchAccount(id: string) { switchAccount(id: string) {
if (this.#accounts.has(id)) { if (this.#accounts.has(id)) {
this.#activeAccount = id; this.#activeAccount = id;
const pubKey = this.#accounts.get(id)?.publicKey || "";
socialGraphInstance.setRoot(pubKey);
this.#save(); this.#save();
} }
} }
@ -140,6 +146,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
if (this.#accounts.has(key)) { if (this.#accounts.has(key)) {
throw new Error("Already logged in with this pubkey"); throw new Error("Already logged in with this pubkey");
} }
socialGraphInstance.setRoot(key);
const initRelays = this.decideInitRelays(relays); const initRelays = this.decideInitRelays(relays);
const newSession = { const newSession = {
...LoggedOut, ...LoggedOut,
@ -180,6 +187,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
if (this.#accounts.has(pubKey)) { if (this.#accounts.has(pubKey)) {
throw new Error("Already logged in with this pubkey"); throw new Error("Already logged in with this pubkey");
} }
socialGraphInstance.setRoot(pubKey);
const initRelays = this.decideInitRelays(relays); const initRelays = this.decideInitRelays(relays);
const newSession = { const newSession = {
...LoggedOut, ...LoggedOut,

View File

@ -1,8 +1,6 @@
import ForceGraph3D, { NodeObject } from "react-force-graph-3d"; import ForceGraph3D, { NodeObject } from "react-force-graph-3d";
import { useContext, useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import { STR, UID } from "../SocialGraph/UniqueIds"; import { MetadataCache, socialGraphInstance, STR, UID } from "@snort/system";
import SocialGraph from "../SocialGraph/SocialGraph";
import { MetadataCache } from "@snort/system";
import { SnortContext } from "@snort/system-react"; import { SnortContext } from "@snort/system-react";
import * as THREE from "three"; import * as THREE from "three";
import { defaultAvatar } from "../SnortUtils"; import { defaultAvatar } from "../SnortUtils";
@ -96,17 +94,17 @@ const NetworkGraph = () => {
const nodesVisited = new Set<UID>(); const nodesVisited = new Set<UID>();
const userCountByDistance = Array.from( const userCountByDistance = Array.from(
{ length: 6 }, { length: 6 },
(_, i) => SocialGraph.usersByFollowDistance.get(i)?.size || 0, (_, i) => socialGraphInstance.usersByFollowDistance.get(i)?.size || 0,
); );
// Go through all the nodes // Go through all the nodes
for (let distance = 0; distance <= showDistance; ++distance) { for (let distance = 0; distance <= showDistance; ++distance) {
const users = SocialGraph.usersByFollowDistance.get(distance); const users = socialGraphInstance.usersByFollowDistance.get(distance);
if (!users) break; if (!users) break;
for (const UID of users) { for (const UID of users) {
if (renderLimit && nodes.size >= renderLimit) break; // Temporary hack if (renderLimit && nodes.size >= renderLimit) break; // Temporary hack
const inboundCount = SocialGraph.followersByUser.get(UID)?.size || 0; const inboundCount = socialGraphInstance.followersByUser.get(UID)?.size || 0;
const outboundCount = SocialGraph.followedByUser.get(UID)?.size || 0; const outboundCount = socialGraphInstance.followedByUser.get(UID)?.size || 0;
const pubkey = STR(UID); const pubkey = STR(UID);
const node = { const node = {
id: UID, id: UID,
@ -132,7 +130,7 @@ const NetworkGraph = () => {
// Add links // Add links
for (const node of nodes.values()) { for (const node of nodes.values()) {
if (direction === Direction.OUTBOUND || direction === Direction.BOTH) { 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 (!nodes.has(followedID)) continue; // Skip links to nodes that we're not rendering
if (nodesVisited.has(followedID)) continue; if (nodesVisited.has(followedID)) continue;
links.push({ links.push({
@ -144,7 +142,7 @@ const NetworkGraph = () => {
} }
// TODO: Fix filtering // TODO: Fix filtering
/* if (direction === Direction.INBOUND || direction === Direction.BOTH) { /* 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; if (nodesVisited.has(followerID)) continue;
const follower = nodes.get(followerID); const follower = nodes.get(followerID);
if (!follower) continue; // Skip links to nodes that we're not rendering if (!follower) continue; // Skip links to nodes that we're not rendering

View File

@ -1,27 +1,53 @@
import { ID, STR, UID } from "./UniqueIds"; import { ID, STR, UID } from "./UniqueIds";
import { LoginStore } from "../Login"; import { HexKey, NostrEvent } from "..";
import { unwrap } from "../SnortUtils";
import { HexKey, MetadataCache, NostrEvent } from "@snort/system";
type Unsubscribe = () => void; export default class SocialGraph {
root: UID;
followDistanceByUser = new Map<UID, number>();
usersByFollowDistance = new Map<number, Set<UID>>();
followedByUser = new Map<UID, Set<UID>>();
followersByUser = new Map<UID, Set<UID>>();
latestFollowEventTimestamps = new Map<UID, number>();
const Key = { constructor(root: HexKey) {
pubKey: null as HexKey | null, this.root = ID(root);
getPubKey: () => { this.followDistanceByUser.set(this.root, 0);
return unwrap(LoginStore.snapshot().publicKey); this.usersByFollowDistance.set(0, new Set([this.root]));
}, }
isMine: (user: HexKey) => user === Key.getPubKey(),
};
export default { setRoot(root: HexKey) {
followDistanceByUser: new Map<UID, number>(), const rootId = ID(root);
usersByFollowDistance: new Map<number, Set<UID>>(), if (rootId === this.root) {
profiles: new Map<UID, MetadataCache>(), // JSON.parsed event.content of profile events return;
followedByUser: new Map<UID, Set<UID>>(), }
followersByUser: new Map<UID, Set<UID>>(), this.root = rootId;
latestFollowEventTimestamps: new Map<UID, number>(), 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<UID>();
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 { try {
const author = ID(event.pubkey); const author = ID(event.pubkey);
const timestamp = event.created_at; const timestamp = event.created_at;
@ -59,27 +85,27 @@ export default {
} catch (e) { } catch (e) {
// might not be logged in or sth // 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 followedUserId = ID(followedUser);
const followerId = ID(follower); const followerId = ID(follower);
return !!this.followedByUser.get(followerId)?.has(followedUserId); return !!this.followedByUser.get(followerId)?.has(followedUserId);
}, }
getFollowDistance: function (user: HexKey): number { getFollowDistance(user: HexKey): number {
try { try {
if (Key.isMine(user)) { const userId = ID(user);
if (userId === this.root) {
return 0; return 0;
} }
const userId = ID(user);
const distance = this.followDistanceByUser.get(userId); const distance = this.followDistanceByUser.get(userId);
return distance === undefined ? 1000 : distance; return distance === undefined ? 1000 : distance;
} catch (e) { } catch (e) {
// might not be logged in or sth // might not be logged in or sth
return 1000; return 1000;
} }
}, }
addUserByFollowDistance(distance: number, user: UID) { addUserByFollowDistance(distance: number, user: UID) {
if (!this.usersByFollowDistance.has(distance)) { if (!this.usersByFollowDistance.has(distance)) {
@ -101,21 +127,12 @@ export default {
this.usersByFollowDistance.get(d)?.delete(user); this.usersByFollowDistance.get(d)?.delete(user);
} }
} }
}, }
ensureRootUser: function () { addFollower(followedUser: UID, follower: UID) {
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") { if (typeof followedUser !== "number" || typeof follower !== "number") {
throw new Error("Invalid user id"); throw new Error("Invalid user id");
} }
this.ensureRootUser();
if (!this.followersByUser.has(followedUser)) { if (!this.followersByUser.has(followedUser)) {
this.followersByUser.set(followedUser, new Set<UID>()); this.followersByUser.set(followedUser, new Set<UID>());
} }
@ -124,11 +141,10 @@ export default {
if (!this.followedByUser.has(follower)) { if (!this.followedByUser.has(follower)) {
this.followedByUser.set(follower, new Set<UID>()); this.followedByUser.set(follower, new Set<UID>());
} }
const myId = ID(Key.getPubKey());
if (followedUser !== myId) { if (followedUser !== this.root) {
let newFollowDistance; let newFollowDistance;
if (follower === myId) { if (follower === this.root) {
// basically same as the next "else" block, but faster // basically same as the next "else" block, but faster
newFollowDistance = 1; newFollowDistance = 1;
this.addUserByFollowDistance(newFollowDistance, followedUser); this.addUserByFollowDistance(newFollowDistance, followedUser);
@ -145,19 +161,20 @@ export default {
} }
this.followedByUser.get(follower)?.add(followedUser); this.followedByUser.get(follower)?.add(followedUser);
if (this.followedByUser.get(myId)?.has(follower)) { if (this.followedByUser.get(this.root)?.has(follower)) {
/* /*
setTimeout(() => { setTimeout(() => {
PubSub.subscribe({ authors: [STR(followedUser)], kinds: [0, 3] }, undefined, true); PubSub.subscribe({ authors: [STR(followedUser)], kinds: [0, 3] }, undefined, true);
}, 0); }, 0);
*/ */
} }
}, }
removeFollower: function (unfollowedUser: UID, follower: UID) {
removeFollower(unfollowedUser: UID, follower: UID) {
this.followersByUser.get(unfollowedUser)?.delete(follower); this.followersByUser.get(unfollowedUser)?.delete(follower);
this.followedByUser.get(follower)?.delete(unfollowedUser); this.followedByUser.get(follower)?.delete(unfollowedUser);
if (unfollowedUser === ID(Key.getPubKey())) { if (unfollowedUser === this.root) {
return; return;
} }
@ -175,60 +192,47 @@ export default {
} else { } else {
this.followDistanceByUser.set(unfollowedUser, smallest); this.followDistanceByUser.set(unfollowedUser, smallest);
} }
}, }
// TODO subscription methods for followersByUser and followedByUser. and maybe messagesByTime. and replies // TODO subscription methods for followersByUser and followedByUser. and maybe messagesByTime. and replies
followerCount: function (address: HexKey) { followerCount(address: HexKey) {
const id = ID(address); const id = ID(address);
return this.followersByUser.get(id)?.size ?? 0; return this.followersByUser.get(id)?.size ?? 0;
}, }
followedByFriendsCount: function (address: HexKey) {
followedByFriendsCount(address: HexKey) {
let count = 0; let count = 0;
const myId = ID(Key.getPubKey());
const id = ID(address); const id = ID(address);
for (const follower of this.followersByUser.get(id) ?? []) { 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? count++; // should we stop at 10?
} }
} }
return count; return count;
}, }
getFollowedByUser: function (
user: HexKey, getFollowedByUser(user: HexKey, includeSelf = false): Set<HexKey> {
cb?: (followedUsers: Set<HexKey>) => void,
includeSelf = false,
): Unsubscribe {
const userId = ID(user); const userId = ID(user);
const callback = () => { const set = new Set<HexKey>();
if (cb) { for (const id of this.followedByUser.get(userId) || []) {
const set = new Set<HexKey>(); set.add(STR(id));
for (const id of this.followedByUser.get(userId) || []) { }
set.add(STR(id)); if (includeSelf) {
} set.add(user);
if (includeSelf) {
set.add(user);
}
cb(set);
}
};
if (this.followedByUser.has(userId) || includeSelf) {
callback();
} }
//return PubSub.subscribe({ kinds: [3], authors: [user] }, callback); //return PubSub.subscribe({ kinds: [3], authors: [user] }, callback);
return () => {}; return set;
}, }
getFollowersByUser: function (address: HexKey, cb?: (followers: Set<HexKey>) => void): Unsubscribe {
getFollowersByUser(address: HexKey): Set<HexKey> {
const userId = ID(address); const userId = ID(address);
const callback = () => { const set = new Set<HexKey>();
if (cb) { for (const id of this.followersByUser.get(userId) || []) {
const set = new Set<HexKey>(); set.add(STR(id));
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 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("");

View File

@ -10,6 +10,8 @@ import { base64 } from "@scure/base";
export * from "./nostr-system"; export * from "./nostr-system";
export { default as EventKind } from "./event-kind"; export { default as EventKind } from "./event-kind";
export { default as SocialGraph, socialGraphInstance } from "./SocialGraph/SocialGraph";
export * from "./SocialGraph/UniqueIds";
export * from "./nostr"; export * from "./nostr";
export * from "./links"; export * from "./links";
export * from "./nips"; export * from "./nips";