diff --git a/.changeset/thin-carpets-help.md b/.changeset/thin-carpets-help.md
new file mode 100644
index 0000000000..c4b0caf0e3
--- /dev/null
+++ b/.changeset/thin-carpets-help.md
@@ -0,0 +1,8 @@
+---
+"@lens-protocol/shared-kernel": minor
+"@lens-protocol/react-web": minor
+"@lens-protocol/domain": minor
+"@lens-protocol/react": minor
+---
+
+**Added** experimental hooks that integrate with @xmtp/react-sdk
diff --git a/examples/web-wagmi/package.json b/examples/web-wagmi/package.json
index c8e2d54ee0..5c1ba51489 100644
--- a/examples/web-wagmi/package.json
+++ b/examples/web-wagmi/package.json
@@ -20,6 +20,7 @@
"@ethersproject/providers": "^5.7.2",
"@lens-protocol/react-web": "workspace:*",
"@lens-protocol/wagmi": "workspace:*",
+ "@xmtp/react-sdk": "1.0.0-preview.40",
"example-shared": "workspace:*",
"react": "^18.2.0",
"react-cool-inview": "^3.0.1",
diff --git a/examples/web-wagmi/src/App.tsx b/examples/web-wagmi/src/App.tsx
index 2c1c37fa26..12bcb82a52 100644
--- a/examples/web-wagmi/src/App.tsx
+++ b/examples/web-wagmi/src/App.tsx
@@ -6,6 +6,7 @@ import {
development,
} from '@lens-protocol/react-web';
import { bindings as wagmiBindings } from '@lens-protocol/wagmi';
+import { XMTPProvider } from '@xmtp/react-sdk';
import toast, { Toaster } from 'react-hot-toast';
import { Route, BrowserRouter as Router, Routes } from 'react-router-dom';
import { WagmiConfig, configureChains, createConfig } from 'wagmi';
@@ -26,6 +27,10 @@ import { UseExplorePublications } from './discovery/UseExplorePublications';
import { UseFeed } from './discovery/UseFeed';
import { UseSearchProfiles } from './discovery/UseSearchProfiles';
import { UseSearchPublications } from './discovery/UseSearchPublications';
+import { InboxPage } from './inbox/InboxPage';
+import { UseConversation } from './inbox/UseConversation';
+import { UseConversations } from './inbox/UseConversations';
+import { UseCreateConversation } from './inbox/UseCreateConversation';
import { MiscPage } from './misc/MiscPage';
import { Polls } from './misc/Polls';
import { UseApproveModule } from './misc/UseApproveModule';
@@ -112,104 +117,119 @@ export function App() {
return (
-
-
-
-
-
-
- } />
+
+
+
+
+
+
+
+ } />
-
- } />
- } />
-
+
+ } />
+ } />
+
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- }
- />
- } />
- } />
- } />
- } />
- }
- />
- } />
- } />
- } />
- } />
- } />
- } />
-
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
+
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
-
- } />
- } />
- } />
- } />
- } />
- } />
-
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
-
- } />
- } />
- } />
- }
- />
-
+
+ } />
+ } />
+ } />
+ }
+ />
+
-
- } />
- } />
- } />
- } />
- }
- />
- } />
- } />
- } />
-
-
-
-
-
-
+
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+
+
);
diff --git a/examples/web-wagmi/src/components/auth/LogoutButton.tsx b/examples/web-wagmi/src/components/auth/LogoutButton.tsx
index b539b5f2a6..bd164cb695 100644
--- a/examples/web-wagmi/src/components/auth/LogoutButton.tsx
+++ b/examples/web-wagmi/src/components/auth/LogoutButton.tsx
@@ -1,12 +1,15 @@
import { useWalletLogout } from '@lens-protocol/react-web';
+import { useClient } from '@xmtp/react-sdk';
import { useDisconnect } from 'wagmi';
export function LogoutButton() {
const { execute: logout, isPending: isLogoutPending } = useWalletLogout();
+ const { disconnect: disconnectXmtp } = useClient();
const { disconnectAsync } = useDisconnect();
const onLogoutClick = async () => {
+ disconnectXmtp();
await logout();
await disconnectAsync();
};
diff --git a/examples/web-wagmi/src/config.ts b/examples/web-wagmi/src/config.ts
index 0ebd821a6e..bd3ef7c855 100644
--- a/examples/web-wagmi/src/config.ts
+++ b/examples/web-wagmi/src/config.ts
@@ -23,4 +23,8 @@ export const CATEGORIES = [
label: 'Misc',
path: '/misc',
},
+ {
+ label: 'Inbox',
+ path: '/inbox',
+ },
];
diff --git a/examples/web-wagmi/src/inbox/InboxPage.tsx b/examples/web-wagmi/src/inbox/InboxPage.tsx
new file mode 100644
index 0000000000..ad379a987a
--- /dev/null
+++ b/examples/web-wagmi/src/inbox/InboxPage.tsx
@@ -0,0 +1,26 @@
+import { LinkCard } from '../components/LinkCard';
+
+const inboxHooks = [
+ {
+ label: 'useConversations',
+ description: `List all conversations. Show a conversation with messages. Send a message.`,
+ path: '/inbox/useConversations',
+ },
+ {
+ label: 'useCreateConversation',
+ description: `Start a new conversation.`,
+ path: '/inbox/useCreateConversation',
+ },
+];
+
+export function InboxPage() {
+ return (
+
+
Inbox
+
+ {inboxHooks.map((link) => (
+
+ ))}
+
+ );
+}
diff --git a/examples/web-wagmi/src/inbox/UseConversation.tsx b/examples/web-wagmi/src/inbox/UseConversation.tsx
new file mode 100644
index 0000000000..d4d46cab1d
--- /dev/null
+++ b/examples/web-wagmi/src/inbox/UseConversation.tsx
@@ -0,0 +1,88 @@
+import { ProfileOwnedByMe, useEnhanceConversation } from '@lens-protocol/react-web';
+import { Conversation, useClient, useConversations } from '@xmtp/react-sdk';
+import { useParams } from 'react-router-dom';
+
+import { LoginButton, WhenLoggedInWithProfile, WhenLoggedOut } from '../components/auth';
+import { Loading } from '../components/loading/Loading';
+import { ConversationCard } from './components/ConversationCard';
+import { EnableConversationsButton } from './components/EnableConversationsButton';
+import { MessageComposer } from './components/MessageComposer';
+import { MessagesCard } from './components/MessagesCard';
+
+type UseConversationsInnerProps = {
+ conversation: Conversation;
+ profile: ProfileOwnedByMe;
+};
+
+function UseConversationInner({ conversation, profile }: UseConversationsInnerProps) {
+ const { data: enhancedConversation, loading } = useEnhanceConversation({ conversation, profile });
+
+ if (loading) return ;
+
+ if (enhancedConversation) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return Conversation not found
;
+}
+
+type EnableConversationsProps = {
+ profile: ProfileOwnedByMe;
+ conversationId: string;
+};
+
+function EnableConversations({ profile, conversationId }: EnableConversationsProps) {
+ const { client } = useClient();
+ const { conversations, error, isLoading } = useConversations();
+
+ if (!client) {
+ return ;
+ }
+
+ if (error) {
+ return An error occurred while fetching conversations
;
+ }
+
+ const requestedConversation = conversations?.find((c) => c.topic === conversationId);
+
+ return (
+
+ {isLoading && }
+
+ {requestedConversation && (
+
+ )}
+
+ );
+}
+
+export function UseConversation() {
+ const { conversationId } = useParams();
+
+ if (!conversationId) {
+ return ConversationId not provided
;
+ }
+
+ return (
+ <>
+
+ useConversation
+
+
+ {({ profile }) => }
+
+
+
+
You must be logged in to use this example.
+
+
+
+ >
+ );
+}
diff --git a/examples/web-wagmi/src/inbox/UseConversations.tsx b/examples/web-wagmi/src/inbox/UseConversations.tsx
new file mode 100644
index 0000000000..ecb236d9c2
--- /dev/null
+++ b/examples/web-wagmi/src/inbox/UseConversations.tsx
@@ -0,0 +1,74 @@
+import { ProfileOwnedByMe, useEnhanceConversations } from '@lens-protocol/react-web';
+import { useClient, useConversations } from '@xmtp/react-sdk';
+import { Link } from 'react-router-dom';
+
+import { LoginButton, WhenLoggedInWithProfile, WhenLoggedOut } from '../components/auth';
+import { ErrorMessage } from '../components/error/ErrorMessage';
+import { Loading } from '../components/loading/Loading';
+import { ConversationCard } from './components/ConversationCard';
+import { EnableConversationsButton } from './components/EnableConversationsButton';
+
+type UseConversationsInnerProps = {
+ profile: ProfileOwnedByMe;
+};
+
+function UseConversationsInner({ profile }: UseConversationsInnerProps) {
+ const {
+ data: conversations,
+ error,
+ loading,
+ } = useEnhanceConversations(useConversations(), { profile });
+
+ if (error) return ;
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+
+ {conversations?.length === 0 &&
No items
}
+
+ {conversations?.map((conversation) => (
+
+
+ Show details
+
+
+ ))}
+
+ );
+}
+
+type EnableConversationsProps = {
+ profile: ProfileOwnedByMe;
+};
+
+function EnableConversations({ profile }: EnableConversationsProps) {
+ const { client } = useClient();
+
+ if (!client) {
+ return ;
+ }
+
+ return ;
+}
+
+export function UseConversations() {
+ return (
+ <>
+
+ useConversations
+
+
+ {({ profile }) => }
+
+
+
+
You must be logged in to use this example.
+
+
+
+ >
+ );
+}
diff --git a/examples/web-wagmi/src/inbox/UseCreateConversation.tsx b/examples/web-wagmi/src/inbox/UseCreateConversation.tsx
new file mode 100644
index 0000000000..bea4cd2c0c
--- /dev/null
+++ b/examples/web-wagmi/src/inbox/UseCreateConversation.tsx
@@ -0,0 +1,57 @@
+import { Profile, ProfileOwnedByMe } from '@lens-protocol/react-web';
+import { useClient } from '@xmtp/react-sdk';
+import { useState } from 'react';
+
+import { LoginButton, WhenLoggedInWithProfile, WhenLoggedOut } from '../components/auth';
+import { ProfileSelector } from '../profiles/components/ProfileSelector';
+import { ConversationComposer } from './components/ConversationComposer';
+import { EnableConversationsButton } from './components/EnableConversationsButton';
+
+type UseCreateConversationInnerProps = {
+ profile: ProfileOwnedByMe;
+};
+
+function UseCreateConversationInner({ profile }: UseCreateConversationInnerProps) {
+ const [peerProfile, setPeerProfile] = useState(null);
+
+ return (
+ <>
+ Select a profile to start a conversation with:
+ setPeerProfile(p)} />
+ {peerProfile && }
+ >
+ );
+}
+
+type EnableConversationsProps = {
+ profile: ProfileOwnedByMe;
+};
+
+function EnableConversations({ profile }: EnableConversationsProps) {
+ const { client } = useClient();
+
+ if (!client) {
+ return ;
+ }
+
+ return ;
+}
+
+export function UseCreateConversation() {
+ return (
+ <>
+
+ useCreateConversation
+
+
+ {({ profile }) => }
+
+
+
+
You must be logged in to use this example.
+
+
+
+ >
+ );
+}
diff --git a/examples/web-wagmi/src/inbox/components/ConversationCard.tsx b/examples/web-wagmi/src/inbox/components/ConversationCard.tsx
new file mode 100644
index 0000000000..65b30f6583
--- /dev/null
+++ b/examples/web-wagmi/src/inbox/components/ConversationCard.tsx
@@ -0,0 +1,35 @@
+import { EnhancedConversation, Profile } from '@lens-protocol/react-web';
+import { ReactNode } from 'react';
+
+import { ProfilePicture } from '../../profiles/components/ProfilePicture';
+
+type PeerProfileProps = {
+ profile: Profile;
+};
+
+function PeerProfile({ profile }: PeerProfileProps) {
+ return (
+
+
+
{`${profile.id} (${profile.handle})`}
+
+ );
+}
+
+type ConversationCardProps = {
+ children?: ReactNode;
+ conversation: EnhancedConversation;
+};
+
+export function ConversationCard({ conversation, children }: ConversationCardProps) {
+ return (
+
+ Conversation with:
+
+ {conversation.peerProfile &&
}
+ {!conversation.peerProfile &&
Address: {conversation.peerAddress}
}
+
+ {children && {children}
}
+
+ );
+}
diff --git a/examples/web-wagmi/src/inbox/components/ConversationComposer.tsx b/examples/web-wagmi/src/inbox/components/ConversationComposer.tsx
new file mode 100644
index 0000000000..f89d9be936
--- /dev/null
+++ b/examples/web-wagmi/src/inbox/components/ConversationComposer.tsx
@@ -0,0 +1,51 @@
+import { Profile, ProfileOwnedByMe, useStartLensConversation } from '@lens-protocol/react-web';
+
+import { never } from '../../utils';
+
+type ConversationComposerProps = {
+ ownedProfile: ProfileOwnedByMe;
+ peerProfile: Profile;
+};
+
+export function ConversationComposer({ ownedProfile, peerProfile }: ConversationComposerProps) {
+ const { startConversation, isLoading, error } = useStartLensConversation({
+ ownedProfile,
+ peerProfile,
+ });
+
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault();
+
+ const form = event.currentTarget;
+
+ const formData = new FormData(form);
+ const content = (formData.get('content') as string | null) ?? never();
+
+ const newConversation = await startConversation(peerProfile.ownedBy, content);
+
+ if (newConversation) {
+ form.reset();
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/examples/web-wagmi/src/inbox/components/EnableConversationsButton.tsx b/examples/web-wagmi/src/inbox/components/EnableConversationsButton.tsx
new file mode 100644
index 0000000000..4723689338
--- /dev/null
+++ b/examples/web-wagmi/src/inbox/components/EnableConversationsButton.tsx
@@ -0,0 +1,26 @@
+import { useXmtpClient } from '@lens-protocol/react-web';
+
+import { ErrorMessage } from '../../components/error/ErrorMessage';
+import { Loading } from '../../components/loading/Loading';
+
+export function EnableConversationsButton() {
+ const { client, error, isLoading, initialize } = useXmtpClient();
+
+ const handleConnect = async () => {
+ await initialize();
+ };
+
+ if (isLoading) return ;
+
+ if (error) return ;
+
+ if (!client) {
+ return (
+
+ );
+ }
+
+ return null;
+}
diff --git a/examples/web-wagmi/src/inbox/components/MessageComposer.tsx b/examples/web-wagmi/src/inbox/components/MessageComposer.tsx
new file mode 100644
index 0000000000..f832125fda
--- /dev/null
+++ b/examples/web-wagmi/src/inbox/components/MessageComposer.tsx
@@ -0,0 +1,47 @@
+import { Conversation, useSendMessage } from '@xmtp/react-sdk';
+
+import { never } from '../../utils';
+
+type MessageComposerProps = {
+ conversation: Conversation;
+};
+
+export function MessageComposer({ conversation }: MessageComposerProps) {
+ const { sendMessage, isLoading, error } = useSendMessage(conversation);
+
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault();
+
+ const form = event.currentTarget;
+
+ const formData = new FormData(form);
+ const content = (formData.get('content') as string | null) ?? never();
+
+ await sendMessage(content);
+
+ if (!error) {
+ form.reset();
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/examples/web-wagmi/src/inbox/components/MessagesCard.tsx b/examples/web-wagmi/src/inbox/components/MessagesCard.tsx
new file mode 100644
index 0000000000..6f9321ad2c
--- /dev/null
+++ b/examples/web-wagmi/src/inbox/components/MessagesCard.tsx
@@ -0,0 +1,39 @@
+import { Conversation, DecodedMessage, useMessages, useStreamMessages } from '@xmtp/react-sdk';
+import { useCallback, useEffect, useState } from 'react';
+
+type MessagesCardProps = {
+ conversation: Conversation;
+};
+
+export function MessagesCard({ conversation }: MessagesCardProps) {
+ const { messages } = useMessages(conversation);
+ const [history, setHistory] = useState([]);
+
+ useEffect(() => {
+ if (messages && messages.length > 0) {
+ setHistory(messages);
+ }
+ }, [messages]);
+
+ const onMessage = useCallback((message: DecodedMessage) => {
+ setHistory((prevMessages) => {
+ const msgsnew = [...prevMessages, message];
+ return msgsnew;
+ });
+ }, []);
+
+ useStreamMessages(conversation, onMessage);
+
+ return (
+
+ {history.length === 0 && No messages yet
}
+
+ {history.map((message) => (
+
+
{message.content}
+
{message.sent.toISOString()}
+
+ ))}
+
+ );
+}
diff --git a/packages/domain/package.json b/packages/domain/package.json
index ec80206b55..b8a2a46bc5 100644
--- a/packages/domain/package.json
+++ b/packages/domain/package.json
@@ -11,6 +11,10 @@
"module": "./entities/dist/lens-protocol-domain-entities.esm.js",
"default": "./entities/dist/lens-protocol-domain-entities.cjs.js"
},
+ "./use-cases/inbox": {
+ "module": "./use-cases/inbox/dist/lens-protocol-domain-use-cases-inbox.esm.js",
+ "default": "./use-cases/inbox/dist/lens-protocol-domain-use-cases-inbox.cjs.js"
+ },
"./use-cases/polls": {
"module": "./use-cases/polls/dist/lens-protocol-domain-use-cases-polls.esm.js",
"default": "./use-cases/polls/dist/lens-protocol-domain-use-cases-polls.cjs.js"
@@ -117,6 +121,7 @@
"entrypoints": [
"entities/index.ts",
"use-cases/lifecycle/index.ts",
+ "use-cases/inbox/index.ts",
"use-cases/profile/index.ts",
"use-cases/polls/index.ts",
"use-cases/publications/index.ts",
diff --git a/packages/domain/src/use-cases/inbox/SignArbitraryMessage.ts b/packages/domain/src/use-cases/inbox/SignArbitraryMessage.ts
new file mode 100644
index 0000000000..06949f9b7c
--- /dev/null
+++ b/packages/domain/src/use-cases/inbox/SignArbitraryMessage.ts
@@ -0,0 +1,31 @@
+import { Result } from '@lens-protocol/shared-kernel';
+
+import {
+ PendingSigningRequestError,
+ Signature,
+ UserRejectedError,
+ WalletConnectionError,
+} from '../../entities';
+import { ActiveWallet } from '../wallets';
+
+type SignMessageResult = Result<
+ Signature,
+ PendingSigningRequestError | UserRejectedError | WalletConnectionError
+>;
+
+interface ISignArbitraryMessagePresenter {
+ present(result: SignMessageResult): void;
+}
+
+export class SignArbitraryMessage {
+ constructor(
+ private readonly activeWallet: ActiveWallet,
+ private readonly presenter: ISignArbitraryMessagePresenter,
+ ) {}
+
+ async execute(request: string): Promise {
+ const wallet = await this.activeWallet.requireActiveWallet();
+ const result = await wallet.signMessage(request);
+ this.presenter.present(result);
+ }
+}
diff --git a/packages/domain/src/use-cases/inbox/index.ts b/packages/domain/src/use-cases/inbox/index.ts
new file mode 100644
index 0000000000..a0b59b93eb
--- /dev/null
+++ b/packages/domain/src/use-cases/inbox/index.ts
@@ -0,0 +1 @@
+export * from './SignArbitraryMessage';
diff --git a/packages/domain/src/use-cases/wallets/WalletLogout.ts b/packages/domain/src/use-cases/wallets/WalletLogout.ts
index 2a88310f3a..bd26a320fd 100644
--- a/packages/domain/src/use-cases/wallets/WalletLogout.ts
+++ b/packages/domain/src/use-cases/wallets/WalletLogout.ts
@@ -21,12 +21,17 @@ export interface IActiveProfileGateway {
reset(): Promise;
}
+export interface IConversationsGateway {
+ reset(): Promise;
+}
+
export class WalletLogout {
constructor(
private walletGateway: IResettableWalletGateway,
private credentialsGateway: IResettableCredentialsGateway,
private activeWallet: ActiveWallet,
private activeProfileGateway: IActiveProfileGateway,
+ private conversationsGateway: IConversationsGateway,
private sessionPresenter: ISessionPresenter,
) {}
@@ -35,6 +40,7 @@ export class WalletLogout {
await this.walletGateway.reset();
await this.activeProfileGateway.reset();
+ await this.conversationsGateway.reset();
await this.credentialsGateway.invalidate();
diff --git a/packages/domain/src/use-cases/wallets/__tests__/WalletLogout.spec.ts b/packages/domain/src/use-cases/wallets/__tests__/WalletLogout.spec.ts
index b303a1ee99..5bfdcaea39 100644
--- a/packages/domain/src/use-cases/wallets/__tests__/WalletLogout.spec.ts
+++ b/packages/domain/src/use-cases/wallets/__tests__/WalletLogout.spec.ts
@@ -6,6 +6,7 @@ import { ISessionPresenter } from '../../lifecycle/ISessionPresenter';
import { ActiveWallet } from '../ActiveWallet';
import {
IActiveProfileGateway,
+ IConversationsGateway,
IResettableCredentialsGateway,
IResettableWalletGateway,
LogoutReason,
@@ -19,12 +20,14 @@ const setupWalletLogout = ({ activeWallet }: { activeWallet: ActiveWallet }) =>
const credentialsGateway = mock();
const activeProfileGateway = mock();
const sessionPresenter = mock();
+ const conversationGateway = mock();
const walletLogout = new WalletLogout(
walletGateway,
credentialsGateway,
activeWallet,
activeProfileGateway,
+ conversationGateway,
sessionPresenter,
);
diff --git a/packages/domain/use-cases/inbox/package.json b/packages/domain/use-cases/inbox/package.json
new file mode 100644
index 0000000000..9328e50617
--- /dev/null
+++ b/packages/domain/use-cases/inbox/package.json
@@ -0,0 +1,4 @@
+{
+ "main": "dist/lens-protocol-domain-use-cases-inbox.cjs.js",
+ "module": "dist/lens-protocol-domain-use-cases-inbox.esm.js"
+}
diff --git a/packages/react-web/jest.config.ts b/packages/react-web/jest.config.ts
index d5b03077c2..23d07751a0 100644
--- a/packages/react-web/jest.config.ts
+++ b/packages/react-web/jest.config.ts
@@ -1,6 +1,7 @@
// eslint-disable-next-line import/no-default-export
export default {
preset: 'ts-jest/presets/js-with-ts',
+ setupFilesAfterEnv: ['./src/__helpers__/jest.setup.ts'],
testEnvironment: 'jsdom',
testRegex: '/__tests__/.*|(\\.|/)spec\\.tsx?$',
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
diff --git a/packages/react-web/package.json b/packages/react-web/package.json
index 06b4143f87..cf934d277b 100644
--- a/packages/react-web/package.json
+++ b/packages/react-web/package.json
@@ -36,6 +36,7 @@
"dependencies": {
"@lens-protocol/api-bindings": "workspace:*",
"@lens-protocol/gated-content": "workspace:*",
+ "@lens-protocol/domain": "workspace:*",
"@lens-protocol/react": "workspace:*",
"@lens-protocol/shared-kernel": "workspace:*",
"@lens-protocol/storage": "workspace:*",
@@ -53,6 +54,7 @@
"@types/jest": "29.2.3",
"@types/jest-when": "^3.5.2",
"@types/react": "^18.0.28",
+ "@xmtp/react-sdk": "1.0.0-preview.40",
"eslint": "^8.34.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
@@ -67,9 +69,15 @@
"typescript": "^4.9.5"
},
"peerDependencies": {
+ "@xmtp/react-sdk": "1.0.0-preview.40",
"ethers": "^5.7.2",
"react": "^18.2.0"
},
+ "peerDependenciesMeta": {
+ "@xmtp/react-sdk": {
+ "optional": true
+ }
+ },
"prettier": "@lens-protocol/prettier-config",
"babel": {
"presets": [
diff --git a/packages/react-web/src/__helpers__/jest.setup.ts b/packages/react-web/src/__helpers__/jest.setup.ts
new file mode 100644
index 0000000000..4138d923be
--- /dev/null
+++ b/packages/react-web/src/__helpers__/jest.setup.ts
@@ -0,0 +1,18 @@
+import { Blob } from 'buffer';
+import crypto from 'crypto';
+import { TextEncoder, TextDecoder } from 'util';
+
+Object.defineProperty(global, 'crypto', {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ value: Object.setPrototypeOf({ subtle: crypto.subtle }, crypto),
+});
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+global.Blob = Blob;
+
+// until https://github.com/jsdom/jsdom/issues/2524 is resolved
+global.TextEncoder = TextEncoder;
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+global.TextDecoder = TextDecoder;
diff --git a/packages/react-web/src/inbox/adapters/SignerAdapter.ts b/packages/react-web/src/inbox/adapters/SignerAdapter.ts
new file mode 100644
index 0000000000..3bf23bdd7d
--- /dev/null
+++ b/packages/react-web/src/inbox/adapters/SignerAdapter.ts
@@ -0,0 +1,48 @@
+import {
+ PendingSigningRequestError,
+ Signature,
+ UserRejectedError,
+ WalletConnectionError,
+} from '@lens-protocol/domain/entities';
+import { SignArbitraryMessage } from '@lens-protocol/domain/use-cases/inbox';
+import type { ActiveWallet } from '@lens-protocol/domain/use-cases/wallets';
+import { IEquatableError } from '@lens-protocol/react';
+import { Deferred, Result } from '@lens-protocol/shared-kernel';
+import { Signer } from '@xmtp/react-sdk';
+
+class PromiseResultPresenter {
+ private deferredResult = new Deferred>();
+
+ present(result: Result): void {
+ this.deferredResult.resolve(result);
+ }
+
+ asResult(): Promise> {
+ return this.deferredResult.promise;
+ }
+}
+
+export class SignerAdapter implements Signer {
+ constructor(private activeWallet: ActiveWallet) {}
+
+ async getAddress() {
+ const wallet = await this.activeWallet.requireActiveWallet();
+
+ return wallet.address;
+ }
+
+ async signMessage(request: string) {
+ const presenter = new PromiseResultPresenter<
+ Signature,
+ PendingSigningRequestError | UserRejectedError | WalletConnectionError
+ >();
+
+ const useCase = new SignArbitraryMessage(this.activeWallet, presenter);
+
+ await useCase.execute(request);
+
+ const result = await presenter.asResult();
+
+ return result.unwrap();
+ }
+}
diff --git a/packages/react-web/src/inbox/helpers/__tests__/conversationIdUtils.spec.ts b/packages/react-web/src/inbox/helpers/__tests__/conversationIdUtils.spec.ts
new file mode 100644
index 0000000000..0c8ae2fbf2
--- /dev/null
+++ b/packages/react-web/src/inbox/helpers/__tests__/conversationIdUtils.spec.ts
@@ -0,0 +1,30 @@
+import { buildConversationId, extractPeerProfileId } from '../conversationIdUtils';
+
+describe('Given a collection of inbox helper functions', () => {
+ describe(`when calling ${buildConversationId.name}`, () => {
+ it('should build the same conversationId for provided two profiles, no matter the order', () => {
+ const result1 = buildConversationId('0x15', '0x18');
+ const result2 = buildConversationId('0x18', '0x15');
+
+ expect(result1).toEqual(result2);
+ });
+ });
+
+ describe(`when calling ${extractPeerProfileId.name}`, () => {
+ it('should return undefined if not a lens conversation', () => {
+ const result1 = extractPeerProfileId('not-lens-conversation', '0x15');
+
+ expect(result1).toBe(undefined);
+ });
+
+ it('should return peer profileId for a lens conversation', () => {
+ const conversationId = buildConversationId('0x15', '0x18');
+
+ const result1 = extractPeerProfileId(conversationId, '0x15');
+ const result2 = extractPeerProfileId(conversationId, '0x18');
+
+ expect(result1).toBe('0x18');
+ expect(result2).toBe('0x15');
+ });
+ });
+});
diff --git a/packages/react-web/src/inbox/helpers/conversationIdUtils.ts b/packages/react-web/src/inbox/helpers/conversationIdUtils.ts
new file mode 100644
index 0000000000..f6f1199acd
--- /dev/null
+++ b/packages/react-web/src/inbox/helpers/conversationIdUtils.ts
@@ -0,0 +1,44 @@
+import { ProfileId, profileId as brandProfileId } from '@lens-protocol/react';
+import { invariant } from '@lens-protocol/shared-kernel';
+
+const CONVERSATION_ID_PREFIX = 'lens.dev/dm';
+
+export function buildConversationId(profileIdA: string, profileIdB: string) {
+ const profileIdAParsed = parseInt(profileIdA, 16);
+ const profileIdBParsed = parseInt(profileIdB, 16);
+ return profileIdAParsed < profileIdBParsed
+ ? `${CONVERSATION_ID_PREFIX}/${profileIdA}-${profileIdB}`
+ : `${CONVERSATION_ID_PREFIX}/${profileIdB}-${profileIdA}`;
+}
+
+function parseConversationId(conversationId: string): [string, string] {
+ const conversationIdWithoutPrefix = conversationId.replace(`${CONVERSATION_ID_PREFIX}/`, '');
+ const [profileIdA, profileIdB] = conversationIdWithoutPrefix.split('-');
+
+ invariant(profileIdA && profileIdB, 'Invalid conversation id');
+
+ return [profileIdA, profileIdB];
+}
+
+function isLensConversation(
+ activeProfileId: string,
+ conversationId?: string,
+): conversationId is string {
+ if (conversationId && conversationId.includes(activeProfileId)) {
+ return true;
+ }
+ return false;
+}
+
+export function extractPeerProfileId(
+ conversationId: string | undefined,
+ activeProfileId: string,
+): ProfileId | undefined {
+ if (isLensConversation(activeProfileId, conversationId)) {
+ const [profileIdA, profileIdB] = parseConversationId(conversationId);
+ const result = profileIdA === activeProfileId ? profileIdB : profileIdA;
+
+ return brandProfileId(result);
+ }
+ return undefined;
+}
diff --git a/packages/react-web/src/inbox/helpers/createUniqueConversationId.ts b/packages/react-web/src/inbox/helpers/createUniqueConversationId.ts
new file mode 100644
index 0000000000..8e40a5df20
--- /dev/null
+++ b/packages/react-web/src/inbox/helpers/createUniqueConversationId.ts
@@ -0,0 +1,10 @@
+import { Conversation } from '@xmtp/react-sdk';
+
+/**
+ * Create a unique conversation ID based on sender/receiver addresses and
+ * context values
+ */
+export const createUniqueConversationId = (conversation: Conversation): string =>
+ [conversation.clientAddress, conversation.peerAddress, conversation.context?.conversationId]
+ .filter((v) => Boolean(v))
+ .join('/');
diff --git a/packages/react-web/src/inbox/helpers/index.ts b/packages/react-web/src/inbox/helpers/index.ts
new file mode 100644
index 0000000000..08345f6d37
--- /dev/null
+++ b/packages/react-web/src/inbox/helpers/index.ts
@@ -0,0 +1,3 @@
+export * from './conversationIdUtils';
+export * from './createUniqueConversationId';
+export * from './notEmpty';
diff --git a/packages/react-web/src/inbox/helpers/notEmpty.ts b/packages/react-web/src/inbox/helpers/notEmpty.ts
new file mode 100644
index 0000000000..4be5afb840
--- /dev/null
+++ b/packages/react-web/src/inbox/helpers/notEmpty.ts
@@ -0,0 +1,3 @@
+export function notEmpty(value: TValue | null | undefined): value is TValue {
+ return value !== null && value !== undefined;
+}
diff --git a/packages/react-web/src/inbox/index.ts b/packages/react-web/src/inbox/index.ts
new file mode 100644
index 0000000000..7a9e11141d
--- /dev/null
+++ b/packages/react-web/src/inbox/index.ts
@@ -0,0 +1,5 @@
+export * from './types';
+export * from './useEnhanceConversation';
+export * from './useEnhanceConversations';
+export * from './useStartLensConversation';
+export * from './useXmtpClient';
diff --git a/packages/react-web/src/inbox/types.ts b/packages/react-web/src/inbox/types.ts
new file mode 100644
index 0000000000..2ead901ff0
--- /dev/null
+++ b/packages/react-web/src/inbox/types.ts
@@ -0,0 +1,6 @@
+import { Profile } from '@lens-protocol/react';
+import { Conversation } from '@xmtp/react-sdk';
+
+export type EnhancedConversation = Conversation & {
+ peerProfile?: Profile;
+};
diff --git a/packages/react-web/src/inbox/useEnhanceConversation.ts b/packages/react-web/src/inbox/useEnhanceConversation.ts
new file mode 100644
index 0000000000..268b1549bd
--- /dev/null
+++ b/packages/react-web/src/inbox/useEnhanceConversation.ts
@@ -0,0 +1,76 @@
+import { ProfileId, ProfileOwnedByMe, ReadResult, useProfile } from '@lens-protocol/react';
+import { Conversation } from '@xmtp/react-sdk';
+import { useMemo } from 'react';
+
+import { extractPeerProfileId } from './helpers';
+import { EnhancedConversation } from './types';
+
+/**
+ * @experimental
+ */
+export type EnhanceConversationRequest = {
+ profile: ProfileOwnedByMe;
+ conversation: Conversation;
+};
+
+/**
+ * Enhance XMTP conversation with a profile of the conversation's peer
+ *
+ * @category Inbox
+ * @group Hooks
+ * @experimental
+ *
+ * @param args - {@link EnhanceConversationRequest}
+ */
+export function useEnhanceConversation({
+ profile,
+ conversation,
+}: EnhanceConversationRequest): ReadResult {
+ const peerProfileId = useMemo(
+ (): ProfileId | undefined =>
+ extractPeerProfileId(conversation.context?.conversationId, profile.id),
+ [conversation, profile],
+ );
+
+ const skip = peerProfileId === undefined;
+
+ const { data: peerProfile, loading } = useProfile(
+ skip
+ ? {
+ skip: true,
+ }
+ : {
+ profileId: peerProfileId,
+ },
+ );
+
+ const enhancedConversation = useMemo((): EnhancedConversation => {
+ if (peerProfile) {
+ // Clone the xmtp Conversation instance with all its methods and add peerProfile
+ // eslint-disable-next-line
+ return Object.assign(Object.create(Object.getPrototypeOf(conversation)), conversation, {
+ peerProfile,
+ });
+ }
+ return conversation;
+ }, [conversation, peerProfile]);
+
+ if (skip) {
+ return {
+ data: conversation,
+ loading: false,
+ };
+ }
+
+ if (loading) {
+ return {
+ data: undefined,
+ loading: true,
+ };
+ }
+
+ return {
+ data: enhancedConversation,
+ loading: false,
+ };
+}
diff --git a/packages/react-web/src/inbox/useEnhanceConversations.ts b/packages/react-web/src/inbox/useEnhanceConversations.ts
new file mode 100644
index 0000000000..26742a7f08
--- /dev/null
+++ b/packages/react-web/src/inbox/useEnhanceConversations.ts
@@ -0,0 +1,129 @@
+import {
+ ProfileId,
+ ProfileOwnedByMe,
+ ReadResult,
+ UnspecifiedError,
+ useProfiles,
+} from '@lens-protocol/react';
+import { assertError } from '@lens-protocol/shared-kernel';
+import { useConversations } from '@xmtp/react-sdk';
+import { useMemo } from 'react';
+
+import { extractPeerProfileId, createUniqueConversationId, notEmpty } from './helpers';
+import { EnhancedConversation } from './types';
+
+/**
+ * @experimental
+ */
+export type EnhanceConversationsRequest = {
+ profile: ProfileOwnedByMe;
+};
+
+/**
+ * Enhance XMTP conversations with profiles of the conversations' peers,
+ * if conversation is between two Lens profiles.
+ *
+ * @category Inbox
+ * @group Hooks
+ * @experimental
+ *
+ * @param args - {@link EnhanceConversationsRequest}
+ */
+export function useEnhanceConversations(
+ useConversationsResult: ReturnType,
+ { profile }: EnhanceConversationsRequest,
+): ReadResult {
+ const { conversations, error: resultError, isLoading: resultLoading } = useConversationsResult;
+
+ const conversationToProfileIdMap: Record = useMemo(() => {
+ return conversations.reduce((acc, c) => {
+ const peerProfileId = extractPeerProfileId(c.context?.conversationId, profile.id);
+
+ return {
+ ...acc,
+ [createUniqueConversationId(c)]: peerProfileId,
+ };
+ }, {});
+ }, [conversations, profile.id]);
+
+ const uniqueProfileIds: ProfileId[] = useMemo(() => {
+ const ids = Object.values(conversationToProfileIdMap).filter(notEmpty);
+ return [...new Set(ids)];
+ }, [conversationToProfileIdMap]);
+
+ const skip = uniqueProfileIds.length === 0;
+
+ const {
+ data: profiles = [],
+ error,
+ loading,
+ } = useProfiles(
+ skip
+ ? {
+ skip: true,
+ }
+ : {
+ profileIds: uniqueProfileIds,
+ },
+ );
+
+ const enhancedConversations = useMemo((): EnhancedConversation[] => {
+ if (profiles.length > 0 && conversations.length > 0) {
+ const eConversations = conversations.map((c): EnhancedConversation => {
+ const id = createUniqueConversationId(c);
+
+ const peerProfile = profiles.find((p) => p.id === conversationToProfileIdMap[id]);
+
+ if (!peerProfile) {
+ return c;
+ }
+
+ // Clone the xmtp Conversation instance with all its methods and add peerProfile
+ // eslint-disable-next-line
+ return Object.assign(Object.create(Object.getPrototypeOf(c)), c, { peerProfile });
+ });
+
+ return eConversations;
+ }
+ return conversations;
+ }, [profiles, conversationToProfileIdMap, conversations]);
+
+ if (skip) {
+ return {
+ data: conversations,
+ error: undefined,
+ loading: false,
+ };
+ }
+
+ if (loading || resultLoading) {
+ return {
+ data: undefined,
+ error: undefined,
+ loading: true,
+ };
+ }
+
+ if (resultError) {
+ assertError(resultError);
+ return {
+ data: undefined,
+ error: resultError,
+ loading: false,
+ };
+ }
+
+ if (error) {
+ return {
+ data: undefined,
+ error,
+ loading: false,
+ };
+ }
+
+ return {
+ data: enhancedConversations,
+ error: undefined,
+ loading: false,
+ };
+}
diff --git a/packages/react-web/src/inbox/useStartLensConversation.ts b/packages/react-web/src/inbox/useStartLensConversation.ts
new file mode 100644
index 0000000000..af904829e3
--- /dev/null
+++ b/packages/react-web/src/inbox/useStartLensConversation.ts
@@ -0,0 +1,36 @@
+import { Profile, ProfileOwnedByMe } from '@lens-protocol/react';
+import { useStartConversation } from '@xmtp/react-sdk';
+
+import { buildConversationId } from './helpers';
+
+/**
+ * @experimental
+ */
+export type StartLensConversationRequest = {
+ ownedProfile: ProfileOwnedByMe;
+ peerProfile: Profile;
+};
+
+/**
+ * @experimental
+ */
+export type UseStartLensConversationResult = ReturnType;
+
+/**
+ * Start a new XMTP conversation between two Lens profiles
+ *
+ * @category Inbox
+ * @group Hooks
+ * @experimental
+ *
+ * @param args - {@link StartLensConversationRequest}
+ */
+export function useStartLensConversation({
+ ownedProfile,
+ peerProfile,
+}: StartLensConversationRequest): UseStartLensConversationResult {
+ return useStartConversation({
+ conversationId: buildConversationId(ownedProfile.id, peerProfile.id),
+ metadata: {},
+ });
+}
diff --git a/packages/react-web/src/inbox/useXmtpClient.ts b/packages/react-web/src/inbox/useXmtpClient.ts
new file mode 100644
index 0000000000..2bccb1f7b1
--- /dev/null
+++ b/packages/react-web/src/inbox/useXmtpClient.ts
@@ -0,0 +1,110 @@
+import {
+ PendingSigningRequestError,
+ UserRejectedError,
+ WalletConnectionError,
+ useActiveWalletInteractor,
+ useInboxKeyStorage,
+} from '@lens-protocol/react';
+import { assertError } from '@lens-protocol/shared-kernel';
+import { IStorage } from '@lens-protocol/storage';
+import { Client, ClientOptions, useClient } from '@xmtp/react-sdk';
+import { useCallback, useState } from 'react';
+
+import { SignerAdapter } from './adapters/SignerAdapter';
+
+async function storeKeys(storage: IStorage, keys: Uint8Array) {
+ await storage.set(Uint8Array.from(keys).toString());
+}
+
+async function loadKeys(storage: IStorage): Promise {
+ const val = await storage.get();
+ return val ? Uint8Array.from(val.split(',').map((c) => parseInt(c))) : null;
+}
+
+/**
+ * @experimental
+ */
+export type InitXmtpClientOptions = Partial>;
+
+/**
+ * @experimental
+ */
+export type UseXmtpClientResult = {
+ client: Client | undefined;
+ disconnect: () => void;
+ error: PendingSigningRequestError | UserRejectedError | WalletConnectionError | Error | undefined;
+ initialize: (options?: InitXmtpClientOptions) => Promise;
+ isLoading: boolean;
+};
+
+const defaultOptions: InitXmtpClientOptions = {
+ persistConversations: true,
+};
+
+/**
+ * Initialize XMTP client using the same Signer as the one provided with {@link LensConfig}.
+ * Store XMTP user's decryption key in storage to improve UX.
+ * Be aware that XMTP user's key must be stored safely.
+ *
+ * @category Inbox
+ * @group Hooks
+ * @experimental
+ *
+ * @param args - {@link StartLensConversationRequest}
+ */
+export function useXmtpClient(): UseXmtpClientResult {
+ const { client, disconnect, isLoading: clientIsLoading, initialize } = useClient();
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState();
+
+ const activeWallet = useActiveWalletInteractor();
+ const storage = useInboxKeyStorage();
+
+ const initializeWithLens = useCallback(
+ async (options: InitXmtpClientOptions = defaultOptions) => {
+ setIsLoading(true);
+ setError(undefined);
+
+ try {
+ const existingKeys = await loadKeys(storage);
+ const signer = new SignerAdapter(activeWallet);
+
+ if (existingKeys) {
+ const newClient = await initialize({
+ keys: existingKeys,
+ signer,
+ options,
+ });
+
+ return newClient;
+ }
+
+ const newClient = await initialize({
+ signer,
+ options,
+ });
+
+ const newKeys = await Client.getKeys(signer);
+ await storeKeys(storage, newKeys);
+
+ return newClient;
+ } catch (e) {
+ assertError(e);
+ setError(e);
+
+ return undefined;
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [activeWallet, initialize, storage],
+ );
+
+ return {
+ client,
+ disconnect,
+ isLoading: isLoading || clientIsLoading,
+ error,
+ initialize: initializeWithLens,
+ };
+}
diff --git a/packages/react-web/src/index.ts b/packages/react-web/src/index.ts
index ffe2d39703..cd2f4fca05 100644
--- a/packages/react-web/src/index.ts
+++ b/packages/react-web/src/index.ts
@@ -23,3 +23,6 @@ export type {
export type EncryptionConfig = never;
export type IStorageProvider = never;
export type IObservableStorageProvider = never;
+
+// xmtp integration
+export * from './inbox';
diff --git a/packages/react/src/environments.ts b/packages/react/src/environments.ts
index ee987e5c31..3fbe732b40 100644
--- a/packages/react/src/environments.ts
+++ b/packages/react/src/environments.ts
@@ -38,6 +38,9 @@ export type EnvironmentConfig = {
handleResolver: ProfileHandleResolver;
snapshot: SnapshotConfig;
gated: GatedEnvironments.EnvironmentConfig;
+ xmtpEnv: {
+ name: 'production' | 'dev' | 'local';
+ };
};
/**
@@ -69,6 +72,9 @@ export const production: EnvironmentConfig = {
sequencer: 'https://seq.snapshot.org',
},
gated: GatedEnvironments.production,
+ xmtpEnv: {
+ name: 'production',
+ },
};
/**
* The development environment configuration
@@ -99,6 +105,9 @@ export const development: EnvironmentConfig = {
sequencer: 'https://testnet.seq.snapshot.org',
},
gated: GatedEnvironments.development,
+ xmtpEnv: {
+ name: 'dev',
+ },
};
/**
@@ -131,6 +140,9 @@ export const sandbox: EnvironmentConfig = {
sequencer: 'https://testnet.seq.snapshot.org',
},
gated: GatedEnvironments.sandbox,
+ xmtpEnv: {
+ name: 'dev',
+ },
};
/**
diff --git a/packages/react/src/experimental/index.ts b/packages/react/src/experimental/index.ts
index a25b087e9e..c7d5dfa542 100644
--- a/packages/react/src/experimental/index.ts
+++ b/packages/react/src/experimental/index.ts
@@ -1 +1,3 @@
+export * from './useActiveWalletInteractor';
export * from './useApolloClient';
+export * from './useInboxKeyStorage';
diff --git a/packages/react/src/experimental/useActiveWalletInteractor.ts b/packages/react/src/experimental/useActiveWalletInteractor.ts
new file mode 100644
index 0000000000..76e5a5005f
--- /dev/null
+++ b/packages/react/src/experimental/useActiveWalletInteractor.ts
@@ -0,0 +1,14 @@
+import { ActiveWallet } from '@lens-protocol/domain/use-cases/wallets';
+
+import { useSharedDependencies } from '../shared';
+
+/**
+ * Returns the internal active wallet interactor.
+ *
+ * @internal
+ */
+export function useActiveWalletInteractor(): ActiveWallet {
+ const { activeWallet } = useSharedDependencies();
+
+ return activeWallet;
+}
diff --git a/packages/react/src/experimental/useInboxKeyStorage.ts b/packages/react/src/experimental/useInboxKeyStorage.ts
new file mode 100644
index 0000000000..857ba80ced
--- /dev/null
+++ b/packages/react/src/experimental/useInboxKeyStorage.ts
@@ -0,0 +1,14 @@
+import { IStorage } from '@lens-protocol/storage';
+
+import { useSharedDependencies } from '../shared';
+
+/**
+ * Returns the internal inbox storage.
+ *
+ * @internal
+ */
+export function useInboxKeyStorage(): IStorage {
+ const { inboxKeyStorage } = useSharedDependencies();
+
+ return inboxKeyStorage;
+}
diff --git a/packages/react/src/helpers/arguments.ts b/packages/react/src/helpers/arguments.ts
index 59a687e538..60c205a4c3 100644
--- a/packages/react/src/helpers/arguments.ts
+++ b/packages/react/src/helpers/arguments.ts
@@ -8,7 +8,7 @@ import {
useSessionVar,
} from '@lens-protocol/api-bindings';
import { ProfileId } from '@lens-protocol/domain/entities';
-import { Overwrite, Prettify } from '@lens-protocol/shared-kernel';
+import { Overwrite, Prettify, UnknownObject } from '@lens-protocol/shared-kernel';
import { useState } from 'react';
import { mediaTransformConfigToQueryVariables } from '../mediaTransforms';
@@ -46,6 +46,19 @@ export function useSnapshotApolloClient(
};
}
+/**
+ * When `skip` prop is true then all other props are optional.
+ * Used to allow to skip apollo API calls
+ */
+export type Skippable =
+ | (Partial & {
+ /**
+ * @experimental
+ */
+ skip: true;
+ })
+ | (T & { skip?: false });
+
export type WithObserverIdOverride = Prettify<
TVariables & {
/**
diff --git a/packages/react/src/inbox/adapters/DisableConversationsGateway.ts b/packages/react/src/inbox/adapters/DisableConversationsGateway.ts
new file mode 100644
index 0000000000..c2ac05508a
--- /dev/null
+++ b/packages/react/src/inbox/adapters/DisableConversationsGateway.ts
@@ -0,0 +1,10 @@
+import { IConversationsGateway } from '@lens-protocol/domain/use-cases/wallets';
+import { IStorage } from '@lens-protocol/storage';
+
+export class DisableConversationsGateway implements IConversationsGateway {
+ constructor(private readonly storage: IStorage) {}
+
+ async reset(): Promise {
+ return this.storage.reset();
+ }
+}
diff --git a/packages/react/src/inbox/index.ts b/packages/react/src/inbox/index.ts
new file mode 100644
index 0000000000..dfa48da67e
--- /dev/null
+++ b/packages/react/src/inbox/index.ts
@@ -0,0 +1,2 @@
+export * from './adapters/DisableConversationsGateway';
+export * from './infrastructure/InboxKeyStorage';
diff --git a/packages/react/src/inbox/infrastructure/InboxKeyStorage.ts b/packages/react/src/inbox/infrastructure/InboxKeyStorage.ts
new file mode 100644
index 0000000000..db4a69137d
--- /dev/null
+++ b/packages/react/src/inbox/infrastructure/InboxKeyStorage.ts
@@ -0,0 +1,14 @@
+import { BaseStorageSchema, IStorageProvider, Storage } from '@lens-protocol/storage';
+import { z } from 'zod';
+
+export const KeyBundleData = z.string();
+
+export type KeyBundleData = z.infer;
+
+export function createInboxKeyStorage(storageProvider: IStorageProvider, namespace: string) {
+ const notificationStorageDataSchema = new BaseStorageSchema(
+ `lens.${namespace}.inbox.keyBundle`,
+ KeyBundleData,
+ );
+ return Storage.createForSchema(notificationStorageDataSchema, storageProvider);
+}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 34912a1f31..29ece4ccc1 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -46,7 +46,7 @@ export type {
/**
* Hooks helpers types
*/
-export type { WithObserverIdOverride } from './helpers/arguments';
+export type { WithObserverIdOverride, Skippable } from './helpers/arguments';
export type { Operation } from './helpers/operations';
export type {
PaginatedArgs,
@@ -60,11 +60,12 @@ export type {
* Storage
*/
export type {
- StorageSubscription,
- StorageSubscriber,
- StorageProviderSubscriber,
- IStorageProvider,
IObservableStorageProvider,
+ IStorage,
+ IStorageProvider,
+ StorageProviderSubscriber,
+ StorageSubscriber,
+ StorageSubscription,
} from '@lens-protocol/storage';
/**
diff --git a/packages/react/src/profile/useProfile.ts b/packages/react/src/profile/useProfile.ts
index 66482789e9..5b28b93b98 100644
--- a/packages/react/src/profile/useProfile.ts
+++ b/packages/react/src/profile/useProfile.ts
@@ -1,9 +1,10 @@
import { Profile, UnspecifiedError, useGetProfile } from '@lens-protocol/api-bindings';
import { ProfileId } from '@lens-protocol/domain/entities';
-import { invariant, XOR } from '@lens-protocol/shared-kernel';
+import { invariant, Prettify, XOR } from '@lens-protocol/shared-kernel';
import { NotFoundError } from '../NotFoundError';
import {
+ Skippable,
useActiveProfileAsDefaultObserver,
useLensApolloClient,
useMediaTransformFromConfig,
@@ -20,16 +21,24 @@ export type UseProfileByHandleArgs = {
handle: string;
};
-export type UseProfileArgs = WithObserverIdOverride<
- XOR
+/**
+ * {@link useProfile} hook arguments
+ */
+export type UseProfileArgs = Prettify<
+ Skippable>>
>;
/**
+ * Get a profile by either a handle or profile Id.
+ *
* @category Profiles
* @group Hooks
+ *
+ * @param args - {@link UseProfileArgs}
*/
export function useProfile({
observerId,
+ skip,
...request
}: UseProfileArgs): ReadResult {
invariant(
@@ -47,6 +56,7 @@ export function useProfile({
observerId,
}),
),
+ skip,
}),
),
),
diff --git a/packages/react/src/profile/useProfiles.ts b/packages/react/src/profile/useProfiles.ts
index df53028d76..78f090b210 100644
--- a/packages/react/src/profile/useProfiles.ts
+++ b/packages/react/src/profile/useProfiles.ts
@@ -1,8 +1,9 @@
import { Profile, useGetAllProfiles } from '@lens-protocol/api-bindings';
import { ProfileId } from '@lens-protocol/domain/entities';
-import { invariant, XOR } from '@lens-protocol/shared-kernel';
+import { invariant, Prettify, XOR } from '@lens-protocol/shared-kernel';
import {
+ Skippable,
useActiveProfileAsDefaultObserver,
useLensApolloClient,
useMediaTransformFromConfig,
@@ -12,19 +13,20 @@ import {
import { PaginatedArgs, PaginatedReadResult, usePaginatedReadResult } from '../helpers/reads';
import { DEFAULT_PAGINATED_QUERY_LIMIT } from '../utils';
+export type UseProfilesByIdsArgs = {
+ profileIds: ProfileId[];
+};
+
+export type UseProfilesByHandlesArgs = {
+ handles: string[];
+};
+
/**
* {@link useProfiles} hook arguments
*/
-export type UseProfilesArgs = PaginatedArgs<
- WithObserverIdOverride<
- XOR<
- {
- handles: string[];
- },
- {
- profileIds: ProfileId[];
- }
- >
+export type UseProfilesArgs = Prettify<
+ Skippable<
+ PaginatedArgs>>
>
>;
@@ -89,6 +91,7 @@ export function useProfiles({
profileIds: byProfileIds,
observerId,
limit = DEFAULT_PAGINATED_QUERY_LIMIT,
+ skip,
}: UseProfilesArgs): PaginatedReadResult {
invariant(
byHandles === undefined || byProfileIds === undefined,
@@ -102,6 +105,7 @@ export function useProfiles({
variables: useMediaTransformFromConfig(
useSourcesFromConfig({ byHandles, byProfileIds, limit, observerId }),
),
+ skip,
}),
),
),
diff --git a/packages/react/src/shared.tsx b/packages/react/src/shared.tsx
index 353b8727c9..020e0e287c 100644
--- a/packages/react/src/shared.tsx
+++ b/packages/react/src/shared.tsx
@@ -25,6 +25,8 @@ import { ConsoleLogger } from './ConsoleLogger';
import { ErrorHandler } from './ErrorHandler';
import { IBindings, LensConfig } from './config';
import { EnvironmentConfig } from './environments';
+import { DisableConversationsGateway } from './inbox/adapters/DisableConversationsGateway';
+import { createInboxKeyStorage } from './inbox/infrastructure/InboxKeyStorage';
import { LogoutHandler, SessionPresenter } from './lifecycle/adapters/SessionPresenter';
import { defaultMediaTransformsConfig, MediaTransformsConfig } from './mediaTransforms';
import { ActiveProfileGateway } from './profile/adapters/ActiveProfileGateway';
@@ -88,6 +90,7 @@ export type SharedDependencies = {
credentialsGateway: CredentialsGateway;
environment: EnvironmentConfig;
followPolicyCallGateway: FollowPolicyCallGateway;
+ inboxKeyStorage: IStorage;
logger: ILogger;
mediaTransforms: MediaTransformsConfig;
notificationStorage: IStorage;
@@ -123,6 +126,7 @@ export function createSharedDependencies(
const walletStorage = createWalletStorage(config.storage, config.environment.name);
const notificationStorage = createNotificationStorage(config.storage, config.environment.name);
const transactionStorage = createTransactionStorage(config.storage, config.environment.name);
+ const inboxKeyStorage = createInboxKeyStorage(config.storage, config.environment.name);
// apollo client
const anonymousApolloClient = createAuthApolloClient({
@@ -210,11 +214,13 @@ export function createSharedDependencies(
transactionQueuePresenter,
);
const tokenAvailability = new TokenAvailability(balanceGateway, tokenGateway, activeWallet);
+ const conversationGateway = new DisableConversationsGateway(inboxKeyStorage);
const walletLogout = new WalletLogout(
walletGateway,
credentialsGateway,
activeWallet,
activeProfileGateway,
+ conversationGateway,
sessionPresenter,
);
const loginPresenter = new WalletLoginPresenter(profileCacheManager);
@@ -243,6 +249,7 @@ export function createSharedDependencies(
credentialsGateway,
environment: config.environment,
followPolicyCallGateway,
+ inboxKeyStorage,
logger,
mediaTransforms,
notificationStorage,
diff --git a/packages/shared-kernel/src/ts-helpers/types.ts b/packages/shared-kernel/src/ts-helpers/types.ts
index 667b0ebab3..7d77736517 100644
--- a/packages/shared-kernel/src/ts-helpers/types.ts
+++ b/packages/shared-kernel/src/ts-helpers/types.ts
@@ -58,6 +58,21 @@ export type XOR =
| (Without & U)
| (Without & T);
+/**
+ * Only one of the properties can be provided at the same time
+ * @internal
+ * @example
+ * ```ts
+ * OneOf;
+ * ```
+ */
+export type OneOf = Omit &
+ {
+ [k in K]: Pick, k> & {
+ [k1 in Exclude]?: never;
+ };
+ }[K];
+
/**
* Ask TS to re-check that A1 extends A2. And if it fails, A2 will be enforced anyway.
* @internal
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 147dd1fb3a..307223d4d8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -134,6 +134,9 @@ importers:
'@lens-protocol/wagmi':
specifier: workspace:*
version: link:../../packages/wagmi
+ '@xmtp/react-sdk':
+ specifier: 1.0.0-preview.40
+ version: 1.0.0-preview.40(react@18.2.0)
example-shared:
specifier: workspace:*
version: link:../shared
@@ -817,6 +820,9 @@ importers:
'@lens-protocol/api-bindings':
specifier: workspace:*
version: link:../api-bindings
+ '@lens-protocol/domain':
+ specifier: workspace:*
+ version: link:../domain
'@lens-protocol/gated-content':
specifier: workspace:*
version: link:../gated-content
@@ -866,6 +872,9 @@ importers:
'@types/react':
specifier: ^18.0.28
version: 18.0.28
+ '@xmtp/react-sdk':
+ specifier: 1.0.0-preview.40
+ version: 1.0.0-preview.40(react@18.2.0)
eslint:
specifier: ^8.34.0
version: 8.34.0
@@ -5892,6 +5901,39 @@ packages:
- supports-color
dev: true
+ /@protobufjs/aspromise@1.1.2:
+ resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
+
+ /@protobufjs/base64@1.1.2:
+ resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
+
+ /@protobufjs/codegen@2.0.4:
+ resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
+
+ /@protobufjs/eventemitter@1.1.0:
+ resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
+
+ /@protobufjs/fetch@1.1.0:
+ resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/inquire': 1.1.0
+
+ /@protobufjs/float@1.0.2:
+ resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
+
+ /@protobufjs/inquire@1.1.0:
+ resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
+
+ /@protobufjs/path@1.1.2:
+ resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
+
+ /@protobufjs/pool@1.1.0:
+ resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
+
+ /@protobufjs/utf8@1.1.0:
+ resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
+
/@randlabs/communication-bridge@1.0.1:
resolution: {integrity: sha512-CzS0U8IFfXNK7QaJFE4pjbxDGfPjbXBEsEaCn9FN15F+ouSAEUQkva3Gl66hrkBZOGexKFEWMwUHIDKpZ2hfVg==}
dev: false
@@ -7650,6 +7692,42 @@ packages:
tslib: 2.5.0
dev: false
+ /@xmtp/proto@3.25.0:
+ resolution: {integrity: sha512-neVPGr40QRAWmIcG3R3d3g6ziSdY8bmKeSFRb6zWANXB3wluHoEGmud5/jZw4u/AY3E6FuNCwVODGku86iIeHw==}
+ dependencies:
+ long: 5.2.3
+ protobufjs: 7.2.3
+ rxjs: 7.8.0
+ undici: 5.22.1
+
+ /@xmtp/react-sdk@1.0.0-preview.40(react@18.2.0):
+ resolution: {integrity: sha512-WNkzVaONaRqhAlxoMd2gY17fa36KcuJHutPtOaoVGzP2QZWpLfiymDe8tm2IVxQdud4EMX4wjxtGRL+hBE3pTQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ react: '>=16.14'
+ dependencies:
+ '@xmtp/xmtp-js': 9.4.1
+ date-fns: 2.30.0
+ dexie: 3.2.4
+ react: 18.2.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ /@xmtp/xmtp-js@9.4.1:
+ resolution: {integrity: sha512-NNK7HcR2aiQhZkHRWGUp1gRJcqzkcyE8g7gCatbyZaNDd0JQBo/wLmiCZ3aibXOHOKLj39eIwVfqCRDejMNflw==}
+ engines: {node: '>=18'}
+ dependencies:
+ '@noble/secp256k1': 1.7.1
+ '@xmtp/proto': 3.25.0
+ async-mutex: 0.4.0
+ elliptic: 6.5.4
+ ethers: 5.7.2
+ long: 5.2.3
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
/JSONStream@1.3.5:
resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
hasBin: true
@@ -8182,6 +8260,11 @@ packages:
dependencies:
tslib: 2.5.0
+ /async-mutex@0.4.0:
+ resolution: {integrity: sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==}
+ dependencies:
+ tslib: 2.5.0
+
/async-retry@1.3.3:
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
dependencies:
@@ -8841,7 +8924,6 @@ packages:
engines: {node: '>=10.16.0'}
dependencies:
streamsearch: 1.1.0
- dev: true
/cache-base@1.0.1:
resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==}
@@ -9525,6 +9607,12 @@ packages:
resolution: {integrity: sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==}
dev: true
+ /date-fns@2.30.0:
+ resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
+ engines: {node: '>=0.11'}
+ dependencies:
+ '@babel/runtime': 7.21.0
+
/debounce@1.2.1:
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
dev: true
@@ -9729,6 +9817,10 @@ packages:
engines: {node: '>=8'}
dev: true
+ /dexie@3.2.4:
+ resolution: {integrity: sha512-VKoTQRSv7+RnffpOJ3Dh6ozknBqzWw/F3iqMdsZg958R0AS8AnY9x9d1lbwENr0gzeGJHXKcGhAMRaqys6SxqA==}
+ engines: {node: '>=6.0'}
+
/diff-sequences@29.4.3:
resolution: {integrity: sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -13607,6 +13699,9 @@ packages:
wrap-ansi: 6.2.0
dev: true
+ /long@5.2.3:
+ resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==}
+
/longest@1.0.1:
resolution: {integrity: sha512-k+yt5n3l48JU4k8ftnKG6V7u32wyH2NfKzeMto9F/QRE0amxy/LayxwlvjjkZEIzqR+19IrtFO8p5kB9QaYUFg==}
engines: {node: '>=0.10.0'}
@@ -15037,6 +15132,24 @@ packages:
object-assign: 4.1.1
react-is: 16.13.1
+ /protobufjs@7.2.3:
+ resolution: {integrity: sha512-TtpvOqwB5Gdz/PQmOjgsrGH1nHjAQVCN7JG4A6r1sXRWESL5rNMAiRcBQlCAdKxZcAbstExQePYG8xof/JVRgg==}
+ engines: {node: '>=12.0.0'}
+ requiresBuild: true
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/base64': 1.1.2
+ '@protobufjs/codegen': 2.0.4
+ '@protobufjs/eventemitter': 1.1.0
+ '@protobufjs/fetch': 1.1.0
+ '@protobufjs/float': 1.0.2
+ '@protobufjs/inquire': 1.1.0
+ '@protobufjs/path': 1.1.2
+ '@protobufjs/pool': 1.1.0
+ '@protobufjs/utf8': 1.1.0
+ '@types/node': 18.15.11
+ long: 5.2.3
+
/proxy-compare@2.5.1:
resolution: {integrity: sha512-oyfc0Tx87Cpwva5ZXezSp5V9vht1c7dZBhvuV/y3ctkgMVUmiAGDVeeB0dKhGSyT0v1ZTEQYpe/RXlBVBNuCLA==}
@@ -16330,7 +16443,6 @@ packages:
/streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
- dev: true
/strict-uri-encode@2.0.0:
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
@@ -17159,6 +17271,12 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
+ /undici@5.22.1:
+ resolution: {integrity: sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==}
+ engines: {node: '>=14.0'}
+ dependencies:
+ busboy: 1.6.0
+
/unicode-canonical-property-names-ecmascript@2.0.0:
resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==}
engines: {node: '>=4'}