diff --git a/src/components/icons.tsx b/src/components/icons.tsx
index 8c094d930..742d14b7a 100644
--- a/src/components/icons.tsx
+++ b/src/components/icons.tsx
@@ -169,3 +169,9 @@ export const CheckIcon = createIcon({
d: "M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414z",
defaultProps,
});
+
+export const NotificationIcon = createIcon({
+ displayName: "NotificationIcon",
+ d: "M5 18h14v-6.969C19 7.148 15.866 4 12 4s-7 3.148-7 7.031V18zm7-16c4.97 0 9 4.043 9 9.031V20H3v-8.969C3 6.043 7.03 2 12 2zM9.5 21h5a2.5 2.5 0 1 1-5 0z",
+ defaultProps,
+});
diff --git a/src/components/page.tsx b/src/components/page.tsx
index 7d0657aa9..85aa7d716 100644
--- a/src/components/page.tsx
+++ b/src/components/page.tsx
@@ -1,7 +1,26 @@
import React from "react";
-import { Avatar, Button, Container, Flex, Heading, IconButton, LinkOverlay, Text, VStack } from "@chakra-ui/react";
+import {
+ Avatar,
+ Button,
+ Container,
+ Flex,
+ Heading,
+ IconButton,
+ LinkOverlay,
+ Text,
+ VStack,
+ Menu,
+ MenuButton,
+ MenuList,
+ MenuItem,
+ MenuItemOption,
+ MenuGroup,
+ MenuOptionGroup,
+ MenuDivider,
+ Box,
+} from "@chakra-ui/react";
import { Link, useNavigate } from "react-router-dom";
-import { FeedIcon, LogoutIcon, ProfileIcon, SettingsIcon } from "./icons";
+import { FeedIcon, LogoutIcon, NotificationIcon, ProfileIcon, SettingsIcon } from "./icons";
import { ErrorBoundary } from "./error-boundary";
import { ConnectedRelays } from "./connected-relays";
@@ -12,87 +31,128 @@ import { ReloadPrompt } from "./reload-prompt";
import { PostModalProvider } from "../providers/post-modal-provider";
import { useReadonlyMode } from "../hooks/use-readonly-mode";
import { ProfileButton } from "./profile-button";
+import { UserAvatar } from "./user-avatar";
+import useSubject from "../hooks/use-subject";
-export const Page = ({ children }: { children: React.ReactNode }) => {
+const MobileProfileHeader = () => {
+ const navigate = useNavigate();
+ const pubkey = useSubject(identity.pubkey);
+
+ return (
+
+
+ }
+ aria-label="Notifications"
+ title="Notifications"
+ size="sm"
+ />
+
+ );
+};
+
+const MobileBottomNav = () => {
+ const navigate = useNavigate();
+
+ return (
+
+ } aria-label="Home" onClick={() => navigate("/")} flexGrow="1" size="lg" />
+ }
+ aria-label="Profile"
+ onClick={() => navigate(`/profile`)}
+ flexGrow="1"
+ size="lg"
+ />
+ }
+ aria-label="Settings"
+ onClick={() => navigate("/settings")}
+ flexGrow="1"
+ size="lg"
+ />
+
+ );
+};
+
+const DesktopSideNav = () => {
const navigate = useNavigate();
- const isMobile = useIsMobile();
const readonly = useReadonlyMode();
- if (isMobile) {
- return (
-
-
-
-
- {children}
-
-
-
- } aria-label="Home" onClick={() => navigate("/")} flexGrow="1" size="lg" />
- }
- aria-label="Profile"
- onClick={() => navigate(`/profile`)}
- flexGrow="1"
- size="lg"
- />
- }
- aria-label="Settings"
- onClick={() => navigate("/settings")}
- flexGrow="1"
- size="lg"
- />
-
+ return (
+
+
+
+
+ noStrudel
- );
- }
+
+
+
+
+ {readonly && (
+
+ Readonly Mode
+
+ )}
+
+
+ );
+};
+
+const FollowingSideNav = () => {
+ return (
+
+ Following
+
+
+ );
+};
+
+export const Page = ({ children }: { children: React.ReactNode }) => {
+ const isMobile = useIsMobile();
return (
+ {isMobile && }
-
-
-
-
- noStrudel
-
-
-
-
-
- {readonly && (
-
- Readonly Mode
-
- )}
-
-
+ {!isMobile && }
{children}
-
- Following
-
-
+ {!isMobile && }
+ {isMobile && }
);
};
diff --git a/src/index.tsx b/src/index.tsx
index ec8db3a02..ed2d35a17 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -3,6 +3,8 @@ import { createRoot } from "react-dom/client";
import { App } from "./app";
import { Providers } from "./providers";
+import "./services/pubkey-relay-weights";
+
const element = document.getElementById("root");
if (!element) throw new Error("missing mount point");
const root = createRoot(element);
diff --git a/src/services/db/index.ts b/src/services/db/index.ts
index af73b625c..c4b8a875e 100644
--- a/src/services/db/index.ts
+++ b/src/services/db/index.ts
@@ -1,4 +1,4 @@
-import { openDB } from "idb";
+import { openDB, deleteDB } from "idb";
import { IDBPDatabase, IDBPTransaction, StoreNames } from "idb";
import { CustomSchema } from "./schema";
@@ -12,29 +12,32 @@ type MigrationFunction = (
const MIGRATIONS: MigrationFunction[] = [
// 0 -> 1
function (db, transaction, event) {
- const metadata = db.createObjectStore("user-metadata", {
+ const metadata = db.createObjectStore("userMetadata", {
keyPath: "pubkey",
});
- const contacts = db.createObjectStore("user-contacts", {
+ const contacts = db.createObjectStore("userContacts", {
keyPath: "pubkey",
});
contacts.createIndex("created_at", "created_at");
contacts.createIndex("contacts", "contacts", { multiEntry: true });
- const dnsIdentifiers = db.createObjectStore("dns-identifiers");
+ const dnsIdentifiers = db.createObjectStore("dnsIdentifiers");
dnsIdentifiers.createIndex("pubkey", "pubkey", { unique: false });
dnsIdentifiers.createIndex("name", "name", { unique: false });
dnsIdentifiers.createIndex("domain", "domain", { unique: false });
dnsIdentifiers.createIndex("updated", "updated", { unique: false });
+ const pubkeyRelayWeights = db.createObjectStore("pubkeyRelayWeights", { keyPath: "pubkey" });
+
// setup data
const settings = db.createObjectStore("settings");
},
];
+const dbName = "storage";
const version = 1;
-const db = await openDB("storage", version, {
+const db = await openDB(dbName, version, {
upgrade(db, oldVersion, newVersion, transaction, event) {
// TODO: why is newVersion sometimes null?
// @ts-ignore
@@ -47,10 +50,16 @@ const db = await openDB("storage", version, {
},
});
-export async function clearData() {
- await db.clear("user-metadata");
- await db.clear("user-contacts");
- await db.clear("dns-identifiers");
+export async function clearCacheData() {
+ await db.clear("userMetadata");
+ await db.clear("userContacts");
+ await db.clear("dnsIdentifiers");
+ await db.clear("pubkeyRelayWeights");
+ window.location.reload();
+}
+
+export async function deleteDatabase() {
+ await deleteDB(dbName);
window.location.reload();
}
diff --git a/src/services/db/schema.ts b/src/services/db/schema.ts
index cb9228060..c58ad0897 100644
--- a/src/services/db/schema.ts
+++ b/src/services/db/schema.ts
@@ -2,29 +2,31 @@ import { DBSchema } from "idb";
import { NostrEvent } from "../../types/nostr-event";
export interface CustomSchema extends DBSchema {
- "user-metadata": {
+ userMetadata: {
key: string;
value: NostrEvent;
};
- "user-contacts": {
+ userContacts: {
key: string;
value: {
pubkey: string;
relays: Record;
contacts: string[];
- // contacts: {
- // pubkey: string;
- // relay?: string;
- // }[];
+ contactRelay: Record;
created_at: number;
};
indexes: { created_at: number; contacts: string };
};
- "dns-identifiers": {
+ dnsIdentifiers: {
key: string;
value: { name: string; domain: string; pubkey: string; relays: string[]; updated: number };
indexes: { name: string; domain: string; pubkey: string; updated: number };
};
+ pubkeyRelayWeights: {
+ key: string;
+ value: { pubkey: string; relays: Record; updated: number };
+ indexes: { pubkey: string };
+ };
settings: {
key: string;
value: any;
diff --git a/src/services/dns-identity.ts b/src/services/dns-identity.ts
index 15c5d9785..7f89c97af 100644
--- a/src/services/dns-identity.ts
+++ b/src/services/dns-identity.ts
@@ -47,14 +47,14 @@ async function addToCache(domain: string, json: IdentityJson) {
for (const name of Object.keys(json.names)) {
const identity = getIdentityFromJson(name, domain, json);
if (identity) {
- wait.push(db.put("dns-identifiers", { ...identity, updated: now }, `${name}@${domain}`));
+ wait.push(db.put("dnsIdentifiers", { ...identity, updated: now }, `${name}@${domain}`));
}
}
await Promise.all(wait);
}
async function getIdentity(address: string) {
- const cached = await db.get("dns-identifiers", address);
+ const cached = await db.get("dnsIdentifiers", address);
if (cached) return cached;
// TODO: if it fails, maybe cache a failure message
@@ -63,13 +63,13 @@ async function getIdentity(address: string) {
async function pruneCache() {
const keys = await db.getAllKeysFromIndex(
- "dns-identifiers",
+ "dnsIdentifiers",
"updated",
IDBKeyRange.upperBound(moment().subtract(1, "hour").unix())
);
for (const pubkey of keys) {
- db.delete("dns-identifiers", pubkey);
+ db.delete("dnsIdentifiers", pubkey);
}
}
diff --git a/src/services/pubkey-relay-weights.ts b/src/services/pubkey-relay-weights.ts
new file mode 100644
index 000000000..5966c0f34
--- /dev/null
+++ b/src/services/pubkey-relay-weights.ts
@@ -0,0 +1,70 @@
+import moment from "moment";
+import db from "./db";
+import { UserContacts } from "./user-contacts";
+
+const changed = new Set();
+const cache: Record | undefined> = {};
+
+async function populateCacheFromDb(pubkey: string) {
+ if (!cache[pubkey]) {
+ cache[pubkey] = (await db.get("pubkeyRelayWeights", pubkey))?.relays;
+ }
+}
+
+async function addWeight(pubkey: string, relay: string, weight: number = 1) {
+ await populateCacheFromDb(pubkey);
+
+ const relays = cache[pubkey] || (cache[pubkey] = {});
+
+ if (relays[relay]) {
+ relays[relay] += weight;
+ } else {
+ relays[relay] = weight;
+ }
+ changed.add(pubkey);
+}
+
+async function saveCache() {
+ const now = moment().unix();
+ const transaction = db.transaction("pubkeyRelayWeights", "readwrite");
+
+ for (const [pubkey, relays] of Object.entries(cache)) {
+ if (changed.has(pubkey)) {
+ if (relays) transaction.store?.put({ pubkey, relays, updated: now });
+ }
+ }
+ changed.clear();
+}
+
+async function handleContactList(contacts: UserContacts) {
+ // save the relays for contacts
+ for (const [pubkey, relay] of Object.entries(contacts.contactRelay)) {
+ if (relay) await addWeight(pubkey, relay);
+ }
+
+ // save this pubkeys relays
+ for (const [relay, opts] of Object.entries(contacts.relays)) {
+ // only save relays this users writes to
+ if (opts.write) {
+ await addWeight(contacts.pubkey, relay);
+ }
+ }
+}
+
+async function getPubkeyRelays(pubkey: string) {
+ await populateCacheFromDb(pubkey);
+ return cache[pubkey] || {};
+}
+const pubkeyRelayWeightsService = {
+ handleContactList,
+ getPubkeyRelays,
+};
+
+setInterval(() => saveCache(), 1000);
+
+if (import.meta.env.DEV) {
+ // @ts-ignore
+ window.pubkeyRelayWeightsService = pubkeyRelayWeightsService;
+}
+
+export default pubkeyRelayWeightsService;
diff --git a/src/services/user-contacts.ts b/src/services/user-contacts.ts
index 80773bc26..92532d269 100644
--- a/src/services/user-contacts.ts
+++ b/src/services/user-contacts.ts
@@ -1,4 +1,4 @@
-import { NostrEvent } from "../types/nostr-event";
+import { isPTag, NostrEvent } from "../types/nostr-event";
import { NostrQuery } from "../types/nostr-query";
import { PubkeySubjectCache } from "../classes/pubkey-subject-cache";
import { NostrSubscription } from "../classes/nostr-subscription";
@@ -6,6 +6,7 @@ import { safeJson } from "../helpers/parse";
import db from "./db";
import settings from "./settings";
import userFollowersService from "./user-followers";
+import pubkeyRelayWeightsService from "./pubkey-relay-weights";
const subscription = new NostrSubscription([], undefined, "user-contacts");
const subjects = new PubkeySubjectCache();
@@ -15,24 +16,25 @@ export type UserContacts = {
pubkey: string;
relays: Record;
contacts: string[];
- // contacts: {
- // pubkey: string;
- // relay?: string;
- // }[];
+ contactRelay: Record;
created_at: number;
};
function parseContacts(event: NostrEvent): UserContacts {
- // const keys = event.tags
- // .filter((tag) => tag[0] === "p" && tag[1])
- // .map((tag) => ({ pubkey: tag[1] as string, relay: tag[2] }));
- const keys = event.tags.filter((tag) => tag[0] === "p" && tag[1]).map((tag) => tag[1]) as string[];
const relays = safeJson(event.content, {}) as UserContacts["relays"];
+ const pubkeys = event.tags.filter(isPTag).map((tag) => tag[1]);
+ const contactRelay = event.tags.filter(isPTag).reduce((dir, tag) => {
+ if (tag[2]) {
+ dir[tag[1]] = tag[2];
+ }
+ return dir;
+ }, {} as Record);
return {
pubkey: event.pubkey,
relays,
- contacts: keys,
+ contacts: pubkeys,
+ contactRelay,
created_at: event.created_at,
};
}
@@ -45,7 +47,7 @@ function requestContacts(pubkey: string, relays: string[] = [], alwaysRequest =
if (alwaysRequest) forceRequestedKeys.add(pubkey);
if (!subject.value) {
- db.get("user-contacts", pubkey).then((cached) => {
+ db.get("userContacts", pubkey).then((cached) => {
if (cached) subject.next(cached);
});
}
@@ -81,21 +83,26 @@ function flushRequests() {
function receiveEvent(event: NostrEvent) {
if (event.kind !== 3) return;
+ const parsed = parseContacts(event);
+
if (subjects.hasSubject(event.pubkey)) {
const subject = subjects.getSubject(event.pubkey);
const latest = subject.getValue();
// make sure the event is newer than whats in the subject
if (!latest || event.created_at > latest.created_at) {
- const parsed = parseContacts(event);
subject.next(parsed);
// send it to the db
- db.put("user-contacts", parsed);
+ db.put("userContacts", parsed);
+ // add it to the pubkey relay weights
+ pubkeyRelayWeightsService.handleContactList(parsed);
}
} else {
- db.get("user-contacts", event.pubkey).then((cached) => {
+ db.get("userContacts", event.pubkey).then((cached) => {
// make sure the event is newer than whats in the db
if (!cached || event.created_at > cached.created_at) {
- db.put("user-contacts", parseContacts(event));
+ db.put("userContacts", parsed);
+ // add it to the pubkey relay weights
+ pubkeyRelayWeightsService.handleContactList(parsed);
}
});
}
diff --git a/src/services/user-followers.ts b/src/services/user-followers.ts
index 1b248751b..e6277c8e9 100644
--- a/src/services/user-followers.ts
+++ b/src/services/user-followers.ts
@@ -28,7 +28,7 @@ function requestFollowers(pubkey: string, relays: string[] = [], alwaysRequest =
if (relays.length) subjects.addRelays(pubkey, relays);
- db.getAllKeysFromIndex("user-contacts", "contacts", pubkey).then((cached) => {
+ db.getAllKeysFromIndex("userContacts", "contacts", pubkey).then((cached) => {
mergeNext(subject, cached);
});
diff --git a/src/services/user-metadata.ts b/src/services/user-metadata.ts
index 33e82b4a1..6689c3e18 100644
--- a/src/services/user-metadata.ts
+++ b/src/services/user-metadata.ts
@@ -21,7 +21,7 @@ function requestMetadata(pubkey: string, relays: string[], alwaysRequest = false
}
if (!subject.value) {
- db.get("user-metadata", pubkey).then((cached) => {
+ db.get("userMetadata", pubkey).then((cached) => {
if (cached) {
const parsed = parseMetadata(cached);
if (parsed) subject.next(parsed);
@@ -70,7 +70,7 @@ subscription.onEvent.subscribe((event) => {
const parsed = parseMetadata(event);
if (parsed) {
subject.next(parsed);
- db.put("user-metadata", event);
+ db.put("userMetadata", event);
forceRequestedKeys.delete(event.pubkey);
}
}
diff --git a/src/types/nostr-event.ts b/src/types/nostr-event.ts
index e7ff41668..5b386909b 100644
--- a/src/types/nostr-event.ts
+++ b/src/types/nostr-event.ts
@@ -39,8 +39,8 @@ export type Kind0ParsedContent = {
};
export function isETag(tag: Tag): tag is ETag {
- return tag[0] === "e";
+ return tag[0] === "e" && tag[1] !== undefined;
}
export function isPTag(tag: Tag): tag is PTag {
- return tag[0] === "p";
+ return tag[0] === "p" && tag[1] !== undefined;
}
diff --git a/src/views/settings/index.tsx b/src/views/settings/index.tsx
index 9b9e6f34c..e18e3de5d 100644
--- a/src/views/settings/index.tsx
+++ b/src/views/settings/index.tsx
@@ -20,15 +20,17 @@ import {
AccordionButton,
Box,
AccordionIcon,
+ ButtonGroup,
} from "@chakra-ui/react";
import { SyntheticEvent, useState } from "react";
import { GlobalIcon, TrashIcon } from "../../components/icons";
import { RelayStatus } from "./relay-status";
import useSubject from "../../hooks/use-subject";
import settings from "../../services/settings";
-import { clearData } from "../../services/db";
+import { clearCacheData, deleteDatabase } from "../../services/db";
import { RelayUrlInput } from "../../components/relay-url-input";
import { useNavigate } from "react-router-dom";
+import { useAsyncFn } from "react-use";
export const SettingsView = () => {
const navigate = useNavigate();
@@ -54,10 +56,17 @@ export const SettingsView = () => {
const [clearing, setClearing] = useState(false);
const handleClearData = async () => {
setClearing(true);
- await clearData();
+ await clearCacheData();
setClearing(false);
};
+ const [deleting, setDeleting] = useState(false);
+ const handleDeleteDatabase = async () => {
+ setDeleting(true);
+ await deleteDatabase();
+ setDeleting(false);
+ };
+
return (
@@ -190,9 +199,14 @@ export const SettingsView = () => {
-
+
+
+
+
diff --git a/src/views/user/components/user-card.tsx b/src/views/user/components/user-card.tsx
index 9a760e939..afb861ac8 100644
--- a/src/views/user/components/user-card.tsx
+++ b/src/views/user/components/user-card.tsx
@@ -1,4 +1,4 @@
-import { Box, Flex, Heading, IconButton, Link } from "@chakra-ui/react";
+import { Box, Flex, Heading, IconButton, Link, Text } from "@chakra-ui/react";
import { Link as ReactRouterLink } from "react-router-dom";
import { useUserMetadata } from "../../../hooks/use-user-metadata";
@@ -7,16 +7,19 @@ import { AddIcon } from "../../../components/icons";
import { UserAvatar } from "../../../components/user-avatar";
import { Bech32Prefix, normalizeToBech32 } from "../../../helpers/nip-19";
-export const UserCard = ({ pubkey }: { pubkey: string }) => {
+export const UserCard = ({ pubkey, relay }: { pubkey: string; relay?: string }) => {
const metadata = useUserMetadata(pubkey);
return (
-
- {getUserDisplayName(metadata, pubkey)}
-
+
+
+ {getUserDisplayName(metadata, pubkey)}
+
+ {relay && {relay}}
+
} aria-label="Follow user" title="Follow" ml="auto" />
diff --git a/src/views/user/following.tsx b/src/views/user/following.tsx
index 475ef0466..1760a2748 100644
--- a/src/views/user/following.tsx
+++ b/src/views/user/following.tsx
@@ -19,7 +19,7 @@ const UserFollowingTab = () => {
<>
{pagination.pageItems.map((pubkey, i) => (
-
+
))}
diff --git a/src/views/user/index.tsx b/src/views/user/index.tsx
index f80a1695a..aaa4ee5c5 100644
--- a/src/views/user/index.tsx
+++ b/src/views/user/index.tsx
@@ -24,7 +24,7 @@ import { UserTipButton } from "../../components/user-tip-button";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity";
import { truncatedId } from "../../helpers/nostr-event";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip-19";
-import { ClipboardIcon, KeyIcon } from "../../components/icons";
+import { KeyIcon } from "../../components/icons";
import { CopyIconButton } from "../../components/copy-icon-button";
const tabs = [
@@ -54,7 +54,7 @@ const UserView = () => {
-
+
{getUserDisplayName(metadata, pubkey)}
@@ -101,7 +101,7 @@ const UserView = () => {
index={activeTab}
onChange={(v) => navigate(tabs[v].path)}
>
-
+
{tabs.map(({ label }) => (
{label}
))}