From c4d299059887bea3dcb1bdf35c06a4970b7c78d4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 24 May 2024 22:30:25 -0500 Subject: [PATCH 01/13] Enable familiarFollowers on Ditto --- src/utils/features.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/features.ts b/src/utils/features.ts index a920055b6..00737592b 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -529,6 +529,7 @@ const getInstanceFeatures = (instance: Instance) => { v.software === MASTODON && gte(v.version, '3.5.0'), v.software === PLEROMA && gte(v.version, '2.5.51') && v.build === REBASED, v.software === TAKAHE, + v.software === DITTO, ]), /** Whether the instance federates. */ From bd81f404778c12c8f0be19cd00fa6285a4170920 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 24 May 2024 22:36:46 -0500 Subject: [PATCH 02/13] familiarFollowers: id -> id[] --- src/actions/familiar-followers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/familiar-followers.ts b/src/actions/familiar-followers.ts index a412509a8..c23a674ba 100644 --- a/src/actions/familiar-followers.ts +++ b/src/actions/familiar-followers.ts @@ -17,7 +17,7 @@ export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: A id: accountId, }); - api(getState).get(`/api/v1/accounts/familiar_followers?id=${accountId}`) + api(getState).get(`/api/v1/accounts/familiar_followers?id[]=${accountId}`) .then(({ data }) => { const accounts = data.find(({ id }: { id: string }) => id === accountId).accounts; From ca8df23cd06bb398f5847c7af3fcde99d2c8d496 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 25 May 2024 11:23:09 -0500 Subject: [PATCH 03/13] Don't fetch soapbox.json if frontend_configurations is available --- src/actions/soapbox.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/actions/soapbox.ts b/src/actions/soapbox.ts index 788775c00..5c7572d3b 100644 --- a/src/actions/soapbox.ts +++ b/src/actions/soapbox.ts @@ -65,8 +65,6 @@ const fetchSoapboxConfig = (host: string | null) => if (data.soapbox_fe) { dispatch(importSoapboxConfig(data.soapbox_fe, host)); return data.soapbox_fe; - } else { - return dispatch(fetchSoapboxJson(host)); } }); } else { From 37736d4e0c33abdd52b38787e2f8d73466561f16 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 25 May 2024 11:29:26 -0500 Subject: [PATCH 04/13] Still dispatch soapboxConfigFail --- src/actions/soapbox.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/actions/soapbox.ts b/src/actions/soapbox.ts index 5c7572d3b..44552e38e 100644 --- a/src/actions/soapbox.ts +++ b/src/actions/soapbox.ts @@ -65,6 +65,8 @@ const fetchSoapboxConfig = (host: string | null) => if (data.soapbox_fe) { dispatch(importSoapboxConfig(data.soapbox_fe, host)); return data.soapbox_fe; + } else { + return dispatch(soapboxConfigFail(new Error('Not found'), host)); } }); } else { From 984185051a990e7483471da66164b97bfb901a07 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 25 May 2024 15:53:20 -0500 Subject: [PATCH 05/13] usePatronUser: disable unless patron is enabled --- src/api/hooks/accounts/usePatronUser.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/hooks/accounts/usePatronUser.ts b/src/api/hooks/accounts/usePatronUser.ts index 283f02b3d..aa78ecd39 100644 --- a/src/api/hooks/accounts/usePatronUser.ts +++ b/src/api/hooks/accounts/usePatronUser.ts @@ -1,15 +1,19 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useEntity } from 'soapbox/entity-store/hooks'; import { useApi } from 'soapbox/hooks/useApi'; +import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig'; import { type PatronUser, patronUserSchema } from 'soapbox/schemas'; function usePatronUser(url?: string) { const api = useApi(); + const soapboxConfig = useSoapboxConfig(); + + const patronEnabled = soapboxConfig.getIn(['extensions', 'patron', 'enabled']) === true; const { entity: patronUser, ...result } = useEntity( [Entities.PATRON_USERS, url || ''], () => api.get(`/api/patron/v1/accounts/${encodeURIComponent(url!)}`), - { schema: patronUserSchema, enabled: !!url }, + { schema: patronUserSchema, enabled: patronEnabled && !!url }, ); return { patronUser, ...result }; From 181bf43bda9a82a5a59a177cf412c84b0fdc30e2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 25 May 2024 20:55:49 -0500 Subject: [PATCH 06/13] Remove useGroupsPath hook --- src/components/sidebar-menu.tsx | 5 +- src/components/sidebar-navigation.tsx | 5 +- src/components/thumb-navigation.tsx | 5 +- src/features/group/manage-group.tsx | 5 +- src/hooks/index.ts | 1 - src/hooks/useGroupsPath.test.ts | 80 --------------------------- src/hooks/useGroupsPath.ts | 23 -------- 7 files changed, 8 insertions(+), 116 deletions(-) delete mode 100644 src/hooks/useGroupsPath.test.ts delete mode 100644 src/hooks/useGroupsPath.ts diff --git a/src/components/sidebar-menu.tsx b/src/components/sidebar-menu.tsx index 847e8d79b..0b7993854 100644 --- a/src/components/sidebar-menu.tsx +++ b/src/components/sidebar-menu.tsx @@ -11,7 +11,7 @@ import { useAccount } from 'soapbox/api/hooks'; import Account from 'soapbox/components/account'; import { Stack, Divider, HStack, Icon, IconButton, Text } from 'soapbox/components/ui'; import ProfileStats from 'soapbox/features/ui/components/profile-stats'; -import { useAppDispatch, useAppSelector, useGroupsPath, useFeatures, useInstance } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useFeatures, useInstance } from 'soapbox/hooks'; import { makeGetOtherAccounts } from 'soapbox/selectors'; import type { List as ImmutableList } from 'immutable'; @@ -88,7 +88,6 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen); const settings = useAppSelector((state) => getSettings(state)); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); - const groupsPath = useGroupsPath(); const instance = useInstance(); const closeButtonRef = React.useRef(null); @@ -210,7 +209,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { {features.groups && ( { const features = useFeatures(); const { isDeveloper } = useSettings(); const { account } = useOwnAccount(); - const groupsPath = useGroupsPath(); const notificationCount = useAppSelector((state) => state.notifications.unread); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); @@ -142,7 +141,7 @@ const SidebarNavigation = () => { {features.groups && ( } diff --git a/src/components/thumb-navigation.tsx b/src/components/thumb-navigation.tsx index 4c13b010e..08d792e22 100644 --- a/src/components/thumb-navigation.tsx +++ b/src/components/thumb-navigation.tsx @@ -3,12 +3,11 @@ import { FormattedMessage } from 'react-intl'; import ThumbNavigationLink from 'soapbox/components/thumb-navigation-link'; import { useStatContext } from 'soapbox/contexts/stat-context'; -import { useAppSelector, useFeatures, useGroupsPath, useOwnAccount } from 'soapbox/hooks'; +import { useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; const ThumbNavigation: React.FC = (): JSX.Element => { const { account } = useOwnAccount(); const features = useFeatures(); - const groupsPath = useGroupsPath(); const { unreadChatsCount } = useStatContext(); @@ -60,7 +59,7 @@ const ThumbNavigation: React.FC = (): JSX.Element => { src={require('@tabler/icons/outline/circles.svg')} activeSrc={require('@tabler/icons/filled/circles.svg')} text={} - to={groupsPath} + to='/groups' exact /> )} diff --git a/src/features/group/manage-group.tsx b/src/features/group/manage-group.tsx index 1fa02f174..bf857996a 100644 --- a/src/features/group/manage-group.tsx +++ b/src/features/group/manage-group.tsx @@ -6,7 +6,7 @@ import { openModal } from 'soapbox/actions/modals'; import { useDeleteGroup, useGroup } from 'soapbox/api/hooks'; import List, { ListItem } from 'soapbox/components/list'; import { CardBody, CardHeader, CardTitle, Column, Spinner, Text } from 'soapbox/components/ui'; -import { useAppDispatch, useBackend, useGroupsPath } from 'soapbox/hooks'; +import { useAppDispatch, useBackend } from 'soapbox/hooks'; import { GroupRoles } from 'soapbox/schemas/group-member'; import toast from 'soapbox/toast'; import { TRUTHSOCIAL } from 'soapbox/utils/features'; @@ -38,7 +38,6 @@ const ManageGroup: React.FC = ({ params }) => { const backend = useBackend(); const dispatch = useAppDispatch(); - const groupsPath = useGroupsPath(); const history = useHistory(); const intl = useIntl(); @@ -70,7 +69,7 @@ const ManageGroup: React.FC = ({ params }) => { deleteGroup.mutate(group.id, { onSuccess() { toast.success(intl.formatMessage(messages.deleteSuccess)); - history.push(groupsPath); + history.push('/groups'); }, }); }, diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 1da5fb9e4..8d3b7a510 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -7,7 +7,6 @@ export { useCompose } from './useCompose'; export { useDebounce } from './useDebounce'; export { useDraggedFiles } from './useDraggedFiles'; export { useGetState } from './useGetState'; -export { useGroupsPath } from './useGroupsPath'; export { useDimensions } from './useDimensions'; export { useFeatures } from './useFeatures'; export { useInstance } from './useInstance'; diff --git a/src/hooks/useGroupsPath.test.ts b/src/hooks/useGroupsPath.test.ts deleted file mode 100644 index 72af53731..000000000 --- a/src/hooks/useGroupsPath.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { __stub } from 'soapbox/api'; -import { buildAccount, buildGroup, buildGroupRelationship } from 'soapbox/jest/factory'; -import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; -import { instanceSchema } from 'soapbox/schemas'; - -import { useGroupsPath } from './useGroupsPath'; - -describe('useGroupsPath()', () => { - test('without the groupsDiscovery feature', () => { - const store = { - instance: instanceSchema.parse({ - version: '2.7.2 (compatible; Pleroma 2.3.0)', - }), - }; - - const { result } = renderHook(useGroupsPath, undefined, store); - - expect(result.current).toEqual('/groups'); - }); - - describe('with the "groupsDiscovery" feature', () => { - let store: any; - - beforeEach(() => { - const userId = '1'; - store = { - instance: instanceSchema.parse({ - version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)', - }), - me: userId, - accounts: { - [userId]: buildAccount({ - id: userId, - acct: 'justin-username', - display_name: 'Justin L', - avatar: 'test.jpg', - source: { - chats_onboarded: false, - }, - }), - }, - }; - }); - - describe('when the user has no groups', () => { - test('should default to the discovery page', () => { - const { result } = renderHook(useGroupsPath, undefined, store); - - expect(result.current).toEqual('/groups/discover'); - }); - }); - - describe('when the user has groups', () => { - beforeEach(() => { - __stub((mock) => { - mock.onGet('/api/v1/groups').reply(200, [ - buildGroup({ - display_name: 'Group', - id: '1', - }), - ]); - - mock.onGet('/api/v1/groups/relationships?id[]=1').reply(200, [ - buildGroupRelationship({ - id: '1', - }), - ]); - }); - }); - - test('should default to the "My Groups" page', async () => { - const { result } = renderHook(useGroupsPath, undefined, store); - - await waitFor(() => { - expect(result.current).toEqual('/groups'); - }); - }); - }); - }); -}); diff --git a/src/hooks/useGroupsPath.ts b/src/hooks/useGroupsPath.ts deleted file mode 100644 index 71aa3c8da..000000000 --- a/src/hooks/useGroupsPath.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useGroups } from 'soapbox/api/hooks/groups/useGroups'; - -import { useFeatures } from './useFeatures'; - -/** - * Determine the correct URL to use for /groups. - * If the user does not have any Groups, let's default to the discovery tab. - * Otherwise, let's default to My Groups. - * - * @returns String (as link) - */ -const useGroupsPath = () => { - const features = useFeatures(); - const { groups } = useGroups(); - - if (!features.groupsDiscovery) { - return '/groups'; - } - - return groups.length > 0 ? '/groups' : '/groups/discover'; -}; - -export { useGroupsPath }; \ No newline at end of file From 8f14830c8a7493cd4cbeaaddba7c221b1eda22ec Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 28 May 2024 14:31:20 -0500 Subject: [PATCH 07/13] Remove external auth keys when they're done being used --- src/actions/auth.ts | 5 +++++ src/actions/external-auth.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/src/actions/auth.ts b/src/actions/auth.ts index 33684075a..bd5dd726b 100644 --- a/src/actions/auth.ts +++ b/src/actions/auth.ts @@ -224,6 +224,11 @@ export const logOut = () => // Clear the account from Sentry. unsetSentryAccount(); + // Remove external auth entries. + localStorage.removeItem('soapbox:external:app'); + localStorage.removeItem('soapbox:external:baseurl'); + localStorage.removeItem('soapbox:external:scopes'); + dispatch({ type: AUTH_LOGGED_OUT, account, standalone }); toast.success(messages.loggedOut); diff --git a/src/actions/external-auth.ts b/src/actions/external-auth.ts index 38ae92800..45edb8ba5 100644 --- a/src/actions/external-auth.ts +++ b/src/actions/external-auth.ts @@ -99,5 +99,6 @@ export const loginWithCode = (code: string) => .then((token: Record) => dispatch(authLoggedIn(token))) .then(({ access_token }: any) => dispatch(verifyCredentials(access_token as string, baseURL))) .then((account: { id: string }) => dispatch(switchAccount(account.id))) + .then(() => localStorage.removeItem('soapbox:external:baseurl')) .then(() => window.location.href = '/'); }; From 7933f83af2016ebe72e12973650ab219155666b1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 May 2024 09:39:02 -0500 Subject: [PATCH 08/13] Feature-gate domain blocks --- src/features/account/components/header.tsx | 2 +- src/features/ui/components/link-footer.tsx | 2 +- src/features/ui/index.tsx | 2 +- src/utils/features.ts | 11 +++++++++++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/features/account/components/header.tsx b/src/features/account/components/header.tsx index 837b5c976..cd95d5945 100644 --- a/src/features/account/components/header.tsx +++ b/src/features/account/components/header.tsx @@ -464,7 +464,7 @@ const Header: React.FC = ({ account }) => { }); } - if (!account.local) { + if (!account.local && features.domainBlocks) { const domain = account.fqn.split('@')[1]; menu.push(null); diff --git a/src/features/ui/components/link-footer.tsx b/src/features/ui/components/link-footer.tsx index 1800528a5..2162b20ca 100644 --- a/src/features/ui/components/link-footer.tsx +++ b/src/features/ui/components/link-footer.tsx @@ -53,7 +53,7 @@ const LinkFooter: React.FC = (): JSX.Element => { {features.followedHashtagsList && ( )} - {features.federating && ( + {features.domainBlocks && ( )} {account.admin && ( diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index 5ffb93aec..552adf723 100644 --- a/src/features/ui/index.tsx +++ b/src/features/ui/index.tsx @@ -268,7 +268,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {features.blocks && } - {features.federating && } + {features.domainBlocks && } {(features.filters || features.filtersV2) && } {(features.filters || features.filtersV2) && } diff --git a/src/utils/features.ts b/src/utils/features.ts index 00737592b..b5aa122be 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -400,6 +400,17 @@ const getInstanceFeatures = (instance: Instance) => { */ dislikes: v.software === FRIENDICA && gte(v.version, '2023.3.0'), + /** + * Ability to block users by domain. + * @see GET /api/v1/domain_blocks + * @see POST /api/v1/domain_blocks + * @see DELETE /api/v1/domain_blocks + */ + domainBlocks: federation.enabled && any([ + v.software === MASTODON && gte(v.compatVersion, '1.4.0'), + v.software === PLEROMA, + ]), + /** * Allow to register on a given domain * @see GET /api/v1/pleroma/admin/domains From 2fd4b82fa03a6863c3456efa73f0b1c3fc0df7fd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 May 2024 16:06:26 +0000 Subject: [PATCH 09/13] Revert "Merge branch 'sign-order' into 'main'" This reverts merge request !3026 --- package.json | 1 - src/api/hooks/nostr/useSignerStream.ts | 31 +---------------------- src/contexts/nostr-context.tsx | 4 +-- src/features/ui/index.tsx | 2 ++ src/init/soapbox-instance.tsx | 34 -------------------------- src/init/soapbox-load.tsx | 5 ++++ src/init/soapbox.tsx | 13 ++++------ 7 files changed, 15 insertions(+), 75 deletions(-) delete mode 100644 src/init/soapbox-instance.tsx diff --git a/package.json b/package.json index a7ac29e08..4a2efa577 100644 --- a/package.json +++ b/package.json @@ -186,7 +186,6 @@ "vite-plugin-html": "^3.2.0", "vite-plugin-require": "^1.1.10", "vite-plugin-static-copy": "^1.0.0", - "websocket-ts": "^2.1.5", "wicg-inert": "^3.1.1", "zod": "^3.23.5" }, diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index 9c6899930..918d40ba3 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useSignerStream.ts @@ -1,6 +1,5 @@ import { NostrEvent, NostrConnectResponse, NSchema as n } from '@nostrify/nostrify'; -import { useEffect, useState } from 'react'; -import { WebsocketEvent } from 'websocket-ts'; +import { useEffect } from 'react'; import { useNostr } from 'soapbox/contexts/nostr-context'; import { nwcRequestSchema } from 'soapbox/schemas/nostr'; @@ -8,9 +7,6 @@ import { nwcRequestSchema } from 'soapbox/schemas/nostr'; function useSignerStream() { const { relay, pubkey, signer } = useNostr(); - const [opened, setOpened] = useState(false); - const [isConnected, setIsConnected] = useState(false); - async function sendConnect(response: NostrConnectResponse) { if (!relay || !pubkey || !signer) return; @@ -119,29 +115,6 @@ function useSignerStream() { } } - useEffect(() => { - if (relay?.socket.readyState === WebSocket.OPEN) { - setOpened(true); - } - - const openHandler = () => { - setOpened(true); - setIsConnected(true); - }; - - const closeHandler = () => { - setIsConnected(false); - }; - - relay?.socket.addEventListener(WebsocketEvent.open, openHandler); - relay?.socket.addEventListener(WebsocketEvent.close, closeHandler); - - return () => { - relay?.socket.removeEventListener(WebsocketEvent.open, openHandler); - relay?.socket.removeEventListener(WebsocketEvent.close, closeHandler); - }; - }, [relay]); - useEffect(() => { if (!relay || !pubkey) return; @@ -159,8 +132,6 @@ function useSignerStream() { }; }, [relay, pubkey, signer]); - - return { opened, isConnected }; } export { useSignerStream }; diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx index 79442d965..fc8129b2b 100644 --- a/src/contexts/nostr-context.tsx +++ b/src/contexts/nostr-context.tsx @@ -1,4 +1,4 @@ -import { NRelay1, NostrSigner } from '@nostrify/nostrify'; +import { NRelay, NRelay1, NostrSigner } from '@nostrify/nostrify'; import React, { createContext, useContext, useState, useEffect, useMemo } from 'react'; import { NKeys } from 'soapbox/features/nostr/keys'; @@ -6,7 +6,7 @@ import { useOwnAccount } from 'soapbox/hooks'; import { useInstance } from 'soapbox/hooks/useInstance'; interface NostrContextType { - relay?: NRelay1; + relay?: NRelay; pubkey?: string; signer?: NostrSigner; } diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index 552adf723..32a52a034 100644 --- a/src/features/ui/index.tsx +++ b/src/features/ui/index.tsx @@ -13,6 +13,7 @@ import { fetchScheduledStatuses } from 'soapbox/actions/scheduled-statuses'; import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions'; import { expandHomeTimeline } from 'soapbox/actions/timelines'; import { useUserStream } from 'soapbox/api/hooks'; +import { useSignerStream } from 'soapbox/api/hooks/nostr/useSignerStream'; import SidebarNavigation from 'soapbox/components/sidebar-navigation'; import ThumbNavigation from 'soapbox/components/thumb-navigation'; import { Layout } from 'soapbox/components/ui'; @@ -461,6 +462,7 @@ const UI: React.FC = ({ children }) => { }, []); useUserStream(); + useSignerStream(); // The user has logged in useEffect(() => { diff --git a/src/init/soapbox-instance.tsx b/src/init/soapbox-instance.tsx deleted file mode 100644 index 12ed477e6..000000000 --- a/src/init/soapbox-instance.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React, { useEffect, useState } from 'react'; - -import { fetchInstance } from 'soapbox/actions/instance'; -import { useSignerStream } from 'soapbox/api/hooks/nostr/useSignerStream'; -import LoadingScreen from 'soapbox/components/loading-screen'; -import { useAppDispatch, useFeatures } from 'soapbox/hooks'; - -interface ISoapboxInstance { - children: React.ReactNode; -} - -const SoapboxInstance: React.FC = ({ children }) => { - const features = useFeatures(); - const dispatch = useAppDispatch(); - - const [isLoaded, setIsLoaded] = useState(false); - const { opened } = useSignerStream(); - - useEffect(() => { - dispatch(fetchInstance()).then(() => { - setIsLoaded(true); - }).catch(() => { - setIsLoaded(true); - }); - }, []); - - if (!isLoaded || (!opened && features.nostr)) { - return ; - } - - return <>{children}; -}; - -export default SoapboxInstance; \ No newline at end of file diff --git a/src/init/soapbox-load.tsx b/src/init/soapbox-load.tsx index 953872289..cc9b883f1 100644 --- a/src/init/soapbox-load.tsx +++ b/src/init/soapbox-load.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { IntlProvider } from 'react-intl'; +import { fetchInstance } from 'soapbox/actions/instance'; import { fetchMe } from 'soapbox/actions/me'; import { loadSoapboxConfig } from 'soapbox/actions/soapbox'; import LoadingScreen from 'soapbox/components/loading-screen'; @@ -16,7 +17,11 @@ import MESSAGES from 'soapbox/messages'; const loadInitial = () => { // @ts-ignore return async(dispatch, getState) => { + // Await for authenticated fetch await dispatch(fetchMe()); + // Await for feature detection + await dispatch(fetchInstance()); + // Await for configuration await dispatch(loadSoapboxConfig()); }; }; diff --git a/src/init/soapbox.tsx b/src/init/soapbox.tsx index 7b1331af2..2b3cce228 100644 --- a/src/init/soapbox.tsx +++ b/src/init/soapbox.tsx @@ -12,7 +12,6 @@ import { preload } from '../actions/preload'; import { store } from '../store'; import SoapboxHead from './soapbox-head'; -import SoapboxInstance from './soapbox-instance'; import SoapboxLoad from './soapbox-load'; import SoapboxMount from './soapbox-mount'; @@ -32,13 +31,11 @@ const Soapbox: React.FC = () => { - - - - - - - + + + + + From 73c76b54efa223917fbd1f9e59e5de287f9d55e7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 May 2024 11:11:49 -0500 Subject: [PATCH 10/13] Revert "Merge branch 'external-baseurl' into 'main'" This reverts commit 42ee95a8fe2da39da6741ef7c1d7383d9130f583, reversing changes made to bda99ba1527142abf8c110f0b7d7dcfce5396504. --- src/api/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/index.ts b/src/api/index.ts index 39d6a14d9..dda1fa508 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -103,7 +103,7 @@ export default (getState: () => RootState, authType: string = 'user'): AxiosInst const state = getState(); const accessToken = getToken(state, authType); const me = state.me; - const baseURL = me ? getAuthBaseURL(state, me) : localStorage.getItem('soapbox:external:baseurl') ?? ''; + const baseURL = me ? getAuthBaseURL(state, me) : ''; const relayUrl = state.getIn(['instance', 'nostr', 'relay']) as string | undefined; const pubkey = state.getIn(['instance', 'nostr', 'pubkey']) as string | undefined; From 41f676fdfb4af10ae501dba8d6bc23ec06d0f208 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 May 2024 14:26:26 -0500 Subject: [PATCH 11/13] Add initial version of NConnect class --- src/api/hooks/nostr/useSignerStream.ts | 140 ++++------------------- src/contexts/nostr-context.tsx | 4 +- src/features/nostr/NConnect.ts | 150 +++++++++++++++++++++++++ src/schemas/nostr.ts | 11 +- 4 files changed, 171 insertions(+), 134 deletions(-) create mode 100644 src/features/nostr/NConnect.ts diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index 918d40ba3..7685d1732 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useSignerStream.ts @@ -1,137 +1,35 @@ -import { NostrEvent, NostrConnectResponse, NSchema as n } from '@nostrify/nostrify'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useNostr } from 'soapbox/contexts/nostr-context'; -import { nwcRequestSchema } from 'soapbox/schemas/nostr'; +import { NConnect } from 'soapbox/features/nostr/NConnect'; function useSignerStream() { - const { relay, pubkey, signer } = useNostr(); + const { relay, signer } = useNostr(); + const [pubkey, setPubkey] = useState(undefined); - async function sendConnect(response: NostrConnectResponse) { - if (!relay || !pubkey || !signer) return; - - const event = await signer.signEvent({ - kind: 24133, - content: await signer.nip04!.encrypt(pubkey, JSON.stringify(response)), - tags: [['p', pubkey]], - created_at: Math.floor(Date.now() / 1000), - }); - - relay.event(event); - } - - async function handleConnectEvent(event: NostrEvent) { - if (!relay || !pubkey || !signer) return; - const decrypted = await signer.nip04!.decrypt(pubkey, event.content); - - const reqMsg = n.json().pipe(n.connectRequest()).safeParse(decrypted); - if (!reqMsg.success) { - console.warn(decrypted); - console.warn(reqMsg.error); - return; - } - - const request = reqMsg.data; - - switch (request.method) { - case 'connect': - return sendConnect({ - id: request.id, - result: 'ack', - }); - case 'sign_event': - return sendConnect({ - id: request.id, - result: JSON.stringify(await signer.signEvent(JSON.parse(request.params[0]))), - }); - case 'ping': - return sendConnect({ - id: request.id, - result: 'pong', - }); - case 'get_relays': - return sendConnect({ - id: request.id, - result: JSON.stringify(await signer.getRelays?.() ?? []), - }); - case 'get_public_key': - return sendConnect({ - id: request.id, - result: await signer.getPublicKey(), - }); - case 'nip04_encrypt': - return sendConnect({ - id: request.id, - result: await signer.nip04!.encrypt(request.params[0], request.params[1]), - }); - case 'nip04_decrypt': - return sendConnect({ - id: request.id, - result: await signer.nip04!.decrypt(request.params[0], request.params[1]), - }); - case 'nip44_encrypt': - return sendConnect({ - id: request.id, - result: await signer.nip44!.encrypt(request.params[0], request.params[1]), - }); - case 'nip44_decrypt': - return sendConnect({ - id: request.id, - result: await signer.nip44!.decrypt(request.params[0], request.params[1]), - }); - default: - return sendConnect({ - id: request.id, - result: '', - error: `Unrecognized method: ${request.method}`, - }); - } - } - - async function handleWalletEvent(event: NostrEvent) { - if (!relay || !pubkey || !signer) return; - - const decrypted = await signer.nip04!.decrypt(pubkey, event.content); - - const reqMsg = n.json().pipe(nwcRequestSchema).safeParse(decrypted); - if (!reqMsg.success) { - console.warn(decrypted); - console.warn(reqMsg.error); - return; - } - - await window.webln?.enable(); - await window.webln?.sendPayment(reqMsg.data.params.invoice); - } - - async function handleEvent(event: NostrEvent) { - switch (event.kind) { - case 24133: - await handleConnectEvent(event); - break; - case 23194: - await handleWalletEvent(event); - break; - } - } + const storageKey = `soapbox:nostr:auth:${pubkey}`; useEffect(() => { - if (!relay || !pubkey) return; + if (signer) { + signer.getPublicKey().then(setPubkey).catch(console.warn); + } + }, [signer]); - const controller = new AbortController(); - const signal = controller.signal; + useEffect(() => { + if (!relay || !signer || !pubkey) return; - (async() => { - for await (const msg of relay.req([{ kinds: [24133, 23194], authors: [pubkey], limit: 0 }], { signal })) { - if (msg[0] === 'EVENT') handleEvent(msg[2]); - } - })(); + const connect = new NConnect({ + relay, + signer, + onAuthorize: (authorizedPubkey) => localStorage.setItem(storageKey, authorizedPubkey), + authorizedPubkey: localStorage.getItem(storageKey) ?? undefined, + }); return () => { - controller.abort(); + connect.close(); }; - }, [relay, pubkey, signer]); + }, [relay, signer, pubkey]); } export { useSignerStream }; diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx index fc8129b2b..1338d6ac5 100644 --- a/src/contexts/nostr-context.tsx +++ b/src/contexts/nostr-context.tsx @@ -7,7 +7,6 @@ import { useInstance } from 'soapbox/hooks/useInstance'; interface NostrContextType { relay?: NRelay; - pubkey?: string; signer?: NostrSigner; } @@ -24,7 +23,6 @@ export const NostrProvider: React.FC = ({ children }) => { const { account } = useOwnAccount(); const url = instance.nostr?.relay; - const pubkey = instance.nostr?.pubkey; const accountPubkey = account?.nostr.pubkey; const signer = useMemo( @@ -42,7 +40,7 @@ export const NostrProvider: React.FC = ({ children }) => { }, [url]); return ( - + {children} ); diff --git a/src/features/nostr/NConnect.ts b/src/features/nostr/NConnect.ts new file mode 100644 index 000000000..8679e62bc --- /dev/null +++ b/src/features/nostr/NConnect.ts @@ -0,0 +1,150 @@ +import { NRelay, NostrConnectRequest, NostrConnectResponse, NostrEvent, NostrSigner, NSchema as n } from '@nostrify/nostrify'; + +interface NConnectOpts { + relay: NRelay; + signer: NostrSigner; + authorizedPubkey: string | undefined; + onAuthorize(pubkey: string): void; +} + +export class NConnect { + + private relay: NRelay; + private signer: NostrSigner; + private authorizedPubkey: string | undefined; + private onAuthorize: (pubkey: string) => void; + + public secret = crypto.randomUUID(); + private controller = new AbortController(); + + constructor(opts: NConnectOpts) { + this.relay = opts.relay; + this.signer = opts.signer; + this.authorizedPubkey = opts.authorizedPubkey; + this.onAuthorize = opts.onAuthorize; + + this.open(); + } + + async open() { + const pubkey = await this.signer.getPublicKey(); + const signal = this.controller.signal; + + for await (const msg of this.relay.req([{ kinds: [24133], '#p': [pubkey], limit: 0 }], { signal })) { + if (msg[0] === 'EVENT') { + const event = msg[2]; + this.handleEvent(event); + } + } + } + + private async handleEvent(event: NostrEvent): Promise { + const decrypted = await this.signer.nip04!.decrypt(event.pubkey, event.content); + const request = n.json().pipe(n.connectRequest()).safeParse(decrypted); + + if (!request.success) { + console.warn(decrypted); + console.warn(request.error); + return; + } + + await this.handleRequest(event.pubkey, request.data); + } + + private async handleRequest(pubkey: string, request: NostrConnectRequest): Promise { + // Connect is a special case. Any pubkey can try to request it. + if (request.method === 'connect') { + return this.handleConnect(pubkey, request as NostrConnectRequest & { method: 'connect' }); + } + + // Prevent unauthorized access. + if (pubkey !== this.authorizedPubkey) { + return this.sendResponse(pubkey, { + id: request.id, + result: '', + error: 'Unauthorized', + }); + } + + // Authorized methods. + switch (request.method) { + case 'sign_event': + return this.sendResponse(pubkey, { + id: request.id, + result: JSON.stringify(await this.signer.signEvent(JSON.parse(request.params[0]))), + }); + case 'ping': + return this.sendResponse(pubkey, { + id: request.id, + result: 'pong', + }); + case 'get_relays': + return this.sendResponse(pubkey, { + id: request.id, + result: JSON.stringify(await this.signer.getRelays?.() ?? []), + }); + case 'get_public_key': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.getPublicKey(), + }); + case 'nip04_encrypt': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.nip04!.encrypt(request.params[0], request.params[1]), + }); + case 'nip04_decrypt': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.nip04!.decrypt(request.params[0], request.params[1]), + }); + case 'nip44_encrypt': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.nip44!.encrypt(request.params[0], request.params[1]), + }); + case 'nip44_decrypt': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.nip44!.decrypt(request.params[0], request.params[1]), + }); + default: + return this.sendResponse(pubkey, { + id: request.id, + result: '', + error: `Unrecognized method: ${request.method}`, + }); + } + } + + private async handleConnect(pubkey: string, request: NostrConnectRequest & { method: 'connect' }) { + const [remotePubkey, secret] = request.params; + + if (secret === this.secret && remotePubkey === await this.signer.getPublicKey()) { + this.secret = crypto.randomUUID(); + this.authorizedPubkey = pubkey; + this.onAuthorize(pubkey); + + await this.sendResponse(pubkey, { + id: request.id, + result: 'ack', + }); + } + } + + private async sendResponse(pubkey: string, response: NostrConnectResponse) { + const event = await this.signer.signEvent({ + kind: 24133, + content: await this.signer.nip04!.encrypt(pubkey, JSON.stringify(response)), + tags: [['p', pubkey]], + created_at: Math.floor(Date.now() / 1000), + }); + + await this.relay.event(event); + } + + close() { + this.controller.abort(); + } + +} \ No newline at end of file diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 59cdd9d68..c94152d22 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -1,16 +1,7 @@ import { NSchema as n } from '@nostrify/nostrify'; import { verifyEvent } from 'nostr-tools'; -import { z } from 'zod'; /** Nostr event schema that also verifies the event's signature. */ const signedEventSchema = n.event().refine(verifyEvent); -/** NIP-47 signer response. */ -const nwcRequestSchema = z.object({ - method: z.literal('pay_invoice'), - params: z.object({ - invoice: z.string(), - }), -}); - -export { signedEventSchema, nwcRequestSchema }; \ No newline at end of file +export { signedEventSchema }; \ No newline at end of file From fd13ff70e376ead6da54f6a4e029521d56d10e80 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 May 2024 15:46:25 -0500 Subject: [PATCH 12/13] Nostr OAuth actions --- src/actions/nostr.ts | 38 +++++++++++++++++++++----- src/actions/oauth.ts | 2 +- src/api/hooks/nostr/useSignerStream.ts | 14 ++++++++-- src/contexts/nostr-context.tsx | 4 +-- src/features/nostr/NConnect.ts | 7 +++-- src/reducers/meta.ts | 5 ++++ 6 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index 6908f06b6..f40f85813 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -1,14 +1,31 @@ -import { nip19 } from 'nostr-tools'; +import { RootState, type AppDispatch } from 'soapbox/store'; -import { type AppDispatch } from 'soapbox/store'; +import { authLoggedIn, verifyCredentials } from './auth'; +import { obtainOAuthToken } from './oauth'; -import { verifyCredentials } from './auth'; +const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET'; /** Log in with a Nostr pubkey. */ function logInNostr(pubkey: string) { - return (dispatch: AppDispatch) => { - const npub = nip19.npubEncode(pubkey); - return dispatch(verifyCredentials(npub)); + return async (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(setNostrPubkey(pubkey)); + + const secret = sessionStorage.getItem('soapbox:nip46:secret'); + if (!secret) { + throw new Error('No secret found in session storage'); + } + + const relay = getState().instance.nostr?.relay; + + const token = await dispatch(obtainOAuthToken({ + grant_type: 'nostr', + pubkey, + relays: relay ? [relay] : undefined, + secret, + })); + + const { access_token } = dispatch(authLoggedIn(token)); + return await dispatch(verifyCredentials(access_token as string)); }; } @@ -23,4 +40,11 @@ function nostrExtensionLogIn() { }; } -export { logInNostr, nostrExtensionLogIn }; \ No newline at end of file +function setNostrPubkey(pubkey: string) { + return { + type: NOSTR_PUBKEY_SET, + pubkey, + }; +} + +export { logInNostr, nostrExtensionLogIn, setNostrPubkey, NOSTR_PUBKEY_SET }; \ No newline at end of file diff --git a/src/actions/oauth.ts b/src/actions/oauth.ts index 1c3c8a748..4147c9409 100644 --- a/src/actions/oauth.ts +++ b/src/actions/oauth.ts @@ -20,7 +20,7 @@ export const OAUTH_TOKEN_REVOKE_REQUEST = 'OAUTH_TOKEN_REVOKE_REQUEST'; export const OAUTH_TOKEN_REVOKE_SUCCESS = 'OAUTH_TOKEN_REVOKE_SUCCESS'; export const OAUTH_TOKEN_REVOKE_FAIL = 'OAUTH_TOKEN_REVOKE_FAIL'; -export const obtainOAuthToken = (params: Record, baseURL?: string) => +export const obtainOAuthToken = (params: Record, baseURL?: string) => (dispatch: AppDispatch) => { dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params }); return baseClient(null, baseURL).post('/oauth/token', params).then(({ data: token }) => { diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index 7685d1732..00517bd0e 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useSignerStream.ts @@ -3,11 +3,15 @@ import { useEffect, useState } from 'react'; import { useNostr } from 'soapbox/contexts/nostr-context'; import { NConnect } from 'soapbox/features/nostr/NConnect'; +const secretStorageKey = 'soapbox:nip46:secret'; + +sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); + function useSignerStream() { const { relay, signer } = useNostr(); const [pubkey, setPubkey] = useState(undefined); - const storageKey = `soapbox:nostr:auth:${pubkey}`; + const authStorageKey = `soapbox:nostr:auth:${pubkey}`; useEffect(() => { if (signer) { @@ -21,8 +25,12 @@ function useSignerStream() { const connect = new NConnect({ relay, signer, - onAuthorize: (authorizedPubkey) => localStorage.setItem(storageKey, authorizedPubkey), - authorizedPubkey: localStorage.getItem(storageKey) ?? undefined, + onAuthorize(authorizedPubkey) { + localStorage.setItem(authStorageKey, authorizedPubkey); + sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); + }, + authorizedPubkey: localStorage.getItem(authStorageKey) ?? undefined, + getSecret: () => sessionStorage.getItem(secretStorageKey)!, }); return () => { diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx index 1338d6ac5..f138a1754 100644 --- a/src/contexts/nostr-context.tsx +++ b/src/contexts/nostr-context.tsx @@ -2,7 +2,7 @@ import { NRelay, NRelay1, NostrSigner } from '@nostrify/nostrify'; import React, { createContext, useContext, useState, useEffect, useMemo } from 'react'; import { NKeys } from 'soapbox/features/nostr/keys'; -import { useOwnAccount } from 'soapbox/hooks'; +import { useAppSelector, useOwnAccount } from 'soapbox/hooks'; import { useInstance } from 'soapbox/hooks/useInstance'; interface NostrContextType { @@ -23,7 +23,7 @@ export const NostrProvider: React.FC = ({ children }) => { const { account } = useOwnAccount(); const url = instance.nostr?.relay; - const accountPubkey = account?.nostr.pubkey; + const accountPubkey = useAppSelector((state) => account?.nostr.pubkey ?? state.meta.pubkey); const signer = useMemo( () => (accountPubkey ? NKeys.get(accountPubkey) : undefined) ?? window.nostr, diff --git a/src/features/nostr/NConnect.ts b/src/features/nostr/NConnect.ts index 8679e62bc..bfbea45da 100644 --- a/src/features/nostr/NConnect.ts +++ b/src/features/nostr/NConnect.ts @@ -5,6 +5,7 @@ interface NConnectOpts { signer: NostrSigner; authorizedPubkey: string | undefined; onAuthorize(pubkey: string): void; + getSecret(): string; } export class NConnect { @@ -13,8 +14,8 @@ export class NConnect { private signer: NostrSigner; private authorizedPubkey: string | undefined; private onAuthorize: (pubkey: string) => void; + private getSecret: () => string; - public secret = crypto.randomUUID(); private controller = new AbortController(); constructor(opts: NConnectOpts) { @@ -22,6 +23,7 @@ export class NConnect { this.signer = opts.signer; this.authorizedPubkey = opts.authorizedPubkey; this.onAuthorize = opts.onAuthorize; + this.getSecret = opts.getSecret; this.open(); } @@ -120,8 +122,7 @@ export class NConnect { private async handleConnect(pubkey: string, request: NostrConnectRequest & { method: 'connect' }) { const [remotePubkey, secret] = request.params; - if (secret === this.secret && remotePubkey === await this.signer.getPublicKey()) { - this.secret = crypto.randomUUID(); + if (secret === this.getSecret() && remotePubkey === await this.signer.getPublicKey()) { this.authorizedPubkey = pubkey; this.onAuthorize(pubkey); diff --git a/src/reducers/meta.ts b/src/reducers/meta.ts index 923a89c1b..a89f5f323 100644 --- a/src/reducers/meta.ts +++ b/src/reducers/meta.ts @@ -1,6 +1,7 @@ import { Record as ImmutableRecord } from 'immutable'; import { fetchInstance } from 'soapbox/actions/instance'; +import { NOSTR_PUBKEY_SET } from 'soapbox/actions/nostr'; import { SW_UPDATING } from 'soapbox/actions/sw'; import type { AnyAction } from 'redux'; @@ -10,6 +11,8 @@ const ReducerRecord = ImmutableRecord({ instance_fetch_failed: false, /** Whether the ServiceWorker is currently updating (and we should display a loading screen). */ swUpdating: false, + /** User's nostr pubkey. */ + pubkey: undefined as string | undefined, }); export default function meta(state = ReducerRecord(), action: AnyAction) { @@ -21,6 +24,8 @@ export default function meta(state = ReducerRecord(), action: AnyAction) { return state; case SW_UPDATING: return state.set('swUpdating', action.isUpdating); + case NOSTR_PUBKEY_SET: + return state.set('pubkey', action.pubkey); default: return state; } From 1430149292e08c1080c0b97db6426501e09a3c99 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 May 2024 16:15:04 -0500 Subject: [PATCH 13/13] grant_type nostr -> nostr_bunker --- src/actions/nostr.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index f40f85813..3ef64339d 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -18,7 +18,7 @@ function logInNostr(pubkey: string) { const relay = getState().instance.nostr?.relay; const token = await dispatch(obtainOAuthToken({ - grant_type: 'nostr', + grant_type: 'nostr_bunker', pubkey, relays: relay ? [relay] : undefined, secret,