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 ( + + + + + + + } as={Link} to={`/u/${pubkey}`}> + Profile + + } onClick={() => identity.logout()}> + Logout + + + + } + 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} ))}