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' ? (
+
+ ) : 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' ? (
+
+ ) : 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,
+ }}
+ >
+
+ {hovered && playing && (
+
+ )}
+ {!playing && (
+
+
+ {!!fileName && (
+ {
+ e.stopPropagation();
+ }}
+ style={{
+ transform: 'translateY(2px)',
+ }}
+ href={url}
+ download={fileName}
+ >
+
+
+ )}
+
+ )}
+
+ );
+};
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
- }}>
-
- {
- (hovered && playing) &&
-
- }
- {
- !playing &&
-
-
- {!!fileName && (
- {
- e.stopPropagation()
- }} style={{
- transform: 'translateY(2px)'
- }} href={url} download={fileName}>
-
- )}
-
- }
-
-
- );
-}
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' ? (
-
- ) : typeLookup[replyAttachmentExtention] === 'video' ? (
-
- ) : (
-
-
-
- {replyMessage.content.filename}
-
-
- )}
-
-
-
- )}
- {attachmentPreview ? (
-
- {attachment?.mimeType !== 'audio/wav' && (
-
- )}
- {attachment?.mimeType === 'audio/wav' ? (
-
- ) : (
-
- {typeLookup[attachmentExtention] === 'image' ? (
-
- ) : 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;
};