Merge pull request 'SocialGraph' (#691) from mmalmi/snort:main into main
Reviewed-on: #691
This commit is contained in:
238
packages/system/src/SocialGraph/SocialGraph.ts
Normal file
238
packages/system/src/SocialGraph/SocialGraph.ts
Normal file
@ -0,0 +1,238 @@
|
||||
import { ID, STR, UID } from "./UniqueIds";
|
||||
import { HexKey, NostrEvent } from "..";
|
||||
|
||||
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>();
|
||||
|
||||
constructor(root: HexKey) {
|
||||
this.root = ID(root);
|
||||
this.followDistanceByUser.set(this.root, 0);
|
||||
this.usersByFollowDistance.set(0, new Set([this.root]));
|
||||
}
|
||||
|
||||
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]));
|
||||
|
||||
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;
|
||||
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(follower: HexKey, followedUser: HexKey): boolean {
|
||||
const followedUserId = ID(followedUser);
|
||||
const followerId = ID(follower);
|
||||
return !!this.followedByUser.get(followerId)?.has(followedUserId);
|
||||
}
|
||||
|
||||
getFollowDistance(user: HexKey): number {
|
||||
try {
|
||||
const userId = ID(user);
|
||||
if (userId === this.root) {
|
||||
return 0;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addFollower(followedUser: UID, follower: UID) {
|
||||
if (typeof followedUser !== "number" || typeof follower !== "number") {
|
||||
throw new Error("Invalid user id");
|
||||
}
|
||||
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>());
|
||||
}
|
||||
|
||||
if (followedUser !== this.root) {
|
||||
let newFollowDistance;
|
||||
if (follower === this.root) {
|
||||
// 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(this.root)?.has(follower)) {
|
||||
/*
|
||||
setTimeout(() => {
|
||||
PubSub.subscribe({ authors: [STR(followedUser)], kinds: [0, 3] }, undefined, true);
|
||||
}, 0);
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
removeFollower(unfollowedUser: UID, follower: UID) {
|
||||
this.followersByUser.get(unfollowedUser)?.delete(follower);
|
||||
this.followedByUser.get(follower)?.delete(unfollowedUser);
|
||||
|
||||
if (unfollowedUser === this.root) {
|
||||
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(address: HexKey) {
|
||||
const id = ID(address);
|
||||
return this.followersByUser.get(id)?.size ?? 0;
|
||||
}
|
||||
|
||||
followedByFriendsCount(address: HexKey) {
|
||||
let count = 0;
|
||||
const id = ID(address);
|
||||
for (const follower of this.followersByUser.get(id) ?? []) {
|
||||
if (this.followedByUser.get(this.root)?.has(follower)) {
|
||||
count++; // should we stop at 10?
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
getFollowedByUser(user: HexKey, includeSelf = false): Set<HexKey> {
|
||||
const userId = ID(user);
|
||||
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 set;
|
||||
}
|
||||
|
||||
getFollowersByUser(address: HexKey): Set<HexKey> {
|
||||
const userId = ID(address);
|
||||
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 set;
|
||||
}
|
||||
}
|
||||
|
||||
export const socialGraphInstance = new SocialGraph("");
|
38
packages/system/src/SocialGraph/UniqueIds.ts
Normal file
38
packages/system/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;
|
@ -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";
|
||||
|
Reference in New Issue
Block a user