add mobile profile button

change database
This commit is contained in:
hzrd149 2023-02-07 17:04:19 -06:00
parent 7c30160b38
commit 2450ecd0d4
15 changed files with 287 additions and 114 deletions

View File

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

View File

@ -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 (
<Flex justifyContent="space-between" px="2" pt="2">
<Menu>
<MenuButton as={Box}>
<UserAvatar pubkey={pubkey} size="sm" />
</MenuButton>
<MenuList>
<MenuItem icon={<ProfileIcon />} as={Link} to={`/u/${pubkey}`}>
Profile
</MenuItem>
<MenuItem icon={<LogoutIcon />} onClick={() => identity.logout()}>
Logout
</MenuItem>
</MenuList>
</Menu>
<IconButton
variant="ghost"
icon={<NotificationIcon />}
aria-label="Notifications"
title="Notifications"
size="sm"
/>
</Flex>
);
};
const MobileBottomNav = () => {
const navigate = useNavigate();
return (
<Flex flexShrink={0} gap="2" padding="2">
<IconButton icon={<FeedIcon />} aria-label="Home" onClick={() => navigate("/")} flexGrow="1" size="lg" />
<IconButton
icon={<ProfileIcon />}
aria-label="Profile"
onClick={() => navigate(`/profile`)}
flexGrow="1"
size="lg"
/>
<IconButton
icon={<SettingsIcon />}
aria-label="Settings"
onClick={() => navigate("/settings")}
flexGrow="1"
size="lg"
/>
</Flex>
);
};
const DesktopSideNav = () => {
const navigate = useNavigate();
const isMobile = useIsMobile();
const readonly = useReadonlyMode();
if (isMobile) {
return (
<Flex direction="column" height="100%">
<ReloadPrompt />
<Flex flexGrow={1} direction="column" overflow="hidden">
<ErrorBoundary>
<PostModalProvider>{children}</PostModalProvider>
</ErrorBoundary>
</Flex>
<Flex flexShrink={0} gap="2" padding="2">
<IconButton icon={<FeedIcon />} aria-label="Home" onClick={() => navigate("/")} flexGrow="1" size="lg" />
<IconButton
icon={<ProfileIcon />}
aria-label="Profile"
onClick={() => navigate(`/profile`)}
flexGrow="1"
size="lg"
/>
<IconButton
icon={<SettingsIcon />}
aria-label="Settings"
onClick={() => navigate("/settings")}
flexGrow="1"
size="lg"
/>
</Flex>
return (
<VStack width="15rem" pt="2" alignItems="stretch" flexShrink={0}>
<Flex gap="2" alignItems="center" position="relative">
<LinkOverlay as={Link} to="/" />
<Avatar src="/apple-touch-icon.png" size="sm" />
<Heading size="md">noStrudel</Heading>
</Flex>
);
}
<ProfileButton />
<Button onClick={() => navigate("/")} leftIcon={<FeedIcon />}>
Home
</Button>
<Button onClick={() => navigate("/settings")} leftIcon={<SettingsIcon />}>
Settings
</Button>
<Button onClick={() => identity.logout()} leftIcon={<LogoutIcon />}>
Logout
</Button>
{readonly && (
<Text color="yellow.500" textAlign="center">
Readonly Mode
</Text>
)}
<ConnectedRelays />
</VStack>
);
};
const FollowingSideNav = () => {
return (
<VStack width="15rem" pt="2" alignItems="stretch" flexShrink={0}>
<Heading size="md">Following</Heading>
<FollowingList />
</VStack>
);
};
export const Page = ({ children }: { children: React.ReactNode }) => {
const isMobile = useIsMobile();
return (
<Container
size="lg"
display="flex"
gap="2"
flexDirection="column"
height="100vh"
overflow="hidden"
position="relative"
padding="0"
>
<ReloadPrompt />
{isMobile && <MobileProfileHeader />}
<Flex gap="4" grow={1} overflow="hidden">
<VStack width="15rem" pt="2" alignItems="stretch" flexShrink={0}>
<Flex gap="2" alignItems="center" position="relative">
<LinkOverlay as={Link} to="/" />
<Avatar src="/apple-touch-icon.png" size="sm" />
<Heading size="md">noStrudel</Heading>
</Flex>
<ProfileButton />
<Button onClick={() => navigate("/")} leftIcon={<FeedIcon />}>
Home
</Button>
<Button onClick={() => navigate("/settings")} leftIcon={<SettingsIcon />}>
Settings
</Button>
<Button onClick={() => identity.logout()} leftIcon={<LogoutIcon />}>
Logout
</Button>
{readonly && (
<Text color="yellow.500" textAlign="center">
Readonly Mode
</Text>
)}
<ConnectedRelays />
</VStack>
{!isMobile && <DesktopSideNav />}
<Flex flexGrow={1} direction="column" overflow="hidden">
<ErrorBoundary>
<PostModalProvider>{children}</PostModalProvider>
</ErrorBoundary>
</Flex>
<VStack width="15rem" pt="2" alignItems="stretch" flexShrink={0}>
<Heading size="md">Following</Heading>
<FollowingList />
</VStack>
{!isMobile && <FollowingSideNav />}
</Flex>
{isMobile && <MobileBottomNav />}
</Container>
);
};

