Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add local first architecture to React SDK #50

Merged
merged 16 commits into from
Aug 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,14 @@ jobs:
run: |
corepack enable
corepack prepare yarn@3.4.1 --activate
- name: Start docker container
run: ./dev/up
- name: Install dependencies
run: yarn
- name: Run tests
run: yarn test
- name: Stop docker container
run: ./dev/down

typecheck:
runs-on: ubuntu-latest
Expand Down
25 changes: 25 additions & 0 deletions dev/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
services:
waku-node:
image: xmtp/node-go:latest
platform: linux/amd64
environment:
- GOWAKU-NODEKEY=8a30dcb604b0b53627a5adc054dbf434b446628d4bd1eccc681d223f0550ce67
command:
- --ws
- --store
- --message-db-connection-string=postgres://postgres:xmtp@db:5432/postgres?sslmode=disable
- --message-db-reader-connection-string=postgres://postgres:xmtp@db:5432/postgres?sslmode=disable
- --lightpush
- --filter
- --ws-port=9001
- --wait-for-db=30s
- --api.authn.enable
ports:
- 9001:9001
- 5555:5555
depends_on:
- db
db:
image: postgres:13
environment:
POSTGRES_PASSWORD: xmtp
4 changes: 4 additions & 0 deletions dev/down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/bash
set -e

docker-compose -p xmtp-js -f dev/docker-compose.yml down
9 changes: 9 additions & 0 deletions dev/up
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/bash
set -e

if ! which docker &>/dev/null; then
echo "Docker required to run dev/up. Install it and run this again."
exit 1
fi

docker-compose -p xmtp-js -f dev/docker-compose.yml up -d
6 changes: 4 additions & 2 deletions examples/react-quickstart/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
"format:base": "prettier --ignore-path ../../.gitignore",
"format:check": "yarn format:base -c .",
"format": "yarn format:base -w .",
"quickstart": "yarn dev",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@heroicons/react": "^2.0.18",
"@rainbow-me/rainbowkit": "^0.12.16",
"@xmtp/content-type-remote-attachment": "^1.0.7",
"@xmtp/react-components": "workspace:*",
"@xmtp/react-sdk": "workspace:*",
"ethers": "5.7.2",
Expand All @@ -27,14 +29,14 @@
"devDependencies": {
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@vitejs/plugin-react": "^4.0.2",
"@vitejs/plugin-react": "^4.0.4",
"@xmtp/tsconfig": "workspace:*",
"autoprefixer": "^10.4.14",
"eslint": "^8.44.0",
"eslint-config-xmtp-web": "workspace:*",
"postcss": "^8.4.25",
"postcss-preset-env": "^8.5.1",
"typescript": "^5.1.6",
"vite": "^4.4.1"
"vite": "^4.4.9"
}
}
2 changes: 1 addition & 1 deletion examples/react-quickstart/src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const App = () => {

// disconnect XMTP client when the wallet changes
useEffect(() => {
disconnect();
void disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signer]);

Expand Down
26 changes: 26 additions & 0 deletions examples/react-quickstart/src/components/ConversationCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useLastMessage, type CachedConversation } from "@xmtp/react-sdk";
import { ConversationPreview } from "@xmtp/react-components";

type ConversationCardProps = {
conversation: CachedConversation;
isSelected: boolean;
onConversationClick?: (conversation: CachedConversation) => void;
};

export const ConversationCard: React.FC<ConversationCardProps> = ({
conversation,
onConversationClick,
isSelected,
}) => {
const lastMessage = useLastMessage(conversation.topic);

return (
<ConversationPreview
key={conversation.topic}
conversation={conversation}
isSelected={isSelected}
onClick={onConversationClick}
lastMessage={lastMessage}
/>
);
};
39 changes: 17 additions & 22 deletions examples/react-quickstart/src/components/Conversations.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useConversations, useStreamConversations } from "@xmtp/react-sdk";
import type { Conversation } from "@xmtp/react-sdk";
import type { CachedConversation } from "@xmtp/react-sdk";
import { ChatBubbleLeftIcon } from "@heroicons/react/24/outline";
import { useCallback, useState } from "react";
import { ConversationPreviewList } from "@xmtp/react-components";
import { ConversationList } from "@xmtp/react-components";
import { Notification } from "./Notification";
import { ConversationCard } from "./ConversationCard";

