diff --git a/packages/dialect-react-ui/.npmrc b/packages/dialect-react-ui/.npmrc new file mode 100644 index 00000000..4810ed99 --- /dev/null +++ b/packages/dialect-react-ui/.npmrc @@ -0,0 +1 @@ +@wordcel:registry=https://npm.pkg.github.com diff --git a/packages/dialect-react-ui/components/Chat/screens/CreateThreadPage/CreateThread.tsx b/packages/dialect-react-ui/components/Chat/screens/CreateThreadPage/CreateThread.tsx index 2326417d..8deaae84 100644 --- a/packages/dialect-react-ui/components/Chat/screens/CreateThreadPage/CreateThread.tsx +++ b/packages/dialect-react-ui/components/Chat/screens/CreateThreadPage/CreateThread.tsx @@ -189,7 +189,7 @@ export default function CreateThread({ -
+

{ className={clsx( 'dt-flex dt-flex-1 dt-flex-col dt-border-neutral-600 dt-overflow-hidden dt-w-full', { - 'md:dt-max-w-[22rem] md:dt-border-r md:dt-flex': inbox, + 'md:dt-max-w-[22em] md:dt-border-r md:dt-flex': inbox, 'dt-hidden': hideList, } )} diff --git a/packages/dialect-react-ui/components/Icon/BackArrow.tsx b/packages/dialect-react-ui/components/Icon/BackArrow.tsx index 3770c3b7..55fa78da 100644 --- a/packages/dialect-react-ui/components/Icon/BackArrow.tsx +++ b/packages/dialect-react-ui/components/Icon/BackArrow.tsx @@ -2,19 +2,15 @@ import type { SVGProps } from 'react'; const SvgBackArrow = (props: SVGProps) => ( - + ); diff --git a/packages/dialect-react-ui/components/Icon/Bell.tsx b/packages/dialect-react-ui/components/Icon/Bell.tsx index 43802efd..9a5dfddf 100644 --- a/packages/dialect-react-ui/components/Icon/Bell.tsx +++ b/packages/dialect-react-ui/components/Icon/Bell.tsx @@ -2,22 +2,18 @@ import type { SVGProps } from 'react'; const SvgBell = (props: SVGProps) => ( - - + ); diff --git a/packages/dialect-react-ui/components/Icon/Check.tsx b/packages/dialect-react-ui/components/Icon/Check.tsx new file mode 100644 index 00000000..8078a2ca --- /dev/null +++ b/packages/dialect-react-ui/components/Icon/Check.tsx @@ -0,0 +1,17 @@ +import type { SVGProps } from 'react'; + +const SvgComponent = (props: SVGProps) => ( + + + +); + +export default SvgComponent; diff --git a/packages/dialect-react-ui/components/Icon/Gear.tsx b/packages/dialect-react-ui/components/Icon/Gear.tsx index 08e8db51..0d7e9b1f 100644 --- a/packages/dialect-react-ui/components/Icon/Gear.tsx +++ b/packages/dialect-react-ui/components/Icon/Gear.tsx @@ -2,16 +2,16 @@ import type { SVGProps } from 'react'; const SvgGear = (props: SVGProps) => ( - + ); diff --git a/packages/dialect-react-ui/components/Icon/NoNotifications.tsx b/packages/dialect-react-ui/components/Icon/NoNotifications.tsx index 8f536ee5..89e11c98 100644 --- a/packages/dialect-react-ui/components/Icon/NoNotifications.tsx +++ b/packages/dialect-react-ui/components/Icon/NoNotifications.tsx @@ -2,25 +2,16 @@ import type { SVGProps } from 'react'; const SvgNoNotifications = (props: SVGProps) => ( - - - - - + + + ); diff --git a/packages/dialect-react-ui/components/Icon/Trash.tsx b/packages/dialect-react-ui/components/Icon/Trash.tsx index 31c67c8d..7b675761 100644 --- a/packages/dialect-react-ui/components/Icon/Trash.tsx +++ b/packages/dialect-react-ui/components/Icon/Trash.tsx @@ -2,21 +2,19 @@ import type { SVGProps } from 'react'; const SvgTrash = (props: SVGProps) => ( - + ); diff --git a/packages/dialect-react-ui/components/Icon/Triangle.tsx b/packages/dialect-react-ui/components/Icon/Triangle.tsx new file mode 100644 index 00000000..3b97ac6e --- /dev/null +++ b/packages/dialect-react-ui/components/Icon/Triangle.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from 'react'; + +const TriangleIcon = (props: SVGProps) => ( + + + +); + +export default TriangleIcon; diff --git a/packages/dialect-react-ui/components/Icon/index.ts b/packages/dialect-react-ui/components/Icon/index.ts index e5c1233c..a9f9c950 100644 --- a/packages/dialect-react-ui/components/Icon/index.ts +++ b/packages/dialect-react-ui/components/Icon/index.ts @@ -22,3 +22,5 @@ export { default as X } from './X'; export { default as MultiarrowVertical } from './MultiarrowVertical'; export { default as Encrypted } from './Encrypted'; export { default as Unencrypted } from './Unencrypted'; +export { default as Check } from './Check'; +export { default as TriangleIcon } from './Triangle'; diff --git a/packages/dialect-react-ui/components/Icon/source/back-arrow.svg b/packages/dialect-react-ui/components/Icon/source/back-arrow.svg index 79dc3258..4ba73054 100644 --- a/packages/dialect-react-ui/components/Icon/source/back-arrow.svg +++ b/packages/dialect-react-ui/components/Icon/source/back-arrow.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/dialect-react-ui/components/Icon/source/bell.svg b/packages/dialect-react-ui/components/Icon/source/bell.svg index a56f24b5..878da9b4 100644 --- a/packages/dialect-react-ui/components/Icon/source/bell.svg +++ b/packages/dialect-react-ui/components/Icon/source/bell.svg @@ -1,4 +1,3 @@ - - - + + diff --git a/packages/dialect-react-ui/components/Icon/source/check.svg b/packages/dialect-react-ui/components/Icon/source/check.svg new file mode 100644 index 00000000..b29f4432 --- /dev/null +++ b/packages/dialect-react-ui/components/Icon/source/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/dialect-react-ui/components/Icon/source/gear.svg b/packages/dialect-react-ui/components/Icon/source/gear.svg index 79ac51ad..6e2cff95 100644 --- a/packages/dialect-react-ui/components/Icon/source/gear.svg +++ b/packages/dialect-react-ui/components/Icon/source/gear.svg @@ -1,3 +1,3 @@ - - - + + + \ No newline at end of file diff --git a/packages/dialect-react-ui/components/Icon/source/no-notifications.svg b/packages/dialect-react-ui/components/Icon/source/no-notifications.svg index 0136baa9..d8b8dc24 100644 --- a/packages/dialect-react-ui/components/Icon/source/no-notifications.svg +++ b/packages/dialect-react-ui/components/Icon/source/no-notifications.svg @@ -1,7 +1,5 @@ - - - - - - - + + + + + \ No newline at end of file diff --git a/packages/dialect-react-ui/components/Icon/source/trash.svg b/packages/dialect-react-ui/components/Icon/source/trash.svg index 0f6dc8d2..db8a651c 100644 --- a/packages/dialect-react-ui/components/Icon/source/trash.svg +++ b/packages/dialect-react-ui/components/Icon/source/trash.svg @@ -1,3 +1,3 @@ - - - + + + \ No newline at end of file diff --git a/packages/dialect-react-ui/components/Icon/source/triangle.svg b/packages/dialect-react-ui/components/Icon/source/triangle.svg new file mode 100644 index 00000000..8f7ec492 --- /dev/null +++ b/packages/dialect-react-ui/components/Icon/source/triangle.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/dialect-react-ui/components/Notifications/index.tsx b/packages/dialect-react-ui/components/Notifications/index.tsx index 14cf4357..7785c9ca 100644 --- a/packages/dialect-react-ui/components/Notifications/index.tsx +++ b/packages/dialect-react-ui/components/Notifications/index.tsx @@ -114,7 +114,7 @@ function InnerNotifications(props: NotificationsProps): JSX.Element { />
diff --git a/packages/dialect-react-ui/components/NotificationsButton/index.tsx b/packages/dialect-react-ui/components/NotificationsButton/index.tsx index 28da1668..93415497 100644 --- a/packages/dialect-react-ui/components/NotificationsButton/index.tsx +++ b/packages/dialect-react-ui/components/NotificationsButton/index.tsx @@ -8,7 +8,9 @@ import { useTheme } from '../common/providers/DialectThemeProvider'; import { useDialectUiId } from '../common/providers/DialectUiManagementProvider'; import type { Channel } from '../common/types'; import IconButton from '../IconButton'; -import Notifications, { NotificationType } from '../Notifications'; +import WordcelNotifications, { + NotificationType, +} from '../WordcelNotifications'; const DEFAULT_POLLING_FOR_NOTIFICATIONS = 15000; // 15 sec refresh default @@ -68,7 +70,7 @@ function WrappedNotificationsButton(props: PropTypes): JSX.Element { - void; + onBackClick?: () => void; + threadId?: ThreadId; +}) { + const { navigate, current } = useRoute(); + const { colors, textStyles, icons, header, notificationHeader } = useTheme(); + const isSettingsOpen = current?.name === RouteName.Settings; + const openSettings = () => { + navigate(RouteName.Settings); + }; + const openThread = () => { + if (!props.threadId) return; + navigate(RouteName.Thread, { + params: { threadId: props.threadId }, + }); + }; + + const BackButton = () => ( + } + onClick={openThread} + className="dt-mr-2 dt-py-1" + /> + ); + + const SettingsButton = () => + props.isReady && !isSettingsOpen ? ( + } onClick={openSettings} /> + ) : null; + + const CloseButton = () => ( + } onClick={props.onModalClose} /> + ); + + const MasterBackButton = () => + props.onBackClick ? ( + } + onClick={props.onBackClick} + className="dt-mr-2 dt-py-1" + /> + ) : null; + + const headerIcons = ( + <> +
+ +
+ +
+
+ + ); + + return ( + <> +
+ {isSettingsOpen ? ( +
+ {props.isWeb3Enabled && } + {!props.isWeb3Enabled && } + + Notification Settings + +
+ ) : ( + <> + + + Notifications + + + )} + {headerIcons} +
+ + ); +} + +export default Header; diff --git a/packages/dialect-react-ui/components/WordcelNotifications/constants.ts b/packages/dialect-react-ui/components/WordcelNotifications/constants.ts new file mode 100644 index 00000000..557b3688 --- /dev/null +++ b/packages/dialect-react-ui/components/WordcelNotifications/constants.ts @@ -0,0 +1,11 @@ +export enum RouteName { + SigningRequest = 'sign_wallet', + TransactionSigning = 'transaction_signing', + EncryptionRequest = 'encryption_request', + Setup = 'setup_notifications', + Main = 'main_notifications', + Settings = 'settings_notifications', + Thread = 'notifications_thread', + CantDecrypt = 'cant_decrypt', + FailingGate = 'failing_gate', +} diff --git a/packages/dialect-react-ui/components/WordcelNotifications/index.tsx b/packages/dialect-react-ui/components/WordcelNotifications/index.tsx new file mode 100644 index 00000000..c9d232f3 --- /dev/null +++ b/packages/dialect-react-ui/components/WordcelNotifications/index.tsx @@ -0,0 +1,186 @@ +import { + AddressType, + useDialectDapp, + useNotificationChannelDappSubscription, + useNotificationSubscriptions, + useThread, +} from '@dialectlabs/react-sdk'; +import clsx from 'clsx'; +import { useCallback, useEffect, useState } from 'react'; +import LoadingThread from '../../entities/LoadingThread'; +import ConnectionWrapper from '../../entities/wrappers/ConnectionWrapper'; +import ThreadEncyprionWrapper from '../../entities/wrappers/ThreadEncryptionWrapper'; +import WalletStatesWrapper from '../../entities/wrappers/WalletStatesWrapper'; +import GatedWrapper from '../common/GatedWrapper'; +import { useTheme } from '../common/providers/DialectThemeProvider'; +import { Route, Router, useRoute } from '../common/providers/Router'; +import type { Channel } from '../common/types'; +import { TriangleIcon } from '../Icon'; +import { RouteName } from './constants'; +import Header from './Header'; +import NotificationsList from './screens/NotificationsList'; +import Settings from './screens/Settings'; + +export type NotificationType = { + name: string; + detail?: string; +}; + +interface NotificationsProps { + onModalClose: () => void; + notifications?: NotificationType[]; + channels?: Channel[]; + onBackClick?: () => void; + gatedView?: string | JSX.Element; + pollingInterval?: number; +} + +const addressType = AddressType.Wallet; + +function InnerNotifications(props: NotificationsProps): JSX.Element { + const { dappAddress } = useDialectDapp(); + if (!dappAddress) { + throw new Error('dapp address should be provided for notifications'); + } + const { thread, isFetchingThread } = useThread({ + findParams: { otherMembers: [dappAddress] }, + }); + + const [isInitialRoutePicked, setInitialRoutePicked] = useState(false); + + const subscription = useNotificationChannelDappSubscription({ + addressType, + }); + + const { isFetching: isFetchingNotificationsSubscriptions } = + useNotificationSubscriptions(); + + const { scrollbar } = useTheme(); + const { navigate } = useRoute(); + + const showThread = useCallback(() => { + if (!thread) { + return; + } + navigate(RouteName.Thread, { + params: { + threadId: thread.id, + }, + }); + }, [navigate, thread]); + + const showSettings = useCallback(() => { + navigate(RouteName.Settings); + }, [navigate]); + + const isLoading = + subscription.isFetchingSubscriptions || + isFetchingThread || + isFetchingNotificationsSubscriptions; + + const isWeb3Enabled = subscription.enabled && Boolean(thread); + + useEffect( + function pickInitialRoute() { + if (isInitialRoutePicked) { + return; + } + + if (isLoading) { + return; + } + + const shouldShowSettings = !isWeb3Enabled; + + if (shouldShowSettings) { + showSettings(); + setInitialRoutePicked(true); + return; + } + + showThread(); + setInitialRoutePicked(true); + }, + [isInitialRoutePicked, isLoading, isWeb3Enabled, showSettings, showThread] + ); + + return ( +
+ +
+
+ {isInitialRoutePicked ? ( + <> + + + + + + + + ) : ( + + )} +
+
+ ); +} + +export default function WordcelNotifications({ + gatedView, + ...props +}: NotificationsProps) { + const { dappAddress } = useDialectDapp(); + const { colors, modal } = useTheme(); + + const fallbackHeader = ( +
+ ); + + return ( +
+
+ + + + + + + + + + + +
+
+ ); +} diff --git a/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/Email.tsx b/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/Email.tsx new file mode 100644 index 00000000..f31c821e --- /dev/null +++ b/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/Email.tsx @@ -0,0 +1,247 @@ +import { + AddressType, + useNotificationChannel, + useNotificationChannelDappSubscription, +} from '@dialectlabs/react-sdk'; +import clsx from 'clsx'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Toggle } from '../../../common'; +import { P } from '../../../common/preflighted'; +import OutlinedInput from '../../../common/primitives/OutlinedInput'; +import { useTheme } from '../../../common/providers/DialectThemeProvider'; +import CancelIcon from '../../../Icon/Cancel'; +import { RightAdornment } from './RightAdorment'; +import { VerificationInput } from './VerificationInput'; + +const addressType = AddressType.Email; + +const Email = () => { + const { textStyles, colors } = useTheme(); + + const { + globalAddress: emailAddress, + create: createAddress, + delete: deleteAddress, + update: updateAddress, + + isUpdatingAddress, + isCreatingAddress, + isDeletingAddress, + isSendingCode, + isVerifyingCode, + + errorFetching: errorFetchingAddresses, + } = useNotificationChannel({ addressType }); + + const { + enabled: subscriptionEnabled, + toggleSubscription, + isToggling, + } = useNotificationChannelDappSubscription({ + addressType, + }); + + const [email, setEmail] = useState(emailAddress?.value ?? ''); + const [isDeleting, setIsDeleting] = useState(false); + + const [error, setError] = useState(null); + + const isEmailSaved = Boolean(emailAddress); + const isVerified = emailAddress?.verified || false; + + const isLoading = + isCreatingAddress || + isDeletingAddress || + isUpdatingAddress || + isVerifyingCode || + isSendingCode || + isToggling; + + const currentError = error || errorFetchingAddresses; + + useEffect(() => { + setEmail(emailAddress?.value || ''); + }, [isEmailSaved, emailAddress?.value]); + + const updateEmail = useCallback(async () => { + try { + await updateAddress({ value: email }); + setError(null); + } catch (e) { + setError(e as Error); + } + }, [email, updateAddress]); + + const createEmail = useCallback(async () => { + try { + const address = await createAddress({ value: email }); + await toggleSubscription({ enabled: true, address }); + setError(null); + } catch (e) { + setError(e as Error); + } + }, [createAddress, email]); + + const deleteEmail = async () => { + try { + await deleteAddress(); + setIsDeleting(false); + setError(null); + } catch (e) { + setError(e as Error); + } + }; + + const toggleEmail = async (nextValue: boolean) => { + try { + await toggleSubscription({ + enabled: nextValue, + }); + setError(null); + } catch (e) { + setError(e as Error); + } + }; + + const onChange = (e: React.ChangeEvent) => { + setEmail(e.target.value); + }; + + const isEditing = emailAddress?.value !== email && isEmailSaved; + + return ( +
+ + + {isEmailSaved && !isVerified ? ( + + ) : ( + + e.target.checkValidity() + ? setError(null) + : setError({ + name: 'incorrectEmail', + message: 'Please enter a valid email', + }) + } + onInvalid={(e) => { + e.preventDefault(); + setError({ + name: 'incorrectEmail', + message: 'Please enter a valid email', + }); + }} + pattern="^\S+@\S+\.\S+$" + rightAdornment={ + { + setIsDeleting(isDelete); + }} + isDeleting={isDeleting} + /> + } + /> + )} + + {(isDeleting || isEditing) && ( +
+ {isDeleting && ( +
+ + Deleting your email here will delete it for all dapps you're + subscribed to. + + setIsDeleting(false)} + className="dt-inline-block dt-cursor-pointer" + > + + Cancel + +
+ )} + {isEditing && ( +
+ + Updating your email here will update it across all dapps you've + subscribed to. + + { + setEmail(emailAddress?.value || ''); + }} + className="dt-inline-block dt-cursor-pointer" + > + + Cancel + +
+ )} +
+ )} + + {currentError && ( +

+ {currentError.message} +

+ )} + + {isEmailSaved && isVerified && !isEditing && ( +
+

+ Notifications {subscriptionEnabled ? 'on' : 'off'} +

+ + { + if (isLoading) return; + toggleEmail(next); + }} + /> +
+ )} +
+ ); +}; + +export default Email; diff --git a/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/RightAdorment.tsx b/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/RightAdorment.tsx new file mode 100644 index 00000000..f22687ee --- /dev/null +++ b/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/RightAdorment.tsx @@ -0,0 +1,101 @@ +import clsx from 'clsx'; +import { Button, Loader } from '../../../common'; +import { useTheme } from '../../../common/providers/DialectThemeProvider'; +import IconButton from '../../../IconButton'; +import Check from '../../../Icon/Check'; + +interface IRightAdorment { + loading: boolean; + currentVal: string; + isSaved: boolean; + isChanging: boolean; + isVerified: boolean; + onSaveCallback: () => void; + onDeleteCallback: () => void; + onUpdateCallback: () => void; + deleteConfirm: (isDelete: boolean) => void; + isDeleting: boolean; +} + +export const RightAdornment = ({ + loading, + currentVal, + isSaved, + isChanging, + isVerified, + onSaveCallback, + onDeleteCallback, + onUpdateCallback, + isDeleting, + deleteConfirm, +}: IRightAdorment) => { + const { icons, addormentButton } = useTheme(); + + const getIcon = () => { + if (loading) { + return ( +
+ +
+ ); + } + + if (currentVal && !isSaved && !isChanging) { + return ( + + ); + } + + if (isSaved && isVerified && !isDeleting && !isChanging) { + return ( + } + onClick={() => { + deleteConfirm(true); + }} + /> + ); + } + + if (isSaved && isVerified && isDeleting) { + return ( + + ); + } + }; + + return ( + <> + {getIcon()} + {isChanging && isChanging && ( + + )} + + ); +}; diff --git a/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/Sms.tsx b/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/Sms.tsx new file mode 100644 index 00000000..c3e6321d --- /dev/null +++ b/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/Sms.tsx @@ -0,0 +1,241 @@ +import { useTheme } from '../../../common/providers/DialectThemeProvider'; +import clsx from 'clsx'; +import { RightAdornment } from './RightAdorment'; +import { + AddressType, + useNotificationChannel, + useNotificationChannelDappSubscription, +} from '@dialectlabs/react-sdk'; +import { useEffect, useState } from 'react'; +import { P } from '../../../common/preflighted'; +import { VerificationInput } from './VerificationInput'; +import OutlinedInput from '../../../common/primitives/OutlinedInput'; +import { Toggle } from '../../../common'; +import CancelIcon from '../../../Icon/Cancel'; + +const addressType = AddressType.PhoneNumber; + +const Sms = () => { + const { + globalAddress: smsAddress, + create: createAddress, + delete: deleteAddress, + update: updateAddress, + + isCreatingAddress, + isUpdatingAddress, + isDeletingAddress, + isSendingCode, + isVerifyingCode, + + errorFetching: errorFetchingAddresses, + } = useNotificationChannel({ addressType }); + + const { + enabled: subscriptionEnabled, + isToggling, + toggleSubscription, + } = useNotificationChannelDappSubscription({ addressType }); + + const { textStyles, colors } = useTheme(); + + const [smsNumber, setSmsNumber] = useState(smsAddress?.value || ''); + const [error, setError] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const isSmsNumberSaved = Boolean(smsAddress); + const isVerified = smsAddress?.verified || false; + + const isLoading = + isCreatingAddress || + isUpdatingAddress || + isDeletingAddress || + isSendingCode || + isVerifyingCode; + + const currentError = error || errorFetchingAddresses; + + useEffect(() => { + setSmsNumber(smsAddress?.value || ''); + }, [isSmsNumberSaved, smsAddress?.value]); + + const updateSmsNumber = async () => { + try { + await updateAddress({ value: smsNumber }); + setError(null); + } catch (e) { + setError(e as Error); + } + }; + + const saveSmsNumber = async () => { + try { + const address = await createAddress({ value: smsNumber }); + await toggleSubscription({ enabled: true, address }); + setError(null); + } catch (e) { + setError(e as Error); + } + }; + + const deleteSmsNumber = async () => { + try { + await deleteAddress(); + setError(null); + setIsDeleting(false); + } catch (e) { + setError(e as Error); + } + }; + + const toggleSms = async (nextValue: boolean) => { + try { + await toggleSubscription({ + enabled: nextValue, + }); + setError(null); + } catch (e) { + setError(e as Error); + } + }; + + const onChange = (e: any) => { + setSmsNumber(e.target.value); + }; + + const isEditing = smsNumber !== smsAddress?.value && isSmsNumberSaved; + + return ( +
+ + {isSmsNumberSaved && !isVerified ? ( + + ) : ( + { + setIsDeleting(isDelete); + }} + isDeleting={isDeleting} + /> + } + onChange={onChange} + onBlur={(e) => + e.target.checkValidity() + ? setError(null) + : setError({ + name: 'incorrectEmail', + message: 'Please enter a valid email', + }) + } + onInvalid={(e) => { + e.preventDefault(); + setError({ + name: 'incorrectEmail', + message: 'Please enter a valid email', + }); + }} + /> + )} + + {(isDeleting || isEditing) && ( +
+ {isDeleting && ( +
+ + Deleting your email here will delete it across all dapps you've + signed up. + + setIsDeleting(false)} + className="dt-inline-block dt-cursor-pointer" + > + + Cancel + +
+ )} + {isEditing && ( +
+ + Updating your sms number here will update it across all dapps + you've signed up. + + { + setSmsNumber(smsAddress?.value || ''); + }} + className="dt-inline-block dt-cursor-pointer" + > + + Cancel + +
+ )} +
+ )} + + {currentError && ( +

+ {currentError.message} +

+ )} + + {isSmsNumberSaved && isVerified && !isEditing && ( +
+ { + if (isToggling) return; + return toggleSms(value); + }} + /> + +

+ Notifications {subscriptionEnabled ? 'on' : 'off'} +

+
+ )} +
+ ); +}; + +export default Sms; diff --git a/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/Telegram.tsx b/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/Telegram.tsx new file mode 100644 index 00000000..74f48e23 --- /dev/null +++ b/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/Telegram.tsx @@ -0,0 +1,291 @@ +import { Toggle } from '../../../common'; +import { useTheme } from '../../../common/providers/DialectThemeProvider'; +import clsx from 'clsx'; +import { + AddressType, + useDapp, + useDialectDapp, + useDialectSdk, + useNotificationChannel, + useNotificationChannelDappSubscription, +} from '@dialectlabs/react-sdk'; +import { useEffect, useMemo, useState } from 'react'; +import { RightAdornment } from './RightAdorment'; +import { VerificationInput } from './VerificationInput'; +import { P } from '../../../common/preflighted'; +import OutlinedInput from '../../../common/primitives/OutlinedInput'; +import CancelIcon from '../../../Icon/Cancel'; + +const addressType = AddressType.Telegram; +const Telegram = () => { + const { + info: { + config: { environment }, + }, + } = useDialectSdk(); + const { + globalAddress: telegramAddress, + create: createAddress, + delete: deleteAddress, + update: updateAddress, + + isCreatingAddress, + isUpdatingAddress, + isDeletingAddress, + isSendingCode, + isVerifyingCode, + + errorFetching: errorFetchingAddresses, + } = useNotificationChannel({ addressType }); + + const { + enabled: subscriptionEnabled, + toggleSubscription, + isToggling, + } = useNotificationChannelDappSubscription({ addressType }); + + const { dapps } = useDapp({ verified: false }); + const { dappAddress } = useDialectDapp(); + + const { textStyles, colors } = useTheme(); + + const [telegramUsername, setTelegramUsername] = useState( + telegramAddress?.value || '' + ); + const [error, setError] = useState(null); + const [isUserDeleting, setIsUserDeleting] = useState(false); + + const isTelegramSaved = Boolean(telegramAddress); + const isVerified = telegramAddress?.verified || false; + const isLoading = + isCreatingAddress || + isUpdatingAddress || + isDeletingAddress || + isSendingCode || + isVerifyingCode || + isToggling; + + const currentError = error || errorFetchingAddresses; + + useEffect(() => { + setTelegramUsername(telegramAddress?.value || ''); + }, [isTelegramSaved, telegramAddress?.value]); + + const updateTelegram = async () => { + try { + await updateAddress({ + value: telegramUsername, + }); + setError(null); + setIsUserDeleting(false); + } catch (e) { + setError(e as Error); + } + }; + + const saveTelegram = async () => { + try { + const value = telegramUsername.replace('@', ''); + const address = await createAddress({ value }); + await toggleSubscription({ enabled: true, address }); + setError(null); + } catch (e) { + setError(e as Error); + } + }; + + const deleteTelegram = async () => { + try { + await deleteAddress(); + setIsUserDeleting(false); + setError(null); + } catch (e) { + setError(e as Error); + } + }; + + const onChange = (e: any) => { + setTelegramUsername(e.target.value); + }; + + const toggleTelegram = async (nextValue: boolean) => { + try { + await toggleSubscription({ + enabled: nextValue, + }); + setError(null); + } catch (e) { + setError(e as Error); + } + }; + + const buildBotUrl = (botUsername: string) => + `https://t.me/${botUsername}?start=${botUsername}`; + + const defaultBotUrl = + environment === 'production' + ? buildBotUrl('DialectLabsBot') + : buildBotUrl('DialectLabsDevBot'); + + const botURL = useMemo(() => { + if (!dappAddress) { + return defaultBotUrl; + } + const dapp = dapps[dappAddress.toBase58()]; + if (!dapp) { + return defaultBotUrl; + } + return buildBotUrl(dapp.telegramUsername); + }, [dappAddress, dapps, defaultBotUrl]); + + const isUserEditing = + telegramAddress?.value !== telegramUsername && isTelegramSaved; + + return ( +
+ + + {isTelegramSaved && !isVerified ? ( + + + 🤖 + + {' '} + Get verification code by starting{' '} + + this bot + with command: /start + + + + Cancel + + + } + /> + ) : ( + { + setIsUserDeleting(isDelete); + }} + isDeleting={isUserDeleting} + /> + } + /> + )} + + {(isUserDeleting || isUserEditing) && ( +
+ {isUserDeleting && ( +
+ + Deleting your telegram handle here will delete it across all + dapps you've signed up. + + setIsUserDeleting(false)} + className="dt-inline-block dt-cursor-pointer" + > + + Cancel + +
+ )} + {isUserEditing && ( +
+ + Updating your telegram handle here will update it across all + dapps you've signed up. + + { + setTelegramUsername(telegramAddress?.value || ''); + }} + className="dt-inline-block dt-cursor-pointer" + > + + Cancel + +
+ )} +
+ )} + + {currentError && ( +

+ {currentError.message} +

+ )} + + {isTelegramSaved && isVerified && !isUserEditing && ( +
+

+ Notifications {subscriptionEnabled ? 'on' : 'off'} +

+ + { + if (isToggling) return; + return toggleTelegram(value); + }} + /> + +
+ )} +
+ ); +}; + +export default Telegram; diff --git a/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/VerificationInput.tsx b/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/VerificationInput.tsx new file mode 100644 index 00000000..f107dbe4 --- /dev/null +++ b/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/VerificationInput.tsx @@ -0,0 +1,133 @@ +import { AddressType, useNotificationChannel } from '@dialectlabs/react-sdk'; +import clsx from 'clsx'; +import { useState } from 'react'; +import { Button } from '../../../common'; +import { P } from '../../../common/preflighted'; +import { useTheme } from '../../../common/providers/DialectThemeProvider'; +import ResendIcon from '../../../Icon/Resend'; +import CancelIcon from '../../../Icon/Cancel'; +import OutlinedInput from '../../../common/primitives/OutlinedInput'; +import { Check } from '../../../Icon'; + +interface IVerificationInputProps { + description?: string; + addressType: AddressType; + onCancel: () => void; + customText?: React.ReactNode; +} + +const VERIFICATION_CODE_REGEX = '^[0-9]{6}$'; + +export const VerificationInput = ({ + addressType, + onCancel, + description, + customText, +}: IVerificationInputProps) => { + const [verificationCode, setVerificationCode] = useState(''); + const [currentError, setCurrentError] = useState(null); + const { verify: verifyCode, resend } = useNotificationChannel({ + addressType, + }); + const { textStyles, addormentButton } = useTheme(); + + const sendCode = async () => { + try { + await verifyCode({ code: verificationCode }); + setCurrentError(null); + } catch (e) { + setCurrentError(e as Error); + } finally { + setVerificationCode(''); + } + }; + + const resendCode = async () => { + try { + await resend(); + setCurrentError(null); + } catch (e) { + setCurrentError(e as Error); + } + }; + + return ( + <> + setVerificationCode(e.target.value)} + onBlur={(e) => + e.target.checkValidity() + ? setCurrentError(null) + : setCurrentError({ + name: 'incorrectEmail', + message: 'Please enter a valid email', + }) + } + onInvalid={(e) => { + e.preventDefault(); + setCurrentError({ + name: 'incorrectEmail', + message: 'Please enter a valid email', + }); + }} + pattern={VERIFICATION_CODE_REGEX} + rightAdornment={ + + } + /> +
+ {customText ? ( + customText + ) : ( + <> + {description} + + + Cancel + + + + Resend code + + + )} +
+ {currentError && ( +

+ {currentError.message} +

+ )} + + ); +}; diff --git a/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/Wallet.tsx b/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/Wallet.tsx new file mode 100644 index 00000000..52742d6d --- /dev/null +++ b/packages/dialect-react-ui/components/WordcelNotifications/screens/NewSettings/Wallet.tsx @@ -0,0 +1,240 @@ +import { + AddressType, + Backend, + Thread, + ThreadMemberScope, + useDialectConnectionInfo, + useDialectDapp, + useDialectWallet, + useNotificationChannel, + useNotificationChannelDappSubscription, + useThread, + useThreads, +} from '@dialectlabs/react-sdk'; +import clsx from 'clsx'; +import { useCallback } from 'react'; +import { shortenAddress } from '../../../../utils/displayUtils'; +import { Button, Loader, Toggle } from '../../../common'; +import { P } from '../../../common/preflighted'; +import { useTheme } from '../../../common/providers/DialectThemeProvider'; +import { Check } from '../../../Icon'; +import IconButton from '../../../IconButton'; + +type Web3Props = { + onThreadDeleted?: () => void; + onThreadCreated?: (thread: Thread) => void; + showLabel?: boolean; +}; + +const addressType = AddressType.Wallet; + +const Wallet = ({ + onThreadDeleted, + onThreadCreated, + showLabel = true, +}: Web3Props) => { + const { adapter: wallet } = useDialectWallet(); + const { dappAddress } = useDialectDapp(); + const { textStyles, outlinedInput, addormentButton, icons, colors } = + useTheme(); + const { create: createThread, isCreatingThread } = useThreads(); + + const { + globalAddress: walletAddress, + create: createAddress, + delete: deleteAddress, + isCreatingAddress, + isDeletingAddress, + } = useNotificationChannel({ addressType }); + + const { + enabled: subscriptionEnabled, + toggleSubscription, + isToggling, + } = useNotificationChannelDappSubscription({ + addressType, + }); + + const { + connected: { + solana: { shouldConnect: isSolanaShouldConnect }, + dialectCloud: { shouldConnect: isDialectCloudShouldConnect }, + }, + } = useDialectConnectionInfo(); + + const isBackendSelectable = + isSolanaShouldConnect && isDialectCloudShouldConnect; + + const backend = + isSolanaShouldConnect && !isBackendSelectable + ? Backend.Solana + : Backend.DialectCloud; + + const { + thread, + delete: deleteDialect, + isDeletingThread, + } = useThread({ + findParams: { otherMembers: dappAddress ? [dappAddress] : [] }, + }); + + const deleteThread = useCallback(async () => { + await deleteDialect(); + await deleteAddress(); + onThreadDeleted?.(); + }, [deleteAddress, deleteDialect, onThreadDeleted]); + + const createWalletThread = useCallback(async () => { + if (!dappAddress) return; + return createThread({ + me: { scopes: [ThreadMemberScope.ADMIN] }, + otherMembers: [ + { publicKey: dappAddress, scopes: [ThreadMemberScope.WRITE] }, + ], + encrypted: false, + backend, + }); + }, [backend, createThread, dappAddress]); + + const createWalletAddress = useCallback(async () => { + if (!wallet.publicKey) { + return; + } + return createAddress({ value: wallet.publicKey?.toBase58() }); + }, [createAddress, wallet.publicKey]); + + const fullEnableWallet = useCallback(async () => { + const address = await createWalletAddress(); + const thread = await createWalletThread(); + if (!thread) { + return; + } + await toggleSubscription({ enabled: true, address }); + onThreadCreated?.(thread); + }, [ + createWalletAddress, + createWalletThread, + onThreadCreated, + toggleSubscription, + ]); + + const isLoading = + isDeletingThread || + isCreatingThread || + isDeletingAddress || + isCreatingAddress || + isToggling; + + const walletEnabled = thread && walletAddress; + + return ( +
+ {showLabel && ( + + )} +
+
+
+ + {shortenAddress(wallet.publicKey || '')} + + {walletEnabled && !isLoading && ( + } + onClick={deleteThread} + /> + )} + {/* when no address and thread */} + {!thread && !walletAddress && !isLoading && ( + + )} + {/* when address exists but no thread */} + {walletAddress && !thread && !isLoading && ( + + )} + {/* when thread exists but no address + Probably this is a *very* old users case */} + {thread && !walletAddress && !isLoading && ( + + )} + {isLoading && ( +
+ +
+ )} +
+
+
+ + {walletEnabled && ( +
+

+ Notifications {subscriptionEnabled ? 'on' : 'off'} +

+ + { + if (isToggling) return; + return toggleSubscription({ enabled: checked }); + }} + /> + + {backend === Backend.Solana && ( +

+ | Rent Deposit (recoverable): 0.058 SOL +

+ )} +
+ )} +
+ ); +}; + +export default Wallet; diff --git a/packages/dialect-react-ui/components/WordcelNotifications/screens/NotificationsList/NoNotifications.tsx b/packages/dialect-react-ui/components/WordcelNotifications/screens/NotificationsList/NoNotifications.tsx new file mode 100644 index 00000000..bd066fdc --- /dev/null +++ b/packages/dialect-react-ui/components/WordcelNotifications/screens/NotificationsList/NoNotifications.tsx @@ -0,0 +1,16 @@ +import { Centered } from '../../../common'; +import { useTheme } from '../../../common/providers/DialectThemeProvider'; + +const NoNotifications = () => { + const { icons } = useTheme(); + return ( + + + {/* TODO: use some textstyle */} + You have no new notification. + Check back again in a few + + ); +}; + +export default NoNotifications; diff --git a/packages/dialect-react-ui/components/WordcelNotifications/screens/NotificationsList/Notification.tsx b/packages/dialect-react-ui/components/WordcelNotifications/screens/NotificationsList/Notification.tsx new file mode 100644 index 00000000..7c182c11 --- /dev/null +++ b/packages/dialect-react-ui/components/WordcelNotifications/screens/NotificationsList/Notification.tsx @@ -0,0 +1,81 @@ +import clsx from 'clsx'; +import { LinkifiedText } from '../../../common'; +import { P } from '../../../common/preflighted'; +import { useTheme } from '../../../common/providers/DialectThemeProvider'; + +type Props = { + message: string; + timestamp: number; +}; + +const timeFormatter = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true, +}); + +export const Notification = ({ message, timestamp }: Props) => { + const { colors, textStyles, notificationMessage, notificationTimestamp } = + useTheme(); + + let parsedMessage; + let profile, link: string, text, name; + try { + parsedMessage = JSON.parse(message); + profile = parsedMessage.profile; + link = parsedMessage.link; + text = parsedMessage.message; + name = parsedMessage.name; + } catch (e) { + parsedMessage = message; + profile = 'https://nftstorage.link/ipfs/bafybeidempjtydftpn6txv3wraqj36pkpnzkkmxvf4qlc3a4sl4wl23agu/wordcel.png'; + link = '' + name = '' + text = message; + } + return ( +
{ + if (link) { + window.open(link, '_blank'); + } + }} + > +
+
+ +
+ {' '} + {name} {text}{' '} +
+
+ +
+
+

+ {timeFormatter.format(timestamp)} +

+
+
+ ); +}; diff --git a/packages/dialect-react-ui/components/WordcelNotifications/screens/NotificationsList/index.tsx b/packages/dialect-react-ui/components/WordcelNotifications/screens/NotificationsList/index.tsx new file mode 100644 index 00000000..7533aa90 --- /dev/null +++ b/packages/dialect-react-ui/components/WordcelNotifications/screens/NotificationsList/index.tsx @@ -0,0 +1,63 @@ +import { ThreadId, useThreadMessages } from '@dialectlabs/react-sdk'; +import React, { useEffect } from 'react'; +import { Divider } from '../../../common'; +import { useTheme } from '../../../common/providers/DialectThemeProvider'; +import { useRoute } from '../../../common/providers/Router'; +import NoNotifications from './NoNotifications'; +import { Notification } from './Notification'; + +interface NotificationsListProps { + refreshInterval?: number; +} + +const NotificationsListWrapper = (props: NotificationsListProps) => { + const { + params: { threadId }, + } = useRoute<{ threadId: ThreadId }>(); + + if (!threadId) { + return null; + } + + return ; +}; + +const NotificationsList = ({ refreshInterval }: NotificationsListProps) => { + const { notificationsDivider } = useTheme(); + + const { + params: { threadId }, + } = useRoute<{ threadId: ThreadId }>(); + + const { messages, setLastReadMessageTime } = useThreadMessages({ + id: threadId, + refreshInterval, + }); + + useEffect( + function markAsRead() { + setLastReadMessageTime(new Date()); + }, + [setLastReadMessageTime] + ); + + if (!messages.length) { + return ; + } + + return ( +
+ {messages.map((message, idx) => ( + + + + + ))} +
+ ); +}; + +export default NotificationsListWrapper; diff --git a/packages/dialect-react-ui/components/WordcelNotifications/screens/Settings/index.tsx b/packages/dialect-react-ui/components/WordcelNotifications/screens/Settings/index.tsx new file mode 100644 index 00000000..bfda0d35 --- /dev/null +++ b/packages/dialect-react-ui/components/WordcelNotifications/screens/Settings/index.tsx @@ -0,0 +1,193 @@ +import clsx from 'clsx'; +import { A, P } from '../../../common/preflighted'; +import type { Channel } from '../../../common/types'; +import { Divider, Footer, Toggle, ValueRow } from '../../../common'; +import { useTheme } from '../../../common/providers/DialectThemeProvider'; +import type { NotificationType } from '../..'; +import { Thread, useNotificationSubscriptions } from '@dialectlabs/react-sdk'; +import Email from '../NewSettings/Email'; +import Sms from '../NewSettings/Sms'; +import Wallet from '../NewSettings/Wallet'; +import Telegram from '../NewSettings/Telegram'; +import { useCallback } from 'react'; +import { RouteName } from '../../constants'; +import { useRoute } from '../../../common/providers/Router'; + +interface RenderNotificationTypeParams { + name: string; + detail?: string; + id?: string; + enabled?: boolean; + type: 'local' | 'remote'; + onToggle?: (value: boolean) => void; +} + +interface SettingsProps { + channels: Channel[]; + notifications?: NotificationType[]; +} + +export const NotificationToggle = ({ + id, + name, + detail, + enabled = true, + onToggle, + type, +}: RenderNotificationTypeParams) => { + const { highlighted, colors, textStyles } = useTheme(); + return ( +
+
+ {name} + {type !== 'local' && ( + + )} +
+ + {detail && ( +

+ {detail} +

+ )} +
+ ); +}; + +function Settings({ + channels, + notifications: notificationsTypes, +}: SettingsProps) { + const { textStyles, xPaddedText } = useTheme(); + const { + subscriptions: notificationSubscriptions, + update: updateNotificationSubscription, + isUpdating, + errorUpdating: errorUpdatingNotificationSubscription, + errorFetching: errorFetchingNotificationsConfigs, + } = useNotificationSubscriptions(); + + const { navigate } = useRoute(); + + const error = + errorFetchingNotificationsConfigs || errorUpdatingNotificationSubscription; + + const showThread = useCallback( + (thread: Thread) => { + navigate(RouteName.Thread, { + params: { + threadId: thread.id, + }, + }); + }, + [navigate] + ); + + return ( + <> +
+ {channels.map((channelSlug) => { + let form; + if (channelSlug === 'web3') { + form = ; + } else if (channelSlug === 'email') { + form = ; + } else if (channelSlug === 'sms') { + form = ; + } else if (channelSlug === 'telegram') { + form = ; + } + return ( +
+ {form} +
+ ); + })} +
+
+ +
+
+ {error && !notificationsTypes ? ( + {error.message}

} + > + {''} +
+ ) : null} + {notificationSubscriptions.length || notificationsTypes?.length ? ( + <> + {notificationSubscriptions.map( + ({ notificationType, subscription }) => ( + { + if (isUpdating) return; + updateNotificationSubscription({ + notificationTypeId: notificationType.id, + config: { + ...subscription.config, + enabled: value, + }, + }); + }} + /> + ) + )} + {/* Render manually passed types in case api doesn't return anything */} + {!notificationSubscriptions.length && + notificationsTypes?.map((notificationType, idx) => ( + + ))} + + ) : null} +
+
+

