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 ( +
+
+ + + + + {!!error &&
Something went wrong
} +
+
+ ); +} 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 ( +
+
+ + + + + {!!error &&
Something went wrong
} +
+
+ ); +} 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'}