diff --git a/packages/@justweb3/ui/src/lib/ui/NotificationBadge/NotificationBadge.module.css b/packages/@justweb3/ui/src/lib/ui/NotificationBadge/NotificationBadge.module.css new file mode 100644 index 00000000..d5c0f5a4 --- /dev/null +++ b/packages/@justweb3/ui/src/lib/ui/NotificationBadge/NotificationBadge.module.css @@ -0,0 +1,27 @@ +.notificationIcon { + position: relative; + display: inline-block; + +} + +.icon { + font-size: 24px; /* Adjust icon size */ + line-height: 40px; /* Center the icon vertically */ + text-align: center; +} + +.badge { + position: absolute; + top: 0; + right: 4px; + background-color: red; + color: white; + font-size: 8px; + font-weight: bold; + line-height: 1; + border-radius: 100px; + padding: 2px 4px; + transform: translate(50%, -50%); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + white-space: nowrap; +} diff --git a/packages/@justweb3/ui/src/lib/ui/NotificationBadge/index.tsx b/packages/@justweb3/ui/src/lib/ui/NotificationBadge/index.tsx new file mode 100644 index 00000000..b83fe06d --- /dev/null +++ b/packages/@justweb3/ui/src/lib/ui/NotificationBadge/index.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import styles from './NotificationBadge.module.css'; +import { SPAN } from '../Text'; + +interface NotificationBadgeProps { + count: number; + icon?: React.ReactNode; + maxCount?: number; +} + +export const NotificationBadge: React.FC = ({ + count, + icon, + maxCount = 99, +}) => { + return ( +
+ {/*
{icon}
*/} + {icon} + {/*{count > 0 && {count}}*/} + {count > 0 && ( + + {count > maxCount ? `${maxCount}+` : count} + + )} +
+ ); +}; + +export default NotificationBadge; diff --git a/packages/@justweb3/ui/src/lib/ui/index.ts b/packages/@justweb3/ui/src/lib/ui/index.ts index 3cd6c64c..53e68816 100644 --- a/packages/@justweb3/ui/src/lib/ui/index.ts +++ b/packages/@justweb3/ui/src/lib/ui/index.ts @@ -18,3 +18,4 @@ export * from './Sheet'; export * from './Label'; export * from './Checkbox'; export * from './Skeleton'; +export * from './NotificationBadge'; diff --git a/packages/@justweb3/ui/src/stories/ui/notification-badge.stories.tsx b/packages/@justweb3/ui/src/stories/ui/notification-badge.stories.tsx new file mode 100644 index 00000000..7477aa44 --- /dev/null +++ b/packages/@justweb3/ui/src/stories/ui/notification-badge.stories.tsx @@ -0,0 +1,57 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { NotificationBadge } from '../../lib/ui'; +import React from 'react'; + +const meta: Meta = { + component: NotificationBadge, + title: 'Design System/UI/NotificationBadge', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const ChatIcon = () => { + return ( + + + + ); +}; + +export const Normal: Story = { + args: { + count: 1, + icon: , + }, +}; + +export const Zero: Story = { + args: { + count: 0, + icon: , + }, +}; + +export const Many: Story = { + args: { + count: 50, + icon: , + }, +}; + +export const ManyWithMax: Story = { + args: { + count: 1000, + icon: , + }, +}; diff --git a/packages/@justweb3/widget/src/lib/components/JustWeb3Button/index.tsx b/packages/@justweb3/widget/src/lib/components/JustWeb3Button/index.tsx index c16067a0..2c10fd20 100644 --- a/packages/@justweb3/widget/src/lib/components/JustWeb3Button/index.tsx +++ b/packages/@justweb3/widget/src/lib/components/JustWeb3Button/index.tsx @@ -35,12 +35,14 @@ import styles from './JustWeb3Button.module.css'; export interface JustWeb3Buttonrops { children: ReactNode; + style?: React.CSSProperties; logout?: () => void; } export const JustWeb3Button: FC = ({ children, logout, + style, }) => { const [openMApps, setOpenMApps] = useState(false); const { plugins, mApps, config } = useContext(JustWeb3Context); @@ -89,6 +91,9 @@ export const JustWeb3Button: FC = ({ = ({ onClick={() => { handleOpenSignInDialog(true); }} + style={{ + ...style, + }} left={} right={ = ({ style={{ backgroundColor: 'var(--justweb3-background-color)', color: 'var(--justweb3-primary-color)', + ...style, }} contentStyle={{ alignItems: 'start', @@ -187,32 +196,6 @@ export const JustWeb3Button: FC = ({ const connectedEnsProfileContent = ( - {/**/} - {/* */} - {/* Profile Overview*/} - {/*

*/} - {/* */} - {/* }*/} - {/* onClick={() => {*/} - {/* openEnsProfile(connectedEns?.ens, connectedEns?.chainId);*/} - {/* }}*/} - {/* >*/} - {/* View Full Profile*/} - {/* */} - {/*
*/} - {/* Profile */} | string | null; - openChat: boolean; - closeChat: () => void; - onChangePeer: ( - peer: CachedConversation | string - ) => void; -} - -export const AllMessageSheet: React.FC = ({ - peer, - closeChat, - openChat, - onChangePeer, -}) => { - const isPeerConversation = useMemo(() => { - return typeof peer !== 'string'; - }, [peer]); - - return ( - !open && closeChat()}> - - - {isPeerConversation ? 'Messages' : 'New Conversation'} - - - {peer !== null && - (isPeerConversation ? ( - } - onBack={closeChat} - /> - ) : ( - { - onChangePeer(conversation); - }} - /> - ))} - - - ); -}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/Chat/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/Chat/index.tsx deleted file mode 100644 index 3f454e35..00000000 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/Chat/index.tsx +++ /dev/null @@ -1,618 +0,0 @@ -import { - useEnsAvatar, - useMountedAccount, - usePrimaryName, - useRecords, -} from '@justaname.id/react'; -import { - ArrowIcon, - Avatar, - BlockedAccountIcon, - Button, - Flex, - LoadingSpinner, - P, - Popover, - PopoverContent, - PopoverTrigger, - TuneIcon, -} from '@justweb3/ui'; -import { - CachedConversation, - ContentTypeMetadata, - useCanMessage, - useConsent, - useMessages, - useStreamMessages, -} from '@xmtp/react-sdk'; -import React, { useEffect, useMemo } from 'react'; -import { useSendReactionMessage } from '../../hooks'; -import { typeLookup } from '../../utils/attachments'; -import { - filterReactionsMessages, - MessageWithReaction, -} from '../../utils/filterReactionsMessages'; -import { formatAddress } from '../../utils/formatAddress'; -import { groupMessagesByDate } from '../../utils/groupMessageByDate'; -import EmojiSelector from '../EmojiSelector'; -import MessageCard from '../MessageCard'; -import { MessageSkeletonCard } from '../MessageSkeletonCard'; -import MessageTextField from '../MessageTextField'; -import { useJustWeb3 } from '@justweb3/widget'; - -export interface ChatProps { - conversation: CachedConversation; - onBack: () => void; -} - -export const Chat: React.FC = ({ conversation, onBack }) => { - const { openEnsProfile } = useJustWeb3(); - const [replyMessage, setReplyMessage] = - React.useState(null); - const [reactionMessage, setReactionMessage] = - React.useState(null); - const [isRequest, setIsRequest] = React.useState(false); - const [isRequestChangeLoading, setIsRequestChangeLoading] = - React.useState(false); - const { entries, allow, refreshConsentList, deny } = useConsent(); - const { mutateAsync: sendReaction } = useSendReactionMessage(conversation); - - const { primaryName } = usePrimaryName({ - address: conversation.peerAddress as `0x${string}`, - }); - const { records } = useRecords({ - ens: primaryName, - }); - const { sanitizeEnsImage } = useEnsAvatar(); - - const { address } = useMountedAccount(); - - const [canMessage, setCanMessage] = React.useState(true); - - const { messages, isLoading } = useMessages(conversation); - - // Queries - - const blockAddress = async (peerAddress: string) => { - setIsRequestChangeLoading(true); - await refreshConsentList(); - await deny([peerAddress]); - await refreshConsentList(); - setIsRequestChangeLoading(false); - onBack(); - }; - - const { canMessage: canMessageFn, isLoading: isCanMessageLoading } = - useCanMessage(); - - useEffect(() => { - if (isCanMessageLoading) return; - canMessageFn(conversation.peerAddress).then((result) => { - setCanMessage(result); - }); - }, [isCanMessageLoading, conversation, canMessageFn]); - - useStreamMessages(conversation); - - // Memo - const filteredMessages = useMemo(() => { - const messagesWithoutRead = messages.filter( - (message) => !(message.contentType === 'xmtp.org/readReceipt:1.0') - ); - const res = filterReactionsMessages(messagesWithoutRead); - return res; - }, [messages]); - - const groupedMessages = useMemo(() => { - return groupMessagesByDate(filteredMessages ?? []); - }, [filteredMessages]); - - useEffect(() => { - const convoConsentState = entries[conversation.peerAddress]?.permissionType; - if (convoConsentState === 'unknown' || convoConsentState === undefined) { - setIsRequest(true); - } else { - setIsRequest(false); - } - }, [entries, conversation.peerAddress]); - - const isMessagesSenderOnly = useMemo(() => { - return filteredMessages.every( - (message) => message.senderAddress === address - ); - }, [filteredMessages, address]); - - const handleAllowAddress = async () => { - setIsRequestChangeLoading(true); - await refreshConsentList(); - await allow([conversation.peerAddress]); - void refreshConsentList(); - setIsRequest(false); - setIsRequestChangeLoading(false); - }; - - const handleEmojiSelect = (emoji: string) => { - if (!reactionMessage) return; - sendReaction({ - action: 'added', - content: emoji, - referenceId: reactionMessage.id, - }); - }; - - useEffect(() => { - if (messages.length == 0) return; - setTimeout(() => { - const lastMessageId = messages[messages.length - 1]?.id; - const element = document.getElementById(lastMessageId); - if (element) { - element.scrollIntoView({ behavior: 'smooth' }); - } - }, 500); - - // await checkMessageIfRead(); - }, [messages, conversation]); - - const isStringContent = - typeof replyMessage?.content === 'string' || - typeof replyMessage?.content?.content === 'string'; - - const mimeType = replyMessage?.content?.mimeType; - const type = mimeType ? typeLookup[mimeType.split('/')?.[1]] : null; - - const computeHeight = useMemo(() => { - const additionalHeight = []; - const height = '100vh - 50px - 3rem - 1.5rem - 73px - 15px'; - if (isRequest) { - additionalHeight.push('-40px'); - } - if (replyMessage) { - if (isStringContent) { - additionalHeight.push('46px'); - } else if (mimeType === 'audio/wav') { - additionalHeight.push('61px'); - } else if (type === 'video' || type === 'image') { - additionalHeight.push('116px'); - } else { - additionalHeight.push('47px'); - } - } - - if (isMessagesSenderOnly) { - additionalHeight.push('59px'); - } - - return `calc( ${height} ${ - additionalHeight.length > 0 ? ' - ' + additionalHeight.join(' - ') : '' - } )`; - }, [ - replyMessage, - isMessagesSenderOnly, - isStringContent, - mimeType, - type, - isRequest, - ]); - - return ( - -
{ - setReactionMessage(null); - const replica = document.getElementById( - `${reactionMessage?.id}-replica` - ); - replica?.remove(); - }} - >
- - - - - - { - if (primaryName) { - openEnsProfile(primaryName); - } - }} - > - - -

- {primaryName - ? primaryName - : formatAddress(conversation.peerAddress)} -

- {primaryName && ( -

- {formatAddress(conversation.peerAddress)} -

- )} -
-
-
- - - - - - - - blockAddress(conversation.peerAddress)} - > - -

- Block -

-
-
-
-
-
-
- - {isCanMessageLoading || isLoading ? ( - - {[...Array(8)].map((_, index) => ( - - ))} - - ) : ( - - {canMessage ? ( - - {groupedMessages && - Object.keys(groupedMessages).map((date, index) => ( - - -
-

- {date} -

-
- - {groupedMessages[date].map((message) => ( - setReplyMessage(msg)} - message={message} - peerAddress={conversation.peerAddress} - key={`message-${message.id}`} - onReaction={(message) => { - setReactionMessage(message); - - const element = document.getElementById( - message.id.toString() - ); - if (!element) return; - const replica = element?.cloneNode( - true - ) as HTMLElement; - replica.id = `${message.id}-replica`; - replica.style.position = 'absolute'; - replica.style.bottom = '310px'; - replica.style.minHeight = '20px'; - replica.style.left = '4.2vw'; - replica.style.zIndex = '90'; - element?.parentElement?.appendChild(replica); - replica.classList.add('replica-animate'); - }} - /> - ))} - - ))} - {reactionMessage && ( -
- { - handleEmojiSelect(emoji); - setReactionMessage(null); - const replica = document.getElementById( - `${reactionMessage?.id}-replica` - ); - replica?.remove(); - }} - /> -
- )} - - ) : ( -
-

Cannot message {conversation.peerAddress}

-
- )} - - )} - - {isRequest ? ( - isRequestChangeLoading ? ( - - - - ) : ( - - - - - ) - ) : ( - - {isMessagesSenderOnly && ( - -

- Message in user’s Requests -

-

- This user has not accepted your message request yet -

-
- )} - setReplyMessage(null)} - conversation={conversation} - replyMessage={replyMessage} - peerAddress={conversation.peerAddress} - /> -
- )} - - - ); -}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatButton/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatMenuButton/index.tsx similarity index 64% rename from packages/@justweb3/xmtp-plugin/src/lib/components/ChatButton/index.tsx rename to packages/@justweb3/xmtp-plugin/src/lib/components/ChatMenuButton/index.tsx index 4c39d872..6e209868 100644 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatButton/index.tsx +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatMenuButton/index.tsx @@ -1,15 +1,26 @@ import { useMountedAccount } from '@justaname.id/react'; -import { ArrowIcon, ClickableItem } from '@justweb3/ui'; +import { ArrowIcon, ClickableItem, SPAN } from '@justweb3/ui'; import { Client, ClientOptions, useClient } from '@xmtp/react-sdk'; import { useEthersSigner } from '../../hooks'; import { XmtpEnvironment } from '../../plugins'; import { loadKeys, storeKeys, wipeKeys } from '../../utils/xmtp'; +import { useJustWeb3XMTP } from '../../providers/JustWeb3XMTPProvider'; +import { useMemo } from 'react'; -export interface ChatButtonProps { +export interface ChatMenuButtonProps { handleOpen: (open: boolean) => void; env: XmtpEnvironment; } -export const ChatButton: React.FC = ({ handleOpen, env }) => { +export const ChatMenuButton: React.FC = ({ + handleOpen, + env, +}) => { + const { conversationsInfo } = useJustWeb3XMTP(); + const totalUnreadCount = useMemo(() => { + return conversationsInfo + .filter((conversation) => conversation.consent === 'allowed') + .reduce((acc, curr) => acc + curr.unreadCount, 0); + }, [conversationsInfo]); const { initialize } = useClient(); const { client } = useClient(); const walletClient = useEthersSigner(); @@ -74,7 +85,36 @@ export const ChatButton: React.FC = ({ handleOpen, env }) => { width: '100%', }} onClick={handleChat} - right={} + right={ + <> + {totalUnreadCount > 0 && ( +
+ + {totalUnreadCount} + +
+ )} + + + } /> ); }; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatHeader/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatHeader/index.tsx new file mode 100644 index 00000000..2d90fed0 --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatHeader/index.tsx @@ -0,0 +1,157 @@ +import { + ArrowIcon, + Avatar, + BlockedAccountIcon, + Flex, + P, + Popover, + PopoverContent, + PopoverTrigger, + TuneIcon, +} from '@justweb3/ui'; +import { formatAddress } from '../../../../utils/formatAddress'; +import { useEnsAvatar } from '@justaname.id/react'; + +export interface ChatHeaderProps { + primaryName: string | undefined; + peerAddress: string; + onBack: () => void; + openEnsProfile: (ens: string) => void; + records: any; + blockAddressHandler: (peerAddress: string) => void; +} + +export const ChatHeader: React.FC = ({ + primaryName, + peerAddress, + onBack, + openEnsProfile, + records, + blockAddressHandler, +}) => { + const { sanitizeEnsImage } = useEnsAvatar(); + + return ( + + + + + + { + if (primaryName) openEnsProfile(primaryName); + }} + > + + +

+ {primaryName || formatAddress(peerAddress)} +

+ {primaryName && ( +

+ {formatAddress(peerAddress)} +

+ )} +
+
+
+ + + + + + + + + blockAddressHandler(peerAddress)} + > + +

+ Block +

+
+
+
+
+
+
+ ); +}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatMessagesList/DateDivider/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatMessagesList/DateDivider/index.tsx new file mode 100644 index 00000000..6b0b7307 --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatMessagesList/DateDivider/index.tsx @@ -0,0 +1,43 @@ +import { Flex, P } from '@justweb3/ui'; + +interface DateDividerProps { + date: string; +} + +export const DateDivider: React.FC = ({ date }) => ( + +
+

+ {date} +

+
+ +); diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatMessagesList/EmojiSelector/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatMessagesList/EmojiSelector/index.tsx new file mode 100644 index 00000000..4dd8f6ba --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatMessagesList/EmojiSelector/index.tsx @@ -0,0 +1,85 @@ +import { Flex, Input } from '@justweb3/ui'; +import React, { useMemo } from 'react'; +import { useDebounce } from '@justweb3/widget'; +import { EmojiObject, emojis } from '../../../../../utils/emojis'; + +interface EmojiSelectorProps { + onEmojiSelect: (emoji: string) => void; +} + +export const EmojiSelector: React.FC = ({ + onEmojiSelect, +}) => { + const [searchValue, setSearchValue] = React.useState(''); + + const { debouncedValue: debouncedSearch } = useDebounce( + searchValue, + 50 + ); + + const onEmojiClickHandler = (emoji: EmojiObject) => { + onEmojiSelect(emoji.name); + }; + + const filteredEmojis = useMemo(() => { + if (debouncedSearch.length === 0) return emojis; + const filteredEmojis = emojis.filter((emoji) => + emoji.name.toLowerCase().includes(debouncedSearch.toLowerCase()) + ); + return filteredEmojis; + }, [debouncedSearch]); + + return ( + + setSearchValue(e.target.value)} + /> +
+ {filteredEmojis.map((emoji, index) => ( + + ))} +
+
+ ); +}; + +export default EmojiSelector; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/MessageCard/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatMessagesList/MessageCard/index.tsx similarity index 93% rename from packages/@justweb3/xmtp-plugin/src/lib/components/MessageCard/index.tsx rename to packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatMessagesList/MessageCard/index.tsx index 0630fb58..8ad5ce6c 100644 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/MessageCard/index.tsx +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatMessagesList/MessageCard/index.tsx @@ -9,15 +9,15 @@ import { } from '@justweb3/ui'; import { CachedConversation, DecodedMessage } from '@xmtp/react-sdk'; import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { useSendReactionMessage } from '../../hooks'; -import { typeLookup } from '../../utils/attachments'; -import { calculateFileSize } from '../../utils/calculateFileSize'; -import { findEmojiByName } from '../../utils/emojis'; -import { MessageWithReaction } from '../../utils/filterReactionsMessages'; -import { formatAddress } from '../../utils/formatAddress'; -import { formatMessageSentTime } from '../../utils/messageTimeFormat'; -import { CustomPlayer } from '../CustomPlayer'; -import VoiceMessageCard from '../VoiceMessageCard'; +import { useSendReactionMessage } from '../../../../../hooks'; +import { typeLookup } from '../../../../../utils/attachments'; +import { calculateFileSize } from '../../../../../utils/calculateFileSize'; +import { findEmojiByName } from '../../../../../utils/emojis'; +import { MessageWithReaction } from '../../../../../utils/filterReactionsMessages'; +import { formatAddress } from '../../../../../utils/formatAddress'; +import { formatMessageSentTime } from '../../../../../utils/messageTimeFormat'; +import VoiceNotePreview from '../../VoiceNotePreview'; +import { VideoPlayerPreview } from '../../VideoPlayerPreview'; interface MessageCardProps { message: MessageWithReaction; @@ -107,7 +107,7 @@ const MeasureAndHyphenateText: React.FC<{ ); }; -const MessageCard: React.FC = ({ +export const MessageCard: React.FC = ({ message, peerAddress, onReply, @@ -259,17 +259,17 @@ const MessageCard: React.FC = ({ senderAddress: message.senderAddress, content: typeLookup[attachmentExtention] === 'image' || - typeLookup[attachmentExtention] === 'video' + typeLookup[attachmentExtention] === 'video' ? { - data: message.content.data, - mimeType: message.content.mimeType, - filename: message.content.filename, - url: URL.createObjectURL( - new Blob([message.content.data], { - type: message.content.mimeType, - }) - ), - } + data: message.content.data, + mimeType: message.content.mimeType, + filename: message.content.filename, + url: URL.createObjectURL( + new Blob([message.content.data], { + type: message.content.mimeType, + }) + ), + } : message.content, contentType: message.contentType, })} @@ -317,7 +317,7 @@ const MessageCard: React.FC = ({ {repliedMessage?.senderAddress === address ? 'YOU' : primaryName ?? - formatAddress(repliedMessage?.senderAddress ?? '')} + formatAddress(repliedMessage?.senderAddress ?? '')}

{isReplyText || isReplyReply ? ( @@ -340,7 +340,7 @@ const MessageCard: React.FC = ({ : repliedMessage.content}

) : isReplyVoice ? ( - = ({ }} /> ) : typeLookup[replyAttachmentExtention] === 'video' ? ( - = ({ ) : ( {isVoice ? ( - + ) : ( {typeLookup[attachmentExtention] === 'image' ? ( @@ -469,7 +469,7 @@ const MessageCard: React.FC = ({
) : typeLookup[attachmentExtention] === 'video' ? ( - ; + setReplyMessage: (msg: MessageWithReaction | null) => void; + setReactionMessage: (msg: MessageWithReaction | null) => void; + reactionMessage: MessageWithReaction | null; + handleEmojiSelect: (emoji: string) => void; + computeHeight: string; +} + +export const ChatMessagesList: React.FC = ({ + canMessage, + groupedMessages, + conversation, + setReplyMessage, + setReactionMessage, + reactionMessage, + handleEmojiSelect, + computeHeight, +}) => { + if (!canMessage) { + return ( + +

Cannot message {conversation.peerAddress}

+
+ ); + } + + return ( + + + {groupedMessages && + Object.keys(groupedMessages).map((date, index) => ( + + + {groupedMessages[date].map((message) => ( + { + setReactionMessage(msg); + const element = document.getElementById(msg.id); + if (!element) return; + const replica = element.cloneNode(true) as HTMLElement; + replica.id = `${msg.id}-replica`; + replica.style.position = 'absolute'; + replica.style.bottom = '310px'; + replica.style.minHeight = '20px'; + replica.style.left = '4.2vw'; + replica.style.zIndex = '90'; + element.parentElement?.appendChild(replica); + replica.classList.add('replica-animate'); + }} + /> + ))} + + ))} + + {reactionMessage && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatReactionOverlay/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatReactionOverlay/index.tsx new file mode 100644 index 00000000..46ede4df --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatReactionOverlay/index.tsx @@ -0,0 +1,29 @@ +import { MessageWithReaction } from '../../../utils/filterReactionsMessages'; + +interface ChatReactionOverlayProps { + reactionMessage: MessageWithReaction | null; + onOverlayClick: () => void; +} + +export const ChatReactionOverlay: React.FC = ({ + reactionMessage, + onOverlayClick, +}) => ( +
+); diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatRequestControl/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatRequestControl/index.tsx new file mode 100644 index 00000000..26d1497b --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatRequestControl/index.tsx @@ -0,0 +1,47 @@ +import { Button, Flex, LoadingSpinner } from '@justweb3/ui'; + +interface ChatRequestControlsProps { + isRequestChangeLoading: boolean; + blockAddressHandler: (peerAddress: string) => void; + peerAddress: string; + handleAllowAddress: () => void; +} + +export const ChatRequestControls: React.FC = ({ + isRequestChangeLoading, + blockAddressHandler, + peerAddress, + handleAllowAddress, +}) => { + if (isRequestChangeLoading) { + return ( + + + + ); + } + + return ( + + + + + ); +}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/AttachmentButtons/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/AttachmentButtons/index.tsx new file mode 100644 index 00000000..5958e761 --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/AttachmentButtons/index.tsx @@ -0,0 +1,45 @@ +// ChatTextField/AttachmentButtons.tsx +import React, { RefObject } from 'react'; +import { AddFolderIcon, AddImageIcon, AddVideoIcon, Flex } from '@justweb3/ui'; + +export interface AttachmentButtonsProps { + onButtonClick: (contentType: 'image' | 'video' | 'application') => void; + acceptedTypes: string | string[] | undefined; + onAttachmentChange: (e: React.ChangeEvent) => void; +} + +export const AttachmentButtons: React.FC< + AttachmentButtonsProps & { inputFileRef: RefObject } +> = ({ onButtonClick, acceptedTypes, onAttachmentChange, inputFileRef }) => ( + + onButtonClick('image')} + style={{ cursor: 'pointer' }} + /> + onButtonClick('video')} + style={{ cursor: 'pointer' }} + /> + onButtonClick('application')} + style={{ cursor: 'pointer' }} + /> + + +); diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/AttachmentPreview/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/AttachmentPreview/index.tsx new file mode 100644 index 00000000..71abf76e --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/AttachmentPreview/index.tsx @@ -0,0 +1,220 @@ +// ChatTextField/AttachmentPreview.tsx +import React from 'react'; +import { + Button, + DocumentIcon, + Flex, + P, + SendIcon, + StopIcon, +} from '@justweb3/ui'; +import { VideoPlayerPreview } from '../../VideoPlayerPreview'; +import { VoiceNoteRecording } from './../VoiceNoteRecording'; +import { typeLookup } from '../../../../../utils/attachments'; +import { Attachment } from '@xmtp/content-type-remote-attachment'; + +export interface AttachmentPreviewProps { + attachment: Attachment | undefined; + attachmentPreview: string | undefined; + disabled?: boolean; + onCancelAttachment: () => void; + onSendAttachment: () => void; + recording: boolean; + recordingValue: string | null; + stopRecording: () => void; + pause: () => void; + reset: () => void; +} + +export const AttachmentPreview: React.FC = ({ + attachment, + attachmentPreview, + disabled, + onCancelAttachment, + onSendAttachment, + recording, + recordingValue, + stopRecording, + pause, + reset, +}) => { + const attachmentExtention = attachment?.mimeType.split('/')?.[1] || ''; + + if (attachmentPreview) { + return ( + + {attachment?.mimeType !== 'audio/wav' && ( +
+ )} + {attachment?.mimeType === 'audio/wav' ? ( + + ) : ( + + {typeLookup[attachmentExtention] === 'image' ? ( + {attachment?.filename} + ) : typeLookup[attachmentExtention] === 'video' ? ( + + ) : ( + + +

+ {attachment?.filename ?? 'Cannot preview'} +

+
+ )} + + + + +
+ )} + { + if (!disabled) onSendAttachment(); + }} + /> + + ); + } else if (recording) { + return ( + +

+ {recordingValue} +

+

+ RECORDING... +

+ { + stopRecording(); + pause(); + reset(); + }} + /> +
+ ); + } + + return null; +}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/MessageInput/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/MessageInput/index.tsx new file mode 100644 index 00000000..c9c61261 --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/MessageInput/index.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Input, MicIcon, SendIcon } from '@justweb3/ui'; + +interface MessageInputProps { + replyMessage: boolean; + disabled?: boolean; + messageValue: string; + setMessageValue: (value: string) => void; + handleSendMessage: () => void; + startRecording: () => void; + start: () => void; +} + +export const MessageInput: React.FC = ({ + replyMessage, + disabled, + messageValue, + setMessageValue, + handleSendMessage, + startRecording, + start, +}) => { + return ( + { + if (disabled) return; + startRecording(); + start(); + }} + /> + ) + } + right={ + + } + disabled={disabled} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSendMessage(); + } + }} + onChange={(e) => setMessageValue(e.target.value)} + /> + ); +}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/ReplyPreview/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/ReplyPreview/index.tsx new file mode 100644 index 00000000..7445ea5a --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/ReplyPreview/index.tsx @@ -0,0 +1,184 @@ +// ChatTextField/ReplyPreview.tsx +import React, { useMemo } from 'react'; +import { CloseIcon, DocumentIcon, Flex, P } from '@justweb3/ui'; +import VoiceNotePreview from '../../VoiceNotePreview'; +import { VideoPlayerPreview } from '../../VideoPlayerPreview'; +import { formatAddress } from '../../../../../utils/formatAddress'; +import { typeLookup } from '../../../../../utils/attachments'; +import { MessageWithReaction } from '../../../../../utils/filterReactionsMessages'; + +export interface ReplyPreviewProps { + replyMessage: MessageWithReaction | null; + onCancelReply: () => void; + isSender: boolean; + primaryName: string | null | undefined; + navigateToRepliedMessage: () => void; +} + +export const ReplyPreview: React.FC = ({ + replyMessage, + onCancelReply, + isSender, + primaryName, + navigateToRepliedMessage, +}) => { + const isReplyText = useMemo(() => { + if (!replyMessage) return false; + return typeof replyMessage.content === 'string'; + }, [replyMessage]); + + const isReplyReply = useMemo(() => { + if (!replyMessage) return false; + return !!replyMessage.content.reference; + }, [replyMessage]); + + const isReplyVoice = useMemo( + () => replyMessage?.content.mimeType === 'audio/wav', + [replyMessage] + ); + + const replyAttachmentExtension = useMemo(() => { + if (!isReplyText && replyMessage && !isReplyReply) { + return replyMessage.content.mimeType.split('/')?.[1] || ''; + } + }, [isReplyText, replyMessage, isReplyReply]); + + const isReplyVideoOrImage = useMemo(() => { + return ( + typeLookup[replyAttachmentExtension] === 'image' || + typeLookup[replyAttachmentExtension] === 'video' + ); + }, [replyAttachmentExtension]); + + if (!replyMessage) return null; + + return ( + + +

+ {isSender + ? 'YOU' + : primaryName ?? formatAddress(replyMessage.senderAddress)} +

+ {isReplyText || isReplyReply ? ( +

+ {isReplyReply ? replyMessage.content.content : replyMessage.content} +

+ ) : isReplyVoice ? ( + + ) : typeLookup[replyAttachmentExtension] === 'image' ? ( + {replyMessage.content.filename} + ) : typeLookup[replyAttachmentExtension] === 'video' ? ( + + ) : ( + + +

+ {replyMessage.content.filename} +

+
+ )} +
+ +
+ ); +}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/VoiceNoteRecording/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/VoiceNoteRecording/index.tsx new file mode 100644 index 00000000..33cf8749 --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/VoiceNoteRecording/index.tsx @@ -0,0 +1,115 @@ +import { CloseIcon, Flex, P, PauseIcon, PlayIcon } from '@justweb3/ui'; +import React, { useEffect, useRef, useState } from 'react'; +import { formatTime } from '../../../../../utils/formatVoiceTime'; +import { useGetAudioDuration } from '../../../../../hooks/useGetAudioDuration'; + +interface VoiceNoteRecordingProps { + audioUrl: string; + onCancel: () => void; +} + +export const VoiceNoteRecording: React.FC = ({ + audioUrl, + onCancel, +}) => { + const [playing, setPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const audioRef = useRef(new Audio()); + + const audioDuration = useGetAudioDuration(audioUrl); + + useEffect(() => { + const audio = new Audio(); + audioRef.current = audio; + + const onTimeUpdate = () => { + setCurrentTime(audio.currentTime); + }; + + const onEnded = () => { + setPlaying(false); + setCurrentTime(0); + }; + + audio.src = audioUrl; + audio.addEventListener('timeupdate', onTimeUpdate); + audio.addEventListener('ended', onEnded); + + return () => { + audio.removeEventListener('timeupdate', onTimeUpdate); + audio.removeEventListener('ended', onEnded); + }; + }, [audioUrl]); + + const handlePlayPause = () => { + const audio = audioRef.current; + if (!audio) return; + + if (playing) { + audio.pause(); + } else { + audio.play(); + } + setPlaying(!playing); + }; + + return ( + + {playing ? ( + + ) : ( + + )} +

+ {playing || currentTime > 0 + ? formatTime(currentTime) + : formatTime(audioDuration ?? 0)} +

+ +
+ ); +}; + +export default VoiceNoteRecording; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/index.tsx new file mode 100644 index 00000000..6698b6ab --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/ChatTextField/index.tsx @@ -0,0 +1,225 @@ +// ChatTextField/index.tsx +import React, { + Dispatch, + SetStateAction, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + CachedConversation, + ContentTypeMetadata, + useClient, +} from '@xmtp/react-sdk'; +import { useMountedAccount, usePrimaryName } from '@justaname.id/react'; +import { Flex, P } from '@justweb3/ui'; +import { AttachmentButtons } from './AttachmentButtons'; +import { ReplyPreview } from './ReplyPreview'; +import { AttachmentPreview } from './AttachmentPreview'; +import { MessageInput } from './MessageInput'; +import { + useAttachmentChange, + useRecordingTimer, + useRecordVoice, + useSendAttachment, + useSendMessages, + useSendReplyMessage, +} from '../../../../hooks'; +import { AttachmentType, typeLookup } from '../../../../utils/attachments'; +import type { Attachment } from '@xmtp/content-type-remote-attachment'; +import { MessageWithReaction } from '../../../../utils/filterReactionsMessages'; + +export interface ChatTextFieldProps { + isMessagesSenderOnly: boolean; + replyMessage: MessageWithReaction | null; + conversation: CachedConversation; + peerAddress: string; + onCancelReply: () => void; + disabled?: boolean; + style?: React.CSSProperties; +} + +export const ChatTextField: React.FC = ({ + isMessagesSenderOnly, + replyMessage, + conversation, + peerAddress, + onCancelReply, + disabled, + style, +}) => { + const [messageValue, setMessageValue] = useState(''); + const [attachment, setAttachment] = useState(); + const [attachmentPreview, setAttachmentPreview] = useState< + string | undefined + >(); + const { client } = useClient(); + const { address } = useMountedAccount(); + const { mutateAsync: sendMessage } = useSendMessages(conversation); + const { mutateAsync: sendReply } = useSendReplyMessage(conversation); + const { mutateAsync: sendAttachment } = useSendAttachment(conversation); + const { primaryName } = usePrimaryName({ + address: peerAddress as `0x${string}`, + }); + + const [acceptedTypes, setAcceptedTypes]: [ + string | string[] | undefined, + Dispatch> + ] = useState(); + const inputFile = useRef(null); + + const { onAttachmentChange } = useAttachmentChange({ + setAttachment, + setAttachmentPreview, + }); + + const { recording, startRecording, stopRecording } = useRecordVoice({ + setAttachment, + setAttachmentPreview, + }); + const { start, pause, reset, recordingValue } = useRecordingTimer({ + stopRecording, + status: recording ? 'recording' : 'idle', + }); + + const handleSendMessage = async () => { + if (!client || disabled || messageValue.length === 0) return; + + if (replyMessage) { + await sendReply({ + message: messageValue, + referenceId: replyMessage.id, + }); + onCancelReply && onCancelReply(); + } else { + await sendMessage(messageValue); + } + + setMessageValue(''); + }; + + const handleSendAttachment = async () => { + if (!client || !attachment || disabled) return; + await sendAttachment(attachment); + setMessageValue(''); + setAttachment(undefined); + setAttachmentPreview(undefined); + }; + + const handleCancelAttachment = () => { + setAttachment(undefined); + setAttachmentPreview(undefined); + }; + + const onButtonClick = (contentType: AttachmentType) => { + if (contentType === 'application') { + setAcceptedTypes('all'); + } else { + const acceptedFileTypeList = Object.keys(typeLookup).reduce( + (acc: string[], key: string) => { + if (typeLookup[key] === contentType) acc.push(`.${key}`); + return acc; + }, + [] + ); + setAcceptedTypes([...acceptedFileTypeList]); + } + }; + + const isSender = useMemo( + () => address === replyMessage?.senderAddress, + [replyMessage, address] + ); + + const navigateToRepliedMessage = () => { + if (!replyMessage) return; + const element = document.getElementById(replyMessage.id.toString()); + if (element) { + element.scrollIntoView({ + block: 'end', + behavior: 'smooth', + }); + } + }; + + useEffect(() => { + if (acceptedTypes) { + inputFile?.current?.click(); + } + }, [acceptedTypes]); + + return ( + + {isMessagesSenderOnly && ( + +

+ Message in user’s Requests +

+

+ This user has not accepted your message request yet +

+
+ )} + + + + +
+ + + + + {!attachmentPreview && !recording && ( + + )} +
+
+
+ ); +}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/MessageSkeletonCard/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/LoadingMessagesList/MessageSkeletonCard/index.tsx similarity index 100% rename from packages/@justweb3/xmtp-plugin/src/lib/components/MessageSkeletonCard/index.tsx rename to packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/LoadingMessagesList/MessageSkeletonCard/index.tsx diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/LoadingMessagesList/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/LoadingMessagesList/index.tsx new file mode 100644 index 00000000..5a65dcdc --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/LoadingMessagesList/index.tsx @@ -0,0 +1,25 @@ +import { Flex } from '@justweb3/ui'; +import { MessageSkeletonCard } from './MessageSkeletonCard'; + +interface LoadingMessagesListProps { + computeHeight: string; +} + +export const LoadingMessagesList: React.FC = ({ + computeHeight, +}) => ( + + {Array.from({ length: 8 }).map((_, index) => ( + + ))} + +); diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/VideoPlayerPreview/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/VideoPlayerPreview/index.tsx new file mode 100644 index 00000000..dd4de5d0 --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/VideoPlayerPreview/index.tsx @@ -0,0 +1,119 @@ +import { DownloadIcon, Flex, PauseIcon, PlayIcon } from '@justweb3/ui'; +import React, { useEffect, useRef, useState } from 'react'; + +export interface VideoPlayerPreviewProps { + style?: React.CSSProperties; + url?: string; + disabled?: boolean; + fileName?: string; +} + +export const VideoPlayerPreview: React.FC = ({ + style, + url = '', + disabled, + fileName, +}) => { + const [playing, setPlaying] = React.useState(false); + const videoRef = useRef(null); + const [hovered, setHovered] = useState(false); + + const togglePlay = () => { + if (disabled) return; + if (videoRef.current) { + if (playing) { + videoRef.current.pause(); + } else { + videoRef.current.play(); + } + setPlaying(!playing); + } + }; + + useEffect(() => { + const handleVisibilityChange = () => { + if (document.hidden && playing && videoRef.current) { + videoRef.current.pause(); + } + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + if (videoRef.current && playing) { + videoRef.current.pause(); + setPlaying(false); + } + }; + }, [playing]); + + return ( + { + setHovered(true); + }} + onMouseLeave={() => { + setHovered(false); + }} + onClick={togglePlay} + style={{ + aspectRatio: '16/9', + position: 'relative', + // TODO: check background color + background: 'var(--justweb3-background-color)', + cursor: 'pointer', + borderRadius: '10px', + border: '1px solid var(--justweb3-primary-color)', + ...style, + }} + > + + ); +}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/VoiceMessageCard/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/VoiceNotePreview/index.tsx similarity index 81% rename from packages/@justweb3/xmtp-plugin/src/lib/components/VoiceMessageCard/index.tsx rename to packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/VoiceNotePreview/index.tsx index 0664a88b..819df525 100644 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/VoiceMessageCard/index.tsx +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/VoiceNotePreview/index.tsx @@ -2,18 +2,18 @@ import { Flex, P, PauseIcon, PlayIcon } from '@justweb3/ui'; import * as Slider from '@radix-ui/react-slider'; import { DecodedMessage } from '@xmtp/xmtp-js'; import React, { useEffect, useMemo, useRef, useState } from 'react'; -import useGetAudioDuration from '../../hooks/useGetAudioDuration'; -import { MessageWithReaction } from '../../utils/filterReactionsMessages'; -import { formatTime } from '../../utils/formatVoiceTime'; +import { formatTime } from '../../../../utils/formatVoiceTime'; +import { useGetAudioDuration } from '../../../../hooks/useGetAudioDuration'; +import { MessageWithReaction } from '../../../../utils/filterReactionsMessages'; -interface VoiceMessageCardProps { +interface VoiceNotePreviewProps { message: MessageWithReaction | DecodedMessage; style?: React.CSSProperties; disabled?: boolean; isReceiver: boolean; } -const VoiceMessageCard: React.FC = ({ +const VoiceNotePreview: React.FC = ({ message, style, disabled, @@ -115,12 +115,11 @@ const VoiceMessageCard: React.FC = ({ width="22" height="22" fill={ - disabled ? - 'var(--justweb3-primary-color)' - : - isReceiver - ? 'var(--justweb3-primary-color)' - : 'var(--justweb3-foreground-color-4)' + disabled + ? 'var(--justweb3-primary-color)' + : isReceiver + ? 'var(--justweb3-primary-color)' + : 'var(--justweb3-foreground-color-4)' } style={{ cursor: 'pointer', @@ -193,19 +192,26 @@ const VoiceMessageCard: React.FC = ({ aria-label="Volume" /> - -

{playing || currentTime > 0 ? formatTime(currentTime) : formatTime(duration ?? 0)}

- + +

+ {playing || currentTime > 0 + ? formatTime(currentTime) + : formatTime(duration ?? 0)} +

); }; -export default VoiceMessageCard; +export default VoiceNotePreview; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/index.tsx new file mode 100644 index 00000000..de637057 --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/Chat/index.tsx @@ -0,0 +1,257 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + useMountedAccount, + usePrimaryName, + useRecords, +} from '@justaname.id/react'; +import { Flex } from '@justweb3/ui'; +import { + CachedConversation, + ContentTypeMetadata, + useCanMessage, + useConsent, + useMessages, + useStreamMessages, +} from '@xmtp/react-sdk'; +import { useJustWeb3 } from '@justweb3/widget'; +import { ChatTextField } from './ChatTextField'; +import { ChatMessagesList } from './ChatMessagesList'; +import { LoadingMessagesList } from './LoadingMessagesList'; +import { ChatRequestControls } from './ChatRequestControl'; +import { ChatHeader } from './ChatHeader'; +import { ChatReactionOverlay } from './ChatReactionOverlay'; +import { + filterReactionsMessages, + MessageWithReaction, +} from '../../../utils/filterReactionsMessages'; +import { useSendReactionMessage } from '../../../hooks'; +import { groupMessagesByDate } from '../../../utils/groupMessageByDate'; +import { typeLookup } from '../../../utils/attachments'; +import { ContentTypeReadReceipt } from '@xmtp/content-type-read-receipt'; +import { useReadReceipt } from '../../../hooks/useReadReceipt'; + +export interface ChatProps { + conversation: CachedConversation; + onBack: () => void; +} + +export const Chat: React.FC = ({ conversation, onBack }) => { + const { openEnsProfile } = useJustWeb3(); + const [replyMessage, setReplyMessage] = useState( + null + ); + const [reactionMessage, setReactionMessage] = + useState(null); + const [isRequest, setIsRequest] = useState(false); + const [isRequestChangeLoading, setIsRequestChangeLoading] = + useState(false); + + const { entries, allow, refreshConsentList, deny } = useConsent(); + const { mutateAsync: sendReaction } = useSendReactionMessage(conversation); + const { primaryName } = usePrimaryName({ + address: conversation.peerAddress as `0x${string}`, + }); + const { records } = useRecords({ ens: primaryName }); + const { address } = useMountedAccount(); + const [canMessage, setCanMessage] = useState(true); + const { messages, isLoading } = useMessages(conversation); + const { canMessage: canMessageFn, isLoading: isCanMessageLoading } = + useCanMessage(); + const { mutateAsync: readReceipt, isPending: isReadReceiptSending } = + useReadReceipt(conversation); + useStreamMessages(conversation); + + useEffect(() => { + const lastMessage = messages[messages.length - 1]; + + if (!lastMessage || isReadReceiptSending) return; + + if (lastMessage?.contentType === ContentTypeReadReceipt.toString()) { + return; + } + + readReceipt(); + }, [messages, readReceipt, isReadReceiptSending]); + + // Determine if user can message + useEffect(() => { + if (isCanMessageLoading) return; + canMessageFn(conversation.peerAddress).then(setCanMessage); + }, [isCanMessageLoading, conversation, canMessageFn]); + + // Check if conversation is in "request" state + useEffect(() => { + const convoConsentState = entries[conversation.peerAddress]?.permissionType; + setIsRequest( + convoConsentState === 'unknown' || convoConsentState === undefined + ); + }, [entries, conversation.peerAddress]); + + // Scroll to last message when messages change + useEffect(() => { + if (messages.length === 0) return; + setTimeout(() => { + const lastMessageId = messages[messages.length - 1]?.id; + const element = document.getElementById(lastMessageId); + if (element) { + element.scrollIntoView({ behavior: 'smooth' }); + } + }, 500); + }, [messages, conversation]); + + // Filter out read receipts and group messages by date + const filteredMessages = useMemo(() => { + const withoutRead = messages.filter( + (message) => message.contentType !== 'xmtp.org/readReceipt:1.0' + ); + return filterReactionsMessages(withoutRead); + }, [messages]); + + const groupedMessages = useMemo(() => { + return groupMessagesByDate(filteredMessages ?? []); + }, [filteredMessages]); + + const isMessagesSenderOnly = useMemo(() => { + return filteredMessages.every( + (message) => message.senderAddress === address + ); + }, [filteredMessages, address]); + + const isStringContent = + typeof replyMessage?.content === 'string' || + typeof replyMessage?.content?.content === 'string'; + + const mimeType = replyMessage?.content?.mimeType; + const type = mimeType ? typeLookup[mimeType.split('/')?.[1]] : null; + + const computeHeight = useMemo(() => { + const baseHeight = 'calc(100vh - 50px - 3rem - 1.5rem - 73px - 15px)'; + const adjustments: string[] = []; + + if (isRequest) adjustments.push('40px'); + if (replyMessage) { + if (isStringContent) { + adjustments.push('46px'); + } else if (mimeType === 'audio/wav') { + adjustments.push('61px'); + } else if (type === 'video' || type === 'image') { + adjustments.push('116px'); + } else { + adjustments.push('47px'); + } + } + if (isMessagesSenderOnly) adjustments.push('59px'); + + if (adjustments.length === 0) return baseHeight; + return `${baseHeight}${adjustments.map((val) => ` - ${val}`).join('')}`; + }, [ + replyMessage, + isMessagesSenderOnly, + isStringContent, + mimeType, + type, + isRequest, + ]); + + // Handlers + const blockAddressHandler = async (peerAddress: string) => { + setIsRequestChangeLoading(true); + await refreshConsentList(); + await deny([peerAddress]); + await refreshConsentList(); + setIsRequestChangeLoading(false); + onBack(); + }; + + const handleAllowAddress = async () => { + setIsRequestChangeLoading(true); + await refreshConsentList(); + await allow([conversation.peerAddress]); + await refreshConsentList(); + setIsRequest(false); + setIsRequestChangeLoading(false); + }; + + const handleEmojiSelect = (emoji: string) => { + if (!reactionMessage) return; + sendReaction({ + action: 'added', + content: emoji, + referenceId: reactionMessage.id, + }); + setReactionMessage(null); + const replica = document.getElementById(`${reactionMessage?.id}-replica`); + replica?.remove(); + }; + + return ( + + {/* Overlay for reaction */} + { + setReactionMessage(null); + const replica = document.getElementById( + `${reactionMessage?.id}-replica` + ); + replica?.remove(); + }} + /> + + + + + {isCanMessageLoading || isLoading ? ( + + ) : ( + + )} + + {isRequest ? ( + + ) : ( + setReplyMessage(null)} + /> + )} + + + ); +}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/NewChat/NewChatTextField/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/NewChat/NewChatTextField/index.tsx new file mode 100644 index 00000000..81b5e15d --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/NewChat/NewChatTextField/index.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import { Flex, Input, LoadingSpinner, SendIcon } from '@justweb3/ui'; + +interface NewChatTextFieldProps { + disabled?: boolean; + onNewConvo: (message: string) => void; + style?: React.CSSProperties; +} + +export const NewChatTextField: React.FC = ({ + disabled, + onNewConvo, + style, +}) => { + const [messageValue, setMessageValue] = useState(''); + const [isNewMessageLoading, setIsNewMessageLoading] = + useState(false); + + const handleSendMessage = async () => { + if (messageValue.trim().length === 0 || disabled) return; + setIsNewMessageLoading(true); + await onNewConvo(messageValue); + setIsNewMessageLoading(false); + setMessageValue(''); + }; + + return ( + + {isNewMessageLoading ? ( + + + + ) : ( + + } + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSendMessage(); + } + }} + onChange={(e) => setMessageValue(e.target.value)} + /> + )} + + ); +}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/NewConversation/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/NewChat/index.tsx similarity index 96% rename from packages/@justweb3/xmtp-plugin/src/lib/components/NewConversation/index.tsx rename to packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/NewChat/index.tsx index 8190c568..6d8e2df6 100644 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/NewConversation/index.tsx +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/NewChat/index.tsx @@ -8,7 +8,6 @@ import { useStartConversation, } from '@xmtp/react-sdk'; import React, { useEffect, useMemo } from 'react'; -import MessageTextField from '../MessageTextField'; import { useDebounce } from '@justweb3/widget'; import { useMountedAccount, @@ -25,14 +24,15 @@ import { P, VerificationsIcon, } from '@justweb3/ui'; +import { NewChatTextField } from './NewChatTextField'; -interface NewConversationProps { +interface NewChatProps { onChatStarted: (conversation: CachedConversation) => void; onBack: () => void; selectedAddress?: string; } -const NewConversation: React.FC = ({ +export const NewChat: React.FC = ({ onChatStarted, onBack, selectedAddress, @@ -102,7 +102,7 @@ const NewConversation: React.FC = ({ } }; - const handleNewConversation = async (message: string) => { + const handleNewChat = async (message: string) => { if (!client) return; const peerAddress = isAddressName && !!resolvedAddress ? resolvedAddress : debouncedAddress; @@ -303,10 +303,9 @@ const NewConversation: React.FC = ({ padding: '0px 1.5rem', }} > - = ({ ); }; -export default NewConversation; +export default NewChat; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/index.tsx index 98960a88..a4c57e5e 100644 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/index.tsx +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ChatSheet/index.tsx @@ -1,184 +1,55 @@ -import { - AddIcon, - Flex, - Sheet, - SheetContent, - SheetTitle, - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from '@justweb3/ui'; -import { - CachedConversation, - ContentTypeMetadata, - useConsent, - useConversations, - useStreamAllMessages, - useStreamConversations, -} from '@xmtp/react-sdk'; -import React, { useEffect, useMemo } from 'react'; -import { ChatList } from '../ChatList'; +import { CachedConversation, ContentTypeMetadata } from '@xmtp/react-sdk'; +import { Sheet, SheetContent, SheetTitle } from '@justweb3/ui'; +import { Chat } from './Chat'; +import { useMemo } from 'react'; +import { NewChat } from './NewChat'; export interface ChatSheetProps { - open?: boolean; - handleOpen?: (open: boolean) => void; - handleOpenChat: ( - conversation: CachedConversation + // peerAddress?: string | null; + peer: CachedConversation | string | null; + openChat: boolean; + closeChat: () => void; + onChangePeer: ( + peer: CachedConversation | string ) => void; - handleNewChat: () => void; } export const ChatSheet: React.FC = ({ - open, - handleOpen, - handleOpenChat, - handleNewChat, + peer, + closeChat, + openChat, + onChangePeer, }) => { - const [tab, setTab] = React.useState('Chats'); - const { conversations, isLoading } = useConversations(); - const [isConsentListLoading, setIsConsentListLoading] = React.useState(true); - const { loadConsentList, entries } = useConsent(); - - const allowedConversations = useMemo(() => { - return conversations.filter( - (convo) => - entries && - entries[convo.peerAddress] && - entries[convo.peerAddress]?.permissionType === 'allowed' - ); - }, [conversations, entries]); - - const blockedConversations = useMemo(() => { - return conversations.filter( - (convo) => - entries && - entries[convo.peerAddress] && - entries[convo.peerAddress]?.permissionType === 'denied' - ); - }, [conversations, entries]); - - const requestConversations = useMemo(() => { - return conversations.filter((convo) => { - if (!entries[convo.peerAddress]) return true; - return entries[convo.peerAddress]?.permissionType === 'unknown'; - }); - }, [conversations, entries]); - - useEffect(() => { - loadConsentList().then(() => { - setIsConsentListLoading(false); - }); - }, [loadConsentList]); - - useStreamConversations(); - useStreamAllMessages(); + const isPeerConversation = useMemo(() => { + return typeof peer !== 'string'; + }, [peer]); return ( - - - Chats - - - - setTab(value)} - style={{ - display: 'flex', - flexDirection: 'column', - marginBottom: '0px', - overflow: 'hidden', - marginTop: '10px', - flex: '1', - }} - > - - - Chats - - - Requests - {requestConversations.length > 0 && ( - - {requestConversations.length} - - )} - - - Blocked - - - {isLoading || isConsentListLoading ? ( -
Loading...
+ !open && closeChat()}> + + + {isPeerConversation ? 'Messages' : 'New Conversation'} + + + {peer !== null && + (isPeerConversation ? ( + } + onBack={closeChat} + /> ) : ( - <> - - - - - - - - - - - )} -
+ { + onChangePeer(conversation); + }} + /> + ))}
); diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/CustomPlayer/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/CustomPlayer/index.tsx deleted file mode 100644 index 1ab77a3c..00000000 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/CustomPlayer/index.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { DownloadIcon, Flex, PauseIcon, PlayIcon } from '@justweb3/ui'; -import React, { useEffect, useRef, useState } from 'react'; - - -export interface CustomPlayerProps { - style?: React.CSSProperties; - url?: string; - disabled?: boolean; - fileName?: string; -} - -export const CustomPlayer: React.FC = ({ - style, - url = '', - disabled, - fileName -}) => { - const [playing, setPlaying] = React.useState(false); - const videoRef = useRef(null); - const [hovered, setHovered] = useState(false) - - const togglePlay = () => { - if (disabled) return; - if (videoRef.current) { - if (playing) { - videoRef.current.pause(); - } else { - videoRef.current.play(); - } - setPlaying(!playing); - } - }; - - useEffect(() => { - const handleVisibilityChange = () => { - if (document.hidden && playing && videoRef.current) { - videoRef.current.pause(); - } - }; - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - if (videoRef.current && playing) { - videoRef.current.pause(); - setPlaying(false); - } - }; - }, [playing]); - - return ( - { setHovered(true) }} - onMouseLeave={() => { setHovered(false) }} - onClick={togglePlay} - style={{ - aspectRatio: '16/9', - position: 'relative', - // TODO: check background color - background: 'var(--justweb3-background-color)', - cursor: 'pointer', - borderRadius: '10px', - border: '1px solid var(--justweb3-primary-color)', - ...style - }}> - - ); -} diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/CustomVoicePreview/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/CustomVoicePreview/index.tsx deleted file mode 100644 index f90bd51d..00000000 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/CustomVoicePreview/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { CloseIcon, Flex, P, PauseIcon, PlayIcon } from '@justweb3/ui'; -import React, { useEffect, useRef, useState } from 'react'; -import useGetAudioDuration from '../../hooks/useGetAudioDuration'; -import { formatTime } from '../../utils/formatVoiceTime'; - - -interface CustomVoicePreviewProps { - audioUrl: string; - onCancel: () => void; -} - -const CustomVoicePreview: React.FC = ({ - audioUrl, - onCancel -}) => { - const [playing, setPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const audioRef = useRef(new Audio()); - - const audioDuration = useGetAudioDuration(audioUrl); - - useEffect(() => { - const audio = new Audio(); - audioRef.current = audio; - - const onTimeUpdate = () => { - setCurrentTime(audio.currentTime); - }; - - const onEnded = () => { - setPlaying(false); - setCurrentTime(0); - }; - - audio.src = audioUrl; - audio.addEventListener('timeupdate', onTimeUpdate); - audio.addEventListener('ended', onEnded); - - return () => { - audio.removeEventListener('timeupdate', onTimeUpdate); - audio.removeEventListener('ended', onEnded); - }; - }, [audioUrl]); - - const handlePlayPause = () => { - const audio = audioRef.current; - if (!audio) return; - - if (playing) { - audio.pause(); - } else { - audio.play(); - } - setPlaying(!playing); - }; - - return ( - - {playing ? - - : - - } -

{playing || currentTime > 0 ? formatTime(currentTime) : formatTime(audioDuration ?? 0)}

- -
- ); -}; - -export default CustomVoicePreview; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/EmojiSelector/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/EmojiSelector/index.tsx deleted file mode 100644 index c57959d1..00000000 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/EmojiSelector/index.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Flex, Input } from '@justweb3/ui'; -import React, { useMemo } from 'react'; -import { EmojiObject, emojis } from '../../utils/emojis'; -import { useDebounced } from '../../hooks'; - - -interface EmojiSelectorProps { - onEmojiSelect: (emoji: string) => void; -} - -const EmojiSelector: React.FC = ({ - onEmojiSelect, -}) => { - const [searchValue, setSearchValue] = React.useState(""); - - const { - value: debouncedSearch, - } = useDebounced(searchValue, 50); - - const onEmojiClickHandler = (emoji: EmojiObject) => { - onEmojiSelect(emoji.name); - - } - - const filteredEmojis = useMemo(() => { - if (debouncedSearch.length === 0) return emojis; - const filteredEmojis = emojis.filter(emoji => - emoji.name.toLowerCase().includes(debouncedSearch.toLowerCase()) - ); - return filteredEmojis; - }, [debouncedSearch]); - - - return ( - - setSearchValue(e.target.value)} - /> -
- {filteredEmojis.map((emoji, index) => ( - - ))} -
-
- - ); -}; - -export default EmojiSelector; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/MessageItem/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/InboxSheet/ChatList/MessageItem/index.tsx similarity index 56% rename from packages/@justweb3/xmtp-plugin/src/lib/components/MessageItem/index.tsx rename to packages/@justweb3/xmtp-plugin/src/lib/components/InboxSheet/ChatList/MessageItem/index.tsx index 19b1bf23..a994b787 100644 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/MessageItem/index.tsx +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/InboxSheet/ChatList/MessageItem/index.tsx @@ -1,41 +1,50 @@ import { attachmentContentTypeConfig, CachedConversation, + CachedMessage, ContentTypeMetadata, reactionContentTypeConfig, replyContentTypeConfig, useConsent, - useLastMessage, useStreamMessages, } from '@xmtp/react-sdk'; import { useEnsAvatar, useRecords } from '@justaname.id/react'; import { Avatar, Button, Flex, formatText, P, SPAN } from '@justweb3/ui'; import React, { useMemo } from 'react'; -import { formatChatDate } from '../../utils/formatChatDate'; +import { formatChatDate } from '../../../../utils/formatChatDate'; export interface MessageItemProps { conversation: CachedConversation; onClick?: () => void; blocked?: boolean; primaryName?: string | null; + conversationInfo?: { + conversationId: string; + unreadCount: number; + consent: 'allowed' | 'blocked' | 'requested'; + lastMessage: CachedMessage; + }; } -export const MessageItem: React.FC = ({ +const MessageItem: React.FC = ({ conversation, onClick, blocked, primaryName, + conversationInfo, }) => { - const lastMessage = useLastMessage(conversation.topic); useStreamMessages(conversation); - // const { primaryName } = usePrimaryName({ - // address: conversation.peerAddress as `0x${string}`, - // }); + // const { messages } = useMessages(conversation); const { records } = useRecords({ ens: primaryName || undefined, }); const { sanitizeEnsImage } = useEnsAvatar(); + // const unreadMessages = useMemo(() => { + // if (!lastMessage) return false; + // return lastMessage.contentType !== ContentTypeReadReceipt.toString(); + // }, [lastMessage]); + const { allow, refreshConsentList } = useConsent(); const allowUser = async () => { @@ -44,7 +53,10 @@ export const MessageItem: React.FC = ({ await refreshConsentList(); }; + console.log('conversationInfo', conversationInfo); + const lastContent = useMemo(() => { + const lastMessage = conversationInfo?.lastMessage; if (!lastMessage) return ''; if (typeof lastMessage.content === 'string') { @@ -72,7 +84,9 @@ export const MessageItem: React.FC = ({ } return lastMessage.contentFallback; - }, [lastMessage]); + }, [conversationInfo, conversationInfo?.lastMessage]); + + console.log(lastContent); return ( = ({ lineHeight: '12px', }} > - {lastMessage - ? lastMessage.senderAddress !== conversation.peerAddress + {conversationInfo?.lastMessage + ? conversationInfo?.lastMessage.senderAddress !== + conversation.peerAddress ? 'You: ' : '' : ''} - {lastMessage + {conversationInfo?.lastMessage ? lastContent ? lastContent : 'No preview available' @@ -148,11 +163,55 @@ export const MessageItem: React.FC = ({ Unblock ) : ( - - {lastMessage?.sentAt ? formatChatDate(lastMessage.sentAt) : ''} - +
+ + {conversationInfo?.lastMessage?.sentAt + ? formatChatDate(conversationInfo?.lastMessage.sentAt) + : ''} + + {!!conversationInfo?.unreadCount && + conversationInfo?.unreadCount > 0 && ( +
+ + {conversationInfo?.unreadCount} + +
+ )} +
)}
); }; + +const MessageItemMemo = React.memo(MessageItem, (prevProps, nextProps) => { + return JSON.stringify(prevProps) === JSON.stringify(nextProps); +}); + +export { MessageItemMemo as MessageItem }; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatList/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/InboxSheet/ChatList/index.tsx similarity index 60% rename from packages/@justweb3/xmtp-plugin/src/lib/components/ChatList/index.tsx rename to packages/@justweb3/xmtp-plugin/src/lib/components/InboxSheet/ChatList/index.tsx index 3ff94d5f..c9cd170e 100644 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatList/index.tsx +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/InboxSheet/ChatList/index.tsx @@ -1,7 +1,11 @@ import { Flex } from '@justweb3/ui'; -import { CachedConversation, ContentTypeMetadata } from '@xmtp/react-sdk'; +import { + CachedConversation, + CachedMessage, + ContentTypeMetadata, +} from '@xmtp/react-sdk'; import React from 'react'; -import { MessageItem } from '../MessageItem'; +import { MessageItem } from './MessageItem'; import { usePrimaryNameBatch } from '@justaname.id/react'; export interface ChatListProps { @@ -10,12 +14,19 @@ export interface ChatListProps { conversation: CachedConversation ) => void; blockedList?: boolean; + conversationsInfo?: { + conversationId: string; + unreadCount: number; + consent: 'allowed' | 'blocked' | 'requested'; + lastMessage: CachedMessage; + }[]; } export const ChatList: React.FC = ({ conversations, handleOpenChat, blockedList, + conversationsInfo, }) => { const { allPrimaryNames } = usePrimaryNameBatch({ addresses: conversations.map((conversation) => conversation.peerAddress), @@ -33,6 +44,15 @@ export const ChatList: React.FC = ({ item.conversationId === conversation.topic + // )?.unreadCount + // } + // + conversationInfo={conversationsInfo?.find( + (item) => item.conversationId === conversation.topic + )} onClick={() => handleOpenChat(conversation)} key={conversation.topic} blocked={blockedList} diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/InboxSheet/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/InboxSheet/index.tsx new file mode 100644 index 00000000..c4496252 --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/InboxSheet/index.tsx @@ -0,0 +1,274 @@ +import { + AddIcon, + Flex, + Sheet, + SheetContent, + SheetTitle, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@justweb3/ui'; +import { + CachedConversation, + CachedMessage, + ContentTypeMetadata, + useConsent, + useConversations, + useStreamAllMessages, + useStreamConversations, +} from '@xmtp/react-sdk'; +import React, { useEffect, useMemo } from 'react'; +import { ChatList } from './ChatList'; + +export interface InboxSheetProps { + open?: boolean; + handleOpen?: (open: boolean) => void; + handleOpenChat: ( + conversation: CachedConversation + ) => void; + handleNewChat: () => void; + onConversationsUpdated: ({ + allowed, + blocked, + requested, + }: { + allowed: CachedConversation[]; + blocked: CachedConversation[]; + requested: CachedConversation[]; + }) => void; + allConversations: { + allowed: CachedConversation[]; + blocked: CachedConversation[]; + requested: CachedConversation[]; + }; + conversationsInfo?: { + conversationId: string; + unreadCount: number; + consent: 'allowed' | 'blocked' | 'requested'; + lastMessage: CachedMessage; + }[]; +} + +export const InboxSheet: React.FC = ({ + open, + handleOpen, + handleOpenChat, + handleNewChat, + onConversationsUpdated, + allConversations, + conversationsInfo, +}) => { + const [tab, setTab] = React.useState('Chats'); + const { conversations, isLoading } = useConversations(); + + const [isConsentListLoading, setIsConsentListLoading] = React.useState(true); + const { loadConsentList, entries } = useConsent(); + + const allowedConversations = useMemo(() => { + return conversations.filter( + (convo) => + entries && + entries[convo.peerAddress] && + entries[convo.peerAddress]?.permissionType === 'allowed' + ); + }, [conversations, entries]); + + const blockedConversations = useMemo(() => { + return conversations.filter( + (convo) => + entries && + entries[convo.peerAddress] && + entries[convo.peerAddress]?.permissionType === 'denied' + ); + }, [conversations, entries]); + + const requestConversations = useMemo(() => { + return conversations.filter((convo) => { + if (!entries[convo.peerAddress]) return true; + return entries[convo.peerAddress]?.permissionType === 'unknown'; + }); + }, [conversations, entries]); + + useEffect(() => { + let _allowedConversations = [] as CachedConversation[]; + let _blockedConversations = [] as CachedConversation[]; + let _requestConversations = [] as CachedConversation[]; + + if ( + allowedConversations.some( + (convo) => + !allConversations.allowed.some((c) => c.topic === convo.topic) + ) + ) { + _allowedConversations = allowedConversations.filter( + (convo) => + !allConversations.allowed.some((c) => c.topic === convo.topic) + ); + // onConversationsUpdated([...allConversations, ...newConversations]); + } + + if ( + blockedConversations.some( + (convo) => + !allConversations.blocked.some((c) => c.topic === convo.topic) + ) + ) { + _blockedConversations = blockedConversations.filter( + (convo) => + !allConversations.blocked.some((c) => c.topic === convo.topic) + ); + } + + if ( + requestConversations.some( + (convo) => + !allConversations.requested.some((c) => c.topic === convo.topic) + ) + ) { + _requestConversations = requestConversations.filter( + (convo) => + !allConversations.requested.some((c) => c.topic === convo.topic) + ); + } + + if ( + _allowedConversations.length > 0 || + _blockedConversations.length > 0 || + _requestConversations.length > 0 + ) { + onConversationsUpdated({ + allowed: [...allConversations.allowed, ..._allowedConversations], + blocked: [...allConversations.blocked, ..._blockedConversations], + requested: [...allConversations.requested, ..._requestConversations], + }); + } + }, [ + allConversations, + allowedConversations, + blockedConversations, + onConversationsUpdated, + requestConversations, + ]); + + useEffect(() => { + loadConsentList().then(() => { + setIsConsentListLoading(false); + }); + }, [loadConsentList]); + + useStreamConversations(); + useStreamAllMessages(); + + return ( + + + Chats + + + + setTab(value)} + style={{ + display: 'flex', + flexDirection: 'column', + marginBottom: '0px', + overflow: 'hidden', + marginTop: '10px', + flex: '1', + }} + > + + + Chats + + + Requests + {requestConversations.length > 0 && ( + + {requestConversations.length} + + )} + + + Blocked + + + {isLoading || isConsentListLoading ? ( +
Loading...
+ ) : ( + <> + + + + + + + + + + + )} +
+
+
+ ); +}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/MessageSheet/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/MessageSheet/index.tsx deleted file mode 100644 index f39ebfbf..00000000 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/MessageSheet/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { CachedConversation, ContentTypeMetadata } from '@xmtp/react-sdk'; -import { Sheet, SheetContent, SheetTitle } from '@justweb3/ui'; -import { Chat } from '../Chat'; - -export interface MessageSheetProps { - conversation: CachedConversation | null; - openChat: boolean; - handleOpenChat: ( - conversation: CachedConversation | null - ) => void; -} - -export const MessageSheet: React.FC = ({ - conversation, - handleOpenChat, - openChat, -}) => { - return ( - !open && handleOpenChat(null)} - > - - Messages - {conversation && handleOpenChat(null)} />} - - - ); -}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/MessageTextField/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/MessageTextField/index.tsx deleted file mode 100644 index 6c6acbfc..00000000 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/MessageTextField/index.tsx +++ /dev/null @@ -1,651 +0,0 @@ -import type { Attachment } from '@xmtp/content-type-remote-attachment'; -import { CachedConversation, useClient } from '@xmtp/react-sdk'; -import React, { - Dispatch, - SetStateAction, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { MessageWithReaction } from '../../utils/filterReactionsMessages'; -import { useMountedAccount, usePrimaryName } from '@justaname.id/react'; -import { - useAttachmentChange, - useRecordingTimer, - useRecordVoice, - useSendAttachment, - useSendMessages, - useSendReplyMessage, -} from '../../hooks'; -import { AttachmentType, typeLookup } from '../../utils/attachments'; -import { - AddFolderIcon, - AddImageIcon, - AddVideoIcon, - Button, - CloseIcon, - DocumentIcon, - Flex, - Input, - LoadingSpinner, - MicIcon, - P, - SendIcon, - StopIcon, -} from '@justweb3/ui'; -import { formatAddress } from '../../utils/formatAddress'; -import VoiceMessageCard from '../VoiceMessageCard'; -import { CustomPlayer } from '../CustomPlayer'; -import CustomVoicePreview from '../CustomVoicePreview'; - -interface MessageTextFieldProps { - newConvo?: boolean; - disabled?: boolean; - conversation?: CachedConversation; - replyMessage?: MessageWithReaction | null; - onCancelReply?: () => void; - onNewConvo?: (message: string) => void; - peerAddress?: string; - style?: React.CSSProperties; -} - -const MessageTextField: React.FC = ({ - newConvo, - disabled, - replyMessage, - onCancelReply, - conversation, - onNewConvo, - peerAddress, - style, -}) => { - const [messageValue, setMessageValue] = React.useState(''); - const [attachment, setAttachment] = React.useState(); - const [attachmentPreview, setAttachmentPreview] = React.useState< - string | undefined - >(); - const [isNewMessageLoading, setIsNewMessageLoading] = - React.useState(false); - // const [selectingAttachment, setSelectingAttachment] = React.useState(false); - const { client } = useClient(); - const { address } = useMountedAccount(); - const { mutateAsync: sendMessage } = useSendMessages(conversation); - const { mutateAsync: sendReply } = useSendReplyMessage(conversation); - const { mutateAsync: sendAttachment } = useSendAttachment(conversation); - - const attachmentExtention = useMemo(() => { - return attachment?.mimeType.split('/')?.[1] || ''; - }, [attachment]); - - const { primaryName } = usePrimaryName({ - address: peerAddress as `0x${string}`, - }); - - // Attachments - const [acceptedTypes, setAcceptedTypes]: [ - string | string[] | undefined, - Dispatch> - ] = useState(); - const inputFile = useRef(null); - const { onAttachmentChange } = useAttachmentChange({ - setAttachment, - setAttachmentPreview, - onError: (error) => { - // showToast("error", error); - }, - }); - - // Recording - const { recording, startRecording, stopRecording } = useRecordVoice({ - setAttachment, - setAttachmentPreview, - }); - const { start, pause, reset, recordingValue } = useRecordingTimer({ - stopRecording, - status: recording ? 'recording' : 'idle', - }); - - // Sending text message - const handleSendMessage = async () => { - if (messageValue.length === 0) return; - if (!client) return; - if (disabled) return; - if (newConvo) { - setIsNewMessageLoading(true); - onNewConvo && onNewConvo(messageValue); - setIsNewMessageLoading(false); - } else { - if (replyMessage) { - sendReply({ - message: messageValue, - referenceId: replyMessage.id, - }); - onCancelReply && onCancelReply(); - } else { - sendMessage(messageValue); - } - } - setMessageValue(''); - }; - - // Sending attachment - const handleSendAttachment = async () => { - if (!client) return; - if (!attachment) return; - if (disabled) return; - if (newConvo) { - onNewConvo && onNewConvo(messageValue); - } else { - sendAttachment(attachment); - } - setMessageValue(''); - setAttachment(undefined); - setAttachmentPreview(undefined); - // setSelectingAttachment(false); - }; - - const handleCancelAttachment = () => { - setAttachment(undefined); - setAttachmentPreview(undefined); - }; - - const onButtonClick = (contentType: AttachmentType) => { - if (contentType === 'application') { - setAcceptedTypes('all'); - } else { - const acceptedFileTypeList = Object.keys(typeLookup).reduce( - (acc: string[], key: string) => { - if (typeLookup[key] === contentType) acc.push(`.${key}`); - return acc; - }, - [] - ); - setAcceptedTypes([...acceptedFileTypeList]); - } - }; - - // Reply message - const isSender = useMemo(() => { - return address === replyMessage?.senderAddress; - }, [replyMessage, address]); - const isReplyVoice = useMemo(() => { - return replyMessage?.content.mimeType === 'audio/wav'; - }, [replyMessage]); - - const isReplyText = useMemo(() => { - if (!replyMessage) return false; - return typeof replyMessage.content === 'string'; - }, [replyMessage]); - - const isReplyReply = useMemo(() => { - if (!replyMessage) return false; - return !!replyMessage.content.reference; - }, [replyMessage]); - - const replyAttachmentExtention = useMemo(() => { - if (!isReplyText && !!replyMessage && !isReplyReply) - return replyMessage.content.mimeType.split('/')?.[1] || ''; - }, [isReplyText, replyMessage, isReplyReply]); - - const navigateToRepliedMessage = () => { - if (!replyMessage) return; - const element = document.getElementById(replyMessage.id.toString()); - if (element) { - element.scrollIntoView({ - block: 'end', - behavior: 'smooth', - }); - } - }; - - useEffect(() => { - if (acceptedTypes) { - inputFile?.current?.click(); - } - }, [acceptedTypes]); - - const isReplyVideoOrImage = useMemo(() => { - return ( - typeLookup[replyAttachmentExtention] === 'image' || - typeLookup[replyAttachmentExtention] === 'video' - ); - }, [replyAttachmentExtention]); - - return ( - - {!replyMessage} - {!newConvo && ( - - onButtonClick('image')} - style={{ - cursor: 'pointer', - }} - /> - onButtonClick('video')} - style={{ - cursor: 'pointer', - }} - /> - onButtonClick('application')} - style={{ - cursor: 'pointer', - }} - /> - - - )} -
- {replyMessage && ( - - -

- {isSender - ? 'YOU' - : primaryName ?? formatAddress(replyMessage.senderAddress)} -

- {isReplyText || isReplyReply ? ( -

- {isReplyReply - ? replyMessage.content.content - : replyMessage.content} -

- ) : isReplyVoice ? ( - - ) : typeLookup[replyAttachmentExtention] === 'image' ? ( - {replyMessage.content.filename} - ) : typeLookup[replyAttachmentExtention] === 'video' ? ( - - ) : ( - - -

- {replyMessage.content.filename} -

-
- )} -
- -
- )} - {attachmentPreview ? ( - - {attachment?.mimeType !== 'audio/wav' && ( -
- )} - {attachment?.mimeType === 'audio/wav' ? ( - - ) : ( - - {typeLookup[attachmentExtention] === 'image' ? ( - {attachment?.filename} - ) : typeLookup[attachmentExtention] === 'video' ? ( - - ) : ( - - -

- {attachment?.filename ?? 'Cannot preview'} -

-
- )} - - - - -
- )} - { - if (disabled) return; - handleSendAttachment(); - }} - /> - - ) : recording ? ( - -

- {recordingValue} -

-

- RECORDING... -

- { - stopRecording(); - pause(); - reset(); - }} - /> -
- ) : isNewMessageLoading ? ( - - - - ) : ( - { - if (disabled) return; - startRecording(); - start(); - }} - /> - ) - } - right={ - { - if (disabled) return; - handleSendMessage(); - }} - /> - } - disabled={disabled} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleSendMessage(); - } - }} - onChange={(e) => setMessageValue(e.target.value)} - /> - )} -
-
- ); -}; - -export default MessageTextField; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/NewMessageSheet/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/NewMessageSheet/index.tsx deleted file mode 100644 index 69a14e41..00000000 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/NewMessageSheet/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Sheet, SheetContent, SheetTitle } from '@justweb3/ui'; -import { CachedConversation } from '@xmtp/react-sdk'; -import NewConversation from '../NewConversation'; - -export interface MessageSheetProps { - openNewChat: boolean; - handleOpenNewChat: (open: boolean) => void; - onChatStarted: (conversation: CachedConversation) => void; - addressOrEns?: string; -} - -export const NewMessageSheet: React.FC = ({ - handleOpenNewChat, - openNewChat, - onChatStarted, - addressOrEns, -}) => { - return ( - !open && handleOpenNewChat(false)} - > - - New Conversation - handleOpenNewChat(false)} - selectedAddress={addressOrEns} - /> - - - ); -}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatWithProfileButton/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/components/ProfileChatButton/index.tsx similarity index 96% rename from packages/@justweb3/xmtp-plugin/src/lib/components/ChatWithProfileButton/index.tsx rename to packages/@justweb3/xmtp-plugin/src/lib/components/ProfileChatButton/index.tsx index 0e839180..c0965bde 100644 --- a/packages/@justweb3/xmtp-plugin/src/lib/components/ChatWithProfileButton/index.tsx +++ b/packages/@justweb3/xmtp-plugin/src/lib/components/ProfileChatButton/index.tsx @@ -8,13 +8,13 @@ import { useEthersSigner } from '../../hooks'; import { loadKeys, storeKeys, wipeKeys } from '../../utils/xmtp'; import { ChainId } from '@justaname.id/sdk'; -export interface ChatWithProfileButtonProps { +export interface ProfileChatButtonProps { ens: string; env: 'local' | 'production' | 'dev'; chainId: ChainId; } -export const ChatWithProfileButton: React.FC = ({ +export const ProfileChatButton: React.FC = ({ ens, env, chainId, diff --git a/packages/@justweb3/xmtp-plugin/src/lib/content-types/readReceipt.ts b/packages/@justweb3/xmtp-plugin/src/lib/content-types/readReceipt.ts new file mode 100644 index 00000000..12c0a2c2 --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/content-types/readReceipt.ts @@ -0,0 +1,120 @@ +import { + ContentTypeReadReceipt, + ReadReceiptCodec, +} from '@xmtp/content-type-read-receipt'; +import { z } from 'zod'; +import { isAfter, parseISO } from 'date-fns'; +import { + CachedConversation, + ContentTypeConfiguration, + ContentTypeMessageProcessor, + ContentTypeMetadataValues, + getCachedConversationByTopic, +} from '@xmtp/react-sdk'; +import { Mutex } from 'async-mutex'; +import { ContentTypeId } from '@xmtp/content-type-primitives'; + +const NAMESPACE = 'readReceipt'; +export type CachedReadReceiptMetadata = { + incoming: string | undefined; + outgoing: string | undefined; +}; +/** + * Retrieve the read receipt from a cached conversation for the given type + * + * @param conversation Cached conversation + * @returns The read receipt date, or `undefined` if the conversation + * has no read receipt for the given type + */ +export const getReadReceipt = ( + conversation: CachedConversation, + type: keyof CachedReadReceiptMetadata +) => { + const metadata = conversation?.metadata?.[NAMESPACE] as + | CachedReadReceiptMetadata + | undefined; + const readReceiptType = metadata?.[type]; + return readReceiptType ? parseISO(readReceiptType) : undefined; +}; +/** + * Check if a cached conversation has a read receipt for the given type + * + * @param conversation Cached conversation + * @returns `true` if the conversation has a read receipt for the given type, + * `false` otherwise + */ +export const hasReadReceipt = ( + conversation: CachedConversation, + type: keyof CachedReadReceiptMetadata +) => getReadReceipt(conversation, type) !== undefined; +const ReadReceiptContentSchema = z.object({}).strict(); +/** + * Validate the content of a read receipt message + * + * @param content Message content + * @returns `true` if the content is valid, `false` otherwise + */ +const isValidReadReceiptContent = (content: unknown) => { + const { success } = ReadReceiptContentSchema.safeParse(content); + return success; +}; +const processReadReceiptMutex = new Mutex(); +/** + * Process a read receipt message + * + * Updates the metadata of its conversation with the timestamp of the + * read receipt. + */ +export const processReadReceipt: ContentTypeMessageProcessor = async ({ + client, + db, + message, + conversation, + updateConversationMetadata, +}) => { + // ensure that only 1 read receipt message is processed at a time to preserve order + await processReadReceiptMutex.runExclusive(async () => { + const contentType = ContentTypeId.fromString(message.contentType); + // always use the latest conversation from the cache + const updatedConversation = await getCachedConversationByTopic( + client.address, + conversation.topic, + db + ); + if (updatedConversation) { + const isIncoming = message.senderAddress !== client.address; + const readReceiptType = isIncoming ? 'incoming' : 'outgoing'; + const readReceiptDate = getReadReceipt( + updatedConversation, + readReceiptType + ); + if ( + ContentTypeReadReceipt.sameAs(contentType) && + conversation && + isValidReadReceiptContent(message.content) && + // ignore read receipts that are older than the current one + (!readReceiptDate || isAfter(message.sentAt, readReceiptDate)) + ) { + const metadata = updatedConversation.metadata?.[NAMESPACE] as + | CachedReadReceiptMetadata + | undefined; + // update conversation metadata with the appropriate read receipt + await updateConversationMetadata({ + ...(metadata ?? {}), + [readReceiptType]: message.sentAt.toISOString(), + } as ContentTypeMetadataValues); + } + } + }); +}; +export const readReceiptContentTypeConfig: ContentTypeConfiguration = { + codecs: [new ReadReceiptCodec()], + contentTypes: [ContentTypeReadReceipt.toString()], + namespace: NAMESPACE, + processors: { + [ContentTypeReadReceipt.toString()]: [processReadReceipt], + }, + validators: { + [ContentTypeReadReceipt.toString()]: isValidReadReceiptContent, + }, +}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/hooks/useGetAudioDuration/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/hooks/useGetAudioDuration/index.tsx index ce7dc3c6..91b2af54 100644 --- a/packages/@justweb3/xmtp-plugin/src/lib/hooks/useGetAudioDuration/index.tsx +++ b/packages/@justweb3/xmtp-plugin/src/lib/hooks/useGetAudioDuration/index.tsx @@ -1,41 +1,44 @@ import { useEffect, useState } from 'react'; -const useGetAudioDuration = (url: string) => { - const [duration, setDuration] = useState(null); - - useEffect(() => { - if (!url) { - setDuration(null); - return; +export const useGetAudioDuration = (url: string) => { + const [duration, setDuration] = useState(null); + + useEffect(() => { + if (!url) { + setDuration(null); + return; + } + + const getDuration = (url: string, next: (duration: number) => void) => { + const _player = new Audio(url); + const durationChangeHandler = function ( + this: HTMLAudioElement, + e: Event + ) { + if (this.duration !== Infinity) { + const duration = this.duration; + _player.remove(); // Cleanup + next(duration); } + }; + + _player.addEventListener('durationchange', durationChangeHandler, false); + _player.load(); + _player.currentTime = 24 * 60 * 60; + _player.volume = 0; + }; + + getDuration(url, (duration: number) => { + setDuration(duration); + }); + + return () => { + const _player = new Audio(url); + _player.remove(); + }; + }, [url]); - const getDuration = (url: string, next: (duration: number) => void) => { - const _player = new Audio(url); - const durationChangeHandler = function (this: HTMLAudioElement, e: Event) { - if (this.duration !== Infinity) { - const duration = this.duration; - _player.remove(); // Cleanup - next(duration); - } - }; - - _player.addEventListener('durationchange', durationChangeHandler, false); - _player.load(); - _player.currentTime = 24 * 60 * 60; - _player.volume = 0; - }; - - getDuration(url, (duration: number) => { - setDuration(duration); - }); - - return () => { - const _player = new Audio(url); - _player.remove(); - }; - }, [url]); - - return duration; + return duration; }; export default useGetAudioDuration; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/hooks/useReadReceipt/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/hooks/useReadReceipt/index.tsx new file mode 100644 index 00000000..eb1695f7 --- /dev/null +++ b/packages/@justweb3/xmtp-plugin/src/lib/hooks/useReadReceipt/index.tsx @@ -0,0 +1,14 @@ +import { useMutation } from '@tanstack/react-query'; +import { CachedConversation, useSendMessage } from '@xmtp/react-sdk'; +import { ContentTypeReadReceipt } from '@xmtp/content-type-read-receipt'; + +export const useReadReceipt = (conversation?: CachedConversation) => { + const { sendMessage } = useSendMessage(); + + return useMutation({ + mutationFn: () => { + if (!conversation) throw new Error('Conversation not found'); + return sendMessage(conversation, {}, ContentTypeReadReceipt); + }, + }); +}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/hooks/useSendMessage/index.ts b/packages/@justweb3/xmtp-plugin/src/lib/hooks/useSendMessage/index.ts index 4a89f1d9..c7866345 100644 --- a/packages/@justweb3/xmtp-plugin/src/lib/hooks/useSendMessage/index.ts +++ b/packages/@justweb3/xmtp-plugin/src/lib/hooks/useSendMessage/index.ts @@ -1,33 +1,33 @@ -import { useMutation } from '@tanstack/react-query' -import { ContentTypeId } from '@xmtp/content-type-primitives' +import { useMutation } from '@tanstack/react-query'; +import { ContentTypeId } from '@xmtp/content-type-primitives'; import { - CachedConversation, - useSendMessage, - DecodedMessage, - SendOptions, -} from '@xmtp/react-sdk' + CachedConversation, + DecodedMessage, + SendOptions, + useSendMessage, +} from '@xmtp/react-sdk'; -export const sendMessages = async ( +export const _sendMessages = async ( + conversation: CachedConversation, + message: string, + sendMessage: ( conversation: CachedConversation, - message: string, - sendMessage: ( - conversation: CachedConversation, - content: T, - contentType?: ContentTypeId, - sendOptions?: Omit, - ) => Promise | undefined>, - contentType?: SendOptions, + content: T, + contentType?: ContentTypeId, + sendOptions?: Omit + ) => Promise | undefined>, + contentType?: SendOptions ) => { - await sendMessage(conversation, message, undefined, contentType) -} + await sendMessage(conversation, message, undefined, contentType); +}; export const useSendMessages = (conversation?: CachedConversation) => { - const { sendMessage } = useSendMessage() + const { sendMessage } = useSendMessage(); - return useMutation({ - mutationFn: (message: string, contentType?: SendOptions) => { - if (!conversation) throw new Error('Conversation not found') - return sendMessages(conversation, message, sendMessage, contentType) - }, - }) -} + return useMutation({ + mutationFn: (message: string, contentType?: SendOptions) => { + if (!conversation) throw new Error('Conversation not found'); + return _sendMessages(conversation, message, sendMessage, contentType); + }, + }); +}; diff --git a/packages/@justweb3/xmtp-plugin/src/lib/plugins/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/plugins/index.tsx index 63c919a9..90d1a0a3 100644 --- a/packages/@justweb3/xmtp-plugin/src/lib/plugins/index.tsx +++ b/packages/@justweb3/xmtp-plugin/src/lib/plugins/index.tsx @@ -1,7 +1,7 @@ import { JustaPlugin } from '@justweb3/widget'; import { JustWeb3XMTPProvider } from '../providers/JustWeb3XMTPProvider'; -import { ChatButton } from '../components/ChatButton'; -import { ChatWithProfileButton } from '../components/ChatWithProfileButton'; +import { ChatMenuButton } from '../components/ChatMenuButton'; +import { ProfileChatButton } from '../components/ProfileChatButton'; export type XmtpEnvironment = 'local' | 'production' | 'dev'; @@ -21,11 +21,11 @@ export const XMTPPlugin = (env: XmtpEnvironment): JustaPlugin => { ); }, ProfileHeader: (pluginApi, ens, chainId, address) => { - return ; + return ; }, SignInMenu: (pluginApi) => { return ( - pluginApi.setState('xmtpOpen', open)} env={env} /> diff --git a/packages/@justweb3/xmtp-plugin/src/lib/providers/JustWeb3XMTPProvider/index.tsx b/packages/@justweb3/xmtp-plugin/src/lib/providers/JustWeb3XMTPProvider/index.tsx index 05d06fc5..1ee5a0a8 100644 --- a/packages/@justweb3/xmtp-plugin/src/lib/providers/JustWeb3XMTPProvider/index.tsx +++ b/packages/@justweb3/xmtp-plugin/src/lib/providers/JustWeb3XMTPProvider/index.tsx @@ -1,29 +1,42 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { attachmentContentTypeConfig, CachedConversation, + CachedMessage, Client, ClientOptions, + ContentTypeConfiguration, ContentTypeMetadata, reactionContentTypeConfig, replyContentTypeConfig, useClient, + useMessages, + useStreamMessages, XMTPProvider, } from '@xmtp/react-sdk'; -import { ChatSheet } from '../../components/ChatSheet'; +import { InboxSheet } from '../../components/InboxSheet'; import { useEthersSigner } from '../../hooks'; import { useMountedAccount } from '@justaname.id/react'; import { loadKeys, storeKeys, wipeKeys } from '../../utils/xmtp'; -import { AllMessageSheet } from '../../components/AllMessageSheet'; +import { readReceiptContentTypeConfig } from '../../content-types/readReceipt'; +import { ContentTypeReadReceipt } from '@xmtp/content-type-read-receipt'; +import { ChatSheet } from '../../components/ChatSheet'; -const contentTypeConfigs = [ +const contentTypeConfigs: ContentTypeConfiguration[] = [ attachmentContentTypeConfig, reactionContentTypeConfig, replyContentTypeConfig, + readReceiptContentTypeConfig, ]; interface JustWeb3XMTPContextProps { handleOpenChat: (address: string) => void; + conversationsInfo: { + conversationId: string; + unreadCount: number; + consent: 'allowed' | 'blocked' | 'requested'; + lastMessage: CachedMessage; + }[]; } const JustWeb3XMTPContext = React.createContext< @@ -46,6 +59,25 @@ export const JustWeb3XMTPProvider: React.FC = ({ const [isXmtpEnabled, setIsXmtpEnabled] = React.useState(false); const [conversation, setConversation] = React.useState | null>(null); + const [conversations, setConversations] = React.useState<{ + allowed: CachedConversation[]; + blocked: CachedConversation[]; + requested: CachedConversation[]; + }>({ + allowed: [], + blocked: [], + requested: [], + }); + const [conversationsInfo, setConversationsInfo] = React.useState< + { + conversationId: string; + unreadCount: number; + consent: 'allowed' | 'blocked' | 'requested'; + lastMessage: CachedMessage; + }[] + >([]); + + console.log(conversations); const handleXmtpEnabled = (enabled: boolean) => { setIsXmtpEnabled(enabled); }; @@ -61,24 +93,142 @@ export const JustWeb3XMTPProvider: React.FC = ({ } }; + const handleConversationInfo = ( + conversationId: string, + unreadCount: number, + lastMessage: CachedMessage, + consent: 'allowed' | 'blocked' | 'requested' + ) => { + setConversationsInfo((prev) => { + const index = prev.findIndex( + (item) => item.conversationId === conversationId + ); + if (index === -1) { + return [ + ...prev, + { + conversationId, + unreadCount, + lastMessage, + consent, + }, + ]; + } + prev[index].unreadCount = unreadCount; + prev[index].lastMessage = lastMessage; + return [...prev]; + }); + }; + + console.log('Conversations Info:', conversationsInfo); + return ( {isXmtpEnabled && ( - handleOpenChat('')} + allConversations={conversations} + onConversationsUpdated={setConversations} + conversationsInfo={conversationsInfo} /> )} - ( + item.conversationId === conversation.topic + )?.unreadCount + } + lastMessage={ + conversationsInfo.find( + (item) => item.conversationId === conversation.topic + )?.lastMessage + } + handleConversationInfo={( + conversationId, + unreadCount, + lastMessage + ) => + handleConversationInfo( + conversationId, + unreadCount, + lastMessage, + 'allowed' + ) + } + /> + ))} + {conversations.blocked.map((conversation) => ( + item.conversationId === conversation.topic + )?.unreadCount + } + lastMessage={ + conversationsInfo.find( + (item) => item.conversationId === conversation.topic + )?.lastMessage + } + handleConversationInfo={( + conversationId, + unreadCount, + lastMessage + ) => + handleConversationInfo( + conversationId, + unreadCount, + lastMessage, + 'blocked' + ) + } + /> + ))} + {conversations.requested.map((conversation) => ( + item.conversationId === conversation.topic + )?.unreadCount + } + lastMessage={ + conversationsInfo.find( + (item) => item.conversationId === conversation.topic + )?.lastMessage + } + handleConversationInfo={( + conversationId, + unreadCount, + lastMessage + ) => + handleConversationInfo( + conversationId, + unreadCount, + lastMessage, + 'requested' + ) + } + /> + ))} + + { setPeerAddress(null); @@ -99,16 +249,122 @@ interface ChecksProps { env: 'local' | 'production' | 'dev'; } +interface GetConversationInfoProps { + conversation: CachedConversation; + handleConversationInfo: ( + conversationId: string, + unreadCount: number, + lastMessage: CachedMessage + ) => void; + unreadCount?: number; + lastMessage?: CachedMessage; +} + +export const GetConversationInfo: React.FC = ({ + conversation, + handleConversationInfo, + unreadCount, + lastMessage, +}) => { + const { messages } = useMessages(conversation); + + useStreamMessages(conversation); + const _unreadCount = useMemo(() => { + let count = 0; + const _messages = [...messages].reverse(); + for (const message of _messages) { + if (message.contentType === ContentTypeReadReceipt.toString()) { + break; + } + + count++; + } + + return count; + }, [messages]); + + const _lastMessage = useMemo(() => { + const _messages = [...messages]; + let lastMessage = _messages[_messages.length - 1]; + if (lastMessage?.contentType === ContentTypeReadReceipt.toString()) { + lastMessage = _messages[_messages.length - 2]; + } + + console.log('Last Message:', lastMessage); + return lastMessage; + }, [messages]); + + useEffect(() => { + if (unreadCount === _unreadCount && _lastMessage?.id === lastMessage?.id) { + return; + } + + console.log( + 'Updating Conversation Info:', + conversation.topic, + _unreadCount, + _lastMessage + ); + handleConversationInfo(conversation.topic, _unreadCount, _lastMessage); + }, [ + conversation.topic, + handleConversationInfo, + _unreadCount, + unreadCount, + _lastMessage, + lastMessage?.id, + ]); + + return null; +}; + export const Checks: React.FC = ({ open, handleXmtpEnabled, env, }) => { - const { client, initialize, isLoading } = useClient(); + const { client, initialize, isLoading, disconnect } = useClient(); const signer = useEthersSigner(); const { address } = useMountedAccount(); const [isInitializing, setIsInitializing] = React.useState(false); const [rejected, setRejected] = React.useState(false); + + useEffect(() => { + async function reinitializeXmtp() { + if (client && address) { + if (client?.address?.toLowerCase() !== address.toLowerCase()) { + await disconnect(); + + if (!signer) { + return; + } + setIsInitializing(true); + const clientOptions: Partial> = { + appVersion: 'JustWeb3/1.0.0/' + env + '/0', + env: env, + }; + let keys = loadKeys(address ?? '', env); + console.log('Keys:', keys); + if (!keys) { + keys = await Client.getKeys(signer, { + env: env, + skipContactPublishing: false, + // persistConversations: false, + }); + storeKeys(address ?? '', keys, env); + } + + await initialize({ + keys, + options: clientOptions, + signer: signer, + }); + } + } + } + reinitializeXmtp(); + }, [client, address, signer, env, initialize, disconnect]); + useEffect(() => { async function initializeXmtp() { if (isInitializing || isLoading || rejected) return; @@ -134,11 +390,14 @@ export const Checks: React.FC = ({ }); storeKeys(address ?? '', keys, env); } + await initialize({ keys, options: clientOptions, signer: signer, }); + + // _client?.registerCodec(new ReadReceiptCodec()); setIsInitializing(false); } catch (error) { console.error('Failed to initialize XMTP Client:', error); @@ -164,12 +423,6 @@ export const Checks: React.FC = ({ handleXmtpEnabled(!!client); }, [client, handleXmtpEnabled]); - // useEffect(() => { - // if (!address) { - // disconnect(); - // } - // }, [connectedEns?.ens, disconnect]); - return null; };