+ By enabling notifications you agree to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + +

+
+
+
+
+ + ); +} + +export default Settings; diff --git a/packages/dialect-react-ui/components/common/ToastMessage.tsx b/packages/dialect-react-ui/components/common/ToastMessage.tsx index 90d6dddc..21e21e5c 100644 --- a/packages/dialect-react-ui/components/common/ToastMessage.tsx +++ b/packages/dialect-react-ui/components/common/ToastMessage.tsx @@ -54,7 +54,7 @@ function ToastMessage({
diff --git a/packages/dialect-react-ui/components/common/primitives.tsx b/packages/dialect-react-ui/components/common/primitives.tsx index 57410a1f..4b318558 100644 --- a/packages/dialect-react-ui/components/common/primitives.tsx +++ b/packages/dialect-react-ui/components/common/primitives.tsx @@ -39,11 +39,10 @@ export function Footer(): JSX.Element { const { colors, textStyles } = useTheme(); return ( -
-
+
@@ -54,16 +53,15 @@ export function Footer(): JSX.Element { rel="noreferrer" className="hover:dt-text-inherit" > - +
-
-
- + {/*
+ {UI_VERSION} / {SDK_VERSION} +
*/}
-
); } @@ -154,15 +152,15 @@ export function Toggle({ const size = toggleSize || 'M'; const translate = - size === 'M' ? 'dt-translate-x-[160%]' : 'dt-translate-x-3/4'; + size === 'M' ? 'dt-translate-x-[190%]' : 'dt-translate-x-3/4'; return (

To continue, please prove you own this wallet by signing a{' '} {hardwareWalletForced ? 'transaction' : 'message'}. It is free and does not involve the network. -
+
setHardwareWalletForced(next)} /> -
diff --git a/packages/dialect-react-ui/entities/wallet-states/SignMessageInfo.tsx b/packages/dialect-react-ui/entities/wallet-states/SignMessageInfo.tsx index 2886d7ec..1c2b9f3a 100644 --- a/packages/dialect-react-ui/entities/wallet-states/SignMessageInfo.tsx +++ b/packages/dialect-react-ui/entities/wallet-states/SignMessageInfo.tsx @@ -12,7 +12,7 @@ const SignMessageInfo = () => { > Waiting for your wallet -

+

To continue please prove you own a wallet by approving signing request. It is free and does not involve the network.

diff --git a/packages/dialect-react-ui/entities/wallet-states/SignTransactionInfo.tsx b/packages/dialect-react-ui/entities/wallet-states/SignTransactionInfo.tsx index 28f95771..26bd7054 100644 --- a/packages/dialect-react-ui/entities/wallet-states/SignTransactionInfo.tsx +++ b/packages/dialect-react-ui/entities/wallet-states/SignTransactionInfo.tsx @@ -12,7 +12,7 @@ const SignTransactionInfo = () => { > Waiting for your wallet -

+

To continue please prove you own this wallet by signing a transaction. This transaction will not be submited to the blockchain, and is free. diff --git a/packages/dialect-react-ui/package.json b/packages/dialect-react-ui/package.json index 8c190690..603f1ff5 100644 --- a/packages/dialect-react-ui/package.json +++ b/packages/dialect-react-ui/package.json @@ -1,7 +1,7 @@ { - "name": "@dialectlabs/react-ui", - "version": "1.0.0-beta.51", - "description": "Dialect's react UI components", + "name": "@wordcel/dialect-react", + "version": "1.0.0-beta.1.7", + "description": "Dialect's react UI components for use in Wordcel", "license": "MIT", "private": false, "sideEffects": false, @@ -56,8 +56,5 @@ "peerDependencies": { "react": ">=17" }, - "repository": { - "type": "git", - "url": "https://github.com/dialectlabs/react" - } + "repository": "https://github.com/wordcel/dialect-react" }