mirror of
https://github.com/luminous-devs/lume.git
synced 2024-10-02 09:50:47 +00:00
add notification widget
This commit is contained in:
parent
507628bcaa
commit
dcacf23625
@ -87,7 +87,7 @@
|
||||
"tauri-controls": "^0.2.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.2",
|
||||
"virtua": "^0.14.0",
|
||||
"virtua": "^0.15.0",
|
||||
"zustand": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -213,8 +213,8 @@ dependencies:
|
||||
specifier: ^0.8.2
|
||||
version: 0.8.2(@tiptap/core@2.1.12)
|
||||
virtua:
|
||||
specifier: ^0.14.0
|
||||
version: 0.14.0(react-dom@18.2.0)(react@18.2.0)
|
||||
specifier: ^0.15.0
|
||||
version: 0.15.0(react-dom@18.2.0)(react@18.2.0)
|
||||
zustand:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3(@types/react@18.2.29)(react@18.2.0)
|
||||
@ -6871,8 +6871,8 @@ packages:
|
||||
vfile-message: 3.1.4
|
||||
dev: false
|
||||
|
||||
/virtua@0.14.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-+g3fxgFuQCqw6PpU5qzTRKhbSUGOeMEap0VbPaIRB1RiK5MfLiGXIMwID1iX1DmvUC/SqsBsJfVvlUaPNGWSVQ==}
|
||||
/virtua@0.15.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-kzwin55Tj85tcpNO7p5p7U12+wT6+CJaDSr98BTNKD6t7QEmigDwE7h6dcP170LrY8tW+scsMUtipcroSeSpAw==}
|
||||
peerDependencies:
|
||||
react: '>=16.14.0'
|
||||
react-dom: '>=16.14.0'
|
||||
|
@ -65,13 +65,6 @@ export default function App() {
|
||||
return { Component: UserScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
async lazy() {
|
||||
const { NotificationScreen } = await import('@app/notifications');
|
||||
return { Component: NotificationScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'nwc',
|
||||
async lazy() {
|
||||
|
@ -1,27 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { NotiUser } from '@app/notifications/components/user';
|
||||
|
||||
import { formatCreatedAt } from '@utils/createdAt';
|
||||
|
||||
export function NotiMention({ event }: { event: NDKEvent }) {
|
||||
const createdAt = formatCreatedAt(event.created_at);
|
||||
const rootId = event.tags.find((el) => el[0])?.[1];
|
||||
|
||||
return (
|
||||
<Link to={`/notes/text/${rootId}`} className="h-min w-full px-3">
|
||||
<div className="group flex items-center justify-between rounded-xl px-3 py-3 hover:bg-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<NotiUser pubkey={event.pubkey} />
|
||||
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
||||
has mention you · {createdAt}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden text-sm font-semibold text-blue-500 group-hover:block">
|
||||
View
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { NotiUser } from '@app/notifications/components/user';
|
||||
|
||||
import { formatCreatedAt } from '@utils/createdAt';
|
||||
|
||||
export function NotiReaction({ event }: { event: NDKEvent }) {
|
||||
const createdAt = formatCreatedAt(event.created_at);
|
||||
const rootId = event.tags.find((el) => el[0])?.[1];
|
||||
|
||||
return (
|
||||
<Link to={`/notes/text/${rootId}`} className="h-min w-full px-3">
|
||||
<div className="group flex items-center justify-between rounded-xl px-3 py-3 hover:bg-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<NotiUser pubkey={event.pubkey} />
|
||||
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
||||
reacted {event.content} · {createdAt}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden text-sm font-semibold text-blue-500 group-hover:block">
|
||||
View
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { NotiUser } from '@app/notifications/components/user';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { formatCreatedAt } from '@utils/createdAt';
|
||||
|
||||
export function NotiRepost({ event }: { event: NDKEvent }) {
|
||||
const { db } = useStorage();
|
||||
|
||||
const createdAt = formatCreatedAt(event.created_at);
|
||||
const rootId = event.tags.find((el) => el[0])?.[1];
|
||||
|
||||
return (
|
||||
<Link to={`/notes/text/${rootId}`} className="h-min w-full px-3">
|
||||
<div className="group flex items-center justify-between rounded-xl px-3 py-3 hover:bg-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<NotiUser pubkey={event.pubkey} />
|
||||
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
||||
repost{' '}
|
||||
{event.pubkey !== db.account.pubkey ? 'a post that mention you' : 'your post'}{' '}
|
||||
· {createdAt}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden text-sm font-semibold text-blue-500 group-hover:block">
|
||||
View
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { NoteSkeleton } from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
import { WidgetKinds, useWidgets } from '@stores/widgets';
|
||||
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
|
||||
export const SimpleNote = memo(function SimpleNote({ id }: { id: string }) {
|
||||
const { db } = useStorage();
|
||||
const { status, data } = useEvent(id);
|
||||
|
||||
const setWidget = useWidgets((state) => state.setWidget);
|
||||
|
||||
const openThread = (event, thread: string) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
setWidget(db, { kind: WidgetKinds.local.thread, title: 'Thread', content: thread });
|
||||
} else {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="mb-2 mt-3 cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="mb-2 mt-3 cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<p>Can't get event from relay</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => openThread(e, id)}
|
||||
onKeyDown={(e) => openThread(e, id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="mb-2 mt-3 cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl"
|
||||
>
|
||||
<User pubkey={data.pubkey} time={data.created_at} variant="mention" />
|
||||
<div className="markdown">
|
||||
<p>
|
||||
{data.content.length > 200
|
||||
? data.content.substring(0, 200) + '...'
|
||||
: data.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,32 +0,0 @@
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { displayNpub } from '@utils/shortenKey';
|
||||
|
||||
export function NotiUser({ pubkey }: { pubkey: string }) {
|
||||
const { status, user } = useProfile(pubkey);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded-md bg-white/10" />
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
|
||||
<span className="h-4 w-1/2 animate-pulse rounded bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-start gap-2">
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
className="h-8 w-8 shrink-0 rounded-md object-cover"
|
||||
/>
|
||||
<span className="max-w-[10rem] truncate font-medium leading-none text-white">
|
||||
{user?.name || user?.display_name || user?.displayName || displayNpub(pubkey, 16)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,85 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { NotiMention } from '@app/notifications/components/mention';
|
||||
import { NotiReaction } from '@app/notifications/components/reaction';
|
||||
import { NotiRepost } from '@app/notifications/components/repost';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
|
||||
import { useActivities } from '@stores/activities';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
|
||||
export function NotificationScreen() {
|
||||
const { db } = useStorage();
|
||||
const { fetchActivities } = useNostr();
|
||||
|
||||
const [activities, setActivities, clearTotalNewActivities] = useActivities((state) => [
|
||||
state.activities,
|
||||
state.setActivities,
|
||||
state.clearTotalNewActivities,
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
[activities]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function getActivities() {
|
||||
const events = await fetchActivities();
|
||||
setActivities(events, db.account.last_login_at);
|
||||
}
|
||||
|
||||
getActivities();
|
||||
|
||||
// clear total new activities
|
||||
clearTotalNewActivities();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto bg-neutral-400 scrollbar-none dark:bg-neutral-600">
|
||||
<div className="grid h-full grid-cols-3">
|
||||
<div className="col-span-2 flex flex-col border-r border-white/5">
|
||||
<TitleBar title="Activities in the last 24 hours" />
|
||||
<div className="flex h-full flex-col">
|
||||
{!activities ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
Loading
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : activities.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-neutral-600 dark:text-neutral-400">
|
||||
Yo!, no new activities around you in the last 24 hours
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
activities.map((event) => renderItem(event))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -17,6 +17,7 @@ import {
|
||||
LocalFilesWidget,
|
||||
LocalFollowsWidget,
|
||||
LocalNetworkWidget,
|
||||
LocalNotificationWidget,
|
||||
LocalThreadWidget,
|
||||
LocalUserWidget,
|
||||
TrendingAccountsWidget,
|
||||
@ -74,6 +75,8 @@ export function SpaceScreen() {
|
||||
return <WidgetList key={widget.id} params={widget} />;
|
||||
case WidgetKinds.other.learnNostr:
|
||||
return <LearnNostrWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.local.notification:
|
||||
return <LocalNotificationWidget key={widget.id} params={widget} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@ -83,7 +86,7 @@ export function SpaceScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchWidgets(db);
|
||||
}, [fetchWidgets]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VList
|
||||
|
@ -4,6 +4,7 @@ import { minidenticon } from 'minidenticons';
|
||||
import { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { AccountMoreActions } from '@shared/accounts/more';
|
||||
@ -17,6 +18,7 @@ import { sendNativeNotification } from '@utils/notification';
|
||||
|
||||
export function ActiveAccount() {
|
||||
const { db } = useStorage();
|
||||
const { ndk } = useNDK();
|
||||
const { status, user } = useProfile(db.account.pubkey);
|
||||
const { sub } = useNostr();
|
||||
|
||||
@ -27,24 +29,38 @@ export function ActiveAccount() {
|
||||
|
||||
useEffect(() => {
|
||||
const filter: NDKFilter = {
|
||||
'#p': [db.account.pubkey],
|
||||
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
'#p': [db.account.pubkey],
|
||||
};
|
||||
|
||||
sub(
|
||||
filter,
|
||||
async (event) => {
|
||||
addActivity(event);
|
||||
|
||||
const user = ndk.getUser({ hexpubkey: event.pubkey });
|
||||
await user.fetchProfile();
|
||||
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return await sendNativeNotification('Mention');
|
||||
return await sendNativeNotification(
|
||||
`${user.profile.displayName || user.profile.name} has replied to your note`
|
||||
);
|
||||
case NDKKind.Repost:
|
||||
return await sendNativeNotification('Repost');
|
||||
return await sendNativeNotification(
|
||||
`${user.profile.displayName || user.profile.name} has reposted to your note`
|
||||
);
|
||||
case NDKKind.Reaction:
|
||||
return await sendNativeNotification('Reaction');
|
||||
return await sendNativeNotification(
|
||||
`${user.profile.displayName || user.profile.name} has reacted ${
|
||||
event.content
|
||||
} to your note`
|
||||
);
|
||||
case NDKKind.Zap:
|
||||
return await sendNativeNotification('Zap');
|
||||
return await sendNativeNotification(
|
||||
`${user.profile.displayName || user.profile.name} has zapped to your note`
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -71,7 +87,7 @@ export function ActiveAccount() {
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="aspect-square h-auto w-full rounded-md"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<Avatar.Fallback delayMs={150}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={db.account.pubkeypubkey}
|
||||
|
97
src/shared/notification/notifyNote.tsx
Normal file
97
src/shared/notification/notifyNote.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
|
||||
import {
|
||||
ArticleNote,
|
||||
FileNote,
|
||||
NoteActions,
|
||||
NoteSkeleton,
|
||||
TextNote,
|
||||
UnknownNote,
|
||||
} from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
import { formatCreatedAt } from '@utils/createdAt';
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
|
||||
export function NotifyNote({
|
||||
id,
|
||||
user,
|
||||
content,
|
||||
kind,
|
||||
time,
|
||||
}: {
|
||||
id: string;
|
||||
user: string;
|
||||
content: string;
|
||||
kind: NDKKind | number;
|
||||
time: number;
|
||||
}) {
|
||||
const createdAt = formatCreatedAt(time, false);
|
||||
const { status, data } = useEvent(id);
|
||||
|
||||
const renderKind = (event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return <TextNote content={event.content} />;
|
||||
case NDKKind.Article:
|
||||
return <ArticleNote event={event} />;
|
||||
case 1063:
|
||||
return <FileNote event={event} />;
|
||||
default:
|
||||
return <UnknownNote event={event} />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderText = (kind: number) => {
|
||||
switch (kind) {
|
||||
case NDKKind.Text:
|
||||
return 'replied';
|
||||
case NDKKind.Reaction:
|
||||
return `reacted ${content}`;
|
||||
case NDKKind.Repost:
|
||||
return 'reposted';
|
||||
case NDKKind.Zap:
|
||||
return 'zapped';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="h-min w-full px-3 pb-3">
|
||||
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-5 flex h-min w-full flex-col gap-2 px-3 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<User pubkey={user} variant="notify" />
|
||||
<p className="font-medium text-neutral-600 dark:text-neutral-400">
|
||||
{renderText(kind)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-medium text-neutral-600 dark:text-neutral-400">
|
||||
{createdAt}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative overflow-hidden rounded-xl bg-neutral-100 px-3 py-4 dark:bg-neutral-900">
|
||||
<div className="relative flex flex-col">
|
||||
<User pubkey={data.pubkey} time={data.created_at} eventId={data.id} />
|
||||
<div className="-mt-4 flex items-start gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
<div className="relative z-20 flex-1">
|
||||
{renderKind(data)}
|
||||
<NoteActions id={data.id} pubkey={data.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -26,7 +26,7 @@ export function TitleBar({ id, title }: { id?: string; title?: string }) {
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="text-sm font-medium tracking-wide text-neutral-900 dark:text-neutral-100">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
@ -28,6 +28,7 @@ export const User = memo(function User({
|
||||
| 'default'
|
||||
| 'simple'
|
||||
| 'mention'
|
||||
| 'notify'
|
||||
| 'repost'
|
||||
| 'chat'
|
||||
| 'large'
|
||||
@ -51,7 +52,7 @@ export const User = memo(function User({
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'mention') {
|
||||
if (variant === 'mention' || variant === 'notify') {
|
||||
return (
|
||||
<div className="relative flex items-center gap-3">
|
||||
<div className="relative z-10 h-6 w-6 shrink-0 animate-pulse overflow-hidden rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
@ -86,7 +87,7 @@ export const User = memo(function User({
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={pubkey}
|
||||
className="h-6 w-6 rounded bg-black dark:bg-white"
|
||||
className="h-6 w-6 rounded-md bg-black dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
@ -104,6 +105,36 @@ export const User = memo(function User({
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'notify') {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="h-8 w-8 rounded-lg"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={pubkey}
|
||||
className="h-8 w-8 rounded-lg bg-black dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{user?.name ||
|
||||
user?.display_name ||
|
||||
user?.displayName ||
|
||||
displayNpub(pubkey, 16)}
|
||||
</h5>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'large') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-2.5">
|
||||
|
@ -6,6 +6,7 @@ export * from './local/thread';
|
||||
export * from './local/files';
|
||||
export * from './local/articles';
|
||||
export * from './local/follows';
|
||||
export * from './local/notification';
|
||||
export * from './global/articles';
|
||||
export * from './global/files';
|
||||
export * from './global/hashtag';
|
||||
|
81
src/shared/widgets/local/notification.tsx
Normal file
81
src/shared/widgets/local/notification.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { VList } from 'virtua';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { NotifyNote } from '@shared/notification/notifyNote';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
import { WidgetWrapper } from '@shared/widgets';
|
||||
|
||||
import { useActivities } from '@stores/activities';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
import { Widget } from '@utils/types';
|
||||
|
||||
export function LocalNotificationWidget({ params }: { params: Widget }) {
|
||||
const { db } = useStorage();
|
||||
const { getAllActivities } = useNostr();
|
||||
|
||||
const [activities, setActivities] = useActivities((state) => [
|
||||
state.activities,
|
||||
state.setActivities,
|
||||
]);
|
||||
|
||||
const renderEvent = useCallback(
|
||||
(event: NDKEvent) => {
|
||||
const rootEventId = event.tags.find((el) => el[0] === 'e')?.[1];
|
||||
if (!rootEventId) return null;
|
||||
if (event.pubkey === db.account.pubkey) return null;
|
||||
return (
|
||||
<NotifyNote
|
||||
id={rootEventId}
|
||||
user={event.pubkey}
|
||||
content={event.content}
|
||||
kind={event.kind}
|
||||
time={event.created_at}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[activities]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function getActivities() {
|
||||
const events = await getAllActivities(48);
|
||||
setActivities(events);
|
||||
}
|
||||
|
||||
getActivities();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<WidgetWrapper>
|
||||
<TitleBar id={params.id} title={params.title} />
|
||||
<div className="h-full px-3">
|
||||
{!activities ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
Loading...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : activities.length < 1 ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||
<p className="mb-1 text-4xl">🎉</p>
|
||||
<p className="text-center font-medium text-neutral-600 dark:text-neutral-400">
|
||||
Hmm! Nothing new yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<VList className="h-full overflow-y-auto scrollbar-none">
|
||||
{activities.map((event) => renderEvent(event))}
|
||||
</VList>
|
||||
)}
|
||||
</div>
|
||||
</WidgetWrapper>
|
||||
);
|
||||
}
|
@ -3,29 +3,20 @@ import { create } from 'zustand';
|
||||
|
||||
interface ActivitiesState {
|
||||
activities: Array<NDKEvent>;
|
||||
totalNewActivities: number;
|
||||
setActivities: (events: NDKEvent[], lastLogin: number) => void;
|
||||
setActivities: (events: NDKEvent[]) => void;
|
||||
addActivity: (event: NDKEvent) => void;
|
||||
clearTotalNewActivities: () => void;
|
||||
}
|
||||
|
||||
export const useActivities = create<ActivitiesState>((set) => ({
|
||||
activities: null,
|
||||
totalNewActivities: 0,
|
||||
setActivities: (events: NDKEvent[], lastLogin: number) => {
|
||||
const totalLatest = events.filter((ev) => ev.created_at > lastLogin)?.length ?? 0;
|
||||
setActivities: (events: NDKEvent[]) => {
|
||||
set(() => ({
|
||||
activities: events,
|
||||
totalNewActivities: totalLatest,
|
||||
}));
|
||||
},
|
||||
addActivity: (event: NDKEvent) => {
|
||||
set((state) => ({
|
||||
activities: state.activities ? [event, ...state.activities] : [event],
|
||||
totalNewActivities: state.totalNewActivities++,
|
||||
}));
|
||||
},
|
||||
clearTotalNewActivities: () => {
|
||||
set(() => ({ totalNewActivities: 0 }));
|
||||
},
|
||||
}));
|
||||
|
@ -24,6 +24,7 @@ export const WidgetKinds = {
|
||||
user: 104,
|
||||
thread: 105,
|
||||
follows: 106,
|
||||
notification: 107,
|
||||
},
|
||||
global: {
|
||||
feeds: 1000,
|
||||
@ -109,6 +110,11 @@ export const DefaultWidgets: Array<WidgetGroup> = [
|
||||
{
|
||||
title: 'Other',
|
||||
data: [
|
||||
{
|
||||
kind: WidgetKinds.local.notification,
|
||||
title: 'Notification',
|
||||
description: 'Everything happens around you',
|
||||
},
|
||||
{
|
||||
kind: WidgetKinds.other.learnNostr,
|
||||
title: 'Learn Nostr',
|
||||
@ -125,9 +131,14 @@ export const useWidgets = create<WidgetState>()(
|
||||
isFetched: false,
|
||||
fetchWidgets: async (db: LumeStorage) => {
|
||||
const dbWidgets = await db.getWidgets();
|
||||
console.log('db widgets: ', dbWidgets);
|
||||
|
||||
// default: add network widget
|
||||
dbWidgets.unshift({
|
||||
id: '9998',
|
||||
title: 'Notification',
|
||||
content: '',
|
||||
kind: WidgetKinds.local.notification,
|
||||
});
|
||||
|
||||
dbWidgets.unshift({
|
||||
id: '9999',
|
||||
title: '',
|
||||
|
@ -1,8 +0,0 @@
|
||||
import { readBinaryFile } from '@tauri-apps/plugin-fs';
|
||||
|
||||
export async function createBlobFromFile(path: string): Promise<Uint8Array> {
|
||||
const file = await readBinaryFile(path);
|
||||
const blob = new Blob([file]);
|
||||
const arr = new Uint8Array(await blob.arrayBuffer());
|
||||
return arr;
|
||||
}
|
@ -1,6 +1,4 @@
|
||||
import { NDKEvent, NDKFilter, NDKKind, NDKSubscription } from '@nostr-dev-kit/ndk';
|
||||
import { message, open } from '@tauri-apps/plugin-dialog';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { NostrEventExt } from 'nostr-fetch';
|
||||
import { useMemo } from 'react';
|
||||
@ -10,7 +8,7 @@ import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { nHoursAgo } from '@utils/date';
|
||||
import { getMultipleRandom } from '@utils/transform';
|
||||
import { NDKEventWithReplies, NostrBuildResponse } from '@utils/types';
|
||||
import { NDKEventWithReplies } from '@utils/types';
|
||||
|
||||
export function useNostr() {
|
||||
const { db } = useStorage();
|
||||
@ -53,9 +51,6 @@ export function useNostr() {
|
||||
list.forEach((item) => {
|
||||
tags.push(['p', item]);
|
||||
});
|
||||
|
||||
// publish event
|
||||
publish({ content: '', kind: NDKKind.Contacts, tags: tags });
|
||||
};
|
||||
|
||||
const removeContact = async (pubkey: string) => {
|
||||
@ -66,30 +61,17 @@ export function useNostr() {
|
||||
list.forEach((item) => {
|
||||
tags.push(['p', item]);
|
||||
});
|
||||
|
||||
// publish event
|
||||
publish({ content: '', kind: NDKKind.Contacts, tags: tags });
|
||||
};
|
||||
|
||||
const fetchActivities = async () => {
|
||||
const getAllActivities = async (limit?: number) => {
|
||||
try {
|
||||
const events = await fetcher.fetchAllEvents(
|
||||
relayUrls,
|
||||
{
|
||||
kinds: [
|
||||
NDKKind.Text,
|
||||
NDKKind.Contacts,
|
||||
NDKKind.Repost,
|
||||
NDKKind.Reaction,
|
||||
NDKKind.Zap,
|
||||
],
|
||||
const events = await ndk.fetchEvents({
|
||||
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
|
||||
'#p': [db.account.pubkey],
|
||||
},
|
||||
{ since: nHoursAgo(24) },
|
||||
{ sort: true }
|
||||
);
|
||||
limit: limit ?? 100,
|
||||
});
|
||||
|
||||
return events as unknown as NDKEvent[];
|
||||
return [...events];
|
||||
} catch (e) {
|
||||
console.error('Error fetching activities', e);
|
||||
}
|
||||
@ -289,28 +271,6 @@ export function useNostr() {
|
||||
return relayMap;
|
||||
};
|
||||
|
||||
const publish = async ({
|
||||
content,
|
||||
kind,
|
||||
tags,
|
||||
}: {
|
||||
content: string;
|
||||
kind: NDKKind | number;
|
||||
tags: string[][];
|
||||
}): Promise<NDKEvent> => {
|
||||
const event = new NDKEvent(ndk);
|
||||
event.content = content;
|
||||
event.kind = kind;
|
||||
event.created_at = Math.floor(Date.now() / 1000);
|
||||
event.pubkey = db.account.pubkey;
|
||||
event.tags = tags;
|
||||
|
||||
await event.sign();
|
||||
await event.publish();
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
const createZap = async (event: NDKEvent, amount: number, message?: string) => {
|
||||
// @ts-expect-error, NostrEvent to NDKEvent
|
||||
const ndkEvent = new NDKEvent(ndk, event);
|
||||
@ -319,87 +279,6 @@ export function useNostr() {
|
||||
return res;
|
||||
};
|
||||
|
||||
const upload = async (file: null | string, nip94?: boolean) => {
|
||||
try {
|
||||
let filepath = file;
|
||||
|
||||
if (!file) {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: 'Media',
|
||||
extensions: [
|
||||
'png',
|
||||
'jpeg',
|
||||
'jpg',
|
||||
'gif',
|
||||
'mp4',
|
||||
'mp3',
|
||||
'webm',
|
||||
'mkv',
|
||||
'avi',
|
||||
'mov',
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (Array.isArray(selected)) {
|
||||
// user selected multiple files
|
||||
} else if (selected === null) {
|
||||
return {
|
||||
url: null,
|
||||
error: 'Cancelled',
|
||||
};
|
||||
} else {
|
||||
filepath = selected.path;
|
||||
}
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', filepath);
|
||||
|
||||
const res: NostrBuildResponse = await fetch(
|
||||
'https://nostr.build/api/v2/upload/files',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = res.data.data[0];
|
||||
const url = data.url;
|
||||
|
||||
if (nip94) {
|
||||
const tags = [
|
||||
['url', url],
|
||||
['x', data.sha256 ?? ''],
|
||||
['m', data.mime ?? 'application/octet-stream'],
|
||||
['size', data.size.toString() ?? '0'],
|
||||
['dim', `${data.dimensions.width}x${data.dimensions.height}` ?? '0'],
|
||||
['blurhash', data.blurhash ?? ''],
|
||||
];
|
||||
|
||||
await publish({ content: '', kind: 1063, tags: tags });
|
||||
}
|
||||
|
||||
return {
|
||||
url: url,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: null,
|
||||
error: 'Upload failed',
|
||||
};
|
||||
} catch (e) {
|
||||
await message(e, { title: 'Lume', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
sub,
|
||||
addContact,
|
||||
@ -409,11 +288,9 @@ export function useNostr() {
|
||||
getContactsByPubkey,
|
||||
getEventsByPubkey,
|
||||
getAllRelaysByUsers,
|
||||
fetchActivities,
|
||||
getAllActivities,
|
||||
fetchNIP04Messages,
|
||||
fetchAllReplies,
|
||||
publish,
|
||||
createZap,
|
||||
upload,
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user