View File

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

View File

@ -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<CustomSchema>("storage", version, {
const db = await openDB<CustomSchema>(dbName, version, {
upgrade(db, oldVersion, newVersion, transaction, event) {
// TODO: why is newVersion sometimes null?
// @ts-ignore
@ -47,10 +50,16 @@ const db = await openDB<CustomSchema>("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();
}

View File

@ -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<string, { read: boolean; write: boolean }>;
contacts: string[];
// contacts: {
// pubkey: string;
// relay?: string;
// }[];
contactRelay: Record<string, string | undefined>;
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<string, number>; updated: number };
indexes: { pubkey: string };
};
settings: {
key: string;
value: any;

View File

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

View File

@ -0,0 +1,70 @@
import moment from "moment";
import db from "./db";
import { UserContacts } from "./user-contacts";
const changed = new Set();
const cache: Record<string, Record<string, number> | 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;

View File

@ -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<UserContacts>();
@ -15,24 +16,25 @@ export type UserContacts = {
pubkey: string;
relays: Record<string, { read: boolean; write: boolean }>;
contacts: string[];
// contacts: {
// pubkey: string;
// relay?: string;
// }[];
contactRelay: Record<string, string | undefined>;
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<string, string>);
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);
}
});
}

View File

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

View File

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

View File

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

View File

@ -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 (
<Flex direction="column" pt="2" pb="2" overflow="auto">
<Accordion defaultIndex={[0]} allowMultiple>
@ -190,9 +199,14 @@ export const SettingsView = () => {
</AccordionButton>
</h2>
<AccordionPanel>
<Button colorScheme="red" onClick={handleClearData} isLoading={clearing} isDisabled={clearing}>
Remove All Data
</Button>
<ButtonGroup>
<Button onClick={handleClearData} isLoading={clearing} isDisabled={clearing}>
Clear cache data
</Button>
<Button colorScheme="red" onClick={handleDeleteDatabase} isLoading={deleting} isDisabled={deleting}>
Delete database
</Button>
</ButtonGroup>
</AccordionPanel>
</AccordionItem>
</Accordion>

View File

@ -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 (
<Box borderWidth="1px" borderRadius="lg" pl="3" pr="3" pt="2" pb="2" overflow="hidden">
<Flex gap="4" alignItems="center">
<UserAvatar pubkey={pubkey} />
<Link as={ReactRouterLink} to={`/u/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}>
<Heading size="sm">{getUserDisplayName(metadata, pubkey)}</Heading>
</Link>
<Box>
<Link as={ReactRouterLink} to={`/u/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}>
<Heading size="sm">{getUserDisplayName(metadata, pubkey)}</Heading>
</Link>
{relay && <Text>{relay}</Text>}
</Box>
<IconButton size="sm" icon={<AddIcon />} aria-label="Follow user" title="Follow" ml="auto" />
</Flex>
</Box>

View File

@ -19,7 +19,7 @@ const UserFollowingTab = () => {
<>
<Grid templateColumns={{ base: "1fr", xl: "repeat(2, 1fr)", "2xl": "repeat(3, 1fr)" }} gap="2">
{pagination.pageItems.map((pubkey, i) => (
<UserCard key={pubkey + i} pubkey={pubkey} />
<UserCard key={pubkey + i} pubkey={pubkey} relay={contacts.contactRelay[pubkey]} />
))}
</Grid>
<PaginationControls {...pagination} buttonSize="sm" />

View File

@ -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 = () => {
<UserAvatar pubkey={pubkey} size={isMobile ? "md" : "xl"} />
<Flex direction="column" gap={isMobile ? 0 : 2} grow="1" overflow="hidden">
<Flex gap="2" justifyContent="space-between" width="100%">
<Flex gap="2" alignItems="center">
<Flex gap="2" alignItems="center" wrap="wrap">
<Heading size={isMobile ? "md" : "lg"}>{getUserDisplayName(metadata, pubkey)}</Heading>
<UserDnsIdentityIcon pubkey={pubkey} />
</Flex>
@ -101,7 +101,7 @@ const UserView = () => {
index={activeTab}
onChange={(v) => navigate(tabs[v].path)}
>
<TabList overflow={isMobile ? "auto" : undefined}>
<TabList overflowY="auto">
{tabs.map(({ label }) => (
<Tab key={label}>{label}</Tab>
))}