Merge remote-tracking branch 'mmalmi/main'

This commit is contained in:
2023-11-16 12:37:48 +00:00
parent 83ffe746b1
commit 3611af9dce
28 changed files with 997 additions and 51 deletions

View File

@ -0,0 +1,48 @@
import { db } from "Db";
import { unixNowMs } from "@snort/shared";
import { EventKind, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { RefreshFeedCache } from "./RefreshFeedCache";
import { LoginSession } from "Login";
import SocialGraph from "SocialGraph/SocialGraph";
export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
constructor() {
super("FollowListCache", db.followLists);
}
buildSub(session: LoginSession, rb: RequestBuilder): void {
const since = this.newest();
rb.withFilter()
.kinds([EventKind.ContactList])
.authors(session.follows.item)
.since(since === 0 ? undefined : since);
}
async onEvent(evs: readonly TaggedNostrEvent[]) {
await Promise.all(
evs.map(async e => {
const update = await super.update({
...e,
created: e.created_at,
loaded: unixNowMs(),
});
if (update !== "no_change") {
SocialGraph.handleFollowEvent(e);
}
}),
);
}
key(of: TaggedNostrEvent): string {
return of.pubkey;
}
takeSnapshot() {
return [...this.cache.values()];
}
override async preload() {
await super.preload();
this.snapshot().forEach(e => SocialGraph.handleFollowEvent(e));
}
}

View File

