diff --git a/package-lock.json b/package-lock.json index 4b5953ea..0acb626f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@apollo/client": "^3.10.5", "@apollo/experimental-nextjs-app-support": "^0.11.2", "@hookform/resolvers": "^3.6.0", + "@microsoft/fetch-event-source": "^2.0.1", "@noble/ciphers": "^0.5.3", "@noble/hashes": "^1.4.0", "@noble/secp256k1": "^2.1.0", @@ -3381,6 +3382,11 @@ "integrity": "sha512-gbkePEBupNydxCelHCESvFSFM8XPh1Zs/OAVRW/rKpEqPAl5PbOM90Si8mv9bvnR53uPD2s/FiRxdvSejpRJew==", "dev": true }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==" + }, "node_modules/@next/env": { "version": "14.2.4", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.4.tgz", diff --git a/package.json b/package.json index ddc489d9..6e8527a1 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@apollo/client": "^3.10.5", "@apollo/experimental-nextjs-app-support": "^0.11.2", "@hookform/resolvers": "^3.6.0", + "@microsoft/fetch-event-source": "^2.0.1", "@noble/ciphers": "^0.5.3", "@noble/hashes": "^1.4.0", "@noble/secp256k1": "^2.1.0", diff --git a/src/app/app/contacts/page.tsx b/src/app/app/contacts/page.tsx index 86cea3d2..7644a427 100644 --- a/src/app/app/contacts/page.tsx +++ b/src/app/app/contacts/page.tsx @@ -1,5 +1,20 @@ +import { cookies } from 'next/headers'; + +import { EventHandler } from '@/components/events/Events'; import { Contacts } from '@/views/contacts/Contacts'; export default function Page() { - return ; + const eventsUrl = process.env.EVENTS_URL; + + const cookieStore = cookies(); + const accessToken = cookieStore.get('amboss_banco_access_token')?.value; + + return ( + <> + {accessToken && eventsUrl ? ( + + ) : null} + + + ); } diff --git a/src/components/events/Events.tsx b/src/components/events/Events.tsx new file mode 100644 index 00000000..e465984f --- /dev/null +++ b/src/components/events/Events.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useApolloClient } from '@apollo/client'; +import { + EventStreamContentType, + fetchEventSource, +} from '@microsoft/fetch-event-source'; +import { FC, useCallback, useEffect } from 'react'; + +import { useToast } from '../ui/use-toast'; + +class RetriableError extends Error {} +class FatalError extends Error {} + +export const EventHandler: FC<{ + accessToken: string; + eventsUrl: string; +}> = ({ accessToken, eventsUrl }) => { + const client = useApolloClient(); + const { toast } = useToast(); + + const showToast = useCallback( + (sender: string | undefined) => { + if (!sender) return; + toast({ + title: 'New Message', + description: sender + ? `You have a new message from ${sender}` + : undefined, + }); + }, + [toast] + ); + + useEffect(() => { + const startSource = async () => { + try { + await fetchEventSource(`${eventsUrl}/contacts`, { + headers: { Authorization: `Bearer ${accessToken}` }, + async onopen(response) { + if ( + response.ok && + response.headers.get('content-type') === EventStreamContentType + ) { + return; + } else if ( + response.status >= 400 && + response.status < 500 && + response.status !== 429 + ) { + throw new FatalError(); + } else { + throw new RetriableError(); + } + }, + onmessage(ev) { + try { + const parsed = JSON.parse(ev.data); + showToast(parsed.sender_money_address); + } catch (error) {} + + client.reFetchObservableQueries(); + }, + onclose() { + throw new RetriableError(); + }, + onerror(err) { + if (err instanceof FatalError) { + throw err; + } else { + return 2000; + } + }, + }); + } catch (error) {} + }; + + startSource(); + }, [accessToken, client, showToast, eventsUrl]); + + return null; +}; diff --git a/src/graphql/queries/__generated__/contacts.generated.tsx b/src/graphql/queries/__generated__/contacts.generated.tsx index b237ddc9..21ff959b 100644 --- a/src/graphql/queries/__generated__/contacts.generated.tsx +++ b/src/graphql/queries/__generated__/contacts.generated.tsx @@ -106,6 +106,7 @@ export type GetWalletContactMessagesQuery = { id: string; contact_is_sender: boolean; payload: string; + created_at: string; }>; }; }; @@ -324,6 +325,7 @@ export const GetWalletContactMessagesDocument = gql` id contact_is_sender payload + created_at } } } diff --git a/src/graphql/queries/contacts.ts b/src/graphql/queries/contacts.ts index f9857cdc..c01dc7cb 100644 --- a/src/graphql/queries/contacts.ts +++ b/src/graphql/queries/contacts.ts @@ -72,6 +72,7 @@ export const GetWalletContactMessages = gql` id contact_is_sender payload + created_at } } } diff --git a/src/graphql/types.ts b/src/graphql/types.ts index fcb46461..82376d58 100644 --- a/src/graphql/types.ts +++ b/src/graphql/types.ts @@ -42,6 +42,7 @@ export type BroadcastLiquidTransactionInput = { export type ContactMessage = { __typename?: 'ContactMessage'; contact_is_sender: Scalars['Boolean']['output']; + created_at: Scalars['String']['output']; id: Scalars['String']['output']; payload: Scalars['String']['output']; }; diff --git a/src/views/contacts/Messages.tsx b/src/views/contacts/Messages.tsx index 136e39a8..ce56a8da 100644 --- a/src/views/contacts/Messages.tsx +++ b/src/views/contacts/Messages.tsx @@ -1,3 +1,4 @@ +import { format } from 'date-fns'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocalStorage } from 'usehooks-ts'; @@ -18,12 +19,13 @@ type Message = { id: string; contact_is_sender: boolean; message: string; + created_at: string; }; export const Messages = () => { const workerRef = useRef(); - const [unecryptedMessage, setUnencryptedMessage] = useState([]); + const [decryptedMessage, setDecryptedMessage] = useState([]); const [workerLoaded, setWorkerLoaded] = useState(false); @@ -40,14 +42,15 @@ export const Messages = () => { const messages = useMemo(() => { if (!data?.wallets.find_one.contacts.find_one.messages.length) return []; - if (unecryptedMessage.length) return unecryptedMessage; + if (decryptedMessage.length) return decryptedMessage; return data.wallets.find_one.contacts.find_one.messages.map(m => ({ id: m.id, contact_is_sender: m.contact_is_sender, message: m.payload, + created_at: m.created_at, })); - }, [unecryptedMessage, data]); + }, [decryptedMessage, data]); useEffect(() => { if (!masterKey) return; @@ -82,7 +85,7 @@ export const Messages = () => { switch (message.type) { case 'decryptMessages': - setUnencryptedMessage(message.payload); + setDecryptedMessage(message.payload); break; case 'loaded': @@ -123,6 +126,9 @@ export const Messages = () => { {m.message.substring(0, 1) === '{' ? 'Encrypted message' : m.message} +

+ {format(new Date(m.created_at), 'yyyy.MM.dd - HH:mm')} +

))} diff --git a/src/views/wallet/Settings.tsx b/src/views/wallet/Settings.tsx index 72ae158f..d2487fe8 100644 --- a/src/views/wallet/Settings.tsx +++ b/src/views/wallet/Settings.tsx @@ -147,13 +147,13 @@ export const WalletSettings: FC<{ walletId: string }> = ({ walletId }) => { disabled={loading || !!mnemonic || !masterKey} onClick={() => handleDecrypt()} > - Unencrypt + Decrypt
- +