type ConversationsProps = {
selectedConversation?: Conversation;
onConversationClick?: (conversation: Conversation) => void;
selectedConversation?: CachedConversation;
onConversationClick?: (conversation: CachedConversation) => void;
};

const NoConversations: React.FC = () => (
Expand All @@ -21,28 +21,23 @@ export const Conversations: React.FC<ConversationsProps> = ({
onConversationClick,
selectedConversation,
}) => {
const [streamedConversations, setStreamedConversations] = useState<
Conversation[]
>([]);
const { conversations, isLoading } = useConversations();
const onConversation = useCallback(
(conversation: Conversation) => {
// prevent duplicates
if (!conversations.some((convo) => convo.topic === conversation.topic)) {
setStreamedConversations((prev) => [...prev, conversation]);
}
},
[conversations],
);
useStreamConversations(onConversation);
useStreamConversations();

const previews = conversations.map((conversation) => (
<ConversationCard
key={conversation.topic}
conversation={conversation}
isSelected={conversation.topic === selectedConversation?.topic}
onConversationClick={onConversationClick}
/>
));

return (
<ConversationPreviewList
<ConversationList
isLoading={isLoading}
conversations={[...conversations, ...streamedConversations]}
onConversationClick={onConversationClick}
conversations={previews}
renderEmpty={<NoConversations />}
selectedConversation={selectedConversation}
/>
);
};
22 changes: 11 additions & 11 deletions examples/react-quickstart/src/components/Inbox.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "./Inbox.css";
import { useCallback, useState } from "react";
import type { Conversation } from "@xmtp/react-sdk";
import { type CachedConversation } from "@xmtp/react-sdk";
import {
ArrowRightOnRectangleIcon,
PlusCircleIcon,
Expand All @@ -13,13 +13,13 @@ import { NoSelectedConversationNotification } from "./NoSelectedConversationNoti

export const Inbox: React.FC = () => {
const { disconnect } = useWallet();
const [conversation, setConversation] = useState<Conversation | undefined>(
undefined,
);
const [selectedConversation, setSelectedConversation] = useState<
CachedConversation | undefined
>(undefined);
const [isNewMessage, setIsNewMessage] = useState(false);

const handleConversationClick = useCallback((convo: Conversation) => {
setConversation(convo);
const handleConversationClick = useCallback((convo: CachedConversation) => {
setSelectedConversation(convo);
setIsNewMessage(false);
}, []);

Expand All @@ -28,8 +28,8 @@ export const Inbox: React.FC = () => {
}, []);

const handleStartNewConversationSuccess = useCallback(
(convo?: Conversation) => {
setConversation(convo);
(convo?: CachedConversation) => {
setSelectedConversation(convo);
setIsNewMessage(false);
},
[],
Expand Down Expand Up @@ -64,14 +64,14 @@ export const Inbox: React.FC = () => {
<div className="InboxConversations__list">
<Conversations
onConversationClick={handleConversationClick}
selectedConversation={conversation}
selectedConversation={selectedConversation}
/>
</div>
<div className="InboxConversations__messages">
{isNewMessage ? (
<NewMessage onSuccess={handleStartNewConversationSuccess} />
) : conversation ? (
<Messages conversation={conversation} />
) : selectedConversation ? (
<Messages conversation={selectedConversation} />
) : (
<NoSelectedConversationNotification
onStartNewConversation={handleStartNewConversation}
Expand Down
36 changes: 13 additions & 23 deletions examples/react-quickstart/src/components/Messages.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,45 @@
import {
useClient,
useMessages,
useSendMessage,
useStreamMessages,
} from "@xmtp/react-sdk";
import type { Conversation, DecodedMessage } from "@xmtp/react-sdk";
import type { CachedConversation } from "@xmtp/react-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import "./Messages.css";
import {
AddressInput,
ConversationMessages,
Messages as MessagesList,
MessageInput,
} from "@xmtp/react-components";

type ConversationMessagesProps = {
conversation: Conversation;
conversation: CachedConversation;
};

export const Messages: React.FC<ConversationMessagesProps> = ({
conversation,
}) => {
const [isSending, setIsSending] = useState(false);
const [streamedMessages, setStreamedMessages] = useState<DecodedMessage[]>(
[],
);
const messageInputRef = useRef<HTMLTextAreaElement>(null);
const { messages, isLoading } = useMessages(conversation);
const onMessage = useCallback(
(message: DecodedMessage) => {
// prevent duplicates
if (!streamedMessages.some((msg) => msg.id === message.id)) {
setStreamedMessages((prev) => [...prev, message]);
}
},
[streamedMessages],
);
useStreamMessages(conversation, onMessage);
const { sendMessage } = useSendMessage(conversation);
const { client } = useClient();
useStreamMessages(conversation);
const { sendMessage } = useSendMessage();

const handleSendMessage = useCallback(
async (message: string) => {
setIsSending(true);
await sendMessage(message);
await sendMessage(conversation, message);
setIsSending(false);
// ensure focus of input by waiting for a browser tick
setTimeout(() => messageInputRef.current?.focus(), 0);
},
[sendMessage],
[conversation, sendMessage],
);

useEffect(() => {
messageInputRef.current?.focus();
setStreamedMessages([]);
}, [conversation]);

return (
Expand All @@ -59,10 +48,11 @@ export const Messages: React.FC<ConversationMessagesProps> = ({
value={conversation.peerAddress}
avatarUrlProps={{ address: conversation.peerAddress }}
/>
<ConversationMessages
<MessagesList
conversation={conversation}
isLoading={isLoading}
messages={[...messages, ...streamedMessages]}
clientAddress={conversation?.clientAddress ?? ""}
messages={messages.filter((message) => message.content !== undefined)}
clientAddress={client?.address}
/>
<div className="MessageInputWrapper">
<MessageInput
Expand Down
18 changes: 8 additions & 10 deletions examples/react-quickstart/src/components/NewMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,13 @@ import {
useCanMessage,
useStartConversation,
} from "@xmtp/react-sdk";
import {
AddressInput,
Messages as ConversationMessages,
MessageInput,
} from "@xmtp/react-components";
import type { Conversation } from "@xmtp/react-sdk";
import { AddressInput, MessageInput } from "@xmtp/react-components";
import type { CachedConversation } from "@xmtp/react-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import "./NewMessage.css";

type NewMessageProps = {
onSuccess?: (conversation?: Conversation) => void;
onSuccess?: (conversation?: CachedConversation) => void;
};

export const NewMessage: React.FC<NewMessageProps> = ({ onSuccess }) => {
Expand All @@ -33,9 +29,11 @@ export const NewMessage: React.FC<NewMessageProps> = ({ onSuccess }) => {
async (message: string) => {
if (peerAddress && isOnNetwork) {
setIsLoading(true);
const conversation = await startConversation(peerAddress, message);
const result = await startConversation(peerAddress, message);
setIsLoading(false);
onSuccess?.(conversation);
if (result) {
onSuccess?.(result.cachedConversation);
}
}
},
[isOnNetwork, onSuccess, peerAddress, startConversation],
Expand Down Expand Up @@ -84,7 +82,7 @@ export const NewMessage: React.FC<NewMessageProps> = ({ onSuccess }) => {
address: isOnNetwork ? peerAddress : "",
}}
/>
<ConversationMessages />
<div />
<div className="NewMessageInputWrapper">
<MessageInput
isDisabled={isLoading || !isValidAddress(peerAddress) || isError}
Expand Down
4 changes: 3 additions & 1 deletion examples/react-quickstart/src/components/XMTPConnect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ const XMTPConnectButton: React.FC<XMTPConnectButtonProps> = ({ label }) => {
const { initialize } = useClient();

const handleConnect = useCallback(() => {
void initialize({ signer });
void initialize({
signer,
});
}, [initialize, signer]);

return (
Expand Down
5 changes: 5 additions & 0 deletions examples/react-quickstart/src/globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
interface ImportMeta {
env: {
VITE_PROJECT_ID: string;
};
}
Loading