diff --git a/.node-version b/.node-version index c9758a53fae..13bbaed213c 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -v23.6.0 +v24.5.0 diff --git a/cspell.json b/cspell.json index 3467b4e789e..75b4729ba36 100644 --- a/cspell.json +++ b/cspell.json @@ -246,12 +246,16 @@ "words": [ "arbitrum", "boba", + "Bsky", "cashtags", "celo", + "deeplink", "endregion", + "firelfy", "linkedin", "luma", "muln", + "pathnames", "reposted", "reposts", "sepolia", @@ -260,6 +264,8 @@ "tweetnacl", "txid", "waitlist", + "WARPCAST", + "webm", "youtube" ] } diff --git a/packages/mask/background/database/persona/helper.ts b/packages/mask/background/database/persona/helper.ts index f048c8acbf4..ea890ff0628 100644 --- a/packages/mask/background/database/persona/helper.ts +++ b/packages/mask/background/database/persona/helper.ts @@ -180,31 +180,32 @@ export async function createPersonaByJsonWebKey(options: { export async function createProfileWithPersona( profileID: ProfileIdentifier, - data: LinkedProfileDetails, - keys: { + linkMeta: LinkedProfileDetails, + persona: { nickname?: string publicKey: EC_Public_JsonWebKey privateKey?: EC_Private_JsonWebKey localKey?: AESJsonWebKey mnemonic?: PersonaRecord['mnemonic'] }, + token?: string | null, ): Promise { - const ec_id = (await ECKeyIdentifier.fromJsonWebKey(keys.publicKey)).unwrap() + const ec_id = (await ECKeyIdentifier.fromJsonWebKey(persona.publicKey)).unwrap() const rec: PersonaRecord = { createdAt: new Date(), updatedAt: new Date(), identifier: ec_id, linkedProfiles: new Map(), - nickname: keys.nickname, - publicKey: keys.publicKey, - privateKey: keys.privateKey, - localKey: keys.localKey, - mnemonic: keys.mnemonic, + nickname: persona.nickname, + publicKey: persona.publicKey, + privateKey: persona.privateKey, + localKey: persona.localKey, + mnemonic: persona.mnemonic, hasLogout: false, } await consistentPersonaDBWriteAccess(async (t) => { await createOrUpdatePersonaDB(rec, { explicitUndefinedField: 'ignore', linkedProfiles: 'merge' }, t) - await attachProfileDB(profileID, ec_id, data, t) + await attachProfileDB(profileID, ec_id, linkMeta, { token }, t) }) } // #endregion diff --git a/packages/mask/background/database/persona/type.ts b/packages/mask/background/database/persona/type.ts index 0ff1cb99b50..86bbe0fa538 100644 --- a/packages/mask/background/database/persona/type.ts +++ b/packages/mask/background/database/persona/type.ts @@ -112,6 +112,7 @@ export interface ProfileRecord { nickname?: string localKey?: AESJsonWebKey linkedPersona?: PersonaIdentifier + token?: string createdAt: Date updatedAt: Date } diff --git a/packages/mask/background/database/persona/web.ts b/packages/mask/background/database/persona/web.ts index 58e32b7c2a5..af6c4598125 100644 --- a/packages/mask/background/database/persona/web.ts +++ b/packages/mask/background/database/persona/web.ts @@ -522,6 +522,7 @@ export async function attachProfileDB( identifier: ProfileIdentifier, attachTo: PersonaIdentifier, data: LinkedProfileDetails, + profileExtra?: { token?: string | null }, t?: FullPersonaDBTransaction<'readwrite'>, ): Promise { t = t || createTransaction(await db(), 'readwrite')('personas', 'profiles', 'relations') @@ -536,6 +537,12 @@ export async function attachProfileDB( await detachProfileDB(identifier, t) } + if (profileExtra?.token) { + profile.token = profileExtra.token + } else if (profileExtra && 'token' in profileExtra && !profileExtra.token) { + delete profile.token + } + profile.linkedPersona = attachTo persona.linkedProfiles.set(identifier, data) diff --git a/packages/mask/background/services/identity/profile/update.ts b/packages/mask/background/services/identity/profile/update.ts index c6c1a2416e8..0697a3c3e22 100644 --- a/packages/mask/background/services/identity/profile/update.ts +++ b/packages/mask/background/services/identity/profile/update.ts @@ -86,14 +86,15 @@ export async function resolveUnknownLegacyIdentity(identifier: ProfileIdentifier export async function attachProfile( source: ProfileIdentifier, target: ProfileIdentifier | PersonaIdentifier, - data: LinkedProfileDetails, + linkMeta: LinkedProfileDetails, + profileExtra?: { token?: string | null }, ): Promise { if (target instanceof ProfileIdentifier) { const profile = await queryProfileDB(target) if (!profile?.linkedPersona) throw new Error('target not found') target = profile.linkedPersona } - return attachProfileDB(source, target, data) + return attachProfileDB(source, target, linkMeta, profileExtra) } export function detachProfile(identifier: ProfileIdentifier): Promise { return detachProfileDB(identifier) @@ -101,7 +102,7 @@ export function detachProfile(identifier: ProfileIdentifier): Promise { /** * Set NextID profile to profileDB - * */ + */ export async function attachNextIDPersonaToProfile(item: ProfileInformationFromNextID, whoAmI: ECKeyIdentifier) { if (!item.linkedPersona) throw new Error('LinkedPersona Not Found') @@ -137,6 +138,7 @@ export async function attachNextIDPersonaToProfile(item: ProfileInformationFromN profileRecord.identifier, item.linkedPersona!, { connectionConfirmState: 'confirmed' }, + undefined, t, ) await createOrUpdateRelationDB( diff --git a/packages/mask/background/services/site-adaptors/connect.ts b/packages/mask/background/services/site-adaptors/connect.ts index f283040a226..7a6143eb503 100644 --- a/packages/mask/background/services/site-adaptors/connect.ts +++ b/packages/mask/background/services/site-adaptors/connect.ts @@ -1,5 +1,3 @@ -import { compact, first, sortBy } from 'lodash-es' -import stringify from 'json-stable-stringify' import { delay } from '@masknet/kit' import { type PersonaIdentifier, @@ -7,9 +5,11 @@ import { currentSetupGuideStatus, SetupGuideStep, } from '@masknet/shared-base' +import stringify from 'json-stable-stringify' +import { compact, first, sortBy } from 'lodash-es' +import type { Tabs } from 'webextension-polyfill' import { definedSiteAdaptors } from '../../../shared/site-adaptors/definitions.js' import type { SiteAdaptor } from '../../../shared/site-adaptors/types.js' -import type { Tabs } from 'webextension-polyfill' async function hasPermission(origin: string): Promise { return browser.permissions.contains({ diff --git a/packages/mask/content-script/components/InjectedComponents/SetupGuide/AccountConnectStatus.tsx b/packages/mask/content-script/components/InjectedComponents/SetupGuide/AccountConnectStatus.tsx index 77099aab16e..da570f1cb74 100644 --- a/packages/mask/content-script/components/InjectedComponents/SetupGuide/AccountConnectStatus.tsx +++ b/packages/mask/content-script/components/InjectedComponents/SetupGuide/AccountConnectStatus.tsx @@ -1,3 +1,4 @@ +import { Trans } from '@lingui/react/macro' import { Icons } from '@masknet/icons' import { BindingDialog, LoadingStatus, SOCIAL_MEDIA_ROUND_ICON_MAPPING, type BindingDialogProps } from '@masknet/shared' import { Sniffings, SOCIAL_MEDIA_NAME } from '@masknet/shared-base' @@ -6,7 +7,6 @@ import { Box, Button, Typography } from '@mui/material' import { memo } from 'react' import { activatedSiteAdaptorUI } from '../../../site-adaptor-infra/ui.js' import { SetupGuideContext } from './SetupGuideContext.js' -import { Trans } from '@lingui/react/macro' const useStyles = makeStyles()((theme) => { return { diff --git a/packages/mask/package.json b/packages/mask/package.json index f0d5c50bad4..2554b15da68 100644 --- a/packages/mask/package.json +++ b/packages/mask/package.json @@ -36,6 +36,7 @@ "@dimensiondev/mask-wallet-core": "0.1.0-20211013082857-eb62e5f", "@ethereumjs/util": "^9.0.3", "@hookform/resolvers": "^3.6.0", + "@lens-protocol/client": "0.0.0-canary-20250408064617", "@masknet/backup-format": "workspace:^", "@masknet/encryption": "workspace:^", "@masknet/flags": "workspace:^", diff --git a/packages/mask/popups/Popup.tsx b/packages/mask/popups/Popup.tsx index 62c4f4fb95c..f274bc3047a 100644 --- a/packages/mask/popups/Popup.tsx +++ b/packages/mask/popups/Popup.tsx @@ -1,10 +1,10 @@ import { PageUIProvider, PersonaContext } from '@masknet/shared' -import { jsxCompose, MaskMessages, PopupRoutes } from '@masknet/shared-base' +import { MaskMessages, PopupRoutes } from '@masknet/shared-base' import { PopupSnackbarProvider } from '@masknet/theme' import { EVMWeb3ContextProvider } from '@masknet/web3-hooks-base' import { ProviderType } from '@masknet/web3-shared-evm' import { Box } from '@mui/material' -import { Suspense, cloneElement, lazy, memo, useEffect, useMemo, useState, type ReactNode } from 'react' +import { Suspense, lazy, memo, useEffect, useMemo, useState, type ReactNode } from 'react' import { useIdleTimer } from 'react-idle-timer' import { createHashRouter, @@ -30,6 +30,7 @@ import { WalletFrame, walletRoutes } from './pages/Wallet/index.js' import { ContactsFrame, contactsRoutes } from './pages/Friends/index.js' import { ErrorBoundaryUIOfError } from '../../shared-base-ui/src/components/ErrorBoundary/ErrorBoundary.js' import { TraderFrame, traderRoutes } from './pages/Trader/index.js' +import { InteractionWalletContext } from './pages/Wallet/Interaction/InteractionContext.js' const personaInitialState = { queryOwnedPersonaInformation: Services.Identity.queryOwnedPersonaInformation, @@ -108,23 +109,31 @@ export default function Popups() { throttle: 10000, }) - return jsxCompose( - , - // eslint-disable-next-line react-compiler/react-compiler - , - , - , - , - , - )( - cloneElement, - <> - {/* https://github.com/TanStack/query/issues/5417 */} - {process.env.NODE_ENV === 'development' ? - - : null} - - , + return ( + + {/* eslint-disable-next-line react-compiler/react-compiler */} + + + + + + + {/* https://github.com/TanStack/query/issues/5417 */} + {process.env.NODE_ENV === 'development' ? + + : null} + + + + + + + + ) } diff --git a/packages/mask/popups/components/SocialAccounts/index.tsx b/packages/mask/popups/components/SocialAccounts/index.tsx index 6cd9b81a169..75ac963bd17 100644 --- a/packages/mask/popups/components/SocialAccounts/index.tsx +++ b/packages/mask/popups/components/SocialAccounts/index.tsx @@ -91,7 +91,7 @@ export const SocialAccounts = memo(function SocialAccounts( onAccountClick(account)}> diff --git a/packages/mask/popups/constants.ts b/packages/mask/popups/constants.ts index 10996ada2a2..53e301ff97d 100644 --- a/packages/mask/popups/constants.ts +++ b/packages/mask/popups/constants.ts @@ -3,7 +3,7 @@ import { EnhanceableSite } from '@masknet/shared-base' export const MATCH_PASSWORD_RE = /^(?=.{8,20}$)(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[^\dA-Za-z]).*/u export const MAX_FILE_SIZE = 10 * 1024 * 1024 -export const SOCIAL_MEDIA_ICON_FILTER_COLOR: Record = { +export const SOCIAL_MEDIA_ICON_FILTER_COLOR: Record = { [EnhanceableSite.Twitter]: 'drop-shadow(0px 6px 12px rgba(29, 161, 242, 0.20))', [EnhanceableSite.Facebook]: 'drop-shadow(0px 6px 12px rgba(60, 89, 155, 0.20))', [EnhanceableSite.Minds]: 'drop-shadow(0px 6px 12px rgba(33, 37, 42, 0.20))', @@ -11,4 +11,7 @@ export const SOCIAL_MEDIA_ICON_FILTER_COLOR: Record { const sites = await Service.SiteAdaptor.getSupportedSites({ isSocialNetwork: true }) return sites.map((x) => x.networkIdentifier as EnhanceableSite) diff --git a/packages/mask/popups/modals/ConnectSocialAccountModal/index.tsx b/packages/mask/popups/modals/ConnectSocialAccountModal/index.tsx index 29f6859406c..d8dc024aba0 100644 --- a/packages/mask/popups/modals/ConnectSocialAccountModal/index.tsx +++ b/packages/mask/popups/modals/ConnectSocialAccountModal/index.tsx @@ -1,23 +1,30 @@ -import { memo, useCallback } from 'react' -import { EMPTY_LIST, type EnhanceableSite } from '@masknet/shared-base' +import Services from '#services' +import { Trans } from '@lingui/react/macro' import { PersonaContext } from '@masknet/shared' +import { EMPTY_LIST, EnhanceableSite, PopupRoutes } from '@masknet/shared-base' import { Telemetry } from '@masknet/web3-telemetry' import { EventType } from '@masknet/web3-telemetry/types' +import { memo, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' import { requestPermissionFromExtensionPage } from '../../../shared-ui/index.js' -import { ActionModal, type ActionModalBaseProps } from '../../components/index.js' +import { EventMap } from '../../../shared/definitions/event.js' import { ConnectSocialAccounts } from '../../components/ConnectSocialAccounts/index.js' +import { ActionModal, type ActionModalBaseProps } from '../../components/index.js' import { useSupportSocialNetworks } from '../../hooks/index.js' -import Services from '#services' -import { EventMap } from '../../../shared/definitions/event.js' -import { Trans } from '@lingui/react/macro' export const ConnectSocialAccountModal = memo(function ConnectSocialAccountModal(props) { const { data: definedSocialNetworks = EMPTY_LIST } = useSupportSocialNetworks() const { currentPersona } = PersonaContext.useContainer() + const navigate = useNavigate() const handleConnect = useCallback( async (networkIdentifier: EnhanceableSite) => { + if (networkIdentifier === EnhanceableSite.Farcaster) { + return navigate(PopupRoutes.ConnectFirefly) + } else if (networkIdentifier === EnhanceableSite.Lens) { + return navigate(PopupRoutes.ConnectLens) + } if (!currentPersona) return if (!(await requestPermissionFromExtensionPage(networkIdentifier))) return await Services.SiteAdaptor.connectSite(currentPersona.identifier, networkIdentifier, undefined) diff --git a/packages/mask/popups/modals/SupportedSitesModal/index.tsx b/packages/mask/popups/modals/SupportedSitesModal/index.tsx index 34e8d50696b..a6e2ea13e9c 100644 --- a/packages/mask/popups/modals/SupportedSitesModal/index.tsx +++ b/packages/mask/popups/modals/SupportedSitesModal/index.tsx @@ -75,21 +75,19 @@ export const SupportedSitesModal = memo(function Supported {!isPending && data ? data.map((x) => { - const Icon = SOCIAL_MEDIA_ROUND_ICON_MAPPING[x.networkIdentifier] - + const networkIdentifier = x.networkIdentifier as EnhanceableSite + const Icon = SOCIAL_MEDIA_ROUND_ICON_MAPPING[networkIdentifier] return ( - handleSwitch({ ...x, networkIdentifier: x.networkIdentifier as EnhanceableSite }) - }> + onClick={() => handleSwitch({ ...x, networkIdentifier })}> {Icon ? (function Supported : null} diff --git a/packages/mask/popups/pages/Personas/AccountDetail/UI.tsx b/packages/mask/popups/pages/Personas/AccountDetail/UI.tsx index 4b4f0ca7c9c..ff1c3df324d 100644 --- a/packages/mask/popups/pages/Personas/AccountDetail/UI.tsx +++ b/packages/mask/popups/pages/Personas/AccountDetail/UI.tsx @@ -1,5 +1,5 @@ import { Trans } from '@lingui/react/macro' -import type { BindingProof, ProfileAccount } from '@masknet/shared-base' +import type { BindingProof, EnhanceableSite, ProfileAccount } from '@masknet/shared-base' import { makeStyles } from '@masknet/theme' import { Box, Button, Typography } from '@mui/material' import { memo, useCallback } from 'react' @@ -52,7 +52,7 @@ export const AccountDetailUI = memo(function AccountDetail diff --git a/packages/mask/popups/pages/Personas/ConnectFirefly/bindFireflySession.ts b/packages/mask/popups/pages/Personas/ConnectFirefly/bindFireflySession.ts new file mode 100644 index 00000000000..4dc6b2f6d5b --- /dev/null +++ b/packages/mask/popups/pages/Personas/ConnectFirefly/bindFireflySession.ts @@ -0,0 +1,91 @@ +import { FireflyAlreadyBoundError } from '@masknet/shared-base' +import { + FarcasterSession, + FIREFLY_ROOT_URL, + fireflySessionHolder, + patchFarcasterSessionRequired, + resolveFireflyResponseData, + type LensSession, +} from '@masknet/web3-providers' +import { SessionType, type FireflyConfigAPI, type Session } from '@masknet/web3-providers/types' +import urlcat from 'urlcat' + +async function bindFarcasterSessionToFirefly(session: FarcasterSession, signal?: AbortSignal) { + const isGrantByPermission = FarcasterSession.isGrantByPermission(session, true) + const isRelayService = FarcasterSession.isRelayService(session) + + if (!isGrantByPermission && !isRelayService) + throw new Error( + '[bindFarcasterSessionToFirefly] Only grant-by-permission or relay service sessions are allowed.', + ) + + const response = await fireflySessionHolder.fetch( + urlcat(FIREFLY_ROOT_URL, '/v3/user/bindFarcaster'), + { + method: 'POST', + body: JSON.stringify({ + token: isGrantByPermission ? session.signerRequestToken : undefined, + channelToken: isRelayService ? session.channelToken : undefined, + isForce: false, + }), + signal, + }, + ) + + if (response.error?.some((x) => x.includes('Farcaster binding timed out'))) { + throw new Error('Bind Farcaster account to Firefly timeout.') + } + + // If the farcaster is already bound to another account, throw an error. + if ( + isRelayService && + response.error?.some((x) => x.includes('This farcaster already bound to the other account')) + ) { + throw new FireflyAlreadyBoundError('Farcaster') + } + + const data = resolveFireflyResponseData(response) + patchFarcasterSessionRequired(session, data.fid, data.farcaster_signer_private_key) + return data +} + +async function bindLensToFirefly(session: LensSession, signal?: AbortSignal) { + const response = await fireflySessionHolder.fetch( + urlcat(FIREFLY_ROOT_URL, '/v3/user/bindLens'), + { + method: 'POST', + body: JSON.stringify({ + accessToken: session.token, + isForce: false, + version: 'v3', + }), + signal, + }, + ) + + if (response.error?.some((x) => x.includes('This wallet already bound to the other account'))) { + throw new FireflyAlreadyBoundError('Lens') + } + + const data = resolveFireflyResponseData(response) + return data +} + +/** + * Bind a lens or farcaster session to the currently logged-in Firefly session. + * @param session + * @param signal + * @returns + */ +export async function bindFireflySession(session: Session, signal?: AbortSignal) { + // Ensure that the Firefly session is resumed before calling this function. + fireflySessionHolder.assertSession() + if (session.type === SessionType.Farcaster) { + return bindFarcasterSessionToFirefly(session as FarcasterSession, signal) + } else if (session.type === SessionType.Lens) { + return bindLensToFirefly(session as LensSession, signal) + } else if (session.type === SessionType.Firefly) { + throw new Error('Not allowed') + } + throw new Error(`Unknown session type: ${session.type}`) +} diff --git a/packages/mask/popups/pages/Personas/ConnectFirefly/bindOrRestoreFireflySession.ts b/packages/mask/popups/pages/Personas/ConnectFirefly/bindOrRestoreFireflySession.ts new file mode 100644 index 00000000000..62acaad4f26 --- /dev/null +++ b/packages/mask/popups/pages/Personas/ConnectFirefly/bindOrRestoreFireflySession.ts @@ -0,0 +1,22 @@ +import { fireflySessionHolder } from '@masknet/web3-providers' +import type { Session } from '@masknet/web3-providers/types' +import { bindFireflySession } from './bindFireflySession' +import { restoreFireflySession } from './restoreFireflySession' + +export async function bindOrRestoreFireflySession(session: Session, signal?: AbortSignal) { + try { + if (fireflySessionHolder.session) { + await bindFireflySession(session, signal) + + // this will return the existing session + return fireflySessionHolder.assertSession( + '[bindOrRestoreFireflySession] Failed to bind farcaster session with firefly.', + ) + } else { + throw new Error('[bindOrRestoreFireflySession] Firefly session is not available.') + } + } catch (error) { + // this will create a new session + return restoreFireflySession(session, signal) + } +} diff --git a/packages/mask/popups/pages/Personas/ConnectFirefly/createAccountByRelayService.ts b/packages/mask/popups/pages/Personas/ConnectFirefly/createAccountByRelayService.ts new file mode 100644 index 00000000000..75eabc33888 --- /dev/null +++ b/packages/mask/popups/pages/Personas/ConnectFirefly/createAccountByRelayService.ts @@ -0,0 +1,69 @@ +import { fetchJSON } from '@masknet/web3-providers/helpers' +import { FarcasterSession, getFarcasterProfileById, type FireflyAccount } from '@masknet/web3-providers' +import urlcat from 'urlcat' +import { bindOrRestoreFireflySession } from './bindOrRestoreFireflySession' + +const FARCASTER_REPLY_URL = 'https://relay.farcaster.xyz' +const NOT_DEPEND_SECRET = '[TO_BE_REPLACED_LATER]' + +interface FarcasterReplyResponse { + channelToken: string + url: string + // the same as url + connectUri: string + // cspell: disable-next-line + /** @example dpO7VRkrPcwyLhyFZ */ + nonce: string +} + +async function createSession(signal?: AbortSignal) { + const url = urlcat(FARCASTER_REPLY_URL, '/v1/channel') + const response = await fetchJSON(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + // cspell: disable-next-line + siweUri: 'https://firefly.social', + domain: 'firefly.social', + }), + signal, + }) + + const now = Date.now() + const farcasterSession = new FarcasterSession( + NOT_DEPEND_SECRET, + NOT_DEPEND_SECRET, + now, + now, + '', + response.channelToken, + ) + + return { + deeplink: response.connectUri, + session: farcasterSession, + } +} + +export async function createAccountByRelayService(callback?: (url: string) => void, signal?: AbortSignal) { + const { deeplink, session } = await createSession(signal) + + // present QR code to the user or open the link in a new tab + callback?.(deeplink) + + // polling for the session to be ready + const fireflySession = await bindOrRestoreFireflySession(session, signal) + console.log('fireflySession', fireflySession) + + // profile id is available after the session is ready + const profile = await getFarcasterProfileById(session.profileId) + + return { + origin: 'sync', + session, + profile, + fireflySession, + } satisfies FireflyAccount +} diff --git a/packages/mask/popups/pages/Personas/ConnectFirefly/index.tsx b/packages/mask/popups/pages/Personas/ConnectFirefly/index.tsx new file mode 100644 index 00000000000..74bffebe405 --- /dev/null +++ b/packages/mask/popups/pages/Personas/ConnectFirefly/index.tsx @@ -0,0 +1,128 @@ +import { Trans, useLingui } from '@lingui/react/macro' +import { PersonaContext, PopupHomeTabType } from '@masknet/shared' +import { + AbortError, + EnhanceableSite, + FarcasterPatchSignerError, + FireflyAlreadyBoundError, + FireflyBindTimeoutError, + PopupRoutes, + ProfileIdentifier, + TimeoutError, +} from '@masknet/shared-base' +import { LoadingBase, makeStyles, usePopupCustomSnackbar } from '@masknet/theme' +import { addAccount, type AccountOptions, type FireflyAccount } from '@masknet/web3-providers' +import { Social } from '@masknet/web3-providers/types' +import { Box } from '@mui/material' +import { memo, useCallback, useState } from 'react' +import { QRCode } from 'react-qrcode-logo' +import { useNavigate } from 'react-router-dom' +import { useMount } from 'react-use' +import urlcat from 'urlcat' +import { useTitle } from '../../../hooks/index.js' +import { createAccountByRelayService } from './createAccountByRelayService.js' +import Services from '#services' + +const useStyles = makeStyles()({ + container: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + position: 'relative', + }, + loading: { + backgroundColor: 'rgba(255,255,255,0.5)', + position: 'absolute', + inset: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, +}) + +function useLogin() { + const { showSnackbar } = usePopupCustomSnackbar() + return useCallback( + async function login(createAccount: () => Promise, options?: Omit) { + try { + const account = await createAccount() + + const done = await addAccount(account, options) + if (done) showSnackbar(Your {Social.Source.Farcaster} account is now connected.) + } catch (error) { + // skip if the error is abort error + if (AbortError.is(error)) return + + // if login timed out, let the user refresh the QR code + if (error instanceof TimeoutError || error instanceof FireflyBindTimeoutError) { + showSnackbar(This QR code is longer valid. Please scan a new one to continue.) + return + } + + // failed to patch the signer + if (error instanceof FarcasterPatchSignerError) throw error + + // if any error occurs, close the modal + // by this we don't need to do error handling in UI part. + // if the account is already bound to another account, show a warning message + if (error instanceof FireflyAlreadyBoundError) { + showSnackbar( + + The account you are trying to log in with is already linked to a different Firefly account. + , + ) + return + } + + throw error + } + }, + [showSnackbar], + ) +} + +export const Component = memo(function ConnectFireflyPage() { + const { t } = useLingui() + const { classes } = useStyles() + const [url, setUrl] = useState('') + + const navigate = useNavigate() + const login = useLogin() + const { currentPersona } = PersonaContext.useContainer() + + useMount(async () => { + await login(async () => { + const account = await createAccountByRelayService((url) => { + setUrl(url) + }) + if (currentPersona) { + await Services.Identity.attachProfile( + ProfileIdentifier.of(EnhanceableSite.Farcaster, account.session.profileId).unwrap(), + currentPersona.identifier, + { connectionConfirmState: 'pending' }, + { token: account.session.token }, + ) + } + return account + }) + }) + + const handleBack = useCallback(() => { + navigate(urlcat(PopupRoutes.Personas, { tab: PopupHomeTabType.ConnectedWallets }), { + replace: true, + }) + }, []) + + useTitle(t`Connect Firefly`, handleBack) + + return ( + + + {!url ? +
+ +
+ : null} +
+ ) +}) diff --git a/packages/mask/popups/pages/Personas/ConnectFirefly/restoreFireflySession.ts b/packages/mask/popups/pages/Personas/ConnectFirefly/restoreFireflySession.ts new file mode 100644 index 00000000000..941c253243c --- /dev/null +++ b/packages/mask/popups/pages/Personas/ConnectFirefly/restoreFireflySession.ts @@ -0,0 +1,55 @@ +import { + FarcasterSession, + FIREFLY_ROOT_URL, + FireflySession, + patchFarcasterSessionRequired, + resolveFireflyResponseData, +} from '@masknet/web3-providers' +import type { FireflyConfigAPI, Session } from '@masknet/web3-providers/types' +import urlcat from 'urlcat' + +export async function restoreFireflySessionFromFarcaster(session: FarcasterSession, signal?: AbortSignal) { + const isGrantByPermission = FarcasterSession.isGrantByPermission(session, true) + const isRelayService = FarcasterSession.isRelayService(session) + if (!isGrantByPermission && !isRelayService) + throw new Error('[restoreFireflySession] Only grant-by-permission or relay service sessions are allowed.') + + const url = urlcat(FIREFLY_ROOT_URL, '/v3/auth/farcaster/login') + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + token: isGrantByPermission ? session.signerRequestToken : undefined, + channelToken: isRelayService ? session.channelToken : undefined, + }), + signal, + }) + + const json: FireflyConfigAPI.LoginResponse = await response.json() + if (!response.ok && json.error?.includes('Farcaster login timed out')) + throw new Error('[restoreFireflySession] Farcaster login timed out.') + + const data = resolveFireflyResponseData(json) + if (data.fid && data.accountId && data.accessToken) { + patchFarcasterSessionRequired(session as FarcasterSession, data.fid, data.farcaster_signer_private_key) + return new FireflySession(data.uid ?? data.accountId, data.accessToken, session, null, false, data) + } + throw new Error('[restoreFireflySession] Failed to restore firefly session.') +} + +/** + * Restore firefly session from a lens or farcaster session. + * @param session + * @param signal + * @returns + */ +export function restoreFireflySession(session: Session, signal?: AbortSignal) { + if (session.type === 'Farcaster') { + return restoreFireflySessionFromFarcaster(session as FarcasterSession, signal) + } else if (session.type === 'Firefly') { + throw new Error('[restoreFireflySession] Firefly session is not allowed.') + } + throw new Error(`[restoreFireflySession] Unknown session type: ${session.type}`) +} diff --git a/packages/mask/popups/pages/Personas/ConnectLens/createLensSession.ts b/packages/mask/popups/pages/Personas/ConnectLens/createLensSession.ts new file mode 100644 index 00000000000..c0d90c094cc --- /dev/null +++ b/packages/mask/popups/pages/Personas/ConnectLens/createLensSession.ts @@ -0,0 +1,27 @@ +import type { SessionClient } from '@lens-protocol/client' +import { LensSession } from '@masknet/web3-providers' +import { ZERO_ADDRESS } from '@masknet/web3-shared-evm' + +const SEVEN_DAYS = 1000 * 60 * 60 * 24 * 7 + +export function createLensSession(profileId: string, sessionClient: SessionClient) { + const now = Date.now() + const credentialsRe = sessionClient.getCredentials() + if (credentialsRe.isErr()) { + throw new Error(credentialsRe.error.message ?? 'Failed to get lens credentials') + } + const credentials = credentialsRe.value + if (!credentials) throw new Error('Failed to get lens credentials') + + const authenticatedRes = sessionClient.getAuthenticatedUser() + if (!authenticatedRes.isOk()) { + throw new Error(authenticatedRes.error.message) + } + const authenticated = authenticatedRes.value + + const address = authenticated.address + + const { accessToken, refreshToken } = credentials + + return new LensSession(profileId, accessToken, now, now + SEVEN_DAYS, refreshToken, address ?? ZERO_ADDRESS) +} diff --git a/packages/mask/popups/pages/Personas/ConnectLens/index.tsx b/packages/mask/popups/pages/Personas/ConnectLens/index.tsx new file mode 100644 index 00000000000..49c63ad05b3 --- /dev/null +++ b/packages/mask/popups/pages/Personas/ConnectLens/index.tsx @@ -0,0 +1,204 @@ +import type { AccountAvailable, EvmAddress } from '@lens-protocol/client' +import { Image, PersonaContext, useAvailableLensAccounts, useLensClient, useMyLensAccount } from '@masknet/shared' +import { EMPTY_LIST, EnhanceableSite, ProfileIdentifier } from '@masknet/shared-base' +import { LoadingBase, makeStyles } from '@masknet/theme' +import { LensV3 } from '@masknet/web3-providers' +import { isSameAddress } from '@masknet/web3-shared-base' +import { + List, + ListItemButton, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + Radio, + Typography, +} from '@mui/material' +import { first } from 'lodash-es' +import { memo, useState } from 'react' +import { Icons } from '@masknet/icons' +import { formatEthereumAddress } from '@masknet/web3-shared-evm' +import { LoadingButton } from '@mui/lab' +import { useAsyncFn } from 'react-use' +import { Trans } from '@lingui/react/macro' +import { createLensSession } from './createLensSession' +import Services from '#services' + +const useStyles = makeStyles()((theme) => ({ + container: { + minHeight: 0, + display: 'flex', + flexDirection: 'column', + }, + loading: { + height: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, + list: { + minHeight: 0, + overflow: 'auto', + marginBottom: theme.spacing(1.5), + scrollbarWidth: 'none', + '::-webkit-scrollbar': { + backgroundColor: 'transparent', + width: 18, + }, + '::-webkit-scrollbar-thumb': { + borderRadius: '20px', + width: 5, + border: '7px solid rgba(0, 0, 0, 0)', + backgroundColor: theme.palette.maskColor.secondaryLine, + backgroundClip: 'padding-box', + }, + }, + avatar: { + borderRadius: 99, + overflow: 'hidden', + }, + primary: { + color: theme.palette.maskColor.main, + fontWeight: 700, + lineHeight: '18px', + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + paddingRight: 50, + }, + second: { + display: 'flex', + columnGap: 4, + alignItems: 'center', + }, + address: { + fontWeight: 700, + fontSize: 14, + lineHeight: '18px', + color: theme.palette.maskColor.second, + }, + managedTag: { + background: theme.palette.maskColor.third, + color: theme.palette.maskColor.bottom, + fontSize: 12, + padding: theme.spacing(0.5), + borderRadius: 4, + lineHeight: '12px', + }, + item: { + padding: theme.spacing(1.5), + borderRadius: 8, + }, + disabled: { + opacity: 0.5, + cursor: 'not-allowed', + }, + listItemText: { + margin: 0, + }, + buttonWrap: { + padding: theme.spacing(1.5), + }, +})) + +export const Component = memo(function ConnectLensView() { + const { classes, cx } = useStyles() + const { data: accounts = EMPTY_LIST, isLoading } = useAvailableLensAccounts() + const myLensAccount = useMyLensAccount() + const myLensAddress = myLensAccount?.account.address + const currentAccount = accounts?.find((p) => isSameAddress(p.account.address, myLensAddress)) || first(accounts) + const lensClient = useLensClient() + const [selected = currentAccount, setSelected] = useState() + const selectedAccountId = selected?.account.username?.id + const { currentPersona } = PersonaContext.useContainer() + + const [{ loading }, connect] = useAsyncFn(async (account: AccountAvailable) => { + const client = await lensClient.login(account as AccountAvailable) + const profileId = account.account.address + const session = createLensSession(profileId, client) + if (currentPersona) { + await Services.Identity.attachProfile( + ProfileIdentifier.of(EnhanceableSite.Lens, profileId).unwrap(), + currentPersona.identifier, + { connectionConfirmState: 'pending' }, + { token: session.token }, + ) + } + }, []) + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+ + {accounts?.map((accountItem) => { + const { account, __typename: accountType } = accountItem + const avatar = LensV3.getAccountAvatar(account) + const name = account.metadata?.name || account.username?.localName + const ownerAddress: EvmAddress = account.username?.ownedBy as EvmAddress + const accountId = account.username?.id + const disabled = accountId === selectedAccountId + return ( + { + if (disabled) return + setSelected(accountItem) + }}> + + {avatar ? + } + /> + : } + + + + {formatEthereumAddress(ownerAddress, 4)} + + {accountType === 'AccountManaged' ? + + Managed + + : null} +
+ } + /> + + + + + ) + })} +
+
+ { + if (!selected) return + connect(selected) + }}> + Connect + +
+ + ) +}) diff --git a/packages/mask/popups/pages/Personas/ConnectWallet/index.tsx b/packages/mask/popups/pages/Personas/ConnectWallet/index.tsx index 29a35cf0108..7a80e319974 100644 --- a/packages/mask/popups/pages/Personas/ConnectWallet/index.tsx +++ b/packages/mask/popups/pages/Personas/ConnectWallet/index.tsx @@ -1,22 +1,16 @@ -import { memo, useCallback } from 'react' -import urlcat from 'urlcat' -import { useAsync, useAsyncFn } from 'react-use' -import { useNavigate } from 'react-router-dom' -import { Avatar, Box, Button, Link, Typography } from '@mui/material' -import { ActionButton, makeStyles, usePopupCustomSnackbar } from '@masknet/theme' +import { Icons } from '@masknet/icons' +import { FormattedAddress, PersonaContext, PopupHomeTabType, WalletIcon } from '@masknet/shared' import { - NextIDPlatform, - type NetworkPluginID, + MaskMessages, NextIDAction, + NextIDPlatform, + PopupModalRoutes, + PopupRoutes, SignType, + type NetworkPluginID, type NextIDPayload, - PopupRoutes, - PopupModalRoutes, - MaskMessages, } from '@masknet/shared-base' -import { formatDomainName, formatEthereumAddress, ProviderType } from '@masknet/web3-shared-evm' -import { FormattedAddress, PersonaContext, PopupHomeTabType, WalletIcon } from '@masknet/shared' -import { EVMExplorerResolver, NextIDProof, EVMProviderResolver, EVMWeb3 } from '@masknet/web3-providers' +import { ActionButton, makeStyles, usePopupCustomSnackbar } from '@masknet/theme' import { useChainContext, useNetworkContext, @@ -24,15 +18,21 @@ import { useReverseAddress, useWallets, } from '@masknet/web3-hooks-base' +import { EVMExplorerResolver, EVMProviderResolver, EVMWeb3, NextIDProof } from '@masknet/web3-providers' import { isSameAddress } from '@masknet/web3-shared-base' -import { Icons } from '@masknet/icons' +import { formatDomainName, formatEthereumAddress, ProviderType } from '@masknet/web3-shared-evm' +import { Avatar, Box, Button, Link, Typography } from '@mui/material' +import { memo, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAsync, useAsyncFn } from 'react-use' +import urlcat from 'urlcat' -import { useTitle } from '../../../hooks/index.js' -import { BottomController } from '../../../components/BottomController/index.js' -import { LoadingMask } from '../../../components/LoadingMask/index.js' import Services from '#services' -import { useModalNavigate } from '../../../components/index.js' import { Trans, useLingui } from '@lingui/react/macro' +import { BottomController } from '../../../components/BottomController/index.js' +import { useModalNavigate } from '../../../components/index.js' +import { LoadingMask } from '../../../components/LoadingMask/index.js' +import { useTitle } from '../../../hooks/index.js' const useStyles = makeStyles()((theme) => ({ provider: { diff --git a/packages/mask/popups/pages/Personas/components/AccountAvatar/index.tsx b/packages/mask/popups/pages/Personas/components/AccountAvatar/index.tsx index 2f7c314496c..5e9d8b88170 100644 --- a/packages/mask/popups/pages/Personas/components/AccountAvatar/index.tsx +++ b/packages/mask/popups/pages/Personas/components/AccountAvatar/index.tsx @@ -42,7 +42,7 @@ const useStyles = makeStyles()((theme) => ({ export interface AccountAvatar extends withClasses<'avatar' | 'container'> { avatar?: string | null - network?: string + network?: EnhanceableSite isValid?: boolean size?: number } diff --git a/packages/mask/popups/pages/Personas/index.tsx b/packages/mask/popups/pages/Personas/index.tsx index d4564687f63..72c9170c8ef 100644 --- a/packages/mask/popups/pages/Personas/index.tsx +++ b/packages/mask/popups/pages/Personas/index.tsx @@ -1,11 +1,12 @@ -import { memo, useEffect } from 'react' -import { useMount, useAsync } from 'react-use' -import { Navigate, Outlet, useNavigate, useSearchParams, type RouteObject } from 'react-router-dom' +import Services from '#services' import { CrossIsolationMessages, PopupModalRoutes, PopupRoutes, relativeRouteOf } from '@masknet/shared-base' -import { PersonaHeader } from './components/PersonaHeader/index.js' import { EVMWeb3ContextProvider } from '@masknet/web3-hooks-base' +import { memo, useEffect } from 'react' +import { Navigate, Outlet, useNavigate, useSearchParams, type RouteObject } from 'react-router-dom' +import { useAsync, useMount } from 'react-use' import { useModalNavigate } from '../../components/index.js' -import Services from '#services' +import { WalletGuard } from '../Wallet/WalletGuard/index.js' +import { PersonaHeader } from './components/PersonaHeader/index.js' const r = relativeRouteOf(PopupRoutes.Personas) export const personaRoute: RouteObject[] = [ @@ -17,6 +18,16 @@ export const personaRoute: RouteObject[] = [ { path: r(PopupRoutes.WalletConnect), lazy: () => import('./WalletConnect/index.js') }, { path: r(PopupRoutes.ExportPrivateKey), lazy: () => import('./ExportPrivateKey/index.js') }, { path: r(PopupRoutes.PersonaAvatarSetting), lazy: () => import('./PersonaAvatarSetting/index.js') }, + { path: r(PopupRoutes.ConnectFirefly), lazy: () => import('./ConnectFirefly/index.js') }, + { + element: , + children: [ + { + path: r(PopupRoutes.ConnectLens), + lazy: () => import('./ConnectLens/index.js'), + }, + ], + }, { path: '*', element: }, ] diff --git a/packages/mask/popups/pages/Wallet/Interaction/InteractionContext.ts b/packages/mask/popups/pages/Wallet/Interaction/InteractionContext.ts index acb61fed71d..32d165b4b81 100644 --- a/packages/mask/popups/pages/Wallet/Interaction/InteractionContext.ts +++ b/packages/mask/popups/pages/Wallet/Interaction/InteractionContext.ts @@ -10,7 +10,7 @@ import { createContainer, useRenderPhraseCallbackOnDepsChange } from '@masknet/s export const { Provider: InteractionWalletContext, useContainer: useInteractionWalletContext } = createContainer( function () { const wallet = useWallet() - const [interactionWallet, setInteractionWallet] = useState() + const [interactionWallet, setInteractionWallet] = useState() function useInteractionWallet(currentInteractingWallet: string | undefined) { useRenderPhraseCallbackOnDepsChange(() => { diff --git a/packages/mask/popups/pages/Wallet/WalletGuard/index.tsx b/packages/mask/popups/pages/Wallet/WalletGuard/index.tsx index 91b5f3acb8e..9ce58a7cef5 100644 --- a/packages/mask/popups/pages/Wallet/WalletGuard/index.tsx +++ b/packages/mask/popups/pages/Wallet/WalletGuard/index.tsx @@ -9,9 +9,13 @@ import { WalletHeader } from '../components/WalletHeader/index.js' import { useWalletLockStatus } from '../hooks/index.js' import { useMessageGuard } from './useMessageGuard.js' import { usePaymentPasswordGuard } from './usePaymentPasswordGuard.js' -import { InteractionWalletContext, useInteractionWalletContext } from '../Interaction/InteractionContext.js' +import { useInteractionWalletContext } from '../Interaction/InteractionContext.js' -export const WalletGuard = memo(function WalletGuard() { +interface Props { + disableHeader?: boolean +} + +export const WalletGuard = memo(function WalletGuard({ disableHeader }: Props) { const wallets = useWallets() const location = useLocation() const [params] = useSearchParams() @@ -19,6 +23,7 @@ export const WalletGuard = memo(function WalletGuard() { const hitPaymentPasswordGuard = usePaymentPasswordGuard() const hitMessageGuard = useMessageGuard() + const { interactionWallet } = useInteractionWalletContext() if (!wallets.length) { return ( @@ -44,18 +49,11 @@ export const WalletGuard = memo(function WalletGuard() { if (hitMessageGuard) return return ( - - - - ) -}) - -function WalletGuardContent() { - const { interactionWallet } = useInteractionWalletContext() - return ( - - + + {!disableHeader ? + + : null} ) -} +}) diff --git a/packages/mask/popups/pages/Wallet/components/WalletHeader/UI.tsx b/packages/mask/popups/pages/Wallet/components/WalletHeader/UI.tsx index 30fa0fe75c6..75695766587 100644 --- a/packages/mask/popups/pages/Wallet/components/WalletHeader/UI.tsx +++ b/packages/mask/popups/pages/Wallet/components/WalletHeader/UI.tsx @@ -169,11 +169,7 @@ export const WalletHeaderUI = memo(function WalletHeaderUI( {!disabled && !wallet.owner ? - + : null}
{isPending ? null : ( diff --git a/packages/mask/popups/pages/Wallet/components/WalletHeader/index.tsx b/packages/mask/popups/pages/Wallet/components/WalletHeader/index.tsx index ae00fc76456..2ee67a9e237 100644 --- a/packages/mask/popups/pages/Wallet/components/WalletHeader/index.tsx +++ b/packages/mask/popups/pages/Wallet/components/WalletHeader/index.tsx @@ -14,7 +14,10 @@ const CUSTOM_HEADER_PATTERNS = [ PopupRoutes.ExportWalletPrivateKey, ] -export const WalletHeader = memo(function WalletHeader() { +interface Props { + isWallet?: boolean +} +export const WalletHeader = memo(function WalletHeader({ isWallet }: Props) { const modalNavigate = useModalNavigate() const { chainId } = useChainContext() const location = useLocation() @@ -29,7 +32,7 @@ export const WalletHeader = memo(function WalletHeader() { const currentNetwork = useNetwork(NetworkPluginID.PLUGIN_EVM, chainId) const matchResetWallet = useMatch(PopupRoutes.ResetWallet) - const matchWallet = PopupRoutes.Wallet === location.pathname + const matchWallet = isWallet || PopupRoutes.Wallet === location.pathname const customHeader = CUSTOM_HEADER_PATTERNS.some((pattern) => matchPath(pattern, location.pathname)) const matchContractInteraction = useMatch(PopupRoutes.ContractInteraction) diff --git a/packages/mask/shared-ui/locale/en-US.json b/packages/mask/shared-ui/locale/en-US.json index 0da14354332..fcd4c6fc9d8 100644 --- a/packages/mask/shared-ui/locale/en-US.json +++ b/packages/mask/shared-ui/locale/en-US.json @@ -359,6 +359,7 @@ "LWJCIn": ["Connected sites"], "LcET2C": ["Privacy Policy"], "LcZh+r": ["Unsupported data backup"], + "Lh3tjm": ["Connect Firefly"], "LkYKvc": ["Please select the correct words in the correct order."], "LqWHk1": ["Hide ", ["0"]], "Lt0Hf0": [ @@ -429,6 +430,7 @@ "QXDgyQ": [ "Please write down the following words in correct order. Keep it safe and do not share with anyone!" ], + "QbIq7s": ["This QR code is longer valid. Please scan a new one to continue."], "Qbo7Ev": ["Write down mnemonic words"], "QcVZFH": ["Step ", ["step"], "/", ["totalSteps"]], "QrHM/A": ["Backup downloaded and merged to local successfully."], @@ -708,6 +710,7 @@ "nUWEsV": ["Max fee is higher than necessary"], "nUeoRs": ["Signing Message (Text)"], "nVT2pJ": ["realMaskNetwork"], + "nWd3Q/": ["The account you are trying to log in with is already linked to a different Firefly account."], "ndRqFD": ["Chain ID"], "ngWO+e": ["No local key"], "npsHM5": ["Back Up to Google Drive"], @@ -732,6 +735,7 @@ "ou+PEI": ["Profile Photo"], "p2/GCq": ["Confirm Password"], "p2xE4C": ["Overwrite current backup"], + "p70+lb": ["Your ", ["0"], " account is now connected."], "p8Aea2": ["The persona name already exists."], "pGElS5": ["Mnemonic"], "pHqZUU": ["You used <0>", ["0"], " for the last cloud backup."], diff --git a/packages/mask/shared-ui/locale/en-US.po b/packages/mask/shared-ui/locale/en-US.po index f5f821c3ca0..3a882311b9f 100644 --- a/packages/mask/shared-ui/locale/en-US.po +++ b/packages/mask/shared-ui/locale/en-US.po @@ -659,6 +659,7 @@ msgstr "" #: popups/components/ConnectedWallet/index.tsx #: popups/components/SocialAccounts/index.tsx #: popups/modals/SelectProviderModal/index.tsx +#: popups/pages/Personas/ConnectLens/index.tsx msgid "Connect" msgstr "" @@ -666,6 +667,10 @@ msgstr "" msgid "Connect and switch between your wallets." msgstr "" +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "Connect Firefly" +msgstr "" + #: popups/modals/SelectProviderModal/index.tsx msgid "Connect Mask Network Account using your wallet." msgstr "" @@ -2786,6 +2791,10 @@ msgstr "" msgid "Text copied!" msgstr "" +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "The account you are trying to log in with is already linked to a different Firefly account." +msgstr "" + #: popups/components/UnlockERC20Token/index.tsx msgid "The approval for this contract will be revoked in case of the amount is 0." msgstr "" @@ -2946,6 +2955,10 @@ msgstr "" msgid "This network name already exists" msgstr "" +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "This QR code is longer valid. Please scan a new one to continue." +msgstr "" + #: dashboard/pages/SetupPersona/Mnemonic/ComponentToPrint.tsx msgid "This QR includes your identity, please keep it safely." msgstr "" @@ -3306,6 +3319,11 @@ msgstr "" #~ msgid "You used <0>{0} for the last cloud backup." #~ msgstr "" +#. placeholder {0}: Social.Source.Farcaster +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "Your {0} account is now connected." +msgstr "" + #: popups/components/SignRequestInfo/index.tsx msgid "Your connection to this site is not encrypted which can be modified by a hostile third party, we strongly suggest you reject this request." msgstr "" diff --git a/packages/mask/shared-ui/locale/ja-JP.json b/packages/mask/shared-ui/locale/ja-JP.json index 48a0b7ec6b9..5b01513ba64 100644 --- a/packages/mask/shared-ui/locale/ja-JP.json +++ b/packages/mask/shared-ui/locale/ja-JP.json @@ -361,6 +361,7 @@ "LWJCIn": ["接続されたサイト"], "LcET2C": ["プライバシー・ポリシー(個人情報に関する方針)"], "LcZh+r": ["サポートされていないバックアップ"], + "Lh3tjm": ["Connect Firefly"], "LkYKvc": ["Please select the correct words in the correct order."], "LqWHk1": ["Hide ", ["0"]], "Lt0Hf0": [ @@ -429,6 +430,7 @@ "QU9aqK": ["You have signed with your wallet."], "QWxok/": ["マスクウォレットと接続"], "QXDgyQ": ["以下の単語を正しい順序で書き留めてください。安全に保管し、誰とも共有しないでください!"], + "QbIq7s": ["This QR code is longer valid. Please scan a new one to continue."], "Qbo7Ev": ["ニーモニックワードを書き留めてください"], "QcVZFH": ["Step ", ["step"], "/", ["totalSteps"]], "QrHM/A": ["Backup downloaded and merged to local successfully."], @@ -708,6 +710,7 @@ "nUWEsV": ["最大手数料が必要以上です"], "nUeoRs": ["署名メッセージ"], "nVT2pJ": ["realMaskNetwork"], + "nWd3Q/": ["The account you are trying to log in with is already linked to a different Firefly account."], "ndRqFD": ["チェーン ID"], "ngWO+e": ["ローカル・キーはありません"], "npsHM5": ["Back Up to Google Drive"], @@ -732,6 +735,7 @@ "ou+PEI": ["プロフィール写真"], "p2/GCq": ["パスワードの確認"], "p2xE4C": ["Overwrite current backup"], + "p70+lb": ["Your ", ["0"], " account is now connected."], "p8Aea2": ["このペルソナ名は既に存在しています."], "pGElS5": ["ニーモニック"], "pHqZUU": ["You used <0>", ["0"], " for the last cloud backup."], diff --git a/packages/mask/shared-ui/locale/ja-JP.po b/packages/mask/shared-ui/locale/ja-JP.po index cf986132ed2..67dc21100d9 100644 --- a/packages/mask/shared-ui/locale/ja-JP.po +++ b/packages/mask/shared-ui/locale/ja-JP.po @@ -664,6 +664,7 @@ msgstr "おめでとうございます!" #: popups/components/ConnectedWallet/index.tsx #: popups/components/SocialAccounts/index.tsx #: popups/modals/SelectProviderModal/index.tsx +#: popups/pages/Personas/ConnectLens/index.tsx msgid "Connect" msgstr "接続" @@ -671,6 +672,10 @@ msgstr "接続" msgid "Connect and switch between your wallets." msgstr "ウォレットを接続して切り替えます。" +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "Connect Firefly" +msgstr "" + #: popups/modals/SelectProviderModal/index.tsx msgid "Connect Mask Network Account using your wallet." msgstr "ウォレットを使用してマスクネットワークアカウントを接続します。" @@ -2791,6 +2796,10 @@ msgstr "テキスト" msgid "Text copied!" msgstr "" +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "The account you are trying to log in with is already linked to a different Firefly account." +msgstr "" + #: popups/components/UnlockERC20Token/index.tsx msgid "The approval for this contract will be revoked in case of the amount is 0." msgstr "" @@ -2951,6 +2960,10 @@ msgstr "このメッセージは無効なEIP-4361メッセージを含んでい msgid "This network name already exists" msgstr "このネットワーク名は既に存在します" +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "This QR code is longer valid. Please scan a new one to continue." +msgstr "" + #: dashboard/pages/SetupPersona/Mnemonic/ComponentToPrint.tsx msgid "This QR includes your identity, please keep it safely." msgstr "" @@ -3311,6 +3324,11 @@ msgstr "" #~ msgid "You used <0>{0} for the last cloud backup." #~ msgstr "" +#. placeholder {0}: Social.Source.Farcaster +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "Your {0} account is now connected." +msgstr "" + #: popups/components/SignRequestInfo/index.tsx msgid "Your connection to this site is not encrypted which can be modified by a hostile third party, we strongly suggest you reject this request." msgstr "このサイトへの接続は暗号化されていないため、敵対的な第三者によって変更されます。このリクエストを拒否することを強くお勧めします。" diff --git a/packages/mask/shared-ui/locale/ko-KR.json b/packages/mask/shared-ui/locale/ko-KR.json index b3bd9ddbf86..2f150ec420c 100644 --- a/packages/mask/shared-ui/locale/ko-KR.json +++ b/packages/mask/shared-ui/locale/ko-KR.json @@ -357,6 +357,7 @@ "LWJCIn": ["연결된 사이트"], "LcET2C": ["개인정보처리방침"], "LcZh+r": ["지원하지 않는 데이터 백업"], + "Lh3tjm": ["Connect Firefly"], "LkYKvc": ["Please select the correct words in the correct order."], "LqWHk1": ["Hide ", ["0"]], "Lt0Hf0": [ @@ -425,6 +426,7 @@ "QU9aqK": ["You have signed with your wallet."], "QWxok/": ["Connect with Mask Wallet"], "QXDgyQ": ["다음 단어를 정확한 순서로 적어주세요. 안전하게 보관하고 다른 사람과 공유하지 마세요!"], + "QbIq7s": ["This QR code is longer valid. Please scan a new one to continue."], "Qbo7Ev": ["니모닉 단어를 적어두세요"], "QcVZFH": ["Step ", ["step"], "/", ["totalSteps"]], "QrHM/A": ["Backup downloaded and merged to local successfully."], @@ -704,6 +706,7 @@ "nUWEsV": ["최대 가스비는 필요한 것보다 높습니다."], "nUeoRs": ["사인 메시지"], "nVT2pJ": ["realMaskNetwork"], + "nWd3Q/": ["The account you are trying to log in with is already linked to a different Firefly account."], "ndRqFD": ["체인 ID"], "ngWO+e": ["로컬 키 없음"], "npsHM5": ["Back Up to Google Drive"], @@ -728,6 +731,7 @@ "ou+PEI": ["프로필 사진"], "p2/GCq": ["비밀번호 확인"], "p2xE4C": ["Overwrite current backup"], + "p70+lb": ["Your ", ["0"], " account is now connected."], "p8Aea2": ["이미 존재된 페르소나입니다"], "pGElS5": ["니모닉"], "pHqZUU": ["You used <0>", ["0"], " for the last cloud backup."], diff --git a/packages/mask/shared-ui/locale/ko-KR.po b/packages/mask/shared-ui/locale/ko-KR.po index 2829c2580a9..71f409dc178 100644 --- a/packages/mask/shared-ui/locale/ko-KR.po +++ b/packages/mask/shared-ui/locale/ko-KR.po @@ -664,6 +664,7 @@ msgstr "축하합니다" #: popups/components/ConnectedWallet/index.tsx #: popups/components/SocialAccounts/index.tsx #: popups/modals/SelectProviderModal/index.tsx +#: popups/pages/Personas/ConnectLens/index.tsx msgid "Connect" msgstr "연결" @@ -671,6 +672,10 @@ msgstr "연결" msgid "Connect and switch between your wallets." msgstr "여기서 월렛을 연결하세요. 여기서 네트워크나 월렛을 바꿀 수 있습니다." +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "Connect Firefly" +msgstr "" + #: popups/modals/SelectProviderModal/index.tsx msgid "Connect Mask Network Account using your wallet." msgstr "Mask Network 계정을 연결하여 월렛을 사용합니다." @@ -2791,6 +2796,10 @@ msgstr "텍스트" msgid "Text copied!" msgstr "" +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "The account you are trying to log in with is already linked to a different Firefly account." +msgstr "" + #: popups/components/UnlockERC20Token/index.tsx msgid "The approval for this contract will be revoked in case of the amount is 0." msgstr "" @@ -2951,6 +2960,10 @@ msgstr "" msgid "This network name already exists" msgstr "이미 존재된 네트워크입니다" +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "This QR code is longer valid. Please scan a new one to continue." +msgstr "" + #: dashboard/pages/SetupPersona/Mnemonic/ComponentToPrint.tsx msgid "This QR includes your identity, please keep it safely." msgstr "" @@ -3311,6 +3324,11 @@ msgstr "" #~ msgid "You used <0>{0} for the last cloud backup." #~ msgstr "" +#. placeholder {0}: Social.Source.Farcaster +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "Your {0} account is now connected." +msgstr "" + #: popups/components/SignRequestInfo/index.tsx msgid "Your connection to this site is not encrypted which can be modified by a hostile third party, we strongly suggest you reject this request." msgstr "" diff --git a/packages/mask/shared-ui/locale/zh-CN.json b/packages/mask/shared-ui/locale/zh-CN.json index 16667cbea4f..f03bca97729 100644 --- a/packages/mask/shared-ui/locale/zh-CN.json +++ b/packages/mask/shared-ui/locale/zh-CN.json @@ -353,6 +353,7 @@ "LWJCIn": ["已连接的网站"], "LcET2C": ["隐私政策"], "LcZh+r": ["不支持的数据备份格式"], + "Lh3tjm": ["Connect Firefly"], "LkYKvc": ["Please select the correct words in the correct order."], "LqWHk1": ["隐藏 ", ["0"]], "Lt0Hf0": [ @@ -421,6 +422,7 @@ "QU9aqK": ["You have signed with your wallet."], "QWxok/": ["Connect with Mask Wallet"], "QXDgyQ": ["请按正确顺序写下以下单词。保证安全,不与任何人分享!"], + "QbIq7s": ["This QR code is longer valid. Please scan a new one to continue."], "Qbo7Ev": ["写下助记词"], "QcVZFH": ["Step ", ["step"], "/", ["totalSteps"]], "QrHM/A": ["Backup downloaded and merged to local successfully."], @@ -698,6 +700,7 @@ "nUWEsV": ["Max fee 高于必要值"], "nUeoRs": ["消息签名"], "nVT2pJ": ["realMaskNetwork"], + "nWd3Q/": ["The account you are trying to log in with is already linked to a different Firefly account."], "ndRqFD": ["Chain ID"], "ngWO+e": ["缺失本地密钥"], "npsHM5": ["Back Up to Google Drive"], @@ -722,6 +725,7 @@ "ou+PEI": ["个人头像"], "p2/GCq": ["确认密码"], "p2xE4C": ["Overwrite current backup"], + "p70+lb": ["Your ", ["0"], " account is now connected."], "p8Aea2": ["此身份名称已存在"], "pGElS5": ["助记词"], "pHqZUU": ["You used <0>", ["0"], " for the last cloud backup."], diff --git a/packages/mask/shared-ui/locale/zh-CN.po b/packages/mask/shared-ui/locale/zh-CN.po index 4e5c323fc35..6b8a6a488e4 100644 --- a/packages/mask/shared-ui/locale/zh-CN.po +++ b/packages/mask/shared-ui/locale/zh-CN.po @@ -664,6 +664,7 @@ msgstr "恭喜您!" #: popups/components/ConnectedWallet/index.tsx #: popups/components/SocialAccounts/index.tsx #: popups/modals/SelectProviderModal/index.tsx +#: popups/pages/Personas/ConnectLens/index.tsx msgid "Connect" msgstr "连接" @@ -671,6 +672,10 @@ msgstr "连接" msgid "Connect and switch between your wallets." msgstr "点击这里连接您的钱包。您可以在此选择网络或更改您的钱包。" +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "Connect Firefly" +msgstr "" + #: popups/modals/SelectProviderModal/index.tsx msgid "Connect Mask Network Account using your wallet." msgstr "使用您的钱包连接Mask Network账户。" @@ -2791,6 +2796,10 @@ msgstr "文本" msgid "Text copied!" msgstr "" +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "The account you are trying to log in with is already linked to a different Firefly account." +msgstr "" + #: popups/components/UnlockERC20Token/index.tsx msgid "The approval for this contract will be revoked in case of the amount is 0." msgstr "" @@ -2951,6 +2960,10 @@ msgstr "" msgid "This network name already exists" msgstr "此网络名称已存在" +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "This QR code is longer valid. Please scan a new one to continue." +msgstr "" + #: dashboard/pages/SetupPersona/Mnemonic/ComponentToPrint.tsx msgid "This QR includes your identity, please keep it safely." msgstr "" @@ -3311,6 +3324,11 @@ msgstr "" #~ msgid "You used <0>{0} for the last cloud backup." #~ msgstr "" +#. placeholder {0}: Social.Source.Farcaster +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "Your {0} account is now connected." +msgstr "" + #: popups/components/SignRequestInfo/index.tsx msgid "Your connection to this site is not encrypted which can be modified by a hostile third party, we strongly suggest you reject this request." msgstr "" diff --git a/packages/mask/shared-ui/locale/zh-TW.json b/packages/mask/shared-ui/locale/zh-TW.json index 26c07dea7b3..0778d124fcf 100644 --- a/packages/mask/shared-ui/locale/zh-TW.json +++ b/packages/mask/shared-ui/locale/zh-TW.json @@ -353,6 +353,7 @@ "LWJCIn": ["已连接的网站"], "LcET2C": ["隐私政策"], "LcZh+r": ["不支持數據備份格式"], + "Lh3tjm": ["Connect Firefly"], "LkYKvc": ["Please select the correct words in the correct order."], "LqWHk1": ["隐藏 ", ["0"]], "Lt0Hf0": [ @@ -421,6 +422,7 @@ "QU9aqK": ["You have signed with your wallet."], "QWxok/": ["Connect with Mask Wallet"], "QXDgyQ": ["请按正确顺序写下以下单词。保证安全,不与任何人分享!"], + "QbIq7s": ["This QR code is longer valid. Please scan a new one to continue."], "Qbo7Ev": ["写下助记词"], "QcVZFH": ["Step ", ["step"], "/", ["totalSteps"]], "QrHM/A": ["Backup downloaded and merged to local successfully."], @@ -698,6 +700,7 @@ "nUWEsV": ["Max fee 高于必要值"], "nUeoRs": ["消息签名"], "nVT2pJ": ["realMaskNetwork"], + "nWd3Q/": ["The account you are trying to log in with is already linked to a different Firefly account."], "ndRqFD": ["Chain ID"], "ngWO+e": ["缺失本地密钥"], "npsHM5": ["Back Up to Google Drive"], @@ -722,6 +725,7 @@ "ou+PEI": ["个人头像"], "p2/GCq": ["确认密码"], "p2xE4C": ["Overwrite current backup"], + "p70+lb": ["Your ", ["0"], " account is now connected."], "p8Aea2": ["此身份名称已存在"], "pGElS5": ["助记词"], "pHqZUU": ["You used <0>", ["0"], " for the last cloud backup."], diff --git a/packages/mask/shared-ui/locale/zh-TW.po b/packages/mask/shared-ui/locale/zh-TW.po index fc4524e27b0..275ced22dfa 100644 --- a/packages/mask/shared-ui/locale/zh-TW.po +++ b/packages/mask/shared-ui/locale/zh-TW.po @@ -664,6 +664,7 @@ msgstr "" #: popups/components/ConnectedWallet/index.tsx #: popups/components/SocialAccounts/index.tsx #: popups/modals/SelectProviderModal/index.tsx +#: popups/pages/Personas/ConnectLens/index.tsx msgid "Connect" msgstr "" @@ -671,6 +672,10 @@ msgstr "" msgid "Connect and switch between your wallets." msgstr "" +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "Connect Firefly" +msgstr "" + #: popups/modals/SelectProviderModal/index.tsx msgid "Connect Mask Network Account using your wallet." msgstr "" @@ -2791,6 +2796,10 @@ msgstr "" msgid "Text copied!" msgstr "" +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "The account you are trying to log in with is already linked to a different Firefly account." +msgstr "" + #: popups/components/UnlockERC20Token/index.tsx msgid "The approval for this contract will be revoked in case of the amount is 0." msgstr "" @@ -2951,6 +2960,10 @@ msgstr "" msgid "This network name already exists" msgstr "" +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "This QR code is longer valid. Please scan a new one to continue." +msgstr "" + #: dashboard/pages/SetupPersona/Mnemonic/ComponentToPrint.tsx msgid "This QR includes your identity, please keep it safely." msgstr "" @@ -3311,6 +3324,11 @@ msgstr "" #~ msgid "You used <0>{0} for the last cloud backup." #~ msgstr "" +#. placeholder {0}: Social.Source.Farcaster +#: popups/pages/Personas/ConnectFirefly/index.tsx +msgid "Your {0} account is now connected." +msgstr "" + #: popups/components/SignRequestInfo/index.tsx msgid "Your connection to this site is not encrypted which can be modified by a hostile third party, we strongly suggest you reject this request." msgstr "" diff --git a/packages/mask/shared/definitions/event.ts b/packages/mask/shared/definitions/event.ts index 1492d75693d..ae921f6109f 100644 --- a/packages/mask/shared/definitions/event.ts +++ b/packages/mask/shared/definitions/event.ts @@ -10,6 +10,8 @@ export const EventMap: Record = { [EnhanceableSite.OpenSea]: EventID.Debug, [EnhanceableSite.Mirror]: EventID.Debug, [EnhanceableSite.Firefly]: EventID.Debug, + [EnhanceableSite.Farcaster]: EventID.Debug, + [EnhanceableSite.Lens]: EventID.Debug, } export const DisconnectEventMap: Record = { diff --git a/packages/mask/shared/site-adaptors/definitions.ts b/packages/mask/shared/site-adaptors/definitions.ts index 9c3a447abff..8f154ff702d 100644 --- a/packages/mask/shared/site-adaptors/definitions.ts +++ b/packages/mask/shared/site-adaptors/definitions.ts @@ -1,21 +1,23 @@ import { FacebookAdaptor } from './implementations/facebook.com.js' +import { FarcasterAdaptor } from './implementations/farcaster.xyz.js' +import { LensAdaptor } from './implementations/hey.xyz.js' import { InstagramAdaptor } from './implementations/instagram.com.js' import { MindsAdaptor } from './implementations/minds.com.js' import { MirrorAdaptor } from './implementations/mirror.xyz.js' import { TwitterAdaptor } from './implementations/twitter.com.js' import type { SiteAdaptor } from './types.js' -const defined = new Map() -export const definedSiteAdaptors: ReadonlyMap = defined - -function defineSiteAdaptor(UI: SiteAdaptor.Definition) { - defined.set(UI.networkIdentifier, UI) -} -defineSiteAdaptor(FacebookAdaptor) -defineSiteAdaptor(InstagramAdaptor) -defineSiteAdaptor(MindsAdaptor) -defineSiteAdaptor(MirrorAdaptor) -defineSiteAdaptor(TwitterAdaptor) +export const definedSiteAdaptors: ReadonlyMap = new Map( + [ + [FacebookAdaptor.networkIdentifier, FacebookAdaptor], + [InstagramAdaptor.networkIdentifier, InstagramAdaptor], + [MindsAdaptor.networkIdentifier, MindsAdaptor], + [MirrorAdaptor.networkIdentifier, MirrorAdaptor], + [TwitterAdaptor.networkIdentifier, TwitterAdaptor], + [FarcasterAdaptor.networkIdentifier, FarcasterAdaptor], + [LensAdaptor.networkIdentifier, LensAdaptor], + ], +) function matches(url: string, pattern: string) { const l = new URL(pattern) diff --git a/packages/mask/shared/site-adaptors/implementations/farcaster.xyz.ts b/packages/mask/shared/site-adaptors/implementations/farcaster.xyz.ts new file mode 100644 index 00000000000..a1e1e982a89 --- /dev/null +++ b/packages/mask/shared/site-adaptors/implementations/farcaster.xyz.ts @@ -0,0 +1,13 @@ +import { EnhanceableSite } from '@masknet/shared-base' +import type { SiteAdaptor } from '../types.js' + +const origins = ['https://farcaster.xyz/*'] + +export const FarcasterAdaptor: SiteAdaptor.Definition = { + name: 'Farcaster', + networkIdentifier: EnhanceableSite.Farcaster, + declarativePermissions: { origins }, + homepage: 'https://farcaster.xyz', + isSocialNetwork: true, + sortIndex: 1, +} diff --git a/packages/mask/shared/site-adaptors/implementations/hey.xyz.ts b/packages/mask/shared/site-adaptors/implementations/hey.xyz.ts new file mode 100644 index 00000000000..6759da7a909 --- /dev/null +++ b/packages/mask/shared/site-adaptors/implementations/hey.xyz.ts @@ -0,0 +1,13 @@ +import { EnhanceableSite } from '@masknet/shared-base' +import type { SiteAdaptor } from '../types.js' + +const origins = ['https://hey.xyz/*'] + +export const LensAdaptor: SiteAdaptor.Definition = { + name: 'Lens', + networkIdentifier: EnhanceableSite.Lens, + declarativePermissions: { origins }, + homepage: 'https://lens.xyz', + isSocialNetwork: true, + sortIndex: 1, +} diff --git a/packages/mask/shared/site-adaptors/types.d.ts b/packages/mask/shared/site-adaptors/types.d.ts index 9fca815db3e..fc7d5fab6c8 100644 --- a/packages/mask/shared/site-adaptors/types.d.ts +++ b/packages/mask/shared/site-adaptors/types.d.ts @@ -1,4 +1,4 @@ -import type { ProfileIdentifier } from '@masknet/shared-base' +import type { EnhanceableSite, ProfileIdentifier } from '@masknet/shared-base' // This file should be a .ts file, not a .d.ts file (that skips type checking). // but this causes "because it would overwrite input file" error in incremental compiling which is annoying. @@ -8,7 +8,7 @@ export declare namespace SiteAdaptor { } export interface Definition { name: string - networkIdentifier: string + networkIdentifier: EnhanceableSite // Note: if declarativePermissions is no longer sufficient, use "false" to indicate it need a load(). declarativePermissions: DeclarativePermissions homepage: string diff --git a/packages/plugins/RSS3/src/SiteAdaptor/SocialFeeds/SocialFeed.tsx b/packages/plugins/RSS3/src/SiteAdaptor/SocialFeeds/SocialFeed.tsx index 1bbbfe33c20..ff6f2d54393 100644 --- a/packages/plugins/RSS3/src/SiteAdaptor/SocialFeeds/SocialFeed.tsx +++ b/packages/plugins/RSS3/src/SiteAdaptor/SocialFeeds/SocialFeed.tsx @@ -183,6 +183,7 @@ const useStyles = makeStyles { diff --git a/packages/shared-base/src/LegacySettings/settings.ts b/packages/shared-base/src/LegacySettings/settings.ts index 0ff2292e279..1cc8ce0cddf 100644 --- a/packages/shared-base/src/LegacySettings/settings.ts +++ b/packages/shared-base/src/LegacySettings/settings.ts @@ -23,6 +23,8 @@ export const pluginIDsSettings = createGlobalSettings> = { [EnhanceableSite.Twitter]: NextIDPlatform.Twitter, } export function resolveNetworkToNextIDPlatform(key: EnhanceableSite): NextIDPlatform | undefined { diff --git a/packages/shared-base/src/Site/index.ts b/packages/shared-base/src/Site/index.ts index 9a9338e985f..342cdef7c9b 100644 --- a/packages/shared-base/src/Site/index.ts +++ b/packages/shared-base/src/Site/index.ts @@ -14,6 +14,8 @@ const matchEnhanceableSiteHost: Record = { process.env.NODE_ENV === 'production' ? /(?:^(?:firefly\.|firefly-staging\.|firefly-canary\.)?mask\.social|[\w-]+\.vercel\.app)$/iu : /^localhost:\d+$/u, + [EnhanceableSite.Farcaster]: /(^|\.)farcaster\.xyz$/iu, + [EnhanceableSite.Lens]: /(^|\.)hey\.xyz$/iu, } const matchExtensionSitePathname: Record = { diff --git a/packages/shared-base/src/Site/types.ts b/packages/shared-base/src/Site/types.ts index db395652312..d269eaf0342 100644 --- a/packages/shared-base/src/Site/types.ts +++ b/packages/shared-base/src/Site/types.ts @@ -2,6 +2,8 @@ export enum EnhanceableSite { Localhost = 'localhost', Twitter = 'twitter.com', Facebook = 'facebook.com', + Farcaster = 'farcaster.xyz', + Lens = 'lens.xyz', Minds = 'minds.com', Instagram = 'instagram.com', OpenSea = 'opensea.io', diff --git a/packages/shared-base/src/constants.ts b/packages/shared-base/src/constants.ts index ce75f9d4f68..e28a8741254 100644 --- a/packages/shared-base/src/constants.ts +++ b/packages/shared-base/src/constants.ts @@ -2,7 +2,7 @@ import { NextIDPlatform } from './NextID/types.js' import { EnhanceableSite } from './Site/types.js' import { PluginID } from './types/PluginID.js' -export const SOCIAL_MEDIA_NAME: Record = { +export const SOCIAL_MEDIA_NAME: Record = { [EnhanceableSite.Twitter]: 'X', [EnhanceableSite.Facebook]: 'Facebook', [EnhanceableSite.Minds]: 'Minds', @@ -10,6 +10,9 @@ export const SOCIAL_MEDIA_NAME: Record = { [EnhanceableSite.OpenSea]: 'OpenSea', [EnhanceableSite.Localhost]: 'Localhost', [EnhanceableSite.Mirror]: 'Mirror', + [EnhanceableSite.Farcaster]: 'Farcaster', + [EnhanceableSite.Firefly]: 'Firefly', + [EnhanceableSite.Lens]: 'Lens', } export const NEXT_ID_PLATFORM_SOCIAL_MEDIA_MAP: Record = { diff --git a/packages/shared-base/src/errors.ts b/packages/shared-base/src/errors.ts new file mode 100644 index 00000000000..f1a7948834e --- /dev/null +++ b/packages/shared-base/src/errors.ts @@ -0,0 +1,49 @@ +export class AbortError extends Error { + override name = 'AbortError' + + constructor(message = 'Aborted') { + super(message) + } + + static is(error: unknown) { + return error instanceof AbortError || (error instanceof DOMException && error.name === 'AbortError') + } +} + +export class FarcasterPatchSignerError extends Error { + override name = 'FarcasterPatchSignerError' + + constructor(public fid: number) { + super(`Failed to patch signer key to Farcaster session: ${fid}`) + } +} + +export class TimeoutError extends Error { + override name = 'TimeoutError' + + constructor(message?: string) { + super(message ?? 'Timeout.') + } +} + +export class FireflyBindTimeoutError extends Error { + override name = 'FireflyBindTimeoutError' + constructor(public source: string) { + super(`Bind ${source} account to Firefly timeout.`) + } +} +export class FireflyAlreadyBoundError extends Error { + override name = 'FireflyAlreadyBoundError' + + constructor(public source: string) { + super(`This ${source} account has already bound to another Firefly account.`) + } +} + +export class NotAllowedError extends Error { + override name = 'NotAllowedError' + + constructor(message?: string) { + super(message ?? 'Not allowed.') + } +} diff --git a/packages/shared-base/src/index.ts b/packages/shared-base/src/index.ts index f51a56eaa1b..18e21038d2b 100644 --- a/packages/shared-base/src/index.ts +++ b/packages/shared-base/src/index.ts @@ -4,6 +4,7 @@ export * from './constants.js' export * from './types.js' export * from './types/index.js' export * from './helpers/index.js' +export * from './errors.js' export * from './Messages/Events.js' export * from './Messages/CrossIsolationEvents.js' diff --git a/packages/shared-base/src/types/Routes.ts b/packages/shared-base/src/types/Routes.ts index 378fe8fc000..d9f8a557fae 100644 --- a/packages/shared-base/src/types/Routes.ts +++ b/packages/shared-base/src/types/Routes.ts @@ -88,6 +88,8 @@ export enum PopupRoutes { WalletConnect = '/personas/wallet-connect', ExportPrivateKey = '/personas/export-private-key', PersonaAvatarSetting = '/personas/avatar-setting', + ConnectFirefly = '/personas/connect-firefly', + ConnectLens = '/personas/connect-lens', Trader = '/trader', } export interface PopupRoutesParamsMap { diff --git a/packages/shared/src/constants.tsx b/packages/shared/src/constants.tsx index 6659721ddf6..5e281ec8521 100644 --- a/packages/shared/src/constants.tsx +++ b/packages/shared/src/constants.tsx @@ -1,7 +1,7 @@ import { Icons, type GeneratedIcon } from '@masknet/icons' import { EnhanceableSite } from '@masknet/shared-base' -export const SOCIAL_MEDIA_ROUND_ICON_MAPPING: Record = { +export const SOCIAL_MEDIA_ROUND_ICON_MAPPING: Record = { [EnhanceableSite.Twitter]: Icons.TwitterXRound, [EnhanceableSite.Facebook]: Icons.FacebookRound, [EnhanceableSite.Minds]: Icons.MindsRound, @@ -9,7 +9,10 @@ export const SOCIAL_MEDIA_ROUND_ICON_MAPPING: Record export enum RSS3_NFT_SITE_KEY { TWITTER = '_nfts', diff --git a/packages/web3-hooks/base/src/useContext.tsx b/packages/web3-hooks/base/src/useContext.tsx index 5ced82d17aa..631c3fd631c 100644 --- a/packages/web3-hooks/base/src/useContext.tsx +++ b/packages/web3-hooks/base/src/useContext.tsx @@ -22,6 +22,7 @@ interface ChainContextGetter { providerType?: Web3Helper.Definition[T]['ProviderType'] // If it's controlled, we prefer passed value over state inside controlled?: boolean + isPopupWallet?: boolean } interface ChainContextSetter { @@ -62,7 +63,7 @@ export function NetworkContextProvider({ */ export const ChainContextProvider = memo(function ChainContextProvider(props: PropsWithChildren) { const { pluginID } = useNetworkContext() - const { controlled } = props + const { controlled, isPopupWallet } = props const globalAccount = useAccount(pluginID) const globalChainId = useChainId(pluginID) @@ -78,7 +79,8 @@ export const ChainContextProvider = memo(function ChainContextProvider(props: Pr const [_providerType, setProviderType] = useState() const location = useLocation() - const is_popup_wallet_page = Sniffings.is_popup_page && location.hash?.includes(PopupRoutes.Wallet) + const is_popup_wallet_page = + Sniffings.is_popup_page && (location.hash?.includes(PopupRoutes.Wallet) || isPopupWallet) const account = controlled ? props.account : (_account ?? props.account ?? (is_popup_wallet_page ? maskAccount : globalAccount)) const chainId = diff --git a/packages/web3-providers/src/AvatarStore/helpers/getAvatar.ts b/packages/web3-providers/src/AvatarStore/helpers/getAvatar.ts index b08a5cb9a67..bd0927f83f8 100644 --- a/packages/web3-providers/src/AvatarStore/helpers/getAvatar.ts +++ b/packages/web3-providers/src/AvatarStore/helpers/getAvatar.ts @@ -17,6 +17,8 @@ const resolveRSS3Key = createLookupTableResolver( + urlcat(FIREFLY_ROOT_URL, '/v2/farcaster-hub/user/friendship', { + sourceFid, + destFid, + }), + { + method: 'GET', + }, + ) + return resolveFireflyResponseData(response) +} diff --git a/packages/web3-providers/src/Farcaster/getFarcasterProfileById.ts b/packages/web3-providers/src/Farcaster/getFarcasterProfileById.ts new file mode 100644 index 00000000000..1d5469bf689 --- /dev/null +++ b/packages/web3-providers/src/Farcaster/getFarcasterProfileById.ts @@ -0,0 +1,21 @@ +import urlcat from 'urlcat' +import { fireflySessionHolder } from '../Firefly/SessionHolder' +import { FIREFLY_ROOT_URL } from '../Firefly/constants' +import { formatFarcasterProfileFromFirefly, resolveFireflyResponseData } from '../Firefly/helpers' +import { getFarcasterFriendship } from './getFarcasterFriendship' +import type { FireflyConfigAPI } from '../types/Firefly' + +export async function getFarcasterProfileById(profileId: string, viewerFid?: string) { + const response = await fireflySessionHolder.fetch( + urlcat(FIREFLY_ROOT_URL, '/v2/farcaster-hub/user/profile', { + fid: profileId, + sourceFid: viewerFid, + }), + { + method: 'GET', + }, + ) + const user = resolveFireflyResponseData(response) + const friendship = viewerFid ? await getFarcasterFriendship(viewerFid, profileId) : null + return formatFarcasterProfileFromFirefly({ ...user, ...friendship }) +} diff --git a/packages/web3-providers/src/Farcaster/index.ts b/packages/web3-providers/src/Farcaster/index.ts new file mode 100644 index 00000000000..dc5ae2d6f49 --- /dev/null +++ b/packages/web3-providers/src/Farcaster/index.ts @@ -0,0 +1,2 @@ +export * from './getFarcasterProfileById.js' +export * from './getFarcasterFriendship.js' diff --git a/packages/web3-providers/src/Firefly/FarcasterSession.ts b/packages/web3-providers/src/Firefly/FarcasterSession.ts new file mode 100644 index 00000000000..d329322d674 --- /dev/null +++ b/packages/web3-providers/src/Firefly/FarcasterSession.ts @@ -0,0 +1,108 @@ +import urlcat from 'urlcat' + +import { SessionType, type Session } from '../types/Session.js' +import { fetchJSON } from '../helpers/fetchJSON.js' +import { BaseSession } from '../Session/Session.js' + +export const WARPCAST_ROOT_URL_V2 = 'https://api.warpcast.com/v2' +export const FAKE_SIGNER_REQUEST_TOKEN = 'fake_signer_request_token' + +export enum FarcasterSponsorship { + Firefly = 'firefly', +} + +export class FarcasterSession extends BaseSession implements Session { + constructor( + /** + * Fid + */ + profileId: string, + /** + * the private key of the signer + */ + token: string, + createdAt: number, + expiresAt: number, + public signerRequestToken?: string, + public channelToken?: string, + public sponsorshipSignature?: string, + public walletAddress?: string, + ) { + super(SessionType.Farcaster, profileId, token, createdAt, expiresAt) + } + + override serialize(): `${SessionType}:${string}:${string}:${string}` { + return [ + super.serialize(), + this.signerRequestToken ?? '', + this.channelToken ?? '', + this.sponsorshipSignature ?? '', + ].join(':') as `${SessionType}:${string}:${string}:${string}` + } + + refresh(): Promise { + throw new Error('Not allowed') + } + + async destroy(): Promise { + const url = urlcat(WARPCAST_ROOT_URL_V2, '/auth') + const response = await fetchJSON<{ + result: { + success: boolean + } + }>(url, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${this.token}`, + }, + body: JSON.stringify({ + method: 'revokeToken', + params: { + timestamp: this.createdAt, + }, + }), + }) + + // indicate the session is destroyed + this.expiresAt = 0 + + if (!response.result.success) throw new Error('Failed to destroy the session.') + return + } + + static isGrantByPermission( + session: Session | null, + // if strict is true, the session must have a valid signer request token + strict = false, + ): session is FarcasterSession & { signerRequestToken: string } { + if (!session) return false + const token = (session as FarcasterSession).signerRequestToken + return ( + session.type === SessionType.Farcaster && + !!token && + // strict mode + (strict ? token !== FAKE_SIGNER_REQUEST_TOKEN : true) + ) + } + + static isSponsorship( + session: Session | null, + strict = false, + ): session is FarcasterSession & { signerRequestToken: string; sponsorshipSignature: string } { + if (!session) return false + return ( + FarcasterSession.isGrantByPermission(session, strict) && + !!(session as FarcasterSession).sponsorshipSignature + ) + } + + static isRelayService(session: Session | null): session is FarcasterSession & { channelToken: string } { + if (!session) return false + return session.type === 'Farcaster' && !!(session as FarcasterSession).channelToken + } + + static isLoginByWallet(session: Session | null): session is FarcasterSession & { walletAddress: string } { + if (!session) return false + return session.type === 'Farcaster' && !!(session as FarcasterSession).walletAddress + } +} diff --git a/packages/web3-providers/src/Firefly/LensSession.ts b/packages/web3-providers/src/Firefly/LensSession.ts new file mode 100644 index 00000000000..f53b76e74ad --- /dev/null +++ b/packages/web3-providers/src/Firefly/LensSession.ts @@ -0,0 +1,29 @@ +import { NotAllowedError } from '@masknet/shared-base' +import { ZERO_ADDRESS } from '@masknet/web3-shared-evm' +import { BaseSession } from '../Session/Session.js' +import { SessionType, type Session } from '../types/Session.js' + +export class LensSession extends BaseSession implements Session { + constructor( + profileId: string, + token: string, + createdAt: number, + expiresAt: number, + public refreshToken: string, + public address: string, + ) { + super(SessionType.Lens, profileId, token, createdAt, expiresAt) + } + + override serialize(): `${SessionType}:${string}:${string}` { + return `${super.serialize()}:${this.refreshToken ?? ''}:${this.address ?? ZERO_ADDRESS}` + } + + refresh(): Promise { + throw new NotAllowedError() + } + + async destroy(): Promise { + throw new NotAllowedError() + } +} diff --git a/packages/web3-providers/src/Firefly/RedPacket.ts b/packages/web3-providers/src/Firefly/RedPacket.ts index 2b215c8f40d..207502397d3 100644 --- a/packages/web3-providers/src/Firefly/RedPacket.ts +++ b/packages/web3-providers/src/Firefly/RedPacket.ts @@ -10,12 +10,10 @@ import { import urlcat from 'urlcat' import { fetchJSON } from '../entry-helpers.js' import { FireflyRedPacketAPI, type FireflyResponse } from '../entry-types.js' +import { FIREFLY_ROOT_URL } from './constants.js' const siteType = getSiteType() const SITE_URL = siteType === EnhanceableSite.Firefly ? location.origin : 'https://firefly.social' -const FIREFLY_ROOT_URL = - process.env.NEXT_PUBLIC_FIREFLY_API_URL || - (process.env.NODE_ENV === 'development' ? 'https://api-dev.firefly.land' : 'https://api.firefly.land') function fetchFireflyJSON(url: string, init?: RequestInit): Promise { return fetchJSON(url, { diff --git a/packages/web3-providers/src/Firefly/Session.ts b/packages/web3-providers/src/Firefly/Session.ts new file mode 100644 index 00000000000..42e8fda70b2 --- /dev/null +++ b/packages/web3-providers/src/Firefly/Session.ts @@ -0,0 +1,76 @@ +import { encodeAsciiPayload, encodeNoAsciiPayload } from '../helpers/encodeSessionPayload.js' +import { BaseSession } from '../Session/Session.js' +import { SessionType, type Session } from '../types/Session.js' + +export type FireflySessionSignature = { + address: string + message: string + signature: string +} + +export type FireflySessionPayload = { + /** + * indicate a new firefly binding when it was created + */ + isNew?: boolean + + /** + * numeric user ID + */ + uid?: string + /** + * UUID of the user + */ + accountId?: string + avatar?: string | null + displayName?: string | null +} + +export class FireflySession extends BaseSession implements Session { + constructor( + accountId: string, + accessToken: string, + public parent: Session | null, + public signature: FireflySessionSignature | null, + /** + * @deprecated + * This field always false. Use `payload.isNew` instead + */ + public isNew?: boolean, + public payload?: FireflySessionPayload, + ) { + super(SessionType.Firefly, accountId, accessToken, 0, 0) + } + + /** + * For users after this patch use accountId in UUID format for events. + * For legacy users use profileId in numeric format for events. + */ + get accountIdForEvent() { + return this.payload?.accountId ?? this.profileId + } + + override serialize(): `${SessionType}:${string}:${string}:${string}` { + return [ + super.serialize(), + // parent session + this.parent ? btoa(this.parent.serialize()) : '', + // signature if session created by signing a message + this.signature ? encodeAsciiPayload(this.signature) : '', + // isNew flag + this.isNew ? '1' : '0', + // extra data payload + this.payload ? encodeNoAsciiPayload(this.payload) : '', + ].join(':') as `${SessionType}:${string}:${string}:${string}` + } + + override async refresh(): Promise { + // throw new NotAllowedError() + throw new Error('Not allowed') + } + + override async destroy(): Promise { + // throw new NotAllowedError() + throw new Error('Not allowed') + } +} diff --git a/packages/web3-providers/src/Firefly/SessionHolder.ts b/packages/web3-providers/src/Firefly/SessionHolder.ts new file mode 100644 index 00000000000..ac73425653e --- /dev/null +++ b/packages/web3-providers/src/Firefly/SessionHolder.ts @@ -0,0 +1,34 @@ +import type { FireflySession } from './Session.js' +import { fetchJSON } from '../helpers/fetchJSON.js' +import { SessionHolder } from '../Session/SessionHolder.js' +import type { NextFetchersOptions } from '../helpers/getNextFetchers.js' + +class FireflySessionHolder extends SessionHolder { + fetchWithSessionGiven(session: FireflySession) { + return (url: string, init?: RequestInit) => { + return fetchJSON(url, { + ...init, + headers: { ...init?.headers, Authorization: `Bearer ${session.token}` }, + }) + } + } + + override async fetchWithSession(url: string, init?: RequestInit, options?: NextFetchersOptions) { + const authToken = this.sessionRequired.token + + return fetchJSON( + url, + { + ...init, + headers: { ...init?.headers, Authorization: `Bearer ${authToken}` }, + }, + options, + ) + } + + override fetchWithoutSession(url: string, init?: RequestInit, options?: NextFetchersOptions) { + return fetchJSON(url, init, options) + } +} + +export const fireflySessionHolder = new FireflySessionHolder() diff --git a/packages/web3-providers/src/Firefly/constants.ts b/packages/web3-providers/src/Firefly/constants.ts index 91938fb1a18..b80c88e7ab3 100644 --- a/packages/web3-providers/src/Firefly/constants.ts +++ b/packages/web3-providers/src/Firefly/constants.ts @@ -5,3 +5,9 @@ export const EMAIL_REGEX = /(([^\s"(),./:;<>@[\\\]]+(\.[^\s"(),./:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}\])|(([\dA-Za-z-]+\.)+[A-Za-z]{2,}))$/u export const URL_REGEX = /((https?:\/\/)?[\da-z]+([.-][\da-z]+)*\.[a-z]{2,}(:\d{1,5})?(\/[^\n ),>]*)?)/giu + +export const FIREFLY_ROOT_URL = + process.env.NEXT_PUBLIC_FIREFLY_API_URL || + (process.env.NODE_ENV === 'development' ? 'https://api-dev.firefly.land' : 'https://api.firefly.land') + +export const NOT_DEPEND_SECRET = '[TO_BE_REPLACED_LATER]' diff --git a/packages/web3-providers/src/Firefly/index.ts b/packages/web3-providers/src/Firefly/index.ts index 5245497e924..3b152effaa4 100644 --- a/packages/web3-providers/src/Firefly/index.ts +++ b/packages/web3-providers/src/Firefly/index.ts @@ -2,4 +2,11 @@ export * from './Config.js' export * from './RedPacket.js' export * from './Twitter.js' export * from './Farcaster.js' +export * from './Session.js' +export * from './FarcasterSession.js' +export * from './LensSession.js' +export * from './SessionHolder.js' +export * from './helpers.js' +export * from './constants.js' +export * from './patchFarcasterSessionRequired.js' export { FIREFLY_SITE_URL } from './constants.js' diff --git a/packages/web3-providers/src/Firefly/patchFarcasterSessionRequired.ts b/packages/web3-providers/src/Firefly/patchFarcasterSessionRequired.ts new file mode 100644 index 00000000000..06e741d718b --- /dev/null +++ b/packages/web3-providers/src/Firefly/patchFarcasterSessionRequired.ts @@ -0,0 +1,15 @@ +import { NOT_DEPEND_SECRET } from './constants' +import { FAKE_SIGNER_REQUEST_TOKEN, type FarcasterSession } from './FarcasterSession' + +export function patchFarcasterSessionRequired(session: FarcasterSession, fid: number, token: string | undefined) { + if (session.profileId === NOT_DEPEND_SECRET) { + session.profileId = fid.toString() + } + if (session.token === NOT_DEPEND_SECRET) { + if (!token) throw new Error(`Failed to patch signer key to Farcaster session: ${fid}`) + + session.token = token + session.signerRequestToken = FAKE_SIGNER_REQUEST_TOKEN + } + return session +} diff --git a/packages/web3-providers/src/FireflyAccount/index.ts b/packages/web3-providers/src/FireflyAccount/index.ts new file mode 100644 index 00000000000..55da72d1b7f --- /dev/null +++ b/packages/web3-providers/src/FireflyAccount/index.ts @@ -0,0 +1,33 @@ +import type { FireflySession } from '../Firefly/Session' +import type { Session } from '../types/Session' +import type { Social } from '../types/Social' + +type AccountOrigin = 'inherent' | 'sync' + +export interface FireflyAccount { + origin?: AccountOrigin + profile: Social.Profile + session: Session + fireflySession?: FireflySession +} +export interface AccountOptions { + // set the account as the current account, default: true + setAsCurrent?: boolean | ((account: FireflyAccount) => Promise) + // skip the belongs to check, default: false + skipBelongsToCheck?: boolean + // resume accounts from firefly, default: false + skipResumeFireflyAccounts?: boolean + // resume the firefly session, default: false + skipResumeFireflySession?: boolean + // skip reporting farcaster signer, default: true + skipReportFarcasterSigner?: boolean + // skip syncing accounts, default: false + skipSyncAccounts?: boolean + // early return signal + signal?: AbortSignal +} + +export async function addAccount(_account: FireflyAccount, _options?: AccountOptions) { + console.log('TODO: addAccount') + return true +} diff --git a/packages/web3-providers/src/Session/Session.ts b/packages/web3-providers/src/Session/Session.ts new file mode 100644 index 00000000000..53b9eaa7f9a --- /dev/null +++ b/packages/web3-providers/src/Session/Session.ts @@ -0,0 +1,27 @@ +import { type SessionType, type Session } from '../types/Session.js' + +export abstract class BaseSession implements Session { + constructor( + public type: SessionType, + public profileId: string, + public token: string, + public createdAt: number, + public expiresAt: number, + ) {} + + serialize(): `${SessionType}:${string}` { + const body = JSON.stringify({ + type: this.type, + token: this.token, + profileId: this.profileId, + createdAt: this.createdAt, + expiresAt: this.expiresAt, + }) + + return `${this.type}:${btoa(body)}` + } + + abstract refresh(): Promise + + abstract destroy(): Promise +} diff --git a/packages/web3-providers/src/Session/SessionHolder.ts b/packages/web3-providers/src/Session/SessionHolder.ts new file mode 100644 index 00000000000..319a60c8fa6 --- /dev/null +++ b/packages/web3-providers/src/Session/SessionHolder.ts @@ -0,0 +1,79 @@ +import type { NextFetchersOptions } from '../helpers/getNextFetchers.js' +import type { Session } from '../types/Session.js' + +export class SessionHolder { + protected internalSession: T | null = null + + private removeQueries() { + if (!this.session) return + } + + get session() { + return this.internalSession + } + + get sessionRequired() { + if (!this.internalSession) throw new Error('No session found.') + return this.internalSession + } + + assertSession(message?: string) { + try { + return this.sessionRequired + } catch (error: unknown) { + if (typeof message === 'string') throw new Error(message) + throw error + } + } + + refreshSession(): Promise { + throw new Error('Not implemented') + } + + resumeSession(session: T) { + this.removeQueries() + this.internalSession = session + } + + removeSession() { + this.removeQueries() + this.internalSession = null + } + + withSession unknown>(callback: K, required = false) { + return callback(required ? this.sessionRequired : this.session) as ReturnType + } + + fetchWithSession( + url: string, + init?: RequestInit, + options?: NextFetchersOptions & { withSession?: boolean }, + ): Promise { + throw new Error('Not implemented') + } + + fetchWithoutSession( + url: string, + init?: RequestInit, + options?: NextFetchersOptions & { withSession?: boolean }, + ): Promise { + throw new Error('Not implemented') + } + + /** + * Fetch w/ or w/o session. + * + * withSession = true: fetch with session + * withSession = false: fetch without session + * withSession = undefined: fetch with session if session exists + * @param url + * @param init + * @param withSession + */ + fetch(url: string, init?: RequestInit, options?: NextFetchersOptions & { withSession?: boolean }): Promise { + if (options?.withSession === true) return this.fetchWithSession(url, init, options) + if (options?.withSession === false) return this.fetchWithoutSession(url, init, options) + if (this.session) return this.fetchWithSession(url, init, options) + return this.fetchWithoutSession(url, init, options) + } +} diff --git a/packages/web3-providers/src/entry-types.ts b/packages/web3-providers/src/entry-types.ts index 6d0b646c0a9..1a806754037 100644 --- a/packages/web3-providers/src/entry-types.ts +++ b/packages/web3-providers/src/entry-types.ts @@ -35,6 +35,7 @@ export * from './types/LensV3.js' export type * from './types/Storage.js' export type * from './types/Snapshot.js' export type * from './types/Store.js' +export * from './types/Session.js' // Provider Implementations export * from './DeBank/types.js' diff --git a/packages/web3-providers/src/entry.ts b/packages/web3-providers/src/entry.ts index fb1e13e0ad0..4312906dbdc 100644 --- a/packages/web3-providers/src/entry.ts +++ b/packages/web3-providers/src/entry.ts @@ -108,7 +108,9 @@ export { Airdrop } from './Airdrop/index.js' // Firefly -export { FireflyConfig, FireflyRedPacket, FireflyTwitter, FireflyFarcaster, FIREFLY_SITE_URL } from './Firefly/index.js' +export * from './Firefly/index.js' +export * from './Farcaster/index.js' +export * from './FireflyAccount/index.js' // FiatCurrencyRate export { FiatCurrencyRate } from './FiatCurrencyRate/index.js' diff --git a/packages/web3-providers/src/helpers/encodeSessionPayload.ts b/packages/web3-providers/src/helpers/encodeSessionPayload.ts new file mode 100644 index 00000000000..d6d6be83fc7 --- /dev/null +++ b/packages/web3-providers/src/helpers/encodeSessionPayload.ts @@ -0,0 +1,17 @@ +import { parseJSON } from './parseJSON' + +export function encodeAsciiPayload(payload: unknown) { + return btoa(JSON.stringify(payload)) +} + +export function decodeAsciiPayload(payload: string) { + return parseJSON(atob(payload)) +} + +export function encodeNoAsciiPayload(payload: unknown) { + return btoa(unescape(encodeURIComponent(JSON.stringify(payload)))) +} + +export function decodeNoAsciiPayload(payload: string) { + return parseJSON(decodeURIComponent(escape(atob(payload)))) +} diff --git a/packages/web3-providers/src/helpers/social.ts b/packages/web3-providers/src/helpers/social.ts index 97166a77c9d..8ea34702bda 100644 --- a/packages/web3-providers/src/helpers/social.ts +++ b/packages/web3-providers/src/helpers/social.ts @@ -143,6 +143,7 @@ export function getProfileUrl(profile: Social.Profile) { if (!profile.handle) return '' return resolveProfileUrl(profile.source, profile.handle) case Social.Source.Farcaster: + case Social.Source.Firefly: if (!profile.profileId) return '' return resolveProfileUrl(profile.source, profile.profileId) default: @@ -167,6 +168,7 @@ export const resolveSourceInUrl = createLookupTableResolver { throw new Error(`Unreachable source = ${source}.`) @@ -177,6 +179,7 @@ export const resolveSocialSourceInUrl = createLookupTableResolver { throw new Error(`Unreachable source = ${source}.`) diff --git a/packages/web3-providers/src/types/Firefly.ts b/packages/web3-providers/src/types/Firefly.ts index 471b6d9fa89..2755b40bbdc 100644 --- a/packages/web3-providers/src/types/Firefly.ts +++ b/packages/web3-providers/src/types/Firefly.ts @@ -6,6 +6,7 @@ type WithNumberChainId = WithoutChainId & { chain_id: number } export interface FireflyResponse { code: number data: T + error?: string[] } export namespace FireflyConfigAPI { @@ -133,6 +134,62 @@ export namespace FireflyConfigAPI { secretAccessKey: string sessionToken: string }> + export type LoginResponse = FireflyResponse<{ + accessToken: string + /** uuid */ + accountId: string + farcaster_signer_public_key?: string + farcaster_signer_private_key?: string + isNew: boolean + fid?: number + uid?: string + avatar?: string + displayName?: string + telegram_username?: string + telegram_user_id?: string + }> + export type BindResponse = FireflyResponse<{ + fid: number + farcaster_signer_public_key?: string + farcaster_signer_private_key?: string + account_id: string + account_raw_id: number + twitters: Array<{ + id: string + handle: string + }> + wallets: Array<{ + _id: number + id: string // the wallet address as id + createdAt: string + connectedAt: string + updatedAt: string + address: string + chain: string + ens: unknown + }> + }> + export interface User { + pfp: string + username: string + display_name: string + bio?: string + following: number + followers: number + addresses: string[] + solanaAddresses: string[] + fid: string + isFollowing?: boolean + /** if followed by the user, no relation to whether you follow the user or not */ + isFollowedBack?: boolean + isPowerUser?: boolean + isProUser?: boolean + } + export type UserResponse = FireflyResponse + export type FriendshipResponse = FireflyResponse<{ + isFollowing: boolean + isFollowedBack: boolean + }> } export namespace FireflyRedPacketAPI { diff --git a/packages/web3-providers/src/types/Session.ts b/packages/web3-providers/src/types/Session.ts new file mode 100644 index 00000000000..dfb47e13e2e --- /dev/null +++ b/packages/web3-providers/src/types/Session.ts @@ -0,0 +1,67 @@ +export enum SessionType { + Apple = 'Apple', + Email = 'Email', + Google = 'Google', + Telegram = 'Telegram', + Twitter = 'Twitter', + Lens = 'Lens', + Farcaster = 'Farcaster', + Firefly = 'Firefly', + Bsky = 'Bsky', +} + +export interface Session { + profileId: string | number + + /** + * The type of social platform that the session is associated with. + */ + type: SessionType + + /** + * The secret associated with the authenticated account. + * It's typically used to validate the authenticity of the user in subsequent + * requests to a server or API. + */ + token: string + + /** + * Represents the time at which the session was established or last updated. + * It's represented as a UNIX timestamp, counting the number of seconds since + * January 1, 1970 (the UNIX epoch). + */ + createdAt: number + + /** + * Specifies when the authentication or session will expire. + * It's represented as a UNIX timestamp, which counts the number of seconds + * since January 1, 1970 (known as the UNIX epoch). + * A value of 0 indicates that the session has no expiration. + */ + expiresAt: number + + /** + * Serializes the session data into a string format. + * This can be useful for storing or transmitting the session data. + * + * @returns A string representation of the session. + */ + serialize(): string + + /** + * Refreshes the session, typically by acquiring a new token or extending the + * expiration time. This method might make an asynchronous call to a backend + * server to perform the refresh operation. + * + * @returns A promise that resolves when the session is successfully refreshed. + */ + refresh(): Promise + + /** + * Destroys the session, ending the authenticated state. This might involve + * invalidating the token on a backend server or performing other cleanup operations. + * + * @returns A promise that resolves when the session is successfully destroyed. + */ + destroy(): Promise +} diff --git a/packages/web3-providers/src/types/Social.ts b/packages/web3-providers/src/types/Social.ts index 43168d5c804..ae60992f31a 100644 --- a/packages/web3-providers/src/types/Social.ts +++ b/packages/web3-providers/src/types/Social.ts @@ -15,12 +15,14 @@ export namespace Social { export enum Source { Farcaster = 'Farcaster', Lens = 'Lens', + Firefly = 'Firefly', } export enum SourceInURL { Farcaster = 'farcaster', Lens = 'lens', + Firefly = '', } - export type SocialSource = Source.Farcaster | Source.Lens + export type SocialSource = Source.Farcaster | Source.Lens | Source.Firefly /** Normalized Channel, different from Farcaster's */ export interface Channel { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c661daba202..d7f378916de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -375,6 +375,9 @@ importers: '@hookform/resolvers': specifier: ^3.6.0 version: 3.6.0(react-hook-form@7.52.0(react@0.0.0-experimental-58af67a8f8-20240628)) + '@lens-protocol/client': + specifier: 0.0.0-canary-20250408064617 + version: 0.0.0-canary-20250408064617(typescript@5.9.2) '@masknet/backup-format': specifier: workspace:^ version: link:../backup-format