Merge pull request 'SocialGraph' (#691) from mmalmi/snort:main into main
Reviewed-on: #691
This commit is contained in:
commit
1687137572
@ -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<TaggedNostrEvent> {
|
||||
constructor() {
|
||||
@ -27,7 +26,7 @@ export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
|
||||
loaded: unixNowMs(),
|
||||
});
|
||||
if (update !== "no_change") {
|
||||
SocialGraph.handleFollowEvent(e);
|
||||
socialGraphInstance.handleFollowEvent(e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@ -43,6 +42,6 @@ export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
|
||||
|
||||
override async preload() {
|
||||
await super.preload();
|
||||
this.snapshot().forEach(e => SocialGraph.handleFollowEvent(e));
|
||||
this.snapshot().forEach(e => socialGraphInstance.handleFollowEvent(e));
|
||||
}
|
||||
}
|
||||
|
@ -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<HTMLDivElement>();
|
||||
|
||||
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 (
|
||||
|
@ -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<LoginSession> {
|
||||
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<LoginSession> {
|
||||
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<LoginSession> {
|
||||
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<LoginSession> {
|
||||
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,
|
||||
|
@ -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<UID>();
|
||||
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
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
mapEventToProfile,
|
||||
PowWorker,
|
||||
encodeTLVEntries,
|
||||
socialGraphInstance,
|
||||
} from "@snort/system";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { removeUndefined, throwIfOffline } from "@snort/shared";
|
||||
@ -109,6 +110,12 @@ System.on("auth", async (c, r, cb) => {
|
||||
}
|
||||
});
|
||||
|
||||
System.on("event", ev => {
|
||||
if (ev.kind === 3) {
|
||||
socialGraphInstance.handleFollowEvent(ev);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchProfile(key: string) {
|
||||
try {
|
||||
throwIfOffline();
|
||||
|
@ -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<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 = {
|
||||
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<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>(),
|
||||
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<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 {
|
||||
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<UID>());
|
||||
}
|
||||
@ -124,11 +141,10 @@ export default {
|
||||
if (!this.followedByUser.has(follower)) {
|
||||
this.followedByUser.set(follower, new Set<UID>());
|
||||
}
|
||||
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<HexKey>) => void,
|
||||
includeSelf = false,
|
||||
): Unsubscribe {
|
||||
}
|
||||
|
||||
getFollowedByUser(user: HexKey, includeSelf = false): Set<HexKey> {
|
||||
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();
|
||||
const set = new Set<HexKey>();
|
||||
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<HexKey>) => void): Unsubscribe {
|
||||
return set;
|
||||
}
|
||||
|
||||
getFollowersByUser(address: HexKey): Set<HexKey> {
|
||||
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();
|
||||
const set = new Set<HexKey>();
|
||||
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("");
|
@ -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";
|
||||
|
Loading…
x
Reference in New Issue
Block a user