Merge branch 'main' into feat-choose-zaps-amount

(((Updating local branch)))
This commit is contained in:
P. Reis 2024-05-29 22:02:07 -03:00
commit f7afa31fb8
28 changed files with 270 additions and 321 deletions

View File

@ -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"
},

View File

@ -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);

View File

@ -99,5 +99,6 @@ export const loginWithCode = (code: string) =>
.then((token: Record<string, string | number>) => 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 = '/');
};

View File

@ -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;

View File

@ -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_bunker',
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 };
function setNostrPubkey(pubkey: string) {
return {
type: NOSTR_PUBKEY_SET,
pubkey,
};
}
export { logInNostr, nostrExtensionLogIn, setNostrPubkey, NOSTR_PUBKEY_SET };

View File

@ -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<string, string | undefined>, baseURL?: string) =>
export const obtainOAuthToken = (params: Record<string, unknown>, baseURL?: string) =>
(dispatch: AppDispatch) => {
dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params });
return baseClient(null, baseURL).post('/oauth/token', params).then(({ data: token }) => {

View File

@ -66,7 +66,7 @@ const fetchSoapboxConfig = (host: string | null) =>
dispatch(importSoapboxConfig(data.soapbox_fe, host));
return data.soapbox_fe;
} else {
return dispatch(fetchSoapboxJson(host));
return dispatch(soapboxConfigFail(new Error('Not found'), host));
}
});
} else {

View File

@ -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<PatronUser>(
[Entities.PATRON_USERS, url || ''],
() => api.get(`/api/patron/v1/accounts/${encodeURIComponent(url!)}`),
{ schema: patronUserSchema, enabled: !!url },
{ schema: patronUserSchema, enabled: patronEnabled && !!url },
);
return { patronUser, ...result };

View File

@ -1,146 +1,43 @@
import { NostrEvent, NostrConnectResponse, NSchema as n } from '@nostrify/nostrify';
import { useEffect, useState } from 'react';
import { WebsocketEvent } from 'websocket-ts';
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, pubkey, signer } = useNostr();
const { relay, signer } = useNostr();
const [pubkey, setPubkey] = useState<string | undefined>(undefined);
const [opened, setOpened] = useState(false);
const [isConnected, setIsConnected] = useState(false);
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 handleEvent(event: NostrEvent) {
switch (event.kind) {
case 24133:
await handleConnectEvent(event);
break;
}
}
const authStorageKey = `soapbox:nostr:auth:${pubkey}`;
useEffect(() => {
if (relay?.socket.readyState === WebSocket.OPEN) {
setOpened(true);
if (signer) {
signer.getPublicKey().then(setPubkey).catch(console.warn);
}
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]);
}, [signer]);
useEffect(() => {
if (!relay || !pubkey) return;
if (!relay || !signer || !pubkey) return;
const controller = new AbortController();
const signal = controller.signal;
(async() => {
for await (const msg of relay.req([{ kinds: [24133], authors: [pubkey], limit: 0 }], { signal })) {
if (msg[0] === 'EVENT') handleEvent(msg[2]);
}
})();
const connect = new NConnect({
relay,
signer,
onAuthorize(authorizedPubkey) {
localStorage.setItem(authStorageKey, authorizedPubkey);
sessionStorage.setItem(secretStorageKey, crypto.randomUUID());
},
authorizedPubkey: localStorage.getItem(authStorageKey) ?? undefined,
getSecret: () => sessionStorage.getItem(secretStorageKey)!,
});
return () => {
controller.abort();
connect.close();
};
}, [relay, pubkey, signer]);
return { opened, isConnected };
}, [relay, signer, pubkey]);
}
export { useSignerStream };

View File

@ -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;

View File

@ -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 && (
<SidebarLink
to={groupsPath}
to='/groups'
icon={require('@tabler/icons/outline/circles.svg')}
text={intl.formatMessage(messages.groups)}
onClick={onClose}

View File

@ -4,7 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Stack } from 'soapbox/components/ui';
import { useStatContext } from 'soapbox/contexts/stat-context';
import ComposeButton from 'soapbox/features/ui/components/compose-button';
import { useAppSelector, useGroupsPath, useFeatures, useOwnAccount, useSettings, useInstance } from 'soapbox/hooks';
import { useAppSelector, useFeatures, useOwnAccount, useSettings, useInstance } from 'soapbox/hooks';
import DropdownMenu, { Menu } from './dropdown-menu';
import SidebarNavigationLink from './sidebar-navigation-link';
@ -26,7 +26,6 @@ const SidebarNavigation = () => {
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 && (
<SidebarNavigationLink
to={groupsPath}
to='/groups'
icon={require('@tabler/icons/outline/circles.svg')}
activeIcon={require('@tabler/icons/filled/circles.svg')}
text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />}

View File

@ -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={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />}
to={groupsPath}
to='/groups'
exact
/>
)}

