This commit is contained in:
reya 2023-11-08 08:21:52 +07:00
parent ce864c8990
commit 6b030f2902
18 changed files with 55 additions and 327 deletions

View File

@ -114,6 +114,9 @@ input::-ms-clear {
inset: 20px 20px auto auto;
cursor: zoom-out;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
}
[data-rmiz-content="found"] img,
@ -153,7 +156,8 @@ input::-ms-clear {
}
[data-rmiz-modal-overlay="visible"] {
background-color: rgba(255, 255, 255, 1);
background-color: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(4px);
}
[data-rmiz-modal-content] {

View File

@ -1,5 +1,3 @@
import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
@ -20,7 +18,7 @@ export function MentionPopupItem({ pubkey, embed }: { pubkey: string; embed?: st
return (
<div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-neutral-200 dark:bg-neutral-800">
<Image
<img
src={user.picture || user.image}
alt={pubkey}
className="shirnk-0 h-8 w-8 rounded-md object-cover"

View File

@ -1,32 +0,0 @@
import { minidenticon } from 'minidenticons';
import { ImgHTMLAttributes, memo, useState } from 'react';
export const Image = memo(function Image({
src,
...props
}: ImgHTMLAttributes<HTMLImageElement>) {
const [isError, setIsError] = useState(false);
if (isError || !src) {
const svgURI =
'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(props.alt, 90, 50));
return (
<img src={svgURI} alt={props.alt} {...props} style={{ backgroundColor: '#000' }} />
);
}
return (
<img
{...props}
src={src}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
setIsError(true);
}}
loading="lazy"
decoding="async"
alt="lume default img"
style={{ contentVisibility: 'auto' }}
/>
);
});

View File

@ -35,11 +35,11 @@ export function ArticleNote({ event }: { event: NDKEvent }) {
<div className="mb-3 h-min w-full px-3">
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">
<User pubkey={event.pubkey} time={event.created_at} eventId={event.id} />
<div>
<div className="px-3">
<Link
to={`/notes/article/${event.id}`}
preventScrollReset={true}
className="flex w-full flex-col rounded-lg border border-neutral-200 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-900"
className="flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900"
>
{metadata.image && (
<img
@ -48,7 +48,7 @@ export function ArticleNote({ event }: { event: NDKEvent }) {
className="h-56 w-full rounded-t-lg object-cover"
/>
)}
<div className="flex flex-col gap-1 rounded-b-lg bg-neutral-200 px-3 py-3 dark:bg-neutral-800">
<div className="flex flex-col gap-1 rounded-b-lg rounded-t-lg bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<h5 className="break-all font-semibold text-neutral-900 dark:text-neutral-100">
{metadata.title}
</h5>

View File

@ -6,7 +6,6 @@ export * from './child';
export * from './notify';
export * from './unknown';
export * from './skeleton';
export * from './stats';
export * from './actions';
export * from './actions/reaction';
export * from './actions/reply';

View File

@ -3,7 +3,7 @@ import { download } from '@tauri-apps/plugin-upload';
import { SyntheticEvent } from 'react';
import Zoom from 'react-medium-image-zoom';
import { DownloadIcon } from '@shared/icons';
import { CancelIcon, DownloadIcon } from '@shared/icons';
export function ImagePreview({ url }: { url: string }) {
const downloadImage = async (url: string) => {
@ -17,7 +17,7 @@ export function ImagePreview({ url }: { url: string }) {
};
return (
<Zoom key={url} zoomMargin={50}>
<Zoom key={url} zoomMargin={50} IconUnzoom={() => <CancelIcon className="h-4 w-4" />}>
<div className="group relative mt-2">
<img
src={url}

View File

@ -49,7 +49,7 @@ export function LinkPreview({ url }: { url: string }) {
<img
src={data.image}
alt={url}
className="h-44 w-full rounded-t-lg bg-white object-cover"
className="h-48 w-full rounded-t-lg bg-white object-cover"
/>
) : null}
<div className="flex flex-col items-start px-3 py-3">

View File

@ -12,25 +12,15 @@ export function Reply({ event, root }: { event: NDKEventWithReplies; root?: stri
const [open, setOpen] = useState(false);
return (
<div>
<div className="flex flex-col gap-2">
<User pubkey={event.pubkey} time={event.created_at} eventId={event.id} />
<MemoizedTextKind content={event.content} />
<div className="-ml-1">
<NoteActions
id={event.id}
pubkey={event.pubkey}
root={root}
extraButtons={false}
/>
</div>
</div>
<div className="pl-4">
<Collapsible.Root open={open} onOpenChange={setOpen}>
{event.replies?.length > 0 ? (
<div>
<Collapsible.Root open={open} onOpenChange={setOpen}>
<div className="rounded-xl bg-neutral-50 dark:bg-neutral-950">
<div className="flex flex-col gap-2 pt-3">
<User pubkey={event.pubkey} time={event.created_at} eventId={event.id} />
<MemoizedTextKind content={event.content} />
<div className="-ml-1 flex items-center justify-between">
{event.replies?.length > 0 ? (
<Collapsible.Trigger asChild>
<div className="inline-flex h-10 items-center gap-1 font-semibold text-blue-500">
<div className="ml-4 inline-flex h-14 items-center gap-1 font-semibold text-blue-500">
<NavArrowDownIcon
className={twMerge('h-3 w-3', open ? 'rotate-180 transform' : '')}
/>
@ -39,13 +29,23 @@ export function Reply({ event, root }: { event: NDKEventWithReplies; root?: stri
(event.replies?.length === 1 ? 'reply' : 'replies')}
</div>
</Collapsible.Trigger>
<Collapsible.Content>
{event.replies?.map((sub) => <SubReply key={sub.id} event={sub} />)}
</Collapsible.Content>
</div>
) : null}
<NoteActions
id={event.id}
pubkey={event.pubkey}
root={root}
extraButtons={false}
/>
</div>
</div>
<div className={twMerge('px-3', open ? 'pb-3' : '')}>
{event.replies?.length > 0 ? (
<Collapsible.Content>
{event.replies?.map((sub) => <SubReply key={sub.id} event={sub} />)}
</Collapsible.Content>
) : null}
</Collapsible.Root>
</div>
</div>
</div>
</Collapsible.Root>
);
}

View File

@ -48,6 +48,7 @@ export function ReplyList({ eventId }: { eventId: string }) {
return (
<div className="mt-3 flex flex-col gap-5">
<h3 className="font-semibold">Replies</h3>
{data?.length === 0 ? (
<div className="mt-2 flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">

View File

@ -5,7 +5,7 @@ import { User } from '@shared/user';
export function SubReply({ event }: { event: NDKEvent }) {
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 rounded-lg bg-neutral-100 pt-3 dark:bg-neutral-900">
<User pubkey={event.pubkey} time={event.created_at} eventId={event.id} />
<MemoizedTextKind content={event.content} />
<div className="-ml-1">

View File

@ -1,85 +0,0 @@
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { decode } from 'light-bolt11-decoder';
import { useNDK } from '@libs/ndk/provider';
import { LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/number';
export function NoteStats({ id }: { id: string }) {
const { ndk } = useNDK();
const { status, data } = useQuery({
queryKey: ['note-stats', id],
queryFn: async () => {
let reactions = 0;
let reposts = 0;
let zaps = 0;
const filter: NDKFilter = {
'#e': [id],
kinds: [6, 7, 9735],
};
const events = await ndk.fetchEvents(filter);
events.forEach((event: NDKEvent) => {
switch (event.kind) {
case 6:
reposts += 1;
break;
case 7:
reactions += 1;
break;
case 9735: {
const bolt11 = event.tags.find((tag) => tag[0] === 'bolt11')[1];
if (bolt11) {
const decoded = decode(bolt11);
const amount = decoded.sections.find((item) => item.name === 'amount');
const sats = amount.value / 1000;
zaps += sats;
}
break;
}
default:
break;
}
});
return { reposts, reactions, zaps };
},
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
if (status === 'pending') {
return (
<div className="flex h-11 items-center">
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
</div>
);
}
return (
<div className="mt-3 flex w-full flex-wrap gap-2">
<div className="flex flex-1 flex-col rounded-lg bg-neutral-100 px-3 py-2 dark:bg-neutral-900">
<div className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data.reactions)}
</div>
<div className="text-sm text-neutral-500 dark:text-neutral-300">Reactions</div>
</div>
<div className="flex flex-1 flex-col rounded-lg bg-neutral-100 px-3 py-2 dark:bg-neutral-900">
<div className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data.reposts)}
</div>
<div className="text-sm text-neutral-500 dark:text-neutral-300">Reposts</div>
</div>
<div className="flex flex-1 flex-col rounded-lg bg-neutral-100 px-3 py-2 dark:bg-neutral-900">
<div className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data.zaps)}
</div>
<div className="text-sm text-neutral-500 dark:text-neutral-300">Zaps</div>
</div>
</div>
);
}

View File

@ -298,7 +298,7 @@ export const User = memo(function User({
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg"
className="h-10 w-10 rounded-lg object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img

View File

@ -3,8 +3,6 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
import { UserStats } from '@app/users/components/stats';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
@ -71,10 +69,9 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
<img
src={user?.picture || user?.image}
alt={pubkey}
className="h-12 w-12 shrink-0 rounded-lg"
className="h-12 w-12 shrink-0 rounded-lg object-cover"
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
/>
<div className="inline-flex items-center gap-2">
{followed ? (
@ -102,7 +99,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
</Link>
</div>
</div>
<div className="mt-2 flex flex-1 flex-col">
<div className="mt-2 flex flex-1 flex-col gap-1.5">
<div className="flex flex-col">
<h5 className="text-lg font-semibold">
{user?.name || user?.display_name || user?.displayName || 'Anon'}
@ -119,11 +116,8 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
</span>
)}
</div>
<div className="flex flex-col gap-3">
<p className="mb-3 mt-2 max-w-[500px] select-text break-words text-neutral-900 dark:text-neutral-100">
{user?.about}
</p>
<UserStats pubkey={pubkey} />
<div className="max-w-[500px] select-text break-words text-neutral-900 dark:text-neutral-100">
{user?.about}
</div>
</div>
</div>

View File

@ -17,6 +17,8 @@ import { TitleBar } from '@shared/titleBar';
import { WidgetWrapper } from '@shared/widgets';
import { LiveUpdater } from '@shared/widgets';
import { FETCH_LIMIT } from '@stores/constants';
export function NewsfeedWidget() {
const { db } = useStorage();
const { relayUrls, ndk, fetcher } = useNDK();
@ -40,7 +42,7 @@ export function NewsfeedWidget() {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: db.account.circles,
},
20,
FETCH_LIMIT,
{ asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal }
);

View File

@ -7,7 +7,6 @@ import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { FollowIcon, UnfollowIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { compactNumber } from '@utils/number';
import { shortenKey } from '@utils/shortenKey';
@ -95,8 +94,9 @@ export function NostrBandUserProfile({ data }: { data: Profile }) {
<div className="rounded-xl bg-neutral-100 px-5 py-5 dark:bg-neutral-900">
<div className="flex items-center justify-between">
<div className="inline-flex items-center gap-2">
<Image
<img
src={profile.picture}
alt={data.pubkey}
className="h-11 w-11 shrink-0 rounded-lg object-cover"
/>
<div className="inline-flex flex-col">
@ -128,12 +128,10 @@ export function NostrBandUserProfile({ data }: { data: Profile }) {
)}
</div>
</div>
<div className="mt-2">
<p className="whitespace-pre-line break-words text-neutral-900 dark:text-neutral-100">
{profile.about || profile.bio}
</p>
<div className="mt-2 whitespace-pre-line break-words text-neutral-900 dark:text-neutral-100">
{profile.about || profile.bio}
</div>
<div className="mt-8">
<div className="mt-5">
{status === 'pending' ? (
<p>Loading...</p>
) : (

View File

@ -11,6 +11,8 @@ import { MemoizedNotifyNote, NoteSkeleton } from '@shared/notes';
import { TitleBar } from '@shared/titleBar';
import { WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@stores/constants';
import { useNostr } from '@utils/hooks/useNostr';
import { sendNativeNotification } from '@utils/notification';
@ -37,7 +39,7 @@ export function NotificationWidget() {
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
'#p': [db.account.pubkey],
},
20,
FETCH_LIMIT,
{ asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal }
);

View File

@ -8,7 +8,7 @@ export const FULL_RELAYS = [
'wss://nostr.mutinywallet.com',
];
export const FETCH_LIMIT = 50;
export const FETCH_LIMIT = 20;
export const WidgetKinds = {
local: {

View File

@ -1,153 +0,0 @@
import { nip19 } from 'nostr-tools';
import {
AddressPointer,
EventPointer,
ProfilePointer,
} from 'nostr-tools/lib/types/nip19';
import { RichContent } from '@utils/types';
function isURL(string: string) {
try {
const url = new URL(string);
if (url.protocol.length > 0) {
if (url.protocol === 'https:' || url.protocol === 'http:') {
return true;
} else {
return false;
}
}
return true;
} catch (e) {
return false;
}
}
export function parser(content: string) {
const richContent: RichContent = {
parsed: null,
images: [],
videos: [],
links: [],
notes: [],
};
const parsed = content
.trim()
.split(/(\s+)/)
.map((word) => {
// url
if (isURL(word)) {
const url = new URL(word);
url.search = '';
if (url.pathname.match(/\.(jpg|jpeg|gif|png|webp|avif|tiff)$/)) {
// image url
richContent.images.push(word);
// remove url from original content
return word.replace(word, '');
}
if (url.pathname.match(/\.(mp4|mov|webm|wmv|flv|mts|avi|ogv|mkv|mp3|m3u8)$/)) {
// video url
richContent.videos.push(word);
// remove url from original content
return word.replace(word, '');
}
// normal url
if (richContent.links.length < 1) {
richContent.links.push(url.toString());
}
}
// hashtag
if (word.startsWith('#') && word.length > 1) {
return word.replace(word, `<Hashtag tag='${word}' />`);
}
// boost
if (word.startsWith('$prism') && word.length > 1) {
return word.replace(word, `<Boost boost='${word}' />`);
}
// nostr account references (depreciated)
if (word.startsWith('@npub1')) {
const npub = word.replace('@', '').replace(/[^a-zA-Z0-9 ]/g, '');
try {
const pubkey = nip19.decode(npub).data as string;
return word.replace(word, `<MentionUser pubkey='${pubkey}' />`);
} catch {
return word;
}
}
// nostr account references
if (word.startsWith('nostr:npub1') || word.startsWith('npub1')) {
const npub = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
try {
const pubkey = nip19.decode(npub).data as string;
return word.replace(word, `<MentionUser pubkey='${pubkey}' />`);
} catch {
return word;
}
}
// nostr profile references
if (word.startsWith('nostr:nprofile1') || word.startsWith('nprofile1')) {
const nprofile = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
try {
const decoded = nip19.decode(nprofile).data as ProfilePointer;
return word.replace(word, `<MentionUser pubkey='${decoded.pubkey}' />`);
} catch {
return word;
}
}
// nostr address references
if (word.startsWith('nostr:naddr1') || word.startsWith('naddr1')) {
const naddr = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
try {
const decoded = nip19.decode(naddr).data as AddressPointer;
return word.replace(word, `<MentionUser pubkey='${decoded.pubkey}' />`);
} catch {
return word;
}
}
// lightning invoice
if (word.startsWith('lnbc') && word.length > 60) {
return word.replace(word, `<Invoice invoice='${word}' />`);
}
// nostr note references
if (word.startsWith('nostr:note1') || word.startsWith('note1')) {
const note = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
try {
const eventId = nip19.decode(note).data as string;
richContent.notes.push(eventId);
return word.replace(word, '');
} catch {
return word;
}
}
// nostr event references
if (word.startsWith('nostr:nevent1') || word.startsWith('nevent1')) {
const nevent = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
try {
const decoded = nip19.decode(nevent).data as EventPointer;
richContent.notes.push(decoded.id);
return word.replace(word, '');
} catch {
return word;
}
}
return word;
});
// update content with parsed version
richContent.parsed = parsed.join(' ').trim();
return richContent;
}