add notifications screen

This commit is contained in:
Ren Amamiya
2023-08-22 16:34:47 +07:00
parent 4830f0b236
commit 0912948b31
18 changed files with 375 additions and 301 deletions

View File

@@ -75,6 +75,13 @@ const router = createBrowserRouter([
return { Component: ChatScreen };
},
},
{
path: 'notifications',
async lazy() {
const { NotificationScreen } = await import('@app/notification');
return { Component: NotificationScreen };
},
},
],
},
{

View File

@@ -18,16 +18,19 @@ export function ImportStep3Screen() {
const [loading, setLoading] = useState(false);
const { db } = useStorage();
const { fetchUserData } = useNostr();
const { fetchUserData, prefetchEvents } = useNostr();
const submit = async () => {
try {
// show loading indicator
setLoading(true);
const data = await fetchUserData();
// prefetch data
const user = await fetchUserData();
const data = await prefetchEvents();
if (data.status === 'ok') {
// redirect to next step
if (user.status === 'ok' && data.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true });
} else {
console.log('error: ', data.message);

View File

@@ -0,0 +1,27 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Hashtag, MentionUser } from '@shared/notes';
import { RichContent } from '@utils/types';
export function NotiContent({ content }: { content: RichContent }) {
return (
<>
<ReactMarkdown
className="markdown"
remarkPlugins={[remarkGfm]}
components={{
del: ({ children }) => {
const key = children[0] as string;
if (key.startsWith('pub') && key.length > 50 && key.length < 100)
return <MentionUser pubkey={key.replace('pub-', '')} />;
if (key.startsWith('tag')) return <Hashtag tag={key.replace('tag-', '')} />;
},
}}
>
{content?.parsed}
</ReactMarkdown>
</>
);
}

View File

@@ -1,14 +1,13 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useMemo } from 'react';
import { MentionNote, NoteContent } from '@shared/notes';
import { NotiUser } from '@shared/notification';
import { NotiContent } from '@app/notification/components/content';
import { NotiUser } from '@app/notification/components/user';
import { formatCreatedAt } from '@utils/createdAt';
import { parser } from '@utils/parser';
export function NotiMention({ event }: { event: NDKEvent }) {
const replyTo = event.tags.find((e) => e[0] === 'e')?.[1];
const createdAt = formatCreatedAt(event.created_at);
const content = useMemo(() => parser(event), [event]);
@@ -17,13 +16,12 @@ export function NotiMention({ event }: { event: NDKEvent }) {
<div className="flex items-start justify-between">
<div className="flex items-start gap-1">
<NotiUser pubkey={event.pubkey} />
<p className="leading-none text-white/50">reply your postr</p>
<p className="leading-none text-white/50">mention you · {createdAt}</p>
</div>
<span className="leading-none text-white/50">{createdAt}</span>
</div>
<div className="-mt-3 pl-[44px]">
<NoteContent content={content} />
{replyTo && <MentionNote id={replyTo} />}
<div className="relative z-10 -mt-6 flex gap-3">
<div className="h-10 w-10 shrink-0" />
<NotiContent content={content} />
</div>
</div>
);

View File

@@ -1,7 +1,8 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { NotiUser } from '@app/notification/components/user';
import { MentionNote } from '@shared/notes';
import { NotiUser } from '@shared/notification';
import { formatCreatedAt } from '@utils/createdAt';
@@ -14,13 +15,15 @@ export function NotiReaction({ event }: { event: NDKEvent }) {
<div className="flex items-start justify-between">
<div className="flex items-start gap-1">
<NotiUser pubkey={event.pubkey} />
<p className="leading-none text-white/50">reacted {event.content}</p>
</div>
<div>
<span className="leading-none text-white/50">{createdAt}</span>
<p className="leading-none text-white/50">
reacted {event.content} · {createdAt}
</p>
</div>
</div>
<div className="-mt-5 pl-[44px]">{root && <MentionNote id={root} />}</div>
<div className="relative z-10 -mt-6 flex gap-3">
<div className="h-10 w-10 shrink-0" />
<div className="flex-1">{root && <MentionNote id={root} />}</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,8 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { NotiUser } from '@app/notification/components/user';
import { MentionNote } from '@shared/notes';
import { NotiUser } from '@shared/notification';
import { formatCreatedAt } from '@utils/createdAt';
@@ -14,13 +15,13 @@ export function NotiRepost({ event }: { event: NDKEvent }) {
<div className="flex items-start justify-between">
<div className="flex items-start gap-1">
<NotiUser pubkey={event.pubkey} />
<p className="leading-none text-white/50">repostr your postr</p>
</div>
<div>
<span className="leading-none text-white/50">{createdAt}</span>
<p className="leading-none text-white/50">repostr your postr · {createdAt}</p>
</div>
</div>
<div className="-mt-5 pl-[44px]">{root && <MentionNote id={root} />}</div>
<div className="relative z-10 -mt-6 flex gap-3">
<div className="h-10 w-10 shrink-0" />
<div className="flex-1">{root && <MentionNote id={root} />}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect } from 'react';
import { NotiMention } from '@app/notification/components/mention';
import { NotiReaction } from '@app/notification/components/reaction';
import { NotiRepost } from '@app/notification/components/repost';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
export function NotificationScreen() {
const { db } = useStorage();
const { sub, fetchActivities } = useNostr();
const { status, data } = useQuery(
['notification', db.account.pubkey],
async () => {
return await fetchActivities();
},
{ refetchOnWindowFocus: false }
);
const renderItem = useCallback(
(event: NDKEvent) => {
switch (event.kind) {
case 1:
return <NotiMention key={event.id} event={event} />;
case 6:
return <NotiRepost key={event.id} event={event} />;
case 7:
return <NotiReaction key={event.id} event={event} />;
default:
return null;
}
},
[data]
);
useEffect(() => {
const filter: NDKFilter = {
'#p': [db.account.pubkey],
kinds: [1, 3, 6, 7, 9735],
since: db.account.last_login_at ?? Math.floor(Date.now() / 1000),
};
sub(filter, async (event) => {
console.log('[notify] new noti', event.id);
});
}, []);
return (
<div className="h-full w-full overflow-y-auto bg-white/10 px-3">
<div className="mb-3 px-3 pt-11">
<h3 className="text-xl font-bold">Notifications</h3>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2 flex flex-col">
{status === 'loading' ? (
<div className="inline-flex items-center justify-center px-4 py-3">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-white" />
</div>
) : data?.length < 1 ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<p className="mb-1 text-4xl">🎉</p>
<p className="font-medium text-white/50">
Yo!, you&apos;ve no new notifications
</p>
</div>
) : (
data.map((event) => renderItem(event))
)}
</div>
</div>
</div>
);
}

View File

@@ -17,42 +17,49 @@ export const NDKInstance = () => {
// TODO: fully support NIP-11
async function verifyRelays(relays: string[]) {
const verifiedRelays: string[] = [];
try {
const urls: string[] = relays.map((relay) => {
if (relay.startsWith('ws')) {
return relay.replace('ws', 'http');
}
if (relay.startsWith('wss')) {
return relay.replace('wss', 'https');
}
});
for (const relay of relays) {
let url: string;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort('timeout'), 5000);
if (relay.startsWith('ws')) {
url = relay.replace('ws', 'http');
}
if (relay.startsWith('wss')) {
url = relay.replace('wss', 'https');
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort('timeout'), 5000);
const res = await fetch(url, {
const requests = urls.map((url) =>
fetch(url, {
headers: { Accept: 'application/nostr+json' },
signal: controller.signal,
});
})
);
const responses = await Promise.all(requests);
const errors = responses.filter((response) => !response.ok);
if (res.ok) {
const data = await res.json();
console.log('relay information: ', data);
verifiedRelays.push(relay);
clearTimeout(timeoutId);
} else {
console.log('relay not working: ', res);
}
} catch (e) {
console.log('fetch error', e);
if (errors.length > 0) {
throw errors.map((response) => Error(response.statusText));
}
}
return verifiedRelays;
const verifiedRelays: string[] = responses.map((res) => {
if (res.url.startsWith('http')) {
return res.url.replace('htto', 'ws');
}
if (res.url.startsWith('https')) {
return res.url.replace('https', 'wss');
}
});
// clear timeout
clearTimeout(timeoutId);
// return all validate relays
return verifiedRelays;
} catch (e) {
e.forEach((error) => console.error(error));
}
}
async function initNDK() {

View File

@@ -21,7 +21,7 @@ export function ComposerModal() {
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-min items-center justify-center gap-1 rounded-md bg-white/10 px-8 text-sm font-medium text-white hover:bg-fuchsia-500 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
className="inline-flex h-9 w-min items-center justify-center gap-1 rounded-md bg-fuchsia-500 px-8 text-sm font-medium text-white hover:bg-fuchsia-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
>
<ComposeIcon className="h-4 w-4" />
Postr

View File

@@ -10,16 +10,18 @@ export function LumeBar() {
const { db } = useStorage();
return (
<div className="rounded-xl bg-white/10 p-2 backdrop-blur-xl">
<div className="flex items-center justify-between">
<ActiveAccount data={db.account} />
<Link
to="/settings/general"
className="inline-flex h-9 w-9 transform items-center justify-center rounded-md bg-white/20 active:translate-y-1"
>
<SettingsIcon className="h-4 w-4 text-white" />
</Link>
<Logout />
<div className="absolute bottom-3 left-1/2 w-max -translate-x-1/2 transform">
<div className="rounded-xl bg-white/10 p-2 backdrop-blur-xl">
<div className="flex items-center gap-2">
<ActiveAccount data={db.account} />
<Link
to="/settings/general"
className="inline-flex h-9 w-9 transform items-center justify-center rounded-md bg-white/20 active:translate-y-1"
>
<SettingsIcon className="h-4 w-4 text-white" />
</Link>
<Logout />
</div>
</div>
</div>
);

View File

@@ -8,6 +8,7 @@ import { ComposerModal } from '@shared/composer/modal';
import {
ArrowLeftIcon,
ArrowRightIcon,
BellIcon,
NavArrowDownIcon,
SpaceIcon,
} from '@shared/icons';
@@ -79,16 +80,29 @@ export function Navigation() {
<span className="inline-flex h-6 w-6 items-center justify-center rounded bg-white/10">
<SpaceIcon className="h-3 w-3 text-white" />
</span>
<span className="font-medium">Space</span>
<span className="text-sm font-medium">Space</span>
</NavLink>
<NavLink
to="/notifications"
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'flex h-9 items-center gap-2.5 rounded-md px-2',
isActive ? 'bg-white/10 text-white' : 'text-white/80'
)
}
>
<span className="inline-flex h-6 w-6 items-center justify-center rounded bg-white/10">
<BellIcon className="h-3 w-3 text-white" />
</span>
<span className="text-sm font-medium">Notifications</span>
</NavLink>
</div>
</Collapsible.Content>
</div>
</Collapsible.Root>
</div>
<div className="absolute bottom-3 left-0 w-full px-10">
<LumeBar />
</div>
<LumeBar />
</div>
);
}

View File

@@ -1,5 +0,0 @@
export * from './user';
export * from './modal';
export * from './types/reaction';
export * from './types/repost';
export * from './types/mention';

View File

@@ -1,100 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import * as Dialog from '@radix-ui/react-dialog';
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { BellIcon, CancelIcon, LoaderIcon } from '@shared/icons';
import { NotiMention, NotiReaction, NotiRepost } from '@shared/notification';
import { nHoursAgo } from '@utils/date';
export function NotificationModal({ pubkey }: { pubkey: string }) {
const { ndk } = useNDK();
const { status, data } = useQuery(
['notification', pubkey],
async () => {
const events = await ndk.fetchEvents({
'#p': [pubkey],
kinds: [1, 6, 7, 9735],
since: nHoursAgo(24),
});
const filterSelf = [...events].filter((el) => el.pubkey !== pubkey);
const sorted = filterSelf.sort((a, b) => a.created_at - b.created_at);
return sorted as unknown as NDKEvent[];
},
{
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
}
);
const renderItem = useCallback(
(event: NDKEvent) => {
switch (event.kind) {
case 1:
return <NotiMention key={event.id} event={event} />;
case 6:
return <NotiRepost key={event.id} event={event} />;
case 7:
return <NotiReaction key={event.id} event={event} />;
default:
return null;
}
},
[data]
);
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-9 transform items-center justify-center rounded-md bg-white/20 active:translate-y-1"
>
<BellIcon className="h-4 w-4 text-white" />
</button>
</Dialog.Trigger>
<Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-xl rounded-xl bg-white/10">
<div className="h-min w-full shrink-0 rounded-t-xl border-b border-white/10 bg-white/5 px-5 py-5">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Dialog.Title className="text-lg font-semibold leading-none text-white">
Notification
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10">
<CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close>
</div>
<Dialog.Description className="text-sm leading-tight text-white/50">
All things happen when you rest in 24 hours ago
</Dialog.Description>
</div>
</div>
<div className="scrollbar-hide flex h-[500px] flex-col divide-y divide-white/10 overflow-y-auto overflow-x-hidden">
{status === 'loading' ? (
<div className="inline-flex items-center justify-center px-4 py-3">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-white" />
</div>
) : data?.length < 1 ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<p className="mb-1 text-4xl">🎉</p>
<p className="font-medium text-white/50">
Yo!, you&apos;ve no new notifications
</p>
</div>
) : (
data.map((event) => renderItem(event))
)}
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -91,8 +91,6 @@ export function useNostr() {
const prefetchEvents = async () => {
try {
if (!ndk) return { status: 'failed', data: [], message: 'NDK instance not found' };
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(ndk));
const dbEventsEmpty = await db.isEventsEmpty();
@@ -100,13 +98,13 @@ export function useNostr() {
if (dbEventsEmpty || db.account.last_login_at === 0) {
since = nHoursAgo(24);
} else {
since = db.account.last_login_at ?? nHoursAgo(24);
since = db.account.last_login_at;
}
console.log("prefetching events with user's network: ", db.account.network.length);
console.log('prefetching events since: ', since);
const events = fetcher.allEventsIterator(
const events = await fetcher.fetchAllEvents(
relayUrls,
{
kinds: [NDKKind.Text, NDKKind.Repost, 1063, NDKKind.Article],
@@ -116,7 +114,7 @@ export function useNostr() {
);
// save all events to database
for await (const event of events) {
for (const event of events) {
let root: string;
let reply: string;
if (event.tags?.[0]?.[0] === 'e' && !event.tags?.[0]?.[3]) {
@@ -125,7 +123,7 @@ export function useNostr() {
root = event.tags.find((el) => el[3] === 'root')?.[1];
reply = event.tags.find((el) => el[3] === 'reply')?.[1];
}
db.createEvent(
await db.createEvent(
event.id,
JSON.stringify(event),
event.pubkey,
@@ -143,6 +141,31 @@ export function useNostr() {
}
};
const fetchActivities = async () => {
try {
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(ndk));
const events = await fetcher.fetchAllEvents(
relayUrls,
{
kinds: [
NDKKind.Text,
NDKKind.Contacts,
NDKKind.Repost,
NDKKind.Reaction,
NDKKind.Zap,
],
'#p': [db.account.pubkey],
},
{ since: nHoursAgo(24) },
{ sort: true }
);
return events as unknown as NDKEvent[];
} catch (e) {
console.error('Error fetching activities', e);
}
};
const publish = async ({
content,
kind,
@@ -184,5 +207,5 @@ export function useNostr() {
return res;
};
return { sub, fetchUserData, prefetchEvents, publish, createZap };
return { sub, fetchUserData, prefetchEvents, fetchActivities, publish, createZap };
}