Merge pull request 'SocialGraph' (#691) from mmalmi/snort:main into main

Reviewed-on: #691
This commit is contained in:
mmalmi
2023-11-18 11:45:12 +00:00
8 changed files with 121 additions and 104 deletions

View File

@ -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));
}
}

View File

@ -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 (

View File

@ -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,

View File

@ -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

View File

@ -1,234 +0,0 @@
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

@ -1,38 +0,0 @@
// 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

@ -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();