add collapsible chat windows

This commit is contained in:
hzrd149 2023-10-29 21:18:58 -05:00
parent 99a2c0bd08
commit 305cd62946
11 changed files with 293 additions and 8 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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 />}
</>
);
}

View File

@ -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";

View 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>
);
}

View File

@ -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;

View File

@ -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);

View File

@ -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();

View File

@ -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;

View File

@ -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}
>