mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-10 12:49:29 +02:00
add collapsible chat windows
This commit is contained in:
parent
99a2c0bd08
commit
305cd62946
121
src/components/chat-windows/chat-window.tsx
Normal file
121
src/components/chat-windows/chat-window.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Card, CardBody, CardHeader, CloseButton, Flex, Heading, IconButton, useToast } from "@chakra-ui/react";
|
||||
import { Kind } from "nostr-tools";
|
||||
import dayjs from "dayjs";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "../icons";
|
||||
import UserName from "../user-name";
|
||||
import MagicTextArea from "../magic-textarea";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import { useReadRelayUrls, useWriteRelayUrls } from "../../hooks/use-client-relays";
|
||||
import { useUserRelays } from "../../hooks/use-user-relays";
|
||||
import { RelayMode } from "../../classes/relay";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import IntersectionObserverProvider from "../../providers/intersection-observer";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import Message from "../../views/messages/message";
|
||||
import { LightboxProvider } from "../lightbox-provider";
|
||||
import { useSigningContext } from "../../providers/signing-provider";
|
||||
import { DraftNostrEvent } from "../../types/nostr-event";
|
||||
import NostrPublishAction from "../../classes/nostr-publish-action";
|
||||
import { correctContentMentions, createEmojiTags } from "../../helpers/nostr/post";
|
||||
import { useContextEmojis } from "../../providers/emoji-provider";
|
||||
|
||||
export default function ChatWindow({ pubkey, onClose }: { pubkey: string; onClose: () => void }) {
|
||||
const toast = useToast();
|
||||
const account = useCurrentAccount()!;
|
||||
const emojis = useContextEmojis();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
const usersRelays = useUserRelays(pubkey);
|
||||
const readRelays = useReadRelayUrls(usersRelays.filter((c) => c.mode & RelayMode.WRITE).map((c) => c.url));
|
||||
const writeRelays = useWriteRelayUrls(usersRelays.filter((c) => c.mode & RelayMode.WRITE).map((c) => c.url));
|
||||
|
||||
const timeline = useTimelineLoader(`${pubkey}-${account.pubkey}-messages`, readRelays, [
|
||||
{ authors: [account.pubkey], kinds: [Kind.EncryptedDirectMessage], "#p": [pubkey] },
|
||||
{ authors: [pubkey], kinds: [Kind.EncryptedDirectMessage], "#p": [account.pubkey] },
|
||||
]);
|
||||
|
||||
const { handleSubmit, getValues, setValue, formState, watch, reset } = useForm({ defaultValues: { content: "" } });
|
||||
watch("content");
|
||||
const { requestSignature, requestEncrypt } = useSigningContext();
|
||||
const submit = handleSubmit(async (values) => {
|
||||
try {
|
||||
if (!values.content) return;
|
||||
let draft: DraftNostrEvent = {
|
||||
kind: Kind.EncryptedDirectMessage,
|
||||
content: values.content,
|
||||
tags: [["p", pubkey]],
|
||||
created_at: dayjs().unix(),
|
||||
};
|
||||
|
||||
draft = createEmojiTags(draft, emojis);
|
||||
draft.content = correctContentMentions(draft.content);
|
||||
|
||||
// encrypt content
|
||||
draft.content = await requestEncrypt(draft.content, pubkey);
|
||||
|
||||
const signed = await requestSignature(draft);
|
||||
const pub = new NostrPublishAction("Send DM", writeRelays, signed);
|
||||
|
||||
reset();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ status: "error", description: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
const messages = useSubject(timeline.timeline);
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<Card size="sm" borderRadius="md" w={expanded ? "md" : "xs"} variant="outline">
|
||||
<CardHeader display="flex" gap="2" alignItems="center">
|
||||
<Heading size="md" mr="8">
|
||||
<UserName pubkey={pubkey} />
|
||||
</Heading>
|
||||
<IconButton
|
||||
aria-label="Toggle Window"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
variant="ghost"
|
||||
icon={expanded ? <ChevronDownIcon /> : <ChevronUpIcon />}
|
||||
ml="auto"
|
||||
size="sm"
|
||||
/>
|
||||
<CloseButton onClick={onClose} />
|
||||
</CardHeader>
|
||||
{expanded && (
|
||||
<>
|
||||
<CardBody
|
||||
maxH="lg"
|
||||
overflowX="hidden"
|
||||
overflowY="auto"
|
||||
pt="0"
|
||||
display="flex"
|
||||
flexDirection="column-reverse"
|
||||
gap="2"
|
||||
>
|
||||
<LightboxProvider>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
{messages.map((event) => (
|
||||
<Message key={event.id} event={event} />
|
||||
))}
|
||||
</IntersectionObserverProvider>
|
||||
</LightboxProvider>
|
||||
</CardBody>
|
||||
<Flex as="form" onSubmit={submit} gap="2">
|
||||
<MagicTextArea
|
||||
isRequired
|
||||
value={getValues().content}
|
||||
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })}
|
||||
/>
|
||||
<Button type="submit" isLoading={formState.isSubmitting}>
|
||||
Send
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
90
src/components/chat-windows/contacts-window.tsx
Normal file
90
src/components/chat-windows/contacts-window.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CloseButton,
|
||||
Heading,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
} from "@chakra-ui/react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { ChevronDownIcon, ChevronUpIcon, SearchIcon } from "../icons";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import directMessagesService from "../../services/direct-messages";
|
||||
import UserAvatar from "../user-avatar";
|
||||
import UserName from "../user-name";
|
||||
|
||||
export default function ContactsWindow({
|
||||
onClose,
|
||||
onSelectPubkey,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSelectPubkey: (pubkey: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
// TODO: find a better way to load recent contacts
|
||||
const [from, setFrom] = useState(() => dayjs().subtract(2, "days"));
|
||||
const conversations = useSubject(directMessagesService.conversations);
|
||||
useEffect(() => directMessagesService.loadDateRange(from), [from]);
|
||||
const sortedConversations = useMemo(() => {
|
||||
return Array.from(conversations).sort((a, b) => {
|
||||
const latestA = directMessagesService.getUserMessages(a).value[0]?.created_at ?? 0;
|
||||
const latestB = directMessagesService.getUserMessages(b).value[0]?.created_at ?? 0;
|
||||
|
||||
return latestB - latestA;
|
||||
});
|
||||
}, [conversations]);
|
||||
|
||||
return (
|
||||
<Card size="sm" borderRadius="md" minW={expanded ? "sm" : 0}>
|
||||
<CardHeader display="flex" gap="2" alignItems="center">
|
||||
<Heading size="md" mr="8">
|
||||
Contacts
|
||||
</Heading>
|
||||
<IconButton
|
||||
aria-label="Toggle Window"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
variant="ghost"
|
||||
icon={expanded ? <ChevronDownIcon /> : <ChevronUpIcon />}
|
||||
ml="auto"
|
||||
size="sm"
|
||||
/>
|
||||
<CloseButton onClick={onClose} />
|
||||
</CardHeader>
|
||||
{expanded && (
|
||||
<CardBody maxH="lg" overflowX="hidden" overflowY="auto" pt="0" display="flex" flexDirection="column" gap="2">
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Work in progress!
|
||||
</Alert>
|
||||
{/* <InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<SearchIcon />
|
||||
</InputLeftElement>
|
||||
<Input autoFocus />
|
||||
</InputGroup> */}
|
||||
{sortedConversations.map((pubkey) => (
|
||||
<Button
|
||||
key={pubkey}
|
||||
leftIcon={<UserAvatar pubkey={pubkey} size="sm" />}
|
||||
justifyContent="flex-start"
|
||||
p="2"
|
||||
variant="ghost"
|
||||
onClick={() => onSelectPubkey(pubkey)}
|
||||
>
|
||||
<UserName pubkey={pubkey} />
|
||||
</Button>
|
||||
))}
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
56
src/components/chat-windows/index.tsx
Normal file
56
src/components/chat-windows/index.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { Flex, IconButton } from "@chakra-ui/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
|
||||
import ContactsWindow from "./contacts-window";
|
||||
import { DirectMessagesIcon } from "../icons";
|
||||
import ChatWindow from "./chat-window";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
|
||||
export default function ChatWindows() {
|
||||
const account = useCurrentAccount();
|
||||
const [pubkeys, setPubkeys] = useState<string[]>([]);
|
||||
const [show, setShow] = useLocalStorage("show-chat-windows", false);
|
||||
|
||||
const openPubkey = useCallback(
|
||||
(pubkey: string) => {
|
||||
setPubkeys((keys) => (keys.includes(pubkey) ? keys : keys.concat(pubkey)));
|
||||
},
|
||||
[setPubkeys],
|
||||
);
|
||||
|
||||
const closePubkey = useCallback(
|
||||
(pubkey: string) => {
|
||||
setPubkeys((keys) => keys.filter((key) => key !== pubkey));
|
||||
},
|
||||
[setPubkeys],
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!show) {
|
||||
return (
|
||||
<IconButton
|
||||
icon={<DirectMessagesIcon boxSize={6} />}
|
||||
aria-label="Show Contacts"
|
||||
onClick={() => setShow(true)}
|
||||
position="fixed"
|
||||
bottom="0"
|
||||
right="0"
|
||||
size="lg"
|
||||
zIndex={1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex direction="row-reverse" position="fixed" bottom="0" right="0" gap="4" alignItems="flex-end" zIndex={1}>
|
||||
<ContactsWindow onClose={() => setShow(false)} onSelectPubkey={openPubkey} />
|
||||
{pubkeys.map((pubkey) => (
|
||||
<ChatWindow key={pubkey} pubkey={pubkey} onClose={() => closePubkey(pubkey)} />
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -12,6 +12,7 @@ import GhostToolbar from "./ghost-toolbar";
|
||||
import { useBreakpointValue } from "../../providers/breakpoint-provider";
|
||||
import SearchModal from "../search-modal";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import ChatWindows from "../chat-windows";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
@ -65,6 +66,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
</Flex>
|
||||
{isGhost && <GhostToolbar />}
|
||||
{searchModal.isOpen && <SearchModal isOpen onClose={searchModal.onClose} />}
|
||||
{!isMobile && <ChatWindows />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ import useSubject from "../../../hooks/use-subject";
|
||||
import { getMatchLink } from "../../../helpers/regexp";
|
||||
import { LightboxProvider } from "../../lightbox-provider";
|
||||
import { isImageURL } from "../../../helpers/url";
|
||||
import { EmbeddedImage, EmbeddedImageProps, GalleryImage } from "../../embed-types";
|
||||
import { EmbeddedImageProps, GalleryImage } from "../../embed-types";
|
||||
import { TrustProvider } from "../../../providers/trust";
|
||||
import PhotoGallery, { PhotoWithoutSize } from "../../photo-gallery";
|
||||
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
|
||||
|
14
src/components/user-name.tsx
Normal file
14
src/components/user-name.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Text, TextProps } from "@chakra-ui/react";
|
||||
|
||||
import { getUserDisplayName } from "../helpers/user-metadata";
|
||||
import { useUserMetadata } from "../hooks/use-user-metadata";
|
||||
|
||||
export default function UserName({ pubkey, ...props }: Omit<TextProps, "children"> & { pubkey: string }) {
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
|
||||
return (
|
||||
<Text as="span" whiteSpace="nowrap" fontWeight="bold" {...props}>
|
||||
{getUserDisplayName(metadata, pubkey)}
|
||||
</Text>
|
||||
);
|
||||
}
|
@ -13,6 +13,7 @@ export function getMessageRecipient(event: NostrEvent): string | undefined {
|
||||
return event.tags.filter(isPTag)[0][1];
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
class DirectMessagesService {
|
||||
incomingSub: NostrMultiSubscription;
|
||||
outgoingSub: NostrMultiSubscription;
|
||||
@ -129,6 +130,7 @@ class DirectMessagesService {
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
const directMessagesService = new DirectMessagesService();
|
||||
|
||||
export default directMessagesService;
|
||||
|
@ -22,6 +22,7 @@ import NostrPublishAction from "../../../classes/nostr-publish-action";
|
||||
import { useWriteRelayUrls } from "../../../hooks/use-client-relays";
|
||||
import CommunityPost from "../components/community-post";
|
||||
import { RouterContext } from "../community-home";
|
||||
import useUserMuteFilter from "../../../hooks/use-user-mute-filter";
|
||||
|
||||
type PendingProps = {
|
||||
event: NostrEvent;
|
||||
@ -84,13 +85,14 @@ function ModPendingPost({ event, community, approvals }: PendingProps) {
|
||||
|
||||
export default function CommunityPendingView() {
|
||||
const account = useCurrentAccount();
|
||||
const muteFilter = useUserMuteFilter();
|
||||
const { community, timeline } = useOutletContext<RouterContext>();
|
||||
|
||||
const events = useSubject(timeline.timeline);
|
||||
|
||||
const mods = getCommunityMods(community);
|
||||
const approvals = buildApprovalMap(events, mods);
|
||||
const pending = events.filter((e) => e.kind !== COMMUNITY_APPROVAL_KIND && !approvals.has(e.id));
|
||||
const pending = events.filter((e) => e.kind !== COMMUNITY_APPROVAL_KIND && !approvals.has(e.id) && !muteFilter(e));
|
||||
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
|
@ -13,7 +13,7 @@ import { useSigningContext } from "../../providers/signing-provider";
|
||||
import clientRelaysService from "../../services/client-relays";
|
||||
import { DraftNostrEvent } from "../../types/nostr-event";
|
||||
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||
import { Message } from "./message";
|
||||
import Message from "./message";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
@ -22,7 +22,6 @@ import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-
|
||||
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
|
||||
import NostrPublishAction from "../../classes/nostr-publish-action";
|
||||
import { LightboxProvider } from "../../components/lightbox-provider";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
|
||||
function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||
const toast = useToast();
|
||||
|
@ -31,7 +31,7 @@ export function MessageContent({ event, text }: { event: NostrEvent; text: strin
|
||||
return <Box whiteSpace="pre-wrap">{content}</Box>;
|
||||
}
|
||||
|
||||
export function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">) {
|
||||
export default function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">) {
|
||||
const account = useCurrentAccount()!;
|
||||
const isOwnMessage = account.pubkey === event.pubkey;
|
||||
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
useColorMode,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { ReplyIcon } from "../../../components/icons";
|
||||
@ -20,7 +21,6 @@ import useClientSideMuteFilter from "../../../hooks/use-client-side-mute-filter"
|
||||
import UserAvatarLink from "../../../components/user-avatar-link";
|
||||
import { UserLink } from "../../../components/user-link";
|
||||
import Timestamp from "../../../components/timestamp";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { NoteContents } from "../../../components/note/text-note-contents";
|
||||
import Expand01 from "../../../components/icons/expand-01";
|
||||
import Minus from "../../../components/icons/minus";
|
||||
@ -32,7 +32,6 @@ import useSubject from "../../../hooks/use-subject";
|
||||
import appSettings from "../../../services/settings/app-settings";
|
||||
import { useBreakpointValue } from "../../../providers/breakpoint-provider";
|
||||
import NoteReactions from "../../../components/note/components/note-reactions";
|
||||
import { useCookie } from "react-use";
|
||||
import BookmarkButton from "../../../components/note/components/bookmark-button";
|
||||
|
||||
const LEVEL_COLORS = ["green", "blue", "red", "purple", "yellow", "cyan", "pink"];
|
||||
@ -133,7 +132,7 @@ export const ThreadPost = ({ post, initShowReplies, focusId, level = -1 }: Threa
|
||||
gap="2"
|
||||
p="2"
|
||||
borderRadius="md"
|
||||
borderWidth=".2rem .2rem .2rem .35rem"
|
||||
borderWidth=".1rem .1rem .1rem .35rem"
|
||||
borderColor={focusId === post.event.id ? focusColor : undefined}
|
||||
borderLeftColor={color + "." + colorValue}
|
||||
>
|
||||
|
Loading…
x
Reference in New Issue
Block a user