View File

@ -1,13 +1,12 @@
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';
import { useOwnAccount } from 'soapbox/hooks';
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { useInstance } from 'soapbox/hooks/useInstance';
interface NostrContextType {
relay?: NRelay1;
pubkey?: string;
relay?: NRelay;
signer?: NostrSigner;
}
@ -24,8 +23,7 @@ export const NostrProvider: React.FC<NostrProviderProps> = ({ children }) => {
const { account } = useOwnAccount();
const url = instance.nostr?.relay;
const pubkey = instance.nostr?.pubkey;
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,
@ -42,7 +40,7 @@ export const NostrProvider: React.FC<NostrProviderProps> = ({ children }) => {
}, [url]);
return (
<NostrContext.Provider value={{ relay, pubkey, signer }}>
<NostrContext.Provider value={{ relay, signer }}>
{children}
</NostrContext.Provider>
);

View File

@ -464,7 +464,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
});
}
if (!account.local) {
if (!account.local && features.domainBlocks) {
const domain = account.fqn.split('@')[1];
menu.push(null);

View File

@ -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<IManageGroup> = ({ params }) => {
const backend = useBackend();
const dispatch = useAppDispatch();
const groupsPath = useGroupsPath();
const history = useHistory();
const intl = useIntl();
@ -70,7 +69,7 @@ const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
deleteGroup.mutate(group.id, {
onSuccess() {
toast.success(intl.formatMessage(messages.deleteSuccess));
history.push(groupsPath);
history.push('/groups');
},
});
},

View File

@ -0,0 +1,151 @@
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;
getSecret(): string;
}
export class NConnect {
private relay: NRelay;
private signer: NostrSigner;
private authorizedPubkey: string | undefined;
private onAuthorize: (pubkey: string) => void;
private getSecret: () => string;
private controller = new AbortController();
constructor(opts: NConnectOpts) {
this.relay = opts.relay;
this.signer = opts.signer;
this.authorizedPubkey = opts.authorizedPubkey;
this.onAuthorize = opts.onAuthorize;
this.getSecret = opts.getSecret;
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<void> {
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<void> {
// 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.getSecret() && remotePubkey === await this.signer.getPublicKey()) {
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();
}
}

View File

@ -53,7 +53,7 @@ const LinkFooter: React.FC = (): JSX.Element => {
{features.followedHashtagsList && (
<FooterLink to='/followed_tags'><FormattedMessage id='navigation_bar.followed_tags' defaultMessage='Followed hashtags' /></FooterLink>
)}
{features.federating && (
{features.domainBlocks && (
<FooterLink to='/domain_blocks'><FormattedMessage id='navigation_bar.domain_blocks' defaultMessage='Domain blocks' /></FooterLink>
)}
{account.admin && (

View File

@ -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';
@ -268,7 +269,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<WrappedRoute path='/follow_requests' page={DefaultPage} component={FollowRequests} content={children} />
{features.blocks && <WrappedRoute path='/blocks' page={DefaultPage} component={Blocks} content={children} />}
{features.federating && <WrappedRoute path='/domain_blocks' page={DefaultPage} component={DomainBlocks} content={children} />}
{features.domainBlocks && <WrappedRoute path='/domain_blocks' page={DefaultPage} component={DomainBlocks} content={children} />}
<WrappedRoute path='/mutes' page={DefaultPage} component={Mutes} content={children} />
{(features.filters || features.filtersV2) && <WrappedRoute path='/filters/new' page={DefaultPage} component={EditFilter} content={children} />}
{(features.filters || features.filtersV2) && <WrappedRoute path='/filters/:id' page={DefaultPage} component={EditFilter} content={children} />}
@ -461,6 +462,7 @@ const UI: React.FC<IUI> = ({ children }) => {
}, []);
useUserStream();
useSignerStream();
// The user has logged in
useEffect(() => {

View File

@ -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';

View File

@ -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');
});
});
});
});
});

View File

@ -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 };

View File

@ -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<ISoapboxInstance> = ({ 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 <LoadingScreen />;
}
return <>{children}</>;
};
export default SoapboxInstance;

View File

@ -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());
};
};

View File

@ -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 = () => {
<QueryClientProvider client={queryClient}>
<StatProvider>
<NostrProvider>
<SoapboxInstance>
<SoapboxHead>
<SoapboxLoad>
<SoapboxMount />
</SoapboxLoad>
</SoapboxHead>
</SoapboxInstance>
</NostrProvider>
</StatProvider>
</QueryClientProvider>

View File

@ -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;
}

View File

@ -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 };
export { signedEventSchema };

View File

@ -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
@ -529,6 +540,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. */