@ -7,6 +7,7 @@ import { Payments } from "./PaymentsCache";
import { GiftWrapCache } from "./GiftWrapCache";
import { NotificationsCache } from "./Notifications";
import { FollowsFeedCache } from "./FollowsFeed";
import { FollowListCache } from "./FollowListCache";
export const SystemDb = new SnortSystemDb();
export const UserCache = new UserProfileCache(SystemDb.users);
@ -19,6 +20,7 @@ export const InteractionCache = new EventInteractionCache();
export const GiftsCache = new GiftWrapCache();
export const Notifications = new NotificationsCache();
export const FollowsFeed = new FollowsFeedCache();
export const FollowLists = new FollowListCache();
export async function preload(follows?: Array<string>) {
const preloads = [
@ -30,6 +32,7 @@ export async function preload(follows?: Array<string>) {
GiftsCache.preload(),
Notifications.preload(),
FollowsFeed.preload(),
FollowLists.preload(),
];
await Promise.all(preloads);
}

View File

@ -2,7 +2,7 @@ import Dexie, { Table } from "dexie";
import { HexKey, NostrEvent, TaggedNostrEvent, u256 } from "@snort/system";
export const NAME = "snortDB";
export const VERSION = 15;
export const VERSION = 16;
export interface SubCache {
id: string;
@ -46,6 +46,7 @@ const STORES = {
gifts: "++id",
notifications: "++id",
followsFeed: "++id, created_at, kind",
followLists: "++pubkey",
};
export class SnortDB extends Dexie {
@ -56,6 +57,7 @@ export class SnortDB extends Dexie {
gifts!: Table<UnwrappedGift>;
notifications!: Table<NostrEventForSession>;
followsFeed!: Table<TaggedNostrEvent>;
followLists!: Table<TaggedNostrEvent>;
constructor() {
super(NAME);

View File

@ -448,7 +448,7 @@ export function NoteCreator() {
className="note-creator-icon"
link=""
showUsername={false}
showFollowingMark={false}
showFollowDistance={false}
/>
{note.pollOptions === undefined && !note.replyTo && (
<AsyncIcon

View File

@ -59,7 +59,7 @@ export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
{sender && (
<ProfileImage
pubkey={anonZap ? "" : sender}
showFollowingMark={false}
showFollowDistance={false}
overrideUsername={anonZap ? formatMessage({ defaultMessage: "Anonymous" }) : undefined}
/>
)}

View File

@ -36,7 +36,7 @@ export function TimelineRenderer(props: TimelineRendererProps) {
<>
<div className="card latest-notes" onClick={() => props.showLatest(false)} ref={ref}>
{props.latest.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} showFollowingMark={false} />;
return <ProfileImage pubkey={p} showUsername={false} link={""} showFollowDistance={false} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
@ -49,7 +49,7 @@ export function TimelineRenderer(props: TimelineRendererProps) {
className="card latest-notes latest-notes-fixed pointer fade-in"
onClick={() => props.showLatest(true)}>
{props.latest.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} showFollowingMark={false} />;
return <ProfileImage pubkey={p} showUsername={false} link={""} showFollowDistance={false} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"

View File

@ -10,6 +10,34 @@ export interface ModalProps {
children: ReactNode;
}
let scrollbarWidth: number | null = null;
const getScrollbarWidth = () => {
if (scrollbarWidth !== null) {
return scrollbarWidth;
}
const outer = document.createElement("div");
outer.style.visibility = "hidden";
outer.style.width = "100px";
document.body.appendChild(outer);
const widthNoScroll = outer.offsetWidth;
outer.style.overflow = "scroll";
const inner = document.createElement("div");
inner.style.width = "100%";
outer.appendChild(inner);
const widthWithScroll = inner.offsetWidth;
outer.parentNode?.removeChild(outer);
scrollbarWidth = widthNoScroll - widthWithScroll;
return scrollbarWidth;
};
export default function Modal(props: ModalProps) {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && props.onClose) {
@ -19,10 +47,13 @@ export default function Modal(props: ModalProps) {
useEffect(() => {
document.body.classList.add("scroll-lock");
document.body.style.paddingRight = `${getScrollbarWidth()}px`;
document.addEventListener("keydown", handleKeyDown);
return () => {
document.body.classList.remove("scroll-lock");
document.body.style.paddingRight = "";
document.removeEventListener("keydown", handleKeyDown);
};
}, []);

View File

@ -16,6 +16,7 @@ export default function SearchBox() {
const { formatMessage } = useIntl();
const [search, setSearch] = useState("");
const [searching, setSearching] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const navigate = useNavigate();
const location = useLocation();
@ -120,13 +121,15 @@ export default function SearchBox() {
value={search}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setTimeout(() => setIsFocused(false), 150)}
/>
{searching ? (
<Spinner width={24} height={24} />
) : (
<Icon name="search" size={24} onClick={() => navigate("/search")} />
)}
{search && !searching && (
{search && !searching && isFocused && (
<div
className="absolute top-full mt-2 w-full border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-black shadow-lg rounded-lg z-10 overflow-hidden"
ref={resultListRef}>

View File

@ -166,7 +166,7 @@ export default function SendSats(props: SendSatsProps) {
<ProfileImage
pubkey={v.value}
showUsername={false}
showFollowingMark={false}
showFollowDistance={false}
imageOverlay={formatShort(Math.floor((amount?.amount ?? 0) * (v.weight / total)))}
/>
))}

View File

@ -8,6 +8,7 @@ import ProfileImage from "./ProfileImage";
import { UserWebsiteLink } from "./UserWebsiteLink";
import Text from "Element/Text";
import { useEffect, useState } from "react";
import useLogin from "../../Hooks/useLogin";
interface RectElement {
getBoundingClientRect(): {
@ -35,6 +36,7 @@ export function ProfileCard({
}) {
const [showProfileMenu, setShowProfileMenu] = useState(false);
const [t, setT] = useState<ReturnType<typeof setTimeout>>();
const { publicKey: myPublicKey } = useLogin(s => ({ publicKey: s.publicKey }));
useEffect(() => {
if (show) {
@ -60,14 +62,14 @@ export function ProfileCard({
align="end">
<div className="flex flex-col g8">
<div className="flex justify-between">
<ProfileImage pubkey={""} profile={user} showProfileCard={false} link="" />
<ProfileImage pubkey={pubkey} profile={user} showProfileCard={false} link="" />
<div className="flex g8">
{/*<button type="button" onClick={() => {
LoginStore.loginWithPubkey(pubkey, LoginSessionType.PublicKey, undefined, undefined, undefined, true);
}}>
<FormattedMessage defaultMessage="Stalk" />
</button>*/}
<FollowButton pubkey={pubkey} />
{myPublicKey !== pubkey && <FollowButton pubkey={pubkey} />}
</div>
</div>
<Text

View File

@ -8,11 +8,11 @@ import classNames from "classnames";
import Avatar from "Element/User/Avatar";
import Nip05 from "Element/User/Nip05";
import useLogin from "Hooks/useLogin";
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;
@ -27,7 +27,7 @@ export interface ProfileImageProps {
size?: number;
onClick?: (e: React.MouseEvent) => void;
imageOverlay?: ReactNode;
showFollowingMark?: boolean;
showFollowDistance?: boolean;
icons?: ReactNode;
showProfileCard?: boolean;
}
@ -45,14 +45,13 @@ export default function ProfileImage({
size,
imageOverlay,
onClick,
showFollowingMark = true,
showFollowDistance = true,
icons,
showProfileCard,
}: ProfileImageProps) {
const user = useUserProfile(profile ? "" : pubkey) ?? profile;
const nip05 = defaultNip ? defaultNip : user?.nip05;
const { follows } = useLogin();
const doesFollow = follows.item.includes(pubkey);
const followDistance = SocialGraph.getFollowDistance(pubkey);
const [ref, hovering] = useHover<HTMLDivElement>();
function handleClick(e: React.MouseEvent) {
@ -63,6 +62,12 @@ export default function ProfileImage({
}
function inner() {
let followDistanceColor = "";
if (followDistance <= 1) {
followDistanceColor = "success";
} else if (followDistance === 2 && SocialGraph.followedByFriendsCount(pubkey) >= 10) {
followDistanceColor = "text-nostr-orange";
}
return (
<>
<div className="avatar-wrapper" ref={ref}>
@ -72,12 +77,12 @@ export default function ProfileImage({
size={size}
imageOverlay={imageOverlay}
icons={
(doesFollow && showFollowingMark) || icons ? (
(followDistance <= 2 && showFollowDistance) || icons ? (
<>
{icons}
{showFollowingMark && (
{showFollowDistance && (
<div className="icon-circle">
<Icon name="check" className="success" size={10} />
<Icon name="check" className={followDistanceColor} size={10} />
</div>
)}
</>

View File

@ -22,7 +22,7 @@ import {
import { SnortPubKey } from "Const";
import { SubscriptionEvent } from "Subscription";
import useRelaysFeedFollows from "./RelaysFeedFollows";
import { FollowsFeed, GiftsCache, Notifications, UserRelays } from "Cache";
import { FollowLists, FollowsFeed, GiftsCache, Notifications, UserRelays } from "Cache";
import { Nip28Chats, Nip4Chats } from "chat";
import { useRefreshFeedCache } from "Hooks/useRefreshFeedcache";
@ -38,6 +38,7 @@ export default function useLoginFeed() {
useRefreshFeedCache(Notifications, true);
useRefreshFeedCache(FollowsFeed, true);
useRefreshFeedCache(GiftsCache, true);
useRefreshFeedCache(FollowLists, false);
useEffect(() => {
system.checkSigs = login.appData.item.preferences.checkSigs;

View File

@ -11,8 +11,14 @@ export interface ImgProxySettings {
export default function useImgProxy() {
const settings = useLogin(s => s.appData.item.preferences.imgProxyConfig);
const te = new TextEncoder();
return {
proxy: (url: string, resize?: number) => proxyImg(url, settings, resize),
};
}
export function proxyImg(url: string, settings?: ImgProxySettings, resize?: number) {
const te = new TextEncoder();
function urlSafe(s: string) {
return s.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
@ -25,17 +31,12 @@ export default function useImgProxy() {
);
return urlSafe(base64.encode(result));
}
return {
proxy: (url: string, resize?: number) => {
if (!settings) return url;
if (url.startsWith("data:") || url.startsWith("blob:")) return url;
const opt = resize ? `rs:fit:${resize}:${resize}/dpr:${window.devicePixelRatio}` : "";
const urlBytes = te.encode(url);
const urlEncoded = urlSafe(base64.encode(urlBytes));
const path = `/${opt}/${urlEncoded}`;
const sig = signUrl(path);
return `${new URL(settings.url).toString()}${sig}${path}`;
},
};
if (!settings) return url;
if (url.startsWith("data:") || url.startsWith("blob:")) return url;
const opt = resize ? `rs:fit:${resize}:${resize}/dpr:${window.devicePixelRatio}` : "";
const urlBytes = te.encode(url);
const urlEncoded = urlSafe(base64.encode(urlBytes));
const path = `/${opt}/${urlEncoded}`;
const sig = signUrl(path);
return `${new URL(settings.url).toString()}${sig}${path}`;
}

View File

@ -15,9 +15,7 @@ export function useLinkList(id: string, fn: (rb: RequestBuilder) => void) {
const listStore = useRequestBuilder(NoteCollection, sub);
return useMemo(() => {
if (listStore.data && listStore.data.length > 0) {
return listStore.data
.map(e => NostrLink.fromTags(e.tags))
.flat();
return listStore.data.map(e => NostrLink.fromTags(e.tags)).flat();
}
return [];
}, [listStore.data]);

View File

@ -50,7 +50,7 @@ export interface UserPreferences {
/**
* Use imgproxy to optimize images
*/
imgProxyConfig: ImgProxySettings | null;
imgProxyConfig?: ImgProxySettings;
/**
* Default page to select on load

View File

@ -65,7 +65,7 @@ export function HashTagHeader({ tag }: { tag: string }) {
<h2>#{tag}</h2>
<div className="flex">
{pubkeys.slice(0, 5).map(a => (
<ProfileImage pubkey={a} showUsername={false} link={""} showFollowingMark={false} size={40} />
<ProfileImage pubkey={a} showUsername={false} link={""} showFollowDistance={false} size={40} />
))}
{pubkeys.length > 5 && (
<span>

View File

@ -0,0 +1,264 @@
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 { SnortContext } from "@snort/system-react";
import * as THREE from "three";
import { defaultAvatar } from "../SnortUtils";
import { proxyImg } from "Hooks/useImgProxy";
import { LoginStore } from "Login";
interface GraphNode {
id: UID;
profile?: MetadataCache;
distance: number;
val: number;
inboundCount: number;
outboundCount: number;
color?: string;
visible: boolean;
// curvature?: number;
}
interface GraphLink {
source: UID;
target: UID;
distance: number;
}
interface GraphMetadata {
// usersByFollowDistance?: Map<number, Set<UID>>;
userCountByDistance: number[];
nodes?: Map<number, GraphNode>;
}
interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
meta?: GraphMetadata;
}
enum Direction {
INBOUND,
OUTBOUND,
BOTH,
}
const avatar = (node: NodeObject<NodeObject<GraphNode>>) => {
const login = LoginStore.snapshot();
return node.profile?.picture
? proxyImg(node.profile?.picture, login.appData.item.preferences.imgProxyConfig)
: defaultAvatar(node.address);
};
const NODE_LIMIT = 500;
interface GraphConfig {
direction: Direction;
renderLimit: number | null;
showDistance: number;
}
const NetworkGraph = () => {
const [graphData, setGraphData] = useState(null as GraphData | null);
const [graphConfig, setGraphConfig] = useState({
direction: Direction.OUTBOUND,
renderLimit: NODE_LIMIT,
showDistance: 2,
});
const [open, setOpen] = useState(false);
const system = useContext(SnortContext);
// const [showDistance, setShowDistance] = useState(2);
// const [direction, setDirection] = useState(Direction.OUTBOUND);
// const [renderLimit, setRenderLimit] = useState(NODE_LIMIT);
const updateConfig = async (changes: Partial<GraphConfig>) => {
setGraphConfig(old => {
const newConfig = Object.assign({}, old, changes);
updateGraph(newConfig).then(graph => setGraphData(graph));
return newConfig;
});
};
const toggleConnections = () => {
if (graphConfig.direction === Direction.OUTBOUND) {
updateConfig({ direction: Direction.BOTH });
} else {
updateConfig({ direction: Direction.OUTBOUND });
}
};
const updateGraph = async (newConfig?: GraphConfig) => {
const { direction, renderLimit, showDistance } = newConfig ?? graphConfig;
const nodes = new Map<number, GraphNode>();
const links: GraphLink[] = [];
const nodesVisited = new Set<UID>();
const userCountByDistance = Array.from(
{ length: 6 },
(_, i) => SocialGraph.usersByFollowDistance.get(i)?.size || 0,
);
// Go through all the nodes
for (let distance = 0; distance <= showDistance; ++distance) {
const users = SocialGraph.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 pubkey = STR(UID);
const node = {
id: UID,
address: pubkey,
profile: system.ProfileLoader.Cache.getFromCache(pubkey),
distance,
inboundCount,
outboundCount,
visible: true, // Setting to false only hides the rendered element, does not prevent calculations
// curvature: 0.6,
// Node size is based on the follower count
val: Math.log10(inboundCount) + 1, // 1 followers -> 1, 10 followers -> 2, 100 followers -> 3, etc.,
} as GraphNode;
// A visibility boost for the origin user:
if (node.distance === 0) {
node.val = 10; // they're always larger than life
node.color = "#603285";
}
nodes.set(UID, node);
}
}
// Add links
for (const node of nodes.values()) {
if (direction === Direction.OUTBOUND || direction === Direction.BOTH) {
for (const followedID of SocialGraph.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({
source: node.id,
target: followedID,
distance: node.distance,
});
}
}
// TODO: Fix filtering
/* if (direction === Direction.INBOUND || direction === Direction.BOTH) {
for (const followerID of SocialGraph.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
links.push({
source: followerID,
target: node.id,
distance: follower.distance,
});
}
}*/
nodesVisited.add(node.id);
}
// Squash cases, where there are a lot of nodes
const graph: GraphData = {
nodes: [...nodes.values()],
links,
meta: {
nodes,
userCountByDistance,
},
};
// console.log('!!', graph);
// for (const l of links) {
// if (!nodes.has(l.source)) {
// console.log('source missing:', l.source);
// }
// if (!nodes.has(l.target)) {
// console.log('target missing:', l.target);
// }
// }
return graph;
};
const refreshData = async () => {
updateGraph().then(setGraphData);
};
useEffect(() => {
refreshData();
}, []);
return (
<div>
{!open && (
<button
className="btn btn-primary"
onClick={() => {
setOpen(true);
refreshData();
}}>
Show graph
</button>
)}
{open && graphData && (
<div className="fixed top-0 left-0 right-0 bottom-0 z-20">
<button className="absolute top-6 right-6 z-30 btn hover:bg-gray-900" onClick={() => setOpen(false)}>
X
</button>
<div className="absolute top-6 right-0 left-0 z-20 flex flex-col content-center justify-center text-center">
<div className="text-center pb-2">Degrees of separation</div>
<div className="flex flex-row justify-center space-x-4">
{graphData.meta?.userCountByDistance?.map((value, i) => {
if (i === 0 || value <= 0) return null;
const isSelected = graphConfig.showDistance === i;
return (
<button
key={i}
className={`btn bg-gray-900 py-4 h-auto flex-col ${
isSelected ? "bg-gray-600 hover:bg-gray-600" : "hover:bg-gray-800"
}`}
onClick={() => isSelected || updateConfig({ showDistance: i })}>
<div className="text-lg block leading-none">{i}</div>
<div className="text-xs">({value})</div>
</button>
);
})}
</div>
</div>
<ForceGraph3D
graphData={graphData}
nodeThreeObject={(node: NodeObject<NodeObject<GraphNode>>) => {
const imgTexture = new THREE.TextureLoader().load(avatar(node));
imgTexture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({ map: imgTexture });
const sprite = new THREE.Sprite(material);
sprite.scale.set(12, 12, 1);
return sprite;
}}
nodeLabel={node => `${node.profile?.name || node.address}`}
nodeAutoColorBy="distance"
linkAutoColorBy="distance"
linkDirectionalParticles={1}
nodeVisibility="visible"
numDimensions={3}
linkDirectionalArrowLength={0}
nodeOpacity={0.9}
/>
<div className="absolute bottom-6 right-6">
<button className="text-lg" onClick={() => toggleConnections()}>
Showing: {graphConfig.direction === Direction.OUTBOUND ? "Outbound" : "All"} connections
</button>
</div>
<div className="absolute bottom-6 left-6">
<span className="text-lg">Render limit: {graphConfig.renderLimit} nodes</span>
</div>
</div>
)}
</div>
);
};
export default NetworkGraph;

View File

@ -62,13 +62,7 @@ export function RelaysTab({ id }: { id: HexKey }) {
export function BookMarksTab({ id }: { id: HexKey }) {
const bookmarks = useCategorizedBookmarks(id, "bookmark");
const reactions = useReactions(`bookmark:reactions:{id}`, bookmarks.map(NostrLink.fromEvent));
return (
<Bookmarks
pubkey={id}
bookmarks={bookmarks}
related={reactions.data ?? []}
/>
);
return <Bookmarks pubkey={id} bookmarks={bookmarks} related={reactions.data ?? []} />;
}
const ProfileTab = {

View File

@ -299,7 +299,7 @@ const PreferencesPage = () => {
onChange={e =>
updatePreferences(id, {
...perf,
imgProxyConfig: e.target.checked ? DefaultImgProxy : null,
imgProxyConfig: e.target.checked ? DefaultImgProxy : undefined,
})
}
/>

View File

@ -104,6 +104,11 @@ const SettingsIndex = () => {
<FormattedMessage defaultMessage="Cache" />
<Icon name="arrowFront" size={16} />
</div>
<div className="settings-row" onClick={() => navigate("/graph")}>
<Icon name="profile" size={24} />
<FormattedMessage {...messages.SocialGraph} />
<Icon name="arrowFront" size={16} />
</div>
<div className="settings-row" onClick={handleLogout}>
<Icon name="logout" size={24} />
<FormattedMessage {...messages.LogOut} />

View File

@ -60,4 +60,5 @@ export default defineMessages({
Nip05: { defaultMessage: "NIP-05" },
ReactionEmoji: { defaultMessage: "Reaction emoji" },
ReactionEmojiHelp: { defaultMessage: "Emoji to send when reactiong to a note" },
SocialGraph: { defaultMessage: "Social Graph" },
});

View File

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

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

View File

@ -23,6 +23,10 @@ import {
import { SnortContext } from "@snort/system-react";
import { removeUndefined, throwIfOffline } from "@snort/shared";
import React, { lazy, Suspense } from "react";
const NetworkGraph = lazy(() => import("Pages/NetworkGraph"));
import * as serviceWorkerRegistration from "serviceWorkerRegistration";
import { IntlProvider } from "IntlProvider";
import { getCountry, unwrap } from "SnortUtils";
@ -224,6 +228,10 @@ const mainRoutes = [
path: "/about",
element: <AboutPage />,
},
{
path: "/graph",
element: <NetworkGraph />,
},
...OnboardingRoutes,
...WalletRoutes,
] as Array<RouteObject>;
@ -282,7 +290,9 @@ root.render(
<StrictMode>
<IntlProvider>
<SnortContext.Provider value={System}>
<RouterProvider router={router} />
<Suspense fallback={<div>Loading...</div>}>
<RouterProvider router={router} />
</Suspense>
</SnortContext.Provider>
</IntlProvider>
</StrictMode>,

View File

@ -368,6 +368,9 @@
"Cu/K85": {
"defaultMessage": "Translated from {lang}"
},
"CzHZoc": {
"defaultMessage": "Social Graph"
},
"D+KzKd": {
"defaultMessage": "Automatically zap every note when loaded"
},

View File

@ -121,6 +121,7 @@
"CmZ9ls": "{n} Muted",
"CsCUYo": "{n} sats",
"Cu/K85": "Translated from {lang}",
"CzHZoc": "Social Graph",
"D+KzKd": "Automatically zap every note when loaded",
"D3idYv": "Settings",
"DBiVK1": "Cache",