wip: finish browse users

This commit is contained in:
Ren Amamiya
2023-09-25 14:35:47 +07:00
parent 9ff74599eb
commit a66770989b
19 changed files with 895 additions and 267 deletions

View File

@@ -0,0 +1,29 @@
import { BaseEdge, EdgeProps, getBezierPath } from 'reactflow';
export function Edge({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
}: EdgeProps) {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
return (
<BaseEdge
path={edgePath}
markerEnd={markerEnd}
style={{ ...style, stroke: '#71717a' }}
/>
);
}

View File

@@ -0,0 +1,17 @@
import { memo } from 'react';
import { useProfile } from '@utils/hooks/useProfile';
export const GroupTitle = memo(function GroupTitle({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
if (status === 'loading') {
return <div className="h-3 w-24 animate-pulse rounded bg-white/10" />;
}
return (
<h3 className="text-sm font-semibold text-fuchsia-500">{`${
user.name || user.display_name
}'s network`}</h3>
);
});

View File

@@ -0,0 +1,14 @@
export function Line({ fromX, fromY, toX, toY }) {
return (
<g>
<path
fill="none"
stroke="#f5d0fe"
strokeWidth={1.5}
className="animated"
d={`M${fromX},${fromY} C ${fromX} ${toY} ${fromX} ${toY} ${toX},${toY}`}
/>
<circle cx={toX} cy={toY} fill="#fff" r={3} stroke="#f5d0fe" strokeWidth={1.5} />
</g>
);
}

View File

@@ -1,142 +0,0 @@
import { useDraggable } from '@dnd-kit/core';
import * as Dialog from '@radix-ui/react-dialog';
import { memo, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { Image } from '@shared/image';
import { NIP05 } from '@shared/nip05';
import { TextNote } from '@shared/notes';
import { User } from '@shared/user';
import { useNostr } from '@utils/hooks/useNostr';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export const UserDrawer = memo(function UserDrawer({ pubkey }: { pubkey: string }) {
const { db } = useStorage();
const { status, user } = useProfile(pubkey);
const { addContact, removeContact } = useNostr();
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: pubkey,
});
const [followed, setFollowed] = useState(false);
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: 20,
}
: undefined;
const followUser = (pubkey: string) => {
try {
addContact(pubkey);
// update state
setFollowed(true);
} catch (error) {
console.log(error);
}
};
const unfollowUser = (pubkey: string) => {
try {
removeContact(pubkey);
// update state
setFollowed(false);
} catch (error) {
console.log(error);
}
};
useEffect(() => {
if (db.account.follows.includes(pubkey)) {
setFollowed(true);
}
}, []);
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button
type="button"
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
>
<User pubkey={pubkey} variant="avatar" />
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content className="fixed right-0 top-0 z-50 flex h-full w-[400px] items-center justify-center px-4 pb-4 pt-16">
<div className="h-full w-full overflow-y-auto rounded-lg border-t border-white/10 bg-white/20 px-3 py-3 backdrop-blur-3xl">
{status === 'loading' ? (
<div>
<p>Loading...</p>
</div>
) : (
<div className="flex flex-col">
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="h-14 w-14 rounded-lg"
/>
<div className="mt-2 flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-2">
<h5 className="text-lg font-semibold leading-none">
{user?.displayName || user?.name || 'No name'}
</h5>
{user?.nip05 ? (
<NIP05
pubkey={pubkey}
nip05={user?.nip05}
className="max-w-[15rem] truncate text-sm leading-none text-white/50"
/>
) : (
<span className="max-w-[15rem] truncate text-sm leading-none text-white/50">
{displayNpub(pubkey, 16)}
</span>
)}
</div>
<div className="flex flex-col gap-4">
{user.about ? <TextNote content={user.about} /> : null}
</div>
<div className="mt-4 inline-flex items-center gap-2">
{followed ? (
<button
type="button"
onClick={() => unfollowUser(pubkey)}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
>
Unfollow
</button>
) : (
<button
type="button"
onClick={() => followUser(pubkey)}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
>
Follow
</button>
)}
<Link
to={`/chats/${pubkey}`}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
>
Message
</Link>
</div>
</div>
</div>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
});

View File

@@ -1,22 +0,0 @@
import { useDroppable } from '@dnd-kit/core';
import { twMerge } from 'tailwind-merge';
import { PlusIcon } from '@shared/icons';
export function UserDropable() {
const { isOver, setNodeRef } = useDroppable({
id: 'newBlock',
});
return (
<div
ref={setNodeRef}
className={twMerge(
'inline-flex h-12 w-12 items-center justify-center rounded-lg border-t border-white/10 backdrop-blur-xl',
isOver ? 'bg-fuchsia-500' : 'bg-white/20 hover:bg-white/30'
)}
>
<PlusIcon className="h-4 w-4 text-white" />
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Handle, Position } from 'reactflow';
import { UserWithDrawer } from '@app/browse/components/userWithDrawer';
import { GroupTitle } from './groupTitle';
export function UserGroupNode({ data }) {
return (
<>
<Handle
type="target"
position={Position.Top}
className="h-2 w-5 rounded-full border-none !bg-fuchsia-400"
/>
<div className="relative mx-3 my-3 flex flex-col gap-1">
{data.title ? (
<h3 className="text-sm font-semibold text-fuchsia-500">{data.title}</h3>
) : (
<GroupTitle pubkey={data.pubkey} />
)}
<div className="grid grid-cols-5 gap-6 rounded-lg border border-fuchsia-500/50 bg-fuchsia-500/10 p-4">
{data.list.map((user: string) => (
<UserWithDrawer key={user} pubkey={user} />
))}
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="h-2 w-5 rounded-full border-none !bg-fuchsia-400"
/>
</>
);
}

View File

@@ -0,0 +1,80 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { LoaderIcon } from '@shared/icons';
import {
ArticleNote,
FileNote,
NoteWrapper,
Repost,
TextNote,
UnknownNote,
} from '@shared/notes';
import { useNostr } from '@utils/hooks/useNostr';
export function UserLatestPosts({ pubkey }: { pubkey: string }) {
const { getEventsByPubkey } = useNostr();
const { status, data } = useQuery(['user-posts', pubkey], async () => {
return await getEventsByPubkey(pubkey);
});
const renderItem = useCallback(
(event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return (
<NoteWrapper key={event.id} event={event}>
<TextNote content={event.content} />
</NoteWrapper>
);
case NDKKind.Repost:
return <Repost key={event.id} event={event} />;
case 1063:
return (
<NoteWrapper key={event.id} event={event}>
<FileNote event={event} />
</NoteWrapper>
);
case NDKKind.Article:
return (
<NoteWrapper key={event.id} event={event}>
<ArticleNote event={event} />
</NoteWrapper>
);
default:
return (
<NoteWrapper key={event.id} event={event}>
<UnknownNote event={event} />
</NoteWrapper>
);
}
},
[data]
);
return (
<div className="mt-4 border-t border-white/5 pt-3">
<h3 className="mb-4 px-3 font-semibold text-white">Latest post</h3>
<div>
{status === 'loading' ? (
<div className="px-3">
<div className="inline-flex h-16 w-full items-center justify-center gap-1.5 rounded-lg bg-white/10 text-sm font-medium text-white/70">
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
Loading latest posts...
</div>
</div>
) : data.length < 1 ? (
<div className="px-3">
<div className="inline-flex h-16 w-full items-center justify-center rounded-lg bg-white/10 text-sm font-medium text-white/70">
No posts from 24 hours ago
</div>
</div>
) : (
data.map((event) => renderItem(event))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { Handle, Position } from 'reactflow';
import { User } from '@shared/user';
export function UserNode({ data }) {
return (
<>
<div className="relative mx-3 my-3 inline-flex h-12 w-12 shrink-0 items-center justify-center">
<span className="absolute inline-flex h-8 w-8 animate-ping rounded-lg bg-green-400 opacity-75"></span>
<div className="relative z-10">
<User pubkey={data.pubkey} variant="avatar" />
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="h-2 w-2 rounded-full border-none !bg-white/20"
/>
</>
);
}

View File

@@ -0,0 +1,130 @@
import * as Dialog from '@radix-ui/react-dialog';
import { memo, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { Image } from '@shared/image';
import { NIP05 } from '@shared/nip05';
import { TextNote } from '@shared/notes';
import { User } from '@shared/user';
import { useNostr } from '@utils/hooks/useNostr';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
import { UserLatestPosts } from './userLatestPosts';
export const UserWithDrawer = memo(function UserWithDrawer({
pubkey,
}: {
pubkey: string;
}) {
const { addContact, removeContact } = useNostr();
const { db } = useStorage();
const { status, user } = useProfile(pubkey);
const [followed, setFollowed] = useState(false);
const followUser = (pubkey: string) => {
try {
addContact(pubkey);
// update state
setFollowed(true);
} catch (error) {
console.log(error);
}
};
const unfollowUser = (pubkey: string) => {
try {
removeContact(pubkey);
// update state
setFollowed(false);
} catch (error) {
console.log(error);
}
};
useEffect(() => {
if (db.account.follows.includes(pubkey)) {
setFollowed(true);
}
}, []);
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button type="button">
<User pubkey={pubkey} variant="avatar" />
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content className="fixed right-0 top-0 z-50 flex h-full w-[400px] items-center justify-center px-4 pb-4 pt-16">
<div className="h-full w-full overflow-y-auto rounded-lg border-t border-white/10 bg-white/20 py-3 backdrop-blur-3xl">
{status === 'loading' ? (
<div>
<p>Loading...</p>
</div>
) : (
<>
<div className="flex flex-col gap-3 px-3">
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="h-12 w-12 rounded-lg"
/>
<div className="flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-1.5">
<h5 className="text-lg font-semibold leading-none">
{user?.displayName || user?.name || 'No name'}
</h5>
{user?.nip05 ? (
<NIP05
pubkey={pubkey}
nip05={user?.nip05}
className="max-w-[15rem] truncate text-sm leading-none text-white/50"
/>
) : (
<span className="max-w-[15rem] truncate text-sm leading-none text-white/50">
{displayNpub(pubkey, 16)}
</span>
)}
{user.about ? <TextNote content={user.about} /> : null}
</div>
<div className="mt-3 inline-flex items-center gap-2">
{followed ? (
<button
type="button"
onClick={() => unfollowUser(pubkey)}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
>
Unfollow
</button>
) : (
<button
type="button"
onClick={() => followUser(pubkey)}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
>
Follow
</button>
)}
<Link
to={`/chats/${pubkey}`}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
>
Message
</Link>
</div>
</div>
</div>
<UserLatestPosts pubkey={pubkey} />
</>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
});

View File

@@ -1,45 +1,43 @@
import { NavLink, Outlet } from 'react-router-dom';
import { ReactFlowProvider } from 'reactflow';
import { twMerge } from 'tailwind-merge';
import { DotsPattern } from '@shared/icons';
export function BrowseScreen() {
return (
<div className="relative h-full w-full">
<div className="absolute left-0 right-0 top-4 z-30 flex w-full items-center justify-between px-3">
<div className="w-10" />
<div className="inline-flex gap-1 rounded-full border-t border-white/10 bg-white/20 p-1 backdrop-blur-xl">
<NavLink
to="/browse/"
className={({ isActive }) =>
twMerge(
'inline-flex h-8 w-20 items-center justify-center rounded-full text-sm font-semibold',
isActive ? 'bg-white/10 hover:bg-white/20' : ' hover:bg-white/5'
)
}
>
Users
</NavLink>
<NavLink
to="/browse/relays"
className={({ isActive }) =>
twMerge(
'inline-flex h-8 w-20 items-center justify-center rounded-full text-sm font-semibold',
isActive ? 'bg-white/10 hover:bg-white/20' : ' hover:bg-white/5'
)
}
>
Relays
</NavLink>
<ReactFlowProvider>
<div className="relative h-full w-full">
<div className="absolute left-0 right-0 top-4 z-30 flex w-full items-center justify-between px-3">
<div className="w-10" />
<div className="inline-flex gap-1 rounded-full border-t border-white/10 bg-white/20 p-1 backdrop-blur-xl">
<NavLink
to="/browse/"
className={({ isActive }) =>
twMerge(
'inline-flex h-8 w-20 items-center justify-center rounded-full text-sm font-semibold',
isActive ? 'bg-white/10 hover:bg-white/20' : ' hover:bg-white/5'
)
}
>
Users
</NavLink>
<NavLink
to="/browse/relays"
className={({ isActive }) =>
twMerge(
'inline-flex h-8 w-20 items-center justify-center rounded-full text-sm font-semibold',
isActive ? 'bg-white/10 hover:bg-white/20' : ' hover:bg-white/5'
)
}
>
Relays
</NavLink>
</div>
<div className="w-10" />
</div>
<div className="relative z-20 h-full w-full">
<Outlet />
</div>
<div className="w-10" />
</div>
<div className="absolute z-10 h-full w-full">
<DotsPattern className="h-full w-full text-white/10" />
</div>
<div className="relative z-20 h-full w-full">
<Outlet />
</div>
</div>
</ReactFlowProvider>
);
}

View File

@@ -1,42 +1,116 @@
import { DndContext } from '@dnd-kit/core';
import { useMemo } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import ReactFlow, {
Background,
ConnectionMode,
addEdge,
useEdgesState,
useNodesState,
useReactFlow,
} from 'reactflow';
import { UserDrawer } from '@app/browse/components/userDrawer';
import { UserDropable } from '@app/browse/components/userDropable';
import { Edge } from '@app/browse//components/edge';
import { UserGroupNode } from '@app/browse//components/userGroupNode';
import { Line } from '@app/browse/components/line';
import { UserNode } from '@app/browse/components/userNode';
import { useStorage } from '@libs/storage/provider';
import { User } from '@shared/user';
import { useNostr } from '@utils/hooks/useNostr';
import { getMultipleRandom } from '@utils/transform';
let id = 2;
const getId = () => `${id++}`;
const nodeTypes = { user: UserNode, userGroup: UserGroupNode };
const edgeTypes = { buttonedge: Edge };
export function BrowseUsersScreen() {
const { db } = useStorage();
const { getContactsByPubkey } = useNostr();
const { project } = useReactFlow();
const data = useMemo(() => getMultipleRandom(db.account.follows, 10), []);
const defaultContacts = useMemo(() => getMultipleRandom(db.account.follows, 10), []);
const reactFlowWrapper = useRef(null);
const connectingNodeId = useRef(null);
const handleDragEnd = (event) => {
console.log(event.id);
};
const initialNodes = [
{
id: '0',
type: 'user',
position: { x: 141, y: 0 },
data: { list: [], title: '', pubkey: db.account.pubkey },
},
{
id: '1',
type: 'userGroup',
position: { x: 0, y: 200 },
data: { list: defaultContacts, title: 'Starting Point', pubkey: '' },
},
];
const initialEdges = [{ id: 'e0-1', type: 'buttonedge', source: '0', target: '1' }];
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), []);
const onConnectStart = useCallback((_, { nodeId }) => {
connectingNodeId.current = nodeId;
}, []);
const onConnectEnd = useCallback(
async (event) => {
const targetIsPane = event.target.classList.contains('react-flow__pane');
if (targetIsPane) {
const { top, left } = reactFlowWrapper.current.getBoundingClientRect();
const id = getId();
const prevData = nodes.slice(-1)[0];
const randomPubkey = getMultipleRandom(prevData.data.list, 1)[0];
const newContactList = await getContactsByPubkey(randomPubkey);
const newNode = {
id,
type: 'userGroup',
position: project({ x: event.clientX - left, y: event.clientY - top }),
data: { list: newContactList, title: null, pubkey: randomPubkey },
};
setNodes((nds) => nds.concat(newNode));
setEdges((eds) =>
eds.concat({
id,
type: 'buttonedge',
source: connectingNodeId.current,
target: id,
})
);
}
},
[project]
);
return (
<DndContext onDragEnd={handleDragEnd}>
<div className="scrollbar-hide flex h-full w-full flex-col items-center justify-center overflow-x-auto overflow-y-auto">
<div className="flex items-center gap-16">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-semibold text-fuchsia-500">Follows</h3>
<div className="grid grid-cols-5 gap-6 rounded-lg border border-fuchsia-500/50 bg-fuchsia-500/10 p-4">
{data.map((user) => (
<UserDrawer key={user} pubkey={user} />
))}
</div>
</div>
<div className="flex items-center gap-16">
<User pubkey={db.account.pubkey} variant="avatar" />
<UserDropable />
</div>
</div>
</div>
</DndContext>
<div className="h-full w-full" ref={reactFlowWrapper}>
<ReactFlow
proOptions={{ hideAttribution: true }}
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionLineComponent={Line}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
connectionMode={ConnectionMode.Loose}
minZoom={0.8}
maxZoom={1.2}
fitView
>
<Background color="#3f3f46" />
</ReactFlow>
</div>
);
}

View File

@@ -42,8 +42,8 @@ export function ChatsList() {
return (
<div className="flex flex-col">
{chats.follows.map((item) => renderItem(item))}
{chats.unknowns.length > 0 && <UnknownsModal data={chats.unknowns} />}
{chats?.follows?.map((item) => renderItem(item))}
{chats?.unknowns?.length > 0 && <UnknownsModal data={chats.unknowns} />}
<NewMessageModal />
</div>
);