Merge remote-tracking branch 'origin/main' into add-nip-05-modal
This commit is contained in:
commit
93bba08567
|
@ -133,7 +133,7 @@
|
||||||
"no-irregular-whitespace": "error",
|
"no-irregular-whitespace": "error",
|
||||||
"no-loop-func": "error",
|
"no-loop-func": "error",
|
||||||
"no-mixed-spaces-and-tabs": "error",
|
"no-mixed-spaces-and-tabs": "error",
|
||||||
"no-nested-ternary": "warn",
|
"no-nested-ternary": "error",
|
||||||
"no-restricted-imports": [
|
"no-restricted-imports": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
|
@ -317,7 +317,7 @@
|
||||||
|
|
||||||
"formatjs/enforce-default-message": "error",
|
"formatjs/enforce-default-message": "error",
|
||||||
"formatjs/enforce-id": "error",
|
"formatjs/enforce-id": "error",
|
||||||
"formatjs/no-literal-string-in-jsx": "warn"
|
"formatjs/no-literal-string-in-jsx": "error"
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
|
|
|
@ -35,7 +35,7 @@
|
||||||
},
|
},
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"> 0.5%",
|
"> 1%",
|
||||||
"last 2 versions",
|
"last 2 versions",
|
||||||
"not dead"
|
"not dead"
|
||||||
],
|
],
|
||||||
|
@ -137,7 +137,6 @@
|
||||||
"react-redux": "^9.0.4",
|
"react-redux": "^9.0.4",
|
||||||
"react-router-dom": "^5.3.0",
|
"react-router-dom": "^5.3.0",
|
||||||
"react-router-dom-v5-compat": "^6.6.2",
|
"react-router-dom-v5-compat": "^6.6.2",
|
||||||
"react-router-scroll-4": "^1.0.0-beta.2",
|
|
||||||
"react-simple-pull-to-refresh": "^1.3.3",
|
"react-simple-pull-to-refresh": "^1.3.3",
|
||||||
"react-sparklines": "^1.7.0",
|
"react-sparklines": "^1.7.0",
|
||||||
"react-sticky-box": "^2.0.0",
|
"react-sticky-box": "^2.0.0",
|
||||||
|
@ -156,7 +155,8 @@
|
||||||
"vite-plugin-html": "^3.2.2",
|
"vite-plugin-html": "^3.2.2",
|
||||||
"vite-plugin-require": "^1.2.14",
|
"vite-plugin-require": "^1.2.14",
|
||||||
"vite-plugin-static-copy": "^1.0.6",
|
"vite-plugin-static-copy": "^1.0.6",
|
||||||
"zod": "^3.23.5"
|
"zod": "^3.23.5",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@formatjs/cli": "^6.2.0",
|
"@formatjs/cli": "^6.2.0",
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
|
||||||
|
|
||||||
import { staticClient } from 'soapbox/api';
|
import { staticClient } from 'soapbox/api';
|
||||||
import { mockStore } from 'soapbox/jest/test-helpers';
|
import { mockStore } from 'soapbox/jest/test-helpers';
|
||||||
|
@ -23,7 +22,7 @@ describe('fetchAboutPage()', () => {
|
||||||
{ type: FETCH_ABOUT_PAGE_REQUEST, slug: 'index' },
|
{ type: FETCH_ABOUT_PAGE_REQUEST, slug: 'index' },
|
||||||
{ type: FETCH_ABOUT_PAGE_SUCCESS, slug: 'index', html: '<h1>Hello world</h1>' },
|
{ type: FETCH_ABOUT_PAGE_SUCCESS, slug: 'index', html: '<h1>Hello world</h1>' },
|
||||||
];
|
];
|
||||||
const store = mockStore(ImmutableMap());
|
const store = mockStore({});
|
||||||
|
|
||||||
return store.dispatch(fetchAboutPage()).then(() => {
|
return store.dispatch(fetchAboutPage()).then(() => {
|
||||||
expect(store.getActions()).toEqual(expectedActions);
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
@ -35,7 +34,7 @@ describe('fetchAboutPage()', () => {
|
||||||
{ type: FETCH_ABOUT_PAGE_REQUEST, slug: 'asdf' },
|
{ type: FETCH_ABOUT_PAGE_REQUEST, slug: 'asdf' },
|
||||||
{ type: FETCH_ABOUT_PAGE_FAIL, slug: 'asdf', error: new Error('Request failed with status code 404') },
|
{ type: FETCH_ABOUT_PAGE_FAIL, slug: 'asdf', error: new Error('Request failed with status code 404') },
|
||||||
];
|
];
|
||||||
const store = mockStore(ImmutableMap());
|
const store = mockStore({});
|
||||||
|
|
||||||
return store.dispatch(fetchAboutPage('asdf')).catch(() => {
|
return store.dispatch(fetchAboutPage('asdf')).catch(() => {
|
||||||
expect(store.getActions()).toEqual(expectedActions);
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
|
|
@ -149,18 +149,15 @@ function closeReports(ids: string[]) {
|
||||||
return patchReports(ids, 'closed');
|
return patchReports(ids, 'closed');
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchUsers(filters: string[] = [], page = 1, query?: string | null, pageSize = 50, url?: string | null) {
|
function fetchUsers(filters: Record<string, boolean>, page = 1, query?: string | null, pageSize = 50, url?: string | null) {
|
||||||
return async (dispatch: AppDispatch, getState: () => RootState) => {
|
return async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
dispatch({ type: ADMIN_USERS_FETCH_REQUEST, filters, page, pageSize });
|
dispatch({ type: ADMIN_USERS_FETCH_REQUEST, filters, page, pageSize });
|
||||||
|
|
||||||
const params: Record<string, any> = {
|
const params: Record<string, any> = {
|
||||||
|
...filters,
|
||||||
username: query,
|
username: query,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (filters.includes('local')) params.local = true;
|
|
||||||
if (filters.includes('active')) params.active = true;
|
|
||||||
if (filters.includes('need_approval')) params.pending = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: accounts, ...response } = await api(getState).get(url || '/api/v1/admin/accounts', { params });
|
const { data: accounts, ...response } = await api(getState).get(url || '/api/v1/admin/accounts', { params });
|
||||||
const next = getLinks(response as AxiosResponse<any, any>).refs.find(link => link.rel === 'next')?.uri;
|
const next = getLinks(response as AxiosResponse<any, any>).refs.find(link => link.rel === 'next')?.uri;
|
||||||
|
|
|
@ -92,8 +92,8 @@ const createAppToken = () =>
|
||||||
const app = getState().auth.app;
|
const app = getState().auth.app;
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
client_id: app.client_id!,
|
client_id: app?.client_id,
|
||||||
client_secret: app.client_secret!,
|
client_secret: app?.client_secret,
|
||||||
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
|
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
|
||||||
grant_type: 'client_credentials',
|
grant_type: 'client_credentials',
|
||||||
scope: getScopes(getState()),
|
scope: getScopes(getState()),
|
||||||
|
@ -109,8 +109,8 @@ const createUserToken = (username: string, password: string) =>
|
||||||
const app = getState().auth.app;
|
const app = getState().auth.app;
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
client_id: app.client_id!,
|
client_id: app?.client_id,
|
||||||
client_secret: app.client_secret!,
|
client_secret: app?.client_secret,
|
||||||
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
|
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
|
||||||
grant_type: 'password',
|
grant_type: 'password',
|
||||||
username: username,
|
username: username,
|
||||||
|
@ -126,8 +126,8 @@ export const otpVerify = (code: string, mfa_token: string) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
const app = getState().auth.app;
|
const app = getState().auth.app;
|
||||||
return api(getState, 'app').post('/oauth/mfa/challenge', {
|
return api(getState, 'app').post('/oauth/mfa/challenge', {
|
||||||
client_id: app.client_id,
|
client_id: app?.client_id,
|
||||||
client_secret: app.client_secret,
|
client_secret: app?.client_secret,
|
||||||
mfa_token: mfa_token,
|
mfa_token: mfa_token,
|
||||||
code: code,
|
code: code,
|
||||||
challenge_type: 'totp',
|
challenge_type: 'totp',
|
||||||
|
@ -208,12 +208,12 @@ export const logOut = (refresh = true) =>
|
||||||
if (!account) return dispatch(noOp);
|
if (!account) return dispatch(noOp);
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
client_id: state.auth.app.client_id!,
|
client_id: state.auth.app?.client_id,
|
||||||
client_secret: state.auth.app.client_secret!,
|
client_secret: state.auth.app?.client_secret,
|
||||||
token: state.auth.users.get(account.url)!.access_token,
|
token: state.auth.users[account.url]?.access_token,
|
||||||
};
|
};
|
||||||
|
|
||||||
return dispatch(revokeOAuthToken(params))
|
return dispatch(revokeOAuthToken(params as Record<string, string>))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
// Clear all stored cache from React Query
|
// Clear all stored cache from React Query
|
||||||
queryClient.invalidateQueries();
|
queryClient.invalidateQueries();
|
||||||
|
@ -246,7 +246,7 @@ export const switchAccount = (accountId: string, background = false) =>
|
||||||
export const fetchOwnAccounts = () =>
|
export const fetchOwnAccounts = () =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
return state.auth.users.forEach((user) => {
|
return Object.values(state.auth.users).forEach((user) => {
|
||||||
const account = selectAccount(state, user.id);
|
const account = selectAccount(state, user.id);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
dispatch(verifyCredentials(user.access_token, user.url))
|
dispatch(verifyCredentials(user.access_token, user.url))
|
||||||
|
|
|
@ -1,121 +0,0 @@
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
|
||||||
|
|
||||||
import { __stub } from 'soapbox/api';
|
|
||||||
import { buildAccount } from 'soapbox/jest/factory';
|
|
||||||
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
|
|
||||||
import { AuthUserRecord, ReducerRecord } from 'soapbox/reducers/auth';
|
|
||||||
|
|
||||||
import { fetchMe, patchMe } from './me';
|
|
||||||
|
|
||||||
vi.mock('../../storage/kv-store', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: {
|
|
||||||
getItemOrError: vi.fn().mockReturnValue(Promise.resolve({})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
let store: ReturnType<typeof mockStore>;
|
|
||||||
|
|
||||||
describe('fetchMe()', () => {
|
|
||||||
describe('without a token', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
const state = rootState;
|
|
||||||
store = mockStore(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('dispatches the correct actions', async() => {
|
|
||||||
const expectedActions = [{ type: 'ME_FETCH_SKIP' }];
|
|
||||||
await store.dispatch(fetchMe());
|
|
||||||
const actions = store.getActions();
|
|
||||||
|
|
||||||
expect(actions).toEqual(expectedActions);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with a token', () => {
|
|
||||||
const accountUrl = 'accountUrl';
|
|
||||||
const token = '123';
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const state = {
|
|
||||||
...rootState,
|
|
||||||
auth: ReducerRecord({
|
|
||||||
me: accountUrl,
|
|
||||||
users: ImmutableMap({
|
|
||||||
[accountUrl]: AuthUserRecord({
|
|
||||||
'access_token': token,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
entities: {
|
|
||||||
'ACCOUNTS': {
|
|
||||||
store: {
|
|
||||||
[accountUrl]: buildAccount({ url: accountUrl }),
|
|
||||||
},
|
|
||||||
lists: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
store = mockStore(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with a successful API response', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
__stub((mock) => {
|
|
||||||
mock.onGet('/api/v1/accounts/verify_credentials').reply(200, {});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('dispatches the correct actions', async() => {
|
|
||||||
const expectedActions = [
|
|
||||||
{ type: 'ME_FETCH_REQUEST' },
|
|
||||||
{ type: 'AUTH_ACCOUNT_REMEMBER_REQUEST', accountUrl },
|
|
||||||
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
|
|
||||||
{
|
|
||||||
type: 'AUTH_ACCOUNT_REMEMBER_SUCCESS',
|
|
||||||
account: {},
|
|
||||||
accountUrl,
|
|
||||||
},
|
|
||||||
{ type: 'VERIFY_CREDENTIALS_REQUEST', token: '123' },
|
|
||||||
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
|
|
||||||
{ type: 'VERIFY_CREDENTIALS_SUCCESS', token: '123', account: {} },
|
|
||||||
];
|
|
||||||
await store.dispatch(fetchMe());
|
|
||||||
const actions = store.getActions();
|
|
||||||
|
|
||||||
expect(actions).toEqual(expectedActions);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('patchMe()', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
const state = rootState;
|
|
||||||
store = mockStore(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with a successful API response', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
__stub((mock) => {
|
|
||||||
mock.onPatch('/api/v1/accounts/update_credentials').reply(200, {});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('dispatches the correct actions', async() => {
|
|
||||||
const expectedActions = [
|
|
||||||
{ type: 'ME_PATCH_REQUEST' },
|
|
||||||
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
|
|
||||||
{
|
|
||||||
type: 'ME_PATCH_SUCCESS',
|
|
||||||
me: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
await store.dispatch(patchMe({}));
|
|
||||||
const actions = store.getActions();
|
|
||||||
|
|
||||||
expect(actions).toEqual(expectedActions);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -33,11 +33,11 @@ const getMeUrl = (state: RootState) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMeToken = (state: RootState) => {
|
function getMeToken(state: RootState): string | undefined {
|
||||||
// Fallback for upgrading IDs to URLs
|
// Fallback for upgrading IDs to URLs
|
||||||
const accountUrl = getMeUrl(state) || state.auth.me;
|
const accountUrl = getMeUrl(state) || state.auth.me;
|
||||||
return state.auth.users.get(accountUrl!)?.access_token;
|
return state.auth.users[accountUrl!]?.access_token;
|
||||||
};
|
}
|
||||||
|
|
||||||
const fetchMe = () =>
|
const fetchMe = () =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
import { RootState, type AppDispatch } from 'soapbox/store';
|
import { NostrSigner, NRelay1, NSecSigner } from '@nostrify/nostrify';
|
||||||
|
import { generateSecretKey } from 'nostr-tools';
|
||||||
|
|
||||||
|
import { NBunker } from 'soapbox/features/nostr/NBunker';
|
||||||
|
import { keyring } from 'soapbox/features/nostr/keyring';
|
||||||
|
import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore';
|
||||||
|
import { type AppDispatch } from 'soapbox/store';
|
||||||
|
|
||||||
import { authLoggedIn, verifyCredentials } from './auth';
|
import { authLoggedIn, verifyCredentials } from './auth';
|
||||||
import { obtainOAuthToken } from './oauth';
|
import { obtainOAuthToken } from './oauth';
|
||||||
|
@ -6,42 +12,83 @@ import { obtainOAuthToken } from './oauth';
|
||||||
const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET';
|
const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET';
|
||||||
|
|
||||||
/** Log in with a Nostr pubkey. */
|
/** Log in with a Nostr pubkey. */
|
||||||
function logInNostr(pubkey: string) {
|
function logInNostr(signer: NostrSigner, relay: NRelay1) {
|
||||||
return async (dispatch: AppDispatch, getState: () => RootState) => {
|
return async (dispatch: AppDispatch) => {
|
||||||
dispatch(setNostrPubkey(pubkey));
|
const authorization = generateBunkerAuth();
|
||||||
|
|
||||||
const secret = sessionStorage.getItem('soapbox:nip46:secret');
|
const pubkey = await signer.getPublicKey();
|
||||||
if (!secret) {
|
const bunkerPubkey = await authorization.signer.getPublicKey();
|
||||||
throw new Error('No secret found in session storage');
|
|
||||||
|
let authorizedPubkey: string | undefined;
|
||||||
|
|
||||||
|
const bunker = new NBunker({
|
||||||
|
relay,
|
||||||
|
userSigner: signer,
|
||||||
|
bunkerSigner: authorization.signer,
|
||||||
|
onConnect(request, event) {
|
||||||
|
const [, secret] = request.params;
|
||||||
|
|
||||||
|
if (secret === authorization.secret) {
|
||||||
|
bunker.authorize(event.pubkey);
|
||||||
|
authorizedPubkey = event.pubkey;
|
||||||
|
return { id: request.id, result: 'ack' };
|
||||||
|
} else {
|
||||||
|
return { id: request.id, result: '', error: 'Invalid secret' };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const relay = getState().instance.nostr?.relay;
|
await bunker.waitReady;
|
||||||
|
|
||||||
// HACK: waits 1 second to ensure the relay subscription is open
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
const token = await dispatch(obtainOAuthToken({
|
const token = await dispatch(obtainOAuthToken({
|
||||||
grant_type: 'nostr_bunker',
|
grant_type: 'nostr_bunker',
|
||||||
pubkey,
|
pubkey: bunkerPubkey,
|
||||||
relays: relay ? [relay] : undefined,
|
relays: [relay.socket.url],
|
||||||
secret,
|
secret: authorization.secret,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
dispatch(setNostrPubkey(undefined));
|
if (!authorizedPubkey) {
|
||||||
|
throw new Error('Authorization failed');
|
||||||
|
}
|
||||||
|
|
||||||
const { access_token } = dispatch(authLoggedIn(token));
|
const accessToken = dispatch(authLoggedIn(token)).access_token as string;
|
||||||
return await dispatch(verifyCredentials(access_token as string));
|
const bunkerState = useBunkerStore.getState();
|
||||||
|
|
||||||
|
keyring.add(authorization.seckey);
|
||||||
|
|
||||||
|
bunkerState.connect({
|
||||||
|
pubkey,
|
||||||
|
accessToken,
|
||||||
|
authorizedPubkey,
|
||||||
|
bunkerPubkey,
|
||||||
|
});
|
||||||
|
|
||||||
|
await dispatch(verifyCredentials(accessToken));
|
||||||
|
|
||||||
|
// TODO: get rid of `vite-plugin-require` and switch to `using` for the bunker. :(
|
||||||
|
bunker.close();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Log in with a Nostr extension. */
|
/** Log in with a Nostr extension. */
|
||||||
function nostrExtensionLogIn() {
|
function nostrExtensionLogIn(relay: NRelay1) {
|
||||||
return async (dispatch: AppDispatch) => {
|
return async (dispatch: AppDispatch) => {
|
||||||
if (!window.nostr) {
|
if (!window.nostr) {
|
||||||
throw new Error('No Nostr signer available');
|
throw new Error('No Nostr signer available');
|
||||||
}
|
}
|
||||||
const pubkey = await window.nostr.getPublicKey();
|
return dispatch(logInNostr(window.nostr, relay));
|
||||||
return dispatch(logInNostr(pubkey));
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a bunker authorization object. */
|
||||||
|
function generateBunkerAuth() {
|
||||||
|
const secret = crypto.randomUUID();
|
||||||
|
const seckey = generateSecretKey();
|
||||||
|
|
||||||
|
return {
|
||||||
|
secret,
|
||||||
|
seckey,
|
||||||
|
signer: new NSecSigner(seckey),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
|
||||||
|
|
||||||
import { __stub } from 'soapbox/api';
|
import { __stub } from 'soapbox/api';
|
||||||
import { mockStore } from 'soapbox/jest/test-helpers';
|
import { mockStore } from 'soapbox/jest/test-helpers';
|
||||||
|
|
||||||
|
@ -19,7 +17,7 @@ describe('preloadMastodon()', () => {
|
||||||
.reply(200, {});
|
.reply(200, {});
|
||||||
});
|
});
|
||||||
|
|
||||||
const store = mockStore(ImmutableMap());
|
const store = mockStore({});
|
||||||
store.dispatch(preloadMastodon(data));
|
store.dispatch(preloadMastodon(data));
|
||||||
const actions = store.getActions();
|
const actions = store.getActions();
|
||||||
|
|
||||||
|
|
|
@ -241,7 +241,8 @@ const saveSettings = (opts?: SettingOpts) =>
|
||||||
const getLocale = (state: RootState, fallback = 'en') => {
|
const getLocale = (state: RootState, fallback = 'en') => {
|
||||||
const localeWithVariant = (getSettings(state).get('locale') as string).replace('_', '-');
|
const localeWithVariant = (getSettings(state).get('locale') as string).replace('_', '-');
|
||||||
const locale = localeWithVariant.split('-')[0];
|
const locale = localeWithVariant.split('-')[0];
|
||||||
return Object.keys(messages).includes(localeWithVariant) ? localeWithVariant : Object.keys(messages).includes(locale) ? locale : fallback;
|
const fallbackLocale = Object.keys(messages).includes(locale) ? locale : fallback;
|
||||||
|
return Object.keys(messages).includes(localeWithVariant) ? localeWithVariant : fallbackLocale;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingsAction =
|
type SettingsAction =
|
||||||
|
|
|
@ -3,16 +3,25 @@ import { useEntities } from 'soapbox/entity-store/hooks';
|
||||||
import { useApi } from 'soapbox/hooks';
|
import { useApi } from 'soapbox/hooks';
|
||||||
import { adminAccountSchema } from 'soapbox/schemas/admin-account';
|
import { adminAccountSchema } from 'soapbox/schemas/admin-account';
|
||||||
|
|
||||||
type Filter = 'local' | 'remote' | 'active' | 'pending' | 'disabled' | 'silenced' | 'suspended' | 'sensitized';
|
interface MastodonAdminFilters {
|
||||||
|
local?: boolean;
|
||||||
|
remote?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
pending?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
silenced?: boolean;
|
||||||
|
suspended?: boolean;
|
||||||
|
sensitized?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/admin/accounts/#v1 */
|
/** https://docs.joinmastodon.org/methods/admin/accounts/#v1 */
|
||||||
export function useAdminAccounts(filters: Filter[] = [], limit?: number) {
|
export function useAdminAccounts(filters: MastodonAdminFilters, limit?: number) {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
for (const filter of filters) {
|
for (const [name, value] of Object.entries(filters)) {
|
||||||
searchParams.append(filter, 'true');
|
searchParams.append(name, value.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof limit === 'number') {
|
if (typeof limit === 'number') {
|
||||||
|
|
|
@ -1,64 +0,0 @@
|
||||||
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 [isSubscribed, setIsSubscribed] = useState(false);
|
|
||||||
const [isSubscribing, setIsSubscribing] = useState(true);
|
|
||||||
|
|
||||||
const { relay, signer, hasNostr } = useNostr();
|
|
||||||
const [pubkey, setPubkey] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
const authStorageKey = `soapbox:nostr:auth:${pubkey}`;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isCancelled = false;
|
|
||||||
|
|
||||||
if (signer && hasNostr) {
|
|
||||||
signer.getPublicKey().then((newPubkey) => {
|
|
||||||
if (!isCancelled) {
|
|
||||||
setPubkey(newPubkey);
|
|
||||||
}
|
|
||||||
}).catch(console.warn);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isCancelled = true;
|
|
||||||
};
|
|
||||||
}, [signer, hasNostr]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!relay || !signer || !pubkey) return;
|
|
||||||
|
|
||||||
const connect = new NConnect({
|
|
||||||
relay,
|
|
||||||
signer,
|
|
||||||
onAuthorize(authorizedPubkey) {
|
|
||||||
localStorage.setItem(authStorageKey, authorizedPubkey);
|
|
||||||
sessionStorage.setItem(secretStorageKey, crypto.randomUUID());
|
|
||||||
},
|
|
||||||
onSubscribed() {
|
|
||||||
setIsSubscribed(true);
|
|
||||||
setIsSubscribing(false);
|
|
||||||
},
|
|
||||||
authorizedPubkey: localStorage.getItem(authStorageKey) ?? undefined,
|
|
||||||
getSecret: () => sessionStorage.getItem(secretStorageKey)!,
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
connect.close();
|
|
||||||
};
|
|
||||||
}, [relay, signer, pubkey]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isSubscribed,
|
|
||||||
isSubscribing,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useSignerStream };
|
|
|
@ -239,7 +239,7 @@ const Account = ({
|
||||||
|
|
||||||
<Stack space={withAccountNote || note ? 1 : 0}>
|
<Stack space={withAccountNote || note ? 1 : 0}>
|
||||||
<HStack alignItems='center' space={1}>
|
<HStack alignItems='center' space={1}>
|
||||||
<Text theme='muted' size='sm' direction='ltr' truncate>@{acct ?? username}</Text>
|
<Text theme='muted' size='sm' direction='ltr' truncate>@{acct ?? username}</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
|
|
||||||
{account.pleroma?.favicon && (
|
{account.pleroma?.favicon && (
|
||||||
<InstanceFavicon account={account} disabled={!withLinkToProfile} />
|
<InstanceFavicon account={account} disabled={!withLinkToProfile} />
|
||||||
|
@ -247,7 +247,7 @@ const Account = ({
|
||||||
|
|
||||||
{(timestamp) ? (
|
{(timestamp) ? (
|
||||||
<>
|
<>
|
||||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
<Text tag='span' theme='muted' size='sm'>·</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
|
|
||||||
{timestampUrl ? (
|
{timestampUrl ? (
|
||||||
<Link to={timestampUrl} className='hover:underline' onClick={(event) => event.stopPropagation()}>
|
<Link to={timestampUrl} className='hover:underline' onClick={(event) => event.stopPropagation()}>
|
||||||
|
@ -261,7 +261,7 @@ const Account = ({
|
||||||
|
|
||||||
{approvalStatus && ['pending', 'rejected'].includes(approvalStatus) && (
|
{approvalStatus && ['pending', 'rejected'].includes(approvalStatus) && (
|
||||||
<>
|
<>
|
||||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
<Text tag='span' theme='muted' size='sm'>·</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
|
|
||||||
<Text tag='span' theme='muted' size='sm'>
|
<Text tag='span' theme='muted' size='sm'>
|
||||||
{approvalStatus === 'pending'
|
{approvalStatus === 'pending'
|
||||||
|
@ -273,7 +273,7 @@ const Account = ({
|
||||||
|
|
||||||
{showEdit ? (
|
{showEdit ? (
|
||||||
<>
|
<>
|
||||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
<Text tag='span' theme='muted' size='sm'>·</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
|
|
||||||
<Icon className='size-5 text-gray-700 dark:text-gray-600' src={require('@tabler/icons/outline/pencil.svg')} />
|
<Icon className='size-5 text-gray-700 dark:text-gray-600' src={require('@tabler/icons/outline/pencil.svg')} />
|
||||||
</>
|
</>
|
||||||
|
@ -281,7 +281,7 @@ const Account = ({
|
||||||
|
|
||||||
{actionType === 'muting' && account.mute_expires_at ? (
|
{actionType === 'muting' && account.mute_expires_at ? (
|
||||||
<>
|
<>
|
||||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
<Text tag='span' theme='muted' size='sm'>·</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
|
|
||||||
<Text theme='muted' size='sm'><RelativeTimestamp timestamp={account.mute_expires_at} futureDate /></Text>
|
<Text theme='muted' size='sm'><RelativeTimestamp timestamp={account.mute_expires_at} futureDate /></Text>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -41,9 +41,11 @@ const Announcement: React.FC<IAnnouncement> = ({ announcement, emojiMap }) => {
|
||||||
hour={skipTime ? undefined : 'numeric'}
|
hour={skipTime ? undefined : 'numeric'}
|
||||||
minute={skipTime ? undefined : '2-digit'}
|
minute={skipTime ? undefined : '2-digit'}
|
||||||
/>
|
/>
|
||||||
|
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||||
{' '}
|
{' '}
|
||||||
-
|
-
|
||||||
{' '}
|
{' '}
|
||||||
|
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||||
<FormattedDate
|
<FormattedDate
|
||||||
value={endsAt}
|
value={endsAt}
|
||||||
hour12
|
hour12
|
||||||
|
|
|
@ -51,7 +51,7 @@ const Reaction: React.FC<IReaction> = ({ announcementId, reaction, emojiMap, sty
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
title={`:${shortCode}:`}
|
title={`:${shortCode}:`} // eslint-disable-line formatjs/no-literal-string-in-jsx
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<span className='block size-4'>
|
<span className='block size-4'>
|
||||||
|
|
|
@ -17,11 +17,11 @@ const AttachmentThumbs = (props: IAttachmentThumbs) => {
|
||||||
const { media, onClick, sensitive } = props;
|
const { media, onClick, sensitive } = props;
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const fallback = <div className='media-gallery--compact' />;
|
const fallback = <div className='!h-[50px] bg-transparent' />;
|
||||||
const onOpenMedia = (media: ImmutableList<Attachment>, index: number) => dispatch(openModal('MEDIA', { media, index }));
|
const onOpenMedia = (media: ImmutableList<Attachment>, index: number) => dispatch(openModal('MEDIA', { media, index }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='attachment-thumbs'>
|
<div className='relative'>
|
||||||
<Suspense fallback={fallback}>
|
<Suspense fallback={fallback}>
|
||||||
<MediaGallery
|
<MediaGallery
|
||||||
media={media}
|
media={media}
|
||||||
|
@ -34,7 +34,11 @@ const AttachmentThumbs = (props: IAttachmentThumbs) => {
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
{onClick && (
|
{onClick && (
|
||||||
<div className='attachment-thumbs__clickable-region' onClick={onClick} />
|
<button
|
||||||
|
className='absolute inset-0 size-full cursor-pointer'
|
||||||
|
onClick={onClick}
|
||||||
|
style={{ background: 'none', border: 'none', padding: 0 }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -31,12 +31,13 @@ const DisplayNameInline: React.FC<IDisplayName> = ({ account, withSuffix = true
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
const suffix = (<span className='display-name'>@{getAcct(account, displayFqn)}</span>);
|
const suffix = (<span className='display-name'>@{getAcct(account, displayFqn)}</span>);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex max-w-80 flex-col items-center justify-center text-center sm:flex-row sm:gap-2'>
|
<div className='flex max-w-80 flex-col items-center justify-center text-center sm:flex-row sm:gap-2'>
|
||||||
{displayName}
|
{displayName}
|
||||||
<span className='hidden text-xl font-bold sm:block'>-</span>
|
<span className='hidden text-xl font-bold sm:block'>-</span> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
{withSuffix && suffix}
|
{withSuffix && suffix}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -33,7 +33,7 @@ const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = t
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
|
|
||||||
const suffix = (<span className='display-name__account'>@{getAcct(account, displayFqn)}</span>);
|
const suffix = (<span className='display-name__account'>@{getAcct(account, displayFqn)}</span>); // eslint-disable-line formatjs/no-literal-string-in-jsx
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className='display-name' data-testid='display-name'>
|
<span className='display-name' data-testid='display-name'>
|
||||||
|
|
|
@ -20,7 +20,7 @@ const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
|
||||||
<HStack alignItems='center' justifyContent='between' data-testid='hashtag'>
|
<HStack alignItems='center' justifyContent='between' data-testid='hashtag'>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Link to={`/tags/${hashtag.name}`} className='hover:underline'>
|
<Link to={`/tags/${hashtag.name}`} className='hover:underline'>
|
||||||
<Text tag='span' size='sm' weight='semibold'>#{hashtag.name}</Text>
|
<Text tag='span' size='sm' weight='semibold'>#{hashtag.name}</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{Boolean(count) && (
|
{Boolean(count) && (
|
||||||
|
|
|
@ -2,7 +2,6 @@ import clsx from 'clsx';
|
||||||
import React, { useState, useRef, useLayoutEffect } from 'react';
|
import React, { useState, useRef, useLayoutEffect } from 'react';
|
||||||
|
|
||||||
import Blurhash from 'soapbox/components/blurhash';
|
import Blurhash from 'soapbox/components/blurhash';
|
||||||
import Icon from 'soapbox/components/icon';
|
|
||||||
import StillImage from 'soapbox/components/still-image';
|
import StillImage from 'soapbox/components/still-image';
|
||||||
import { MIMETYPE_ICONS } from 'soapbox/components/upload';
|
import { MIMETYPE_ICONS } from 'soapbox/components/upload';
|
||||||
import { useSettings, useSoapboxConfig } from 'soapbox/hooks';
|
import { useSettings, useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
@ -12,6 +11,8 @@ import { truncateFilename } from 'soapbox/utils/media';
|
||||||
import { isIOS } from '../is-mobile';
|
import { isIOS } from '../is-mobile';
|
||||||
import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media-aspect-ratio';
|
import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media-aspect-ratio';
|
||||||
|
|
||||||
|
import SvgIcon from './ui/icon/svg-icon';
|
||||||
|
|
||||||
import type { Property } from 'csstype';
|
import type { Property } from 'csstype';
|
||||||
import type { List as ImmutableList } from 'immutable';
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
|
@ -60,6 +61,7 @@ interface IItem {
|
||||||
dimensions: Dimensions;
|
dimensions: Dimensions;
|
||||||
last?: boolean;
|
last?: boolean;
|
||||||
total: number;
|
total: number;
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Item: React.FC<IItem> = ({
|
const Item: React.FC<IItem> = ({
|
||||||
|
@ -71,6 +73,7 @@ const Item: React.FC<IItem> = ({
|
||||||
dimensions,
|
dimensions,
|
||||||
last,
|
last,
|
||||||
total,
|
total,
|
||||||
|
compact,
|
||||||
}) => {
|
}) => {
|
||||||
const { autoPlayGif } = useSettings();
|
const { autoPlayGif } = useSettings();
|
||||||
const { mediaPreview } = useSoapboxConfig();
|
const { mediaPreview } = useSoapboxConfig();
|
||||||
|
@ -111,16 +114,21 @@ const Item: React.FC<IItem> = ({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVideoHover: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
|
const handleVideoHover = (event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
const video = event.currentTarget;
|
||||||
video.playbackRate = 3.0;
|
video.playbackRate = 3.0;
|
||||||
video.play();
|
video.play();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVideoLeave: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
|
const handleVideoLeave = (event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
const video = event.currentTarget;
|
||||||
video.pause();
|
video.pause();
|
||||||
video.currentTime = 0;
|
video.currentTime = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFocus: React.FocusEventHandler<HTMLVideoElement> = handleVideoHover;
|
||||||
|
const handleBlur: React.FocusEventHandler<HTMLVideoElement> = handleVideoLeave;
|
||||||
|
|
||||||
let width: Dimensions['w'] = 100;
|
let width: Dimensions['w'] = 100;
|
||||||
let height: Dimensions['h'] = '100%';
|
let height: Dimensions['h'] = '100%';
|
||||||
let top: Dimensions['t'] = 'auto';
|
let top: Dimensions['t'] = 'auto';
|
||||||
|
@ -144,43 +152,29 @@ const Item: React.FC<IItem> = ({
|
||||||
let thumbnail: React.ReactNode = '';
|
let thumbnail: React.ReactNode = '';
|
||||||
const ext = attachment.url.split('.').pop()?.toLowerCase();
|
const ext = attachment.url.split('.').pop()?.toLowerCase();
|
||||||
|
|
||||||
/*if (attachment.type === 'unknown' && ['gb', 'gbc'].includes(ext!)) {
|
if (attachment.type === 'unknown') {
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx('media-gallery__item', {
|
|
||||||
standalone,
|
|
||||||
'rounded-md': total > 1,
|
|
||||||
})}
|
|
||||||
key={attachment.id}
|
|
||||||
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
|
|
||||||
>
|
|
||||||
<Suspense fallback={<div className='media-gallery__item-thumbnail' />}>
|
|
||||||
<Gameboy className='media-gallery__item-thumbnail cursor-default' src={attachment.url} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else */if (attachment.type === 'unknown') {
|
|
||||||
const filename = truncateFilename(attachment.url, MAX_FILENAME_LENGTH);
|
const filename = truncateFilename(attachment.url, MAX_FILENAME_LENGTH);
|
||||||
const attachmentIcon = (
|
const attachmentIcon = (
|
||||||
<Icon
|
<SvgIcon
|
||||||
className='size-16 text-gray-800 dark:text-gray-200'
|
className={clsx('size-16 text-gray-800 dark:text-gray-200', { 'size-8': compact })}
|
||||||
src={MIMETYPE_ICONS[attachment.getIn(['pleroma', 'mime_type']) as string] || require('@tabler/icons/outline/paperclip.svg')}
|
src={MIMETYPE_ICONS[attachment.getIn(['pleroma', 'mime_type']) as string] || require('@tabler/icons/outline/paperclip.svg')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx('media-gallery__item', {
|
className={clsx('relative float-left box-border block overflow-hidden rounded-sm border-0', {
|
||||||
standalone,
|
standalone,
|
||||||
'rounded-md': total > 1,
|
'rounded-md': total > 1,
|
||||||
|
'!size-[50px] !inset-auto !float-left !mr-[50px]': compact,
|
||||||
})}
|
})}
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
|
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
|
||||||
>
|
>
|
||||||
<a className='media-gallery__item-thumbnail' href={attachment.url} target='_blank' style={{ cursor: 'pointer' }}>
|
<a className='relative z-[1] block size-full cursor-zoom-in leading-none text-gray-400 no-underline' href={attachment.url} target='_blank' style={{ cursor: 'pointer' }}>
|
||||||
<Blurhash hash={attachment.blurhash} className='media-gallery__preview' />
|
<Blurhash hash={attachment.blurhash} className='absolute left-0 top-0 z-0 size-full rounded-lg bg-gray-200 object-cover dark:bg-gray-900' />
|
||||||
<span className='media-gallery__item__icons'>{attachmentIcon}</span>
|
<span className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'>{attachmentIcon}</span>
|
||||||
<span className='media-gallery__filename__label'>{filename}</span>
|
<span className='pointer-events-none absolute bottom-1.5 left-1.5 z-[1] block bg-black/50 px-1.5 py-0.5 text-[11px] font-semibold leading-[18px] text-white opacity-90 transition-opacity duration-100 ease-linear'>{filename}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -189,7 +183,7 @@ const Item: React.FC<IItem> = ({
|
||||||
|
|
||||||
thumbnail = (
|
thumbnail = (
|
||||||
<a
|
<a
|
||||||
className='media-gallery__item-thumbnail'
|
className='relative z-[1] block size-full cursor-zoom-in leading-none text-gray-400 no-underline'
|
||||||
href={attachment.url}
|
href={attachment.url}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
|
@ -213,9 +207,9 @@ const Item: React.FC<IItem> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
thumbnail = (
|
thumbnail = (
|
||||||
<div className={clsx('media-gallery__gifv', { autoplay: autoPlayGif })}>
|
<div className='group relative size-full overflow-hidden'>
|
||||||
<video
|
<video
|
||||||
className='media-gallery__item-gifv-thumbnail'
|
className='relative top-0 z-10 size-full transform-none cursor-zoom-in rounded-md object-cover'
|
||||||
aria-label={attachment.description}
|
aria-label={attachment.description}
|
||||||
title={attachment.description}
|
title={attachment.description}
|
||||||
role='application'
|
role='application'
|
||||||
|
@ -228,61 +222,65 @@ const Item: React.FC<IItem> = ({
|
||||||
{...conditionalAttributes}
|
{...conditionalAttributes}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className='media-gallery__gifv__label'>GIF</span>
|
<span className={clsx('pointer-events-none absolute bottom-1.5 left-1.5 z-[1] block bg-black/50 px-1.5 py-0.5 text-[11px] font-semibold leading-[18px] text-white opacity-90 transition-opacity duration-100 ease-linear group-hover:opacity-100', { 'hidden': autoPlayGif })}>GIF</span> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (attachment.type === 'audio') {
|
} else if (attachment.type === 'audio') {
|
||||||
thumbnail = (
|
thumbnail = (
|
||||||
<a
|
<a
|
||||||
className={clsx('media-gallery__item-thumbnail')}
|
className={clsx('relative z-[1] block size-full cursor-zoom-in leading-none text-gray-400 no-underline')}
|
||||||
href={attachment.url}
|
href={attachment.url}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
title={attachment.description}
|
title={attachment.description}
|
||||||
>
|
>
|
||||||
<span className='media-gallery__item__icons'><Icon src={require('@tabler/icons/outline/volume.svg')} /></span>
|
<span className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'><SvgIcon className='size-24' src={require('@tabler/icons/outline/volume.svg')} /></span>
|
||||||
<span className='media-gallery__file-extension__label uppercase'>{ext}</span>
|
<span className={clsx('pointer-events-none absolute bottom-1.5 left-1.5 z-[1] block bg-black/50 px-1.5 py-0.5 text-[11px] font-semibold uppercase leading-[18px] text-white opacity-90 transition-opacity duration-100 ease-linear', { 'hidden': compact })}>{ext}</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
} else if (attachment.type === 'video') {
|
} else if (attachment.type === 'video') {
|
||||||
thumbnail = (
|
thumbnail = (
|
||||||
<a
|
<a
|
||||||
className={clsx('media-gallery__item-thumbnail')}
|
className={clsx('relative z-[1] block size-full cursor-zoom-in leading-none text-gray-400 no-underline')}
|
||||||
href={attachment.url}
|
href={attachment.url}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
title={attachment.description}
|
title={attachment.description}
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
|
className='size-full object-cover'
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
onMouseOver={handleVideoHover}
|
onMouseOver={handleVideoHover}
|
||||||
onMouseOut={handleVideoLeave}
|
onMouseOut={handleVideoLeave}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
>
|
>
|
||||||
<source src={attachment.url} />
|
<source src={attachment.url} />
|
||||||
</video>
|
</video>
|
||||||
<span className='media-gallery__file-extension__label uppercase'>{ext}</span>
|
<span className={clsx('pointer-events-none absolute bottom-1.5 left-1.5 z-[1] block bg-black/50 px-1.5 py-0.5 text-[11px] font-semibold uppercase leading-[18px] text-white opacity-90 transition-opacity duration-100 ease-linear', { 'hidden': compact })}>{ext}</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx('media-gallery__item', `media-gallery__item--${attachment.type}`, {
|
className={clsx('relative float-left box-border block overflow-hidden rounded-sm border-0', {
|
||||||
standalone,
|
standalone,
|
||||||
'rounded-md': total > 1,
|
'rounded-md': total > 1,
|
||||||
|
'!size-[50px] !inset-auto !float-left !mr-[50px]': compact,
|
||||||
})}
|
})}
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
|
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
|
||||||
>
|
>
|
||||||
{last && total > ATTACHMENT_LIMIT && (
|
{last && total > ATTACHMENT_LIMIT && (
|
||||||
<div className='media-gallery__item-overflow'>
|
<div className={clsx('pointer-events-none absolute inset-0 z-[2] flex size-full items-center justify-center bg-white/75 text-center text-[50px] font-bold text-gray-800', { '!text-5': compact })}> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
+{total - ATTACHMENT_LIMIT + 1}
|
+{total - ATTACHMENT_LIMIT + 1}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Blurhash
|
<Blurhash
|
||||||
hash={attachment.blurhash}
|
hash={attachment.blurhash}
|
||||||
className='media-gallery__preview'
|
className='absolute left-0 top-0 z-0 size-full rounded-lg bg-gray-200 object-cover dark:bg-gray-900'
|
||||||
/>
|
/>
|
||||||
{visible && thumbnail}
|
{visible && thumbnail}
|
||||||
</div>
|
</div>
|
||||||
|
@ -561,6 +559,7 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
|
||||||
dimensions={sizeData.itemsDimensions[i]}
|
dimensions={sizeData.itemsDimensions[i]}
|
||||||
last={i === ATTACHMENT_LIMIT - 1}
|
last={i === ATTACHMENT_LIMIT - 1}
|
||||||
total={media.size}
|
total={media.size}
|
||||||
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
@ -578,7 +577,7 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(className, 'media-gallery', { 'media-gallery--compact': compact })}
|
className={clsx(className, 'relative isolate box-border h-auto w-full overflow-hidden rounded-lg', { '!h-[50px] bg-transparent': compact })}
|
||||||
style={sizeData.style}
|
style={sizeData.style}
|
||||||
ref={node}
|
ref={node}
|
||||||
>
|
>
|
||||||
|
|
|
@ -63,7 +63,7 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
|
||||||
</Text>
|
</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Text theme='muted'>·</Text>
|
<Text theme='muted'>·</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -75,7 +75,7 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
|
||||||
</Text>
|
</Text>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Text theme='muted'>·</Text>
|
<Text theme='muted'>·</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -85,7 +85,7 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
|
||||||
|
|
||||||
{poll.expires_at !== null && (
|
{poll.expires_at !== null && (
|
||||||
<>
|
<>
|
||||||
<Text theme='muted'>·</Text>
|
<Text theme='muted'>·</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
<Text weight='medium' theme='muted' data-testid='poll-expiration'>{timeRemaining}</Text>
|
<Text weight='medium' theme='muted' data-testid='poll-expiration'>{timeRemaining}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -152,7 +152,7 @@ const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='text-primary-600 dark:text-white'>
|
<div className='text-primary-600 dark:text-white'>
|
||||||
<Text weight='medium' theme='inherit'>{Math.round(percent)}%</Text>
|
<Text weight='medium' theme='inherit'>{Math.round(percent)}%</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</div>
|
</div>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface IScrollContext {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScrollContext: React.FC<IScrollContext> = ({ children }) => {
|
||||||
|
const location = useLocation<{ soapboxModalKey?: number } | undefined>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!location.state?.soapboxModalKey) {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
|
@ -15,8 +15,7 @@ import { useAppDispatch, useAppSelector, useFeatures, useInstance } from 'soapbo
|
||||||
import { useSettingsNotifications } from 'soapbox/hooks/useSettingsNotifications';
|
import { useSettingsNotifications } from 'soapbox/hooks/useSettingsNotifications';
|
||||||
import { makeGetOtherAccounts } from 'soapbox/selectors';
|
import { makeGetOtherAccounts } from 'soapbox/selectors';
|
||||||
|
|
||||||
import type { List as ImmutableList } from 'immutable';
|
import type { Account as AccountEntity } from 'soapbox/schemas/account';
|
||||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
followers: { id: 'account.followers', defaultMessage: 'Followers' },
|
followers: { id: 'account.followers', defaultMessage: 'Followers' },
|
||||||
|
@ -86,7 +85,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const me = useAppSelector((state) => state.me);
|
const me = useAppSelector((state) => state.me);
|
||||||
const { account } = useAccount(me || undefined);
|
const { account } = useAccount(me || undefined);
|
||||||
const otherAccounts: ImmutableList<AccountEntity> = useAppSelector((state) => getOtherAccounts(state));
|
const otherAccounts = useAppSelector((state) => getOtherAccounts(state));
|
||||||
const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
|
const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
|
||||||
const settings = useAppSelector((state) => getSettings(state));
|
const settings = useAppSelector((state) => getSettings(state));
|
||||||
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
|
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { Icon, Text } from './ui';
|
import { Icon, Text } from './ui';
|
||||||
|
|
||||||
|
@ -24,7 +24,9 @@ interface ISidebarNavigationLink {
|
||||||
/** Desktop sidebar navigation link. */
|
/** Desktop sidebar navigation link. */
|
||||||
const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, ref: React.ForwardedRef<HTMLAnchorElement>): JSX.Element => {
|
const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, ref: React.ForwardedRef<HTMLAnchorElement>): JSX.Element => {
|
||||||
const { icon, activeIcon, text, to = '', count, countMax, onClick } = props;
|
const { icon, activeIcon, text, to = '', count, countMax, onClick } = props;
|
||||||
const isActive = location.pathname === to;
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const isActive = pathname === to;
|
||||||
|
|
||||||
const handleClick: React.EventHandler<React.MouseEvent> = (e) => {
|
const handleClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
|
|
|
@ -105,16 +105,20 @@ const SiteErrorBoundary: React.FC<ISiteErrorBoundary> = ({ children }) => {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Text theme='muted'>
|
<Text theme='muted'>
|
||||||
|
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||||
<Text weight='medium' tag='span' theme='muted'>{sourceCode.displayName}:</Text>
|
<Text weight='medium' tag='span' theme='muted'>{sourceCode.displayName}:</Text>
|
||||||
|
|
||||||
{' '}{sourceCode.version}
|
{' '}{sourceCode.version}
|
||||||
|
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div className='mt-10'>
|
<div className='mt-10'>
|
||||||
<a href='/' className='text-base font-medium text-primary-600 hover:underline dark:text-accent-blue'>
|
<a href='/' className='text-base font-medium text-primary-600 hover:underline dark:text-accent-blue'>
|
||||||
|
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||||
<FormattedMessage id='alert.unexpected.return_home' defaultMessage='Return Home' />
|
<FormattedMessage id='alert.unexpected.return_home' defaultMessage='Return Home' />
|
||||||
{' '}
|
{' '}
|
||||||
<span className='inline-block rtl:rotate-180' aria-hidden='true'>→</span>
|
<span className='inline-block rtl:rotate-180' aria-hidden='true'>→</span>
|
||||||
|
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -321,7 +321,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(openModal('CONFIRM', {
|
||||||
icon: require('@tabler/icons/outline/ban.svg'),
|
icon: require('@tabler/icons/outline/ban.svg'),
|
||||||
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.acct }} />,
|
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.acct }} />,
|
||||||
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong className='break-words'>@{account.acct}</strong> }} />,
|
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong className='break-words'>@{account.acct}</strong> }} />, // eslint-disable-line formatjs/no-literal-string-in-jsx
|
||||||
confirm: intl.formatMessage(messages.blockConfirm),
|
confirm: intl.formatMessage(messages.blockConfirm),
|
||||||
onConfirm: () => dispatch(blockAccount(account.id)),
|
onConfirm: () => dispatch(blockAccount(account.id)),
|
||||||
secondary: intl.formatMessage(messages.blockAndReport),
|
secondary: intl.formatMessage(messages.blockAndReport),
|
||||||
|
|
|
@ -56,7 +56,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
|
||||||
to={`/@${account.acct}`}
|
to={`/@${account.acct}`}
|
||||||
className='reply-mentions__account max-w-[200px] truncate align-bottom'
|
className='reply-mentions__account max-w-[200px] truncate align-bottom'
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
@{shortenNostr(account.username)}
|
@{shortenNostr(account.username)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
|
@ -334,6 +334,7 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={minHandlers}>
|
<HotKeys handlers={minHandlers}>
|
||||||
<div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
|
<div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
|
||||||
|
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||||
<Text theme='muted'>
|
<Text theme='muted'>
|
||||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {status.filtered.join(', ')}.
|
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {status.filtered.join(', ')}.
|
||||||
{' '}
|
{' '}
|
||||||
|
@ -341,6 +342,7 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
|
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
|
||||||
</button>
|
</button>
|
||||||
</Text>
|
</Text>
|
||||||
|
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
|
|
|
@ -118,9 +118,11 @@ const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveConte
|
||||||
|
|
||||||
{status.spoiler_text && (
|
{status.spoiler_text && (
|
||||||
<div className='py-4 italic'>
|
<div className='py-4 italic'>
|
||||||
|
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||||
<Text className='line-clamp-6' theme='white' size='md' weight='medium'>
|
<Text className='line-clamp-6' theme='white' size='md' weight='medium'>
|
||||||
“<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} />”
|
“<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} />”
|
||||||
</Text>
|
</Text>
|
||||||
|
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -13,14 +13,14 @@ describe('<Button />', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the children', () => {
|
it('renders the children', () => {
|
||||||
render(<Button><p>children</p></Button>);
|
render(<Button><p>children</p></Button>); // eslint-disable-line formatjs/no-literal-string-in-jsx
|
||||||
|
|
||||||
expect(screen.getByRole('button')).toHaveTextContent('children');
|
expect(screen.getByRole('button')).toHaveTextContent('children');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the props.text instead of children', () => {
|
it('renders the props.text instead of children', () => {
|
||||||
const text = 'foo';
|
const text = 'foo';
|
||||||
const children = <p>children</p>;
|
const children = <p>children</p>; // eslint-disable-line formatjs/no-literal-string-in-jsx
|
||||||
render(<Button text={text}>{children}</Button>);
|
render(<Button text={text}>{children}</Button>);
|
||||||
|
|
||||||
expect(screen.getByRole('button')).toHaveTextContent('foo');
|
expect(screen.getByRole('button')).toHaveTextContent('foo');
|
||||||
|
@ -63,13 +63,13 @@ describe('<Button />', () => {
|
||||||
|
|
||||||
describe('to prop', () => {
|
describe('to prop', () => {
|
||||||
it('renders a link', () => {
|
it('renders a link', () => {
|
||||||
render(<Button to='/'>link</Button>);
|
render(<Button to='/'>link</Button>); // eslint-disable-line formatjs/no-literal-string-in-jsx
|
||||||
|
|
||||||
expect(screen.getByRole('link')).toBeInTheDocument();
|
expect(screen.getByRole('link')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render a link', () => {
|
it('does not render a link', () => {
|
||||||
render(<Button>link</Button>);
|
render(<Button>link</Button>); //eslint-disable-line formatjs/no-literal-string-in-jsx
|
||||||
|
|
||||||
expect(screen.queryAllByRole('link')).toHaveLength(0);
|
expect(screen.queryAllByRole('link')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
@ -77,13 +77,13 @@ describe('<Button />', () => {
|
||||||
|
|
||||||
describe('icon prop', () => {
|
describe('icon prop', () => {
|
||||||
it('renders an icon', () => {
|
it('renders an icon', () => {
|
||||||
render(<Button icon='icon.png'>button</Button>);
|
render(<Button icon='icon.png'>button</Button>); // eslint-disable-line formatjs/no-literal-string-in-jsx
|
||||||
|
|
||||||
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render an icon', () => {
|
it('does not render an icon', () => {
|
||||||
render(<Button>button</Button>);
|
render(<Button>button</Button>); // eslint-disable-line formatjs/no-literal-string-in-jsx
|
||||||
|
|
||||||
expect(screen.queryAllByTestId('icon')).toHaveLength(0);
|
expect(screen.queryAllByTestId('icon')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,7 +12,7 @@ describe('<Card />', () => {
|
||||||
<CardTitle title='Card Title' />
|
<CardTitle title='Card Title' />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardBody>
|
<CardBody> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
Card Body
|
Card Body
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>,
|
</Card>,
|
||||||
|
|
|
@ -6,7 +6,7 @@ import FormActions from './form-actions';
|
||||||
|
|
||||||
describe('<FormActions />', () => {
|
describe('<FormActions />', () => {
|
||||||
it('renders successfully', () => {
|
it('renders successfully', () => {
|
||||||
render(<FormActions><div data-testid='child'>child</div></FormActions>);
|
render(<FormActions><div data-testid='child'>child</div></FormActions>); {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
|
|
||||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,7 +8,7 @@ describe('<Form />', () => {
|
||||||
it('renders children', () => {
|
it('renders children', () => {
|
||||||
const onSubmitMock = vi.fn();
|
const onSubmitMock = vi.fn();
|
||||||
render(
|
render(
|
||||||
<Form onSubmit={onSubmitMock}>children</Form>,
|
<Form onSubmit={onSubmitMock}>children</Form>, // eslint-disable-line formatjs/no-literal-string-in-jsx
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId('form')).toHaveTextContent('children');
|
expect(screen.getByTestId('form')).toHaveTextContent('children');
|
||||||
|
@ -17,7 +17,7 @@ describe('<Form />', () => {
|
||||||
it('handles onSubmit prop', () => {
|
it('handles onSubmit prop', () => {
|
||||||
const onSubmitMock = vi.fn();
|
const onSubmitMock = vi.fn();
|
||||||
render(
|
render(
|
||||||
<Form onSubmit={onSubmitMock}>children</Form>,
|
<Form onSubmit={onSubmitMock}>children</Form>, // eslint-disable-line formatjs/no-literal-string-in-jsx
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.submit(
|
fireEvent.submit(
|
||||||
|
|
|
@ -166,7 +166,7 @@ const Upload: React.FC<IUpload> = ({
|
||||||
onDragEnter={onDragEnter}
|
onDragEnter={onDragEnter}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
>
|
>
|
||||||
<Blurhash hash={media.blurhash} className='media-gallery__preview' />
|
<Blurhash hash={media.blurhash} className='absolute left-0 top-0 z-0 size-full rounded-lg bg-gray-200 object-cover dark:bg-gray-900' />
|
||||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
||||||
{({ scale }) => (
|
{({ scale }) => (
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
import { NRelay, NRelay1, NostrSigner } from '@nostrify/nostrify';
|
import { NRelay1 } from '@nostrify/nostrify';
|
||||||
import React, { createContext, useContext, useState, useEffect, useMemo } from 'react';
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
|
||||||
import { NKeys } from 'soapbox/features/nostr/keys';
|
|
||||||
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
|
|
||||||
import { useInstance } from 'soapbox/hooks/useInstance';
|
import { useInstance } from 'soapbox/hooks/useInstance';
|
||||||
|
|
||||||
interface NostrContextType {
|
interface NostrContextType {
|
||||||
relay?: NRelay;
|
relay?: NRelay1;
|
||||||
signer?: NostrSigner;
|
isRelayLoading: boolean;
|
||||||
hasNostr: boolean;
|
|
||||||
isRelayOpen: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NostrContext = createContext<NostrContextType | undefined>(undefined);
|
const NostrContext = createContext<NostrContextType | undefined>(undefined);
|
||||||
|
@ -20,39 +16,32 @@ interface NostrProviderProps {
|
||||||
|
|
||||||
export const NostrProvider: React.FC<NostrProviderProps> = ({ children }) => {
|
export const NostrProvider: React.FC<NostrProviderProps> = ({ children }) => {
|
||||||
const { instance } = useInstance();
|
const { instance } = useInstance();
|
||||||
const hasNostr = !!instance.nostr;
|
|
||||||
|
|
||||||
const [relay, setRelay] = useState<NRelay1>();
|
const [relay, setRelay] = useState<NRelay1>();
|
||||||
const [isRelayOpen, setIsRelayOpen] = useState(false);
|
const [isRelayLoading, setIsRelayLoading] = useState(true);
|
||||||
|
|
||||||
const { account } = useOwnAccount();
|
const relayUrl = instance.nostr?.relay;
|
||||||
|
|
||||||
const url = instance.nostr?.relay;
|
|
||||||
const accountPubkey = useAppSelector((state) => state.meta.pubkey ?? account?.nostr.pubkey);
|
|
||||||
|
|
||||||
const signer = useMemo(
|
|
||||||
() => (accountPubkey ? NKeys.get(accountPubkey) : undefined) ?? window.nostr,
|
|
||||||
[accountPubkey, window.nostr],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRelayOpen = () => {
|
const handleRelayOpen = () => {
|
||||||
setIsRelayOpen(true);
|
setIsRelayLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (url) {
|
if (relayUrl) {
|
||||||
const relay = new NRelay1(url);
|
const relay = new NRelay1(relayUrl);
|
||||||
relay.socket.underlyingWebsocket.addEventListener('open', handleRelayOpen);
|
relay.socket.underlyingWebsocket.addEventListener('open', handleRelayOpen);
|
||||||
setRelay(relay);
|
setRelay(relay);
|
||||||
|
} else {
|
||||||
|
setIsRelayLoading(false);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
relay?.socket.underlyingWebsocket.removeEventListener('open', handleRelayOpen);
|
relay?.socket.underlyingWebsocket.removeEventListener('open', handleRelayOpen);
|
||||||
relay?.close();
|
relay?.close();
|
||||||
};
|
};
|
||||||
}, [url]);
|
}, [relayUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NostrContext.Provider value={{ relay, signer, isRelayOpen, hasNostr }}>
|
<NostrContext.Provider value={{ relay, isRelayLoading }}>
|
||||||
{children}
|
{children}
|
||||||
</NostrContext.Provider>
|
</NostrContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { produce, enableMapSet } from 'immer';
|
import { produce } from 'immer';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ENTITIES_IMPORT,
|
ENTITIES_IMPORT,
|
||||||
|
@ -17,8 +17,6 @@ import { createCache, createList, updateStore, updateList } from './utils';
|
||||||
import type { DeleteEntitiesOpts } from './actions';
|
import type { DeleteEntitiesOpts } from './actions';
|
||||||
import type { EntitiesTransaction, Entity, EntityCache, EntityListState, ImportPosition } from './types';
|
import type { EntitiesTransaction, Entity, EntityCache, EntityListState, ImportPosition } from './types';
|
||||||
|
|
||||||
enableMapSet();
|
|
||||||
|
|
||||||
/** Entity reducer state. */
|
/** Entity reducer state. */
|
||||||
interface State {
|
interface State {
|
||||||
[entityType: string]: EntityCache | undefined;
|
[entityType: string]: EntityCache | undefined;
|
||||||
|
|
|
@ -39,7 +39,7 @@ const AboutPage: React.FC = () => {
|
||||||
const alsoAvailable = (defaultLocale) && (
|
const alsoAvailable = (defaultLocale) && (
|
||||||
<div>
|
<div>
|
||||||
<FormattedMessage id='about.also_available' defaultMessage='Available in:' />
|
<FormattedMessage id='about.also_available' defaultMessage='Available in:' />
|
||||||
{' '}
|
{' '} {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
<ul className='inline list-none p-0'>
|
<ul className='inline list-none p-0'>
|
||||||
<li className="inline after:content-['_·_']">
|
<li className="inline after:content-['_·_']">
|
||||||
<a href='#' onClick={() => setLocale(defaultLocale)}>
|
<a href='#' onClick={() => setLocale(defaultLocale)}>
|
||||||
|
|
|
@ -2,8 +2,8 @@ import clsx from 'clsx';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import Blurhash from 'soapbox/components/blurhash';
|
import Blurhash from 'soapbox/components/blurhash';
|
||||||
import Icon from 'soapbox/components/icon';
|
|
||||||
import StillImage from 'soapbox/components/still-image';
|
import StillImage from 'soapbox/components/still-image';
|
||||||
|
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||||
import { useSettings } from 'soapbox/hooks';
|
import { useSettings } from 'soapbox/hooks';
|
||||||
import { isIOS } from 'soapbox/is-mobile';
|
import { isIOS } from 'soapbox/is-mobile';
|
||||||
|
|
||||||
|
@ -80,9 +80,9 @@ const MediaItem: React.FC<IMediaItem> = ({ attachment, onOpenMedia }) => {
|
||||||
conditionalAttributes.autoPlay = true;
|
conditionalAttributes.autoPlay = true;
|
||||||
}
|
}
|
||||||
thumbnail = (
|
thumbnail = (
|
||||||
<div className={clsx('media-gallery__gifv', { autoplay: autoPlayGif })}>
|
<div className='group relative size-full overflow-hidden'>
|
||||||
<video
|
<video
|
||||||
className='media-gallery__item-gifv-thumbnail'
|
className='relative top-0 z-10 size-full transform-none cursor-zoom-in rounded-md object-cover'
|
||||||
aria-label={attachment.description}
|
aria-label={attachment.description}
|
||||||
title={attachment.description}
|
title={attachment.description}
|
||||||
role='application'
|
role='application'
|
||||||
|
@ -94,7 +94,7 @@ const MediaItem: React.FC<IMediaItem> = ({ attachment, onOpenMedia }) => {
|
||||||
{...conditionalAttributes}
|
{...conditionalAttributes}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className='media-gallery__gifv__label'>GIF</span>
|
<span className={clsx('pointer-events-none absolute bottom-1.5 left-1.5 z-[1] block bg-black/50 px-1.5 py-0.5 text-[11px] font-semibold leading-[18px] text-white opacity-90 transition-opacity duration-100 ease-linear group-hover:opacity-100', { 'hidden': autoPlayGif })}>GIF</span> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (attachment.type === 'audio') {
|
} else if (attachment.type === 'audio') {
|
||||||
|
@ -102,28 +102,28 @@ const MediaItem: React.FC<IMediaItem> = ({ attachment, onOpenMedia }) => {
|
||||||
const fileExtensionLastIndex = remoteURL.lastIndexOf('.');
|
const fileExtensionLastIndex = remoteURL.lastIndexOf('.');
|
||||||
const fileExtension = remoteURL.slice(fileExtensionLastIndex + 1).toUpperCase();
|
const fileExtension = remoteURL.slice(fileExtensionLastIndex + 1).toUpperCase();
|
||||||
thumbnail = (
|
thumbnail = (
|
||||||
<div className='media-gallery__item-thumbnail'>
|
<div className='relative z-[1] block size-full cursor-zoom-in leading-none text-gray-400 no-underline'>
|
||||||
<span className='media-gallery__item__icons'><Icon src={require('@tabler/icons/outline/volume.svg')} /></span>
|
<span className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'><SvgIcon className='size-24' src={require('@tabler/icons/outline/volume.svg')} /></span>
|
||||||
<span className='media-gallery__file-extension__label'>{fileExtension}</span>
|
<span className='pointer-events-none absolute bottom-1.5 left-1.5 z-[1] block bg-black/50 px-1.5 py-0.5 text-[11px] font-semibold leading-[18px] text-white opacity-90 transition-opacity duration-100 ease-linear'>{fileExtension}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
icon = (
|
icon = (
|
||||||
<span className='media-gallery__item__icons'>
|
<span className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||||
<Icon src={require('@tabler/icons/outline/eye-off.svg')} />
|
<SvgIcon className='size-24' src={require('@tabler/icons/outline/eye-off.svg')} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='col-span-1'>
|
<div className='col-span-1'>
|
||||||
<a className='media-gallery__item-thumbnail aspect-1' href={status.url} target='_blank' onClick={handleClick} title={title}>
|
<a className='relative z-[1] block aspect-1 size-full cursor-zoom-in leading-none text-gray-400 no-underline' href={status.url} target='_blank' onClick={handleClick} title={title}>
|
||||||
<Blurhash
|
<Blurhash
|
||||||
hash={attachment.blurhash}
|
hash={attachment.blurhash}
|
||||||
className={clsx('media-gallery__preview', {
|
className={clsx('absolute left-0 top-0 z-0 size-full rounded-lg bg-gray-200 object-cover dark:bg-gray-900', {
|
||||||
'media-gallery__preview--hidden': visible,
|
'hidden': visible,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
{visible && thumbnail}
|
{visible && thumbnail}
|
||||||
|
|
|
@ -137,7 +137,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(openModal('CONFIRM', {
|
||||||
icon: require('@tabler/icons/outline/ban.svg'),
|
icon: require('@tabler/icons/outline/ban.svg'),
|
||||||
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.acct }} />,
|
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.acct }} />,
|
||||||
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong className='break-words'>@{account.acct}</strong> }} />,
|
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong className='break-words'>@{account.acct}</strong> }} />, // eslint-disable-line formatjs/no-literal-string-in-jsx
|
||||||
confirm: intl.formatMessage(messages.blockConfirm),
|
confirm: intl.formatMessage(messages.blockConfirm),
|
||||||
onConfirm: () => dispatch(blockAccount(account.id)),
|
onConfirm: () => dispatch(blockAccount(account.id)),
|
||||||
secondary: intl.formatMessage(messages.blockAndReport),
|
secondary: intl.formatMessage(messages.blockAndReport),
|
||||||
|
@ -222,7 +222,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
||||||
const unfollowModal = getSettings(getState()).get('unfollowModal');
|
const unfollowModal = getSettings(getState()).get('unfollowModal');
|
||||||
if (unfollowModal) {
|
if (unfollowModal) {
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(openModal('CONFIRM', {
|
||||||
message: <FormattedMessage id='confirmations.remove_from_followers.message' defaultMessage='Are you sure you want to remove {name} from your followers?' values={{ name: <strong className='break-words'>@{account.acct}</strong> }} />,
|
message: <FormattedMessage id='confirmations.remove_from_followers.message' defaultMessage='Are you sure you want to remove {name} from your followers?' values={{ name: <strong className='break-words'>@{account.acct}</strong> }} />, // eslint-disable-line formatjs/no-literal-string-in-jsx
|
||||||
confirm: intl.formatMessage(messages.removeFromFollowersConfirm),
|
confirm: intl.formatMessage(messages.removeFromFollowersConfirm),
|
||||||
onConfirm: () => dispatch(removeFromFollowers(account.id)),
|
onConfirm: () => dispatch(removeFromFollowers(account.id)),
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -52,7 +52,7 @@ const Announcement: React.FC<IAnnouncement> = ({ announcement }) => {
|
||||||
<Text tag='span' size='sm' weight='medium'>
|
<Text tag='span' size='sm' weight='medium'>
|
||||||
<FormattedMessage id='admin.announcements.starts_at' defaultMessage='Starts at:' />
|
<FormattedMessage id='admin.announcements.starts_at' defaultMessage='Starts at:' />
|
||||||
</Text>
|
</Text>
|
||||||
{' '}
|
{' '} {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
<FormattedDate value={announcement.starts_at} year='2-digit' month='short' day='2-digit' weekday='short' />
|
<FormattedDate value={announcement.starts_at} year='2-digit' month='short' day='2-digit' weekday='short' />
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
@ -61,7 +61,7 @@ const Announcement: React.FC<IAnnouncement> = ({ announcement }) => {
|
||||||
<Text tag='span' size='sm' weight='medium'>
|
<Text tag='span' size='sm' weight='medium'>
|
||||||
<FormattedMessage id='admin.announcements.ends_at' defaultMessage='Ends at:' />
|
<FormattedMessage id='admin.announcements.ends_at' defaultMessage='Ends at:' />
|
||||||
</Text>
|
</Text>
|
||||||
{' '}
|
{' '} {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
<FormattedDate value={announcement.ends_at} year='2-digit' month='short' day='2-digit' weekday='short' />
|
<FormattedDate value={announcement.ends_at} year='2-digit' month='short' day='2-digit' weekday='short' />
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -18,7 +18,14 @@ const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const { accounts } = useAdminAccounts(['local', 'active'], limit);
|
const { accounts } = useAdminAccounts({
|
||||||
|
local: true,
|
||||||
|
active: true,
|
||||||
|
pending: false,
|
||||||
|
disabled: false,
|
||||||
|
silenced: false,
|
||||||
|
suspended: false,
|
||||||
|
}, limit);
|
||||||
|
|
||||||
const handleAction = () => {
|
const handleAction = () => {
|
||||||
history.push('/soapbox/admin/users');
|
history.push('/soapbox/admin/users');
|
||||||
|
|
|
@ -96,7 +96,7 @@ const Report: React.FC<IReport> = ({ id }) => {
|
||||||
defaultMessage='Report on {acct}'
|
defaultMessage='Report on {acct}'
|
||||||
values={{ acct: (
|
values={{ acct: (
|
||||||
<HoverRefWrapper accountId={targetAccount.id} inline>
|
<HoverRefWrapper accountId={targetAccount.id} inline>
|
||||||
<Link to={`/@${acct}`} title={acct}>@{acct}</Link>
|
<Link to={`/@${acct}`} title={acct}>@{acct}</Link> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</HoverRefWrapper>
|
</HoverRefWrapper>
|
||||||
) }}
|
) }}
|
||||||
/>
|
/>
|
||||||
|
@ -129,14 +129,14 @@ const Report: React.FC<IReport> = ({ id }) => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HStack space={1}>
|
<HStack space={1}>
|
||||||
<Text theme='muted' tag='span'>—</Text>
|
<Text theme='muted' tag='span'>—</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
|
|
||||||
<HoverRefWrapper accountId={account.id} inline>
|
<HoverRefWrapper accountId={account.id} inline>
|
||||||
<Link
|
<Link
|
||||||
to={`/@${reporterAcct}`}
|
to={`/@${reporterAcct}`}
|
||||||
title={reporterAcct}
|
title={reporterAcct}
|
||||||
className='text-primary-600 hover:underline dark:text-accent-blue'
|
className='text-primary-600 hover:underline dark:text-accent-blue'
|
||||||
>
|
> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
@{reporterAcct}
|
@{reporterAcct}
|
||||||
</Link>
|
</Link>
|
||||||
</HoverRefWrapper>
|
</HoverRefWrapper>
|
||||||
|
|
|
@ -26,7 +26,7 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
|
||||||
<Account
|
<Account
|
||||||
key={adminAccount.id}
|
key={adminAccount.id}
|
||||||
account={account}
|
account={account}
|
||||||
acct={`${adminAccount.username}@${adminAccount.domain}`}
|
acct={adminAccount.domain ? `${adminAccount.username}@${adminAccount.domain}` : adminAccount.username}
|
||||||
note={adminAccount?.invite_request || ''}
|
note={adminAccount?.invite_request || ''}
|
||||||
action={(
|
action={(
|
||||||
<AuthorizeRejectButtons
|
<AuthorizeRejectButtons
|
||||||
|
|
|
@ -51,7 +51,9 @@ const Domain: React.FC<IDomain> = ({ domain }) => {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const domainState = domain.last_checked_at ? (domain.resolves ? 'active' : 'error') : 'pending';
|
const resolveState = domain.resolves ? 'active' : 'error';
|
||||||
|
const domainState = domain.last_checked_at ? resolveState : 'pending';
|
||||||
|
|
||||||
const domainStateLabel = {
|
const domainStateLabel = {
|
||||||
active: <FormattedMessage id='admin.domains.resolve.success_label' defaultMessage='Resolves correctly' />,
|
active: <FormattedMessage id='admin.domains.resolve.success_label' defaultMessage='Resolves correctly' />,
|
||||||
error: <FormattedMessage id='admin.domains.resolve.fail_label' defaultMessage='Not resolving' />,
|
error: <FormattedMessage id='admin.domains.resolve.fail_label' defaultMessage='Not resolving' />,
|
||||||
|
@ -69,7 +71,7 @@ const Domain: React.FC<IDomain> = ({ domain }) => {
|
||||||
<Text tag='span' size='sm' weight='medium'>
|
<Text tag='span' size='sm' weight='medium'>
|
||||||
<FormattedMessage id='admin.domains.name' defaultMessage='Domain:' />
|
<FormattedMessage id='admin.domains.name' defaultMessage='Domain:' />
|
||||||
</Text>
|
</Text>
|
||||||
{' '}
|
{' '} {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
{domain.domain}
|
{domain.domain}
|
||||||
</Text>
|
</Text>
|
||||||
<Text tag='span' size='sm' weight='medium'>
|
<Text tag='span' size='sm' weight='medium'>
|
||||||
|
|
|
@ -40,7 +40,7 @@ const Relay: React.FC<IRelay> = ({ relay }) => {
|
||||||
<Text tag='span' size='sm' weight='medium'>
|
<Text tag='span' size='sm' weight='medium'>
|
||||||
<FormattedMessage id='admin.relays.url' defaultMessage='Instance URL:' />
|
<FormattedMessage id='admin.relays.url' defaultMessage='Instance URL:' />
|
||||||
</Text>
|
</Text>
|
||||||
{' '}
|
{' '} {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
{relay.actor}
|
{relay.actor}
|
||||||
</Text>
|
</Text>
|
||||||
{relay.followed_back && (
|
{relay.followed_back && (
|
||||||
|
|
|
@ -51,7 +51,7 @@ const Rule: React.FC<IRule> = ({ rule }) => {
|
||||||
<Text tag='span' size='sm' weight='medium'>
|
<Text tag='span' size='sm' weight='medium'>
|
||||||
<FormattedMessage id='admin.rule.priority' defaultMessage='Priority:' />
|
<FormattedMessage id='admin.rule.priority' defaultMessage='Priority:' />
|
||||||
</Text>
|
</Text>
|
||||||
{' '}
|
{' '} {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
{rule.priority}
|
{rule.priority}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -20,7 +20,7 @@ const AwaitingApproval: React.FC = () => {
|
||||||
const [isLoading, setLoading] = useState(true);
|
const [isLoading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchUsers(['local', 'need_approval']))
|
dispatch(fetchUsers({ pending: true }))
|
||||||
.then(() => setLoading(false))
|
.then(() => setLoading(false))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
@ -94,10 +94,12 @@ const Dashboard: React.FC = () => {
|
||||||
label={<FormattedMessage id='column.admin.moderation_log' defaultMessage='Moderation Log' />}
|
label={<FormattedMessage id='column.admin.moderation_log' defaultMessage='Moderation Log' />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{features.nostr && (
|
||||||
<ListItem
|
<ListItem
|
||||||
to='/soapbox/admin/zap-split'
|
to='/soapbox/admin/zap-split'
|
||||||
label={<FormattedMessage id='column.admin.zap_split' defaultMessage='Manage Zap Split' />}
|
label={<FormattedMessage id='column.admin.zap_split' defaultMessage='Manage Zap Split' />}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{features.adminAnnouncements && (
|
{features.adminAnnouncements && (
|
||||||
<ListItem
|
<ListItem
|
||||||
|
@ -159,7 +161,7 @@ const Dashboard: React.FC = () => {
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem label={<FormattedMessage id='admin.software.backend' defaultMessage='Backend' />}>
|
<ListItem label={<FormattedMessage id='admin.software.backend' defaultMessage='Backend' />}>
|
||||||
<span>{v.software + (v.build ? `+${v.build}` : '')} {v.version}</span>
|
<span>{v.software + (v.build ? `+${v.build}` : '')} {v.version}</span> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,14 @@ const messages = defineMessages({
|
||||||
const UserIndex: React.FC = () => {
|
const UserIndex: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { accounts, isLoading, hasNextPage, fetchNextPage } = useAdminAccounts(['local']);
|
const { accounts, isLoading, hasNextPage, fetchNextPage } = useAdminAccounts({
|
||||||
|
local: true,
|
||||||
|
active: true,
|
||||||
|
pending: false,
|
||||||
|
disabled: false,
|
||||||
|
silenced: false,
|
||||||
|
suspended: false,
|
||||||
|
});
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
|
|
|
@ -75,7 +75,7 @@ const Aliases = () => {
|
||||||
<HStack alignItems='center' justifyContent='between' space={1} key={i} className='p-2'>
|
<HStack alignItems='center' justifyContent='between' space={1} key={i} className='p-2'>
|
||||||
<div>
|
<div>
|
||||||
<Text tag='span' theme='muted'><FormattedMessage id='aliases.account_label' defaultMessage='Old account:' /></Text>
|
<Text tag='span' theme='muted'><FormattedMessage id='aliases.account_label' defaultMessage='Old account:' /></Text>
|
||||||
{' '}
|
{' '} {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
<Text tag='span'>{alias}</Text>
|
<Text tag='span'>{alias}</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={alias} aria-label={intl.formatMessage(messages.delete)}>
|
<div className='flex items-center' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={alias} aria-label={intl.formatMessage(messages.delete)}>
|
||||||
|
|
|
@ -4,7 +4,7 @@ import throttle from 'lodash/throttle';
|
||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import Icon from 'soapbox/components/icon';
|
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||||
import { formatTime, getPointerPosition } from 'soapbox/features/video';
|
import { formatTime, getPointerPosition } from 'soapbox/features/video';
|
||||||
|
|
||||||
import Visualizer from './visualizer';
|
import Visualizer from './visualizer';
|
||||||
|
@ -64,10 +64,11 @@ const Audio: React.FC<IAudio> = (props) => {
|
||||||
const [duration, setDuration] = useState<number | undefined>(undefined);
|
const [duration, setDuration] = useState<number | undefined>(undefined);
|
||||||
const [paused, setPaused] = useState(true);
|
const [paused, setPaused] = useState(true);
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
|
const [preVolume, setPreVolume] = useState(0);
|
||||||
const [volume, setVolume] = useState(0.5);
|
const [volume, setVolume] = useState(0.5);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const [seekHovered, setSeekHovered] = useState(false);
|
||||||
const visualizer = useRef<Visualizer>(new Visualizer(TICK_SIZE));
|
const visualizer = useRef<Visualizer>(new Visualizer(TICK_SIZE));
|
||||||
const audioContext = useRef<AudioContext | null>(null);
|
const audioContext = useRef<AudioContext | null>(null);
|
||||||
|
|
||||||
|
@ -150,12 +151,20 @@ const Audio: React.FC<IAudio> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleMute = () => {
|
const toggleMute = () => {
|
||||||
const nextMuted = !muted;
|
|
||||||
|
|
||||||
setMuted(nextMuted);
|
|
||||||
|
|
||||||
if (audio.current) {
|
if (audio.current) {
|
||||||
audio.current.muted = nextMuted;
|
const muted = !audio.current.muted;
|
||||||
|
setMuted(muted);
|
||||||
|
audio.current.muted = muted;
|
||||||
|
|
||||||
|
if (muted) {
|
||||||
|
setPreVolume(audio.current.volume);
|
||||||
|
audio.current.volume = 0;
|
||||||
|
setVolume(0);
|
||||||
|
} else {
|
||||||
|
audio.current.volume = preVolume;
|
||||||
|
setVolume(preVolume);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -259,6 +268,14 @@ const Audio: React.FC<IAudio> = (props) => {
|
||||||
setHovered(false);
|
setHovered(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSeekEnter = () => {
|
||||||
|
setSeekHovered(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeekLeave = () => {
|
||||||
|
setSeekHovered(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleLoadedData = () => {
|
const handleLoadedData = () => {
|
||||||
if (audio.current) {
|
if (audio.current) {
|
||||||
setDuration(audio.current.duration);
|
setDuration(audio.current.duration);
|
||||||
|
@ -438,7 +455,8 @@ const Audio: React.FC<IAudio> = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx('audio-player', { editable })}
|
role='menuitem'
|
||||||
|
className={clsx('relative box-border overflow-hidden rounded-[10px] bg-black pb-11', { 'rounded-none h-full': editable })}
|
||||||
ref={player}
|
ref={player}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: _getBackgroundColor(),
|
backgroundColor: _getBackgroundColor(),
|
||||||
|
@ -446,8 +464,6 @@ const Audio: React.FC<IAudio> = (props) => {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: fullscreen ? '100%' : (height || props.height),
|
height: fullscreen ? '100%' : (height || props.height),
|
||||||
}}
|
}}
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
|
@ -466,7 +482,7 @@ const Audio: React.FC<IAudio> = (props) => {
|
||||||
<canvas
|
<canvas
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className='audio-player__canvas absolute left-0 top-0 w-full'
|
className='absolute left-0 top-0 w-full'
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
ref={canvas}
|
ref={canvas}
|
||||||
|
@ -490,86 +506,95 @@ const Audio: React.FC<IAudio> = (props) => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='video-player__seek' onMouseDown={handleMouseDown} ref={seek}>
|
<div className='relative h-6 cursor-pointer' onMouseDown={handleMouseDown} onMouseEnter={handleSeekEnter} onMouseLeave={handleSeekLeave} ref={seek}>
|
||||||
|
|
||||||
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
|
<div className='absolute top-0 block h-1 rounded-md bg-white/20' style={{ width: `${buffer}%` }} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className='video-player__seek__progress'
|
className='absolute top-0 block h-1 rounded-md bg-accent-500'
|
||||||
style={{ width: `${progress}%`, backgroundColor: accentColor }}
|
style={{ width: `${progress}%`, backgroundColor: accentColor }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={clsx('video-player__seek__handle', { active: dragging })}
|
className={clsx('absolute -top-1 z-30 -ml-1.5 size-3 rounded-full bg-accent-500 opacity-0 shadow-[1px_2px_6px_rgba(0,0,0,0.3)] transition-opacity duration-100', { 'opacity-100': dragging || seekHovered })}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
style={{ left: `${progress}%`, backgroundColor: accentColor }}
|
style={{ left: `${progress}%`, backgroundColor: accentColor }}
|
||||||
onKeyDown={handleAudioKeyDown}
|
onKeyDown={handleAudioKeyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='video-player__controls active'>
|
<div className={clsx('absolute inset-x-0 bottom-0 z-20 box-border bg-gradient-to-t from-black/70 to-transparent px-[10px] opacity-100 transition-opacity duration-100 ease-linear')}>
|
||||||
<div className='video-player__buttons-bar'>
|
<div className='my-[-5px] flex justify-between pb-3.5'>
|
||||||
<div className='video-player__buttons left'>
|
<div className='flex w-full flex-auto items-center truncate text-[16px]'>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
title={intl.formatMessage(paused ? messages.play : messages.pause)}
|
title={intl.formatMessage(paused ? messages.play : messages.pause)}
|
||||||
aria-label={intl.formatMessage(paused ? messages.play : messages.pause)}
|
aria-label={intl.formatMessage(paused ? messages.play : messages.pause)}
|
||||||
className='player-button'
|
className={clsx('inline-block flex-none border-0 bg-transparent px-[6px] py-[5px] text-[16px] text-white/75 opacity-75 outline-none hover:text-white hover:opacity-100 active:text-white active:opacity-100 ')}
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
>
|
>
|
||||||
<Icon src={paused ? require('@tabler/icons/outline/player-play.svg') : require('@tabler/icons/outline/player-pause.svg')} />
|
<SvgIcon className='w-5' src={paused ? require('@tabler/icons/outline/player-play.svg') : require('@tabler/icons/outline/player-pause.svg')} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
title={intl.formatMessage(muted ? messages.unmute : messages.mute)}
|
title={intl.formatMessage(muted ? messages.unmute : messages.mute)}
|
||||||
aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)}
|
aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)}
|
||||||
className='player-button'
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
className={clsx('inline-block flex-none border-0 bg-transparent px-[6px] py-[5px] text-[16px] text-white/75 opacity-75 outline-none hover:text-white hover:opacity-100 active:text-white active:opacity-100')}
|
||||||
onClick={toggleMute}
|
onClick={toggleMute}
|
||||||
>
|
>
|
||||||
<Icon src={muted ? require('@tabler/icons/outline/volume-3.svg') : require('@tabler/icons/outline/volume.svg')} />
|
<SvgIcon className='w-5' src={muted ? require('@tabler/icons/outline/volume-3.svg') : require('@tabler/icons/outline/volume.svg')} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={clsx('video-player__volume', { active: hovered })}
|
className={clsx('relative inline-flex h-6 flex-none cursor-pointer overflow-hidden transition-all duration-100 ease-linear', { 'overflow-visible w-[50px] mr-[16px]': hovered })} onMouseDown={handleVolumeMouseDown} ref={slider}
|
||||||
ref={slider}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseDown={handleVolumeMouseDown}
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className='video-player__volume__current'
|
|
||||||
style={{
|
style={{
|
||||||
width: `${volume * 100}%`,
|
content: '',
|
||||||
backgroundColor: _getAccentColor(),
|
width: '50px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.35)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
display: 'block',
|
||||||
|
position: 'absolute',
|
||||||
|
height: '4px',
|
||||||
|
left: '0',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className={clsx('absolute left-0 top-1/2 block h-1 -translate-y-1/2 rounded-md bg-accent-500')} style={{ width: `${volume * 100}%` }} />
|
||||||
<span
|
<span
|
||||||
className='video-player__volume__handle'
|
className={clsx('absolute left-0 top-1/2 z-30 -ml-1.5 size-3 -translate-y-1/2 rounded-full bg-accent-500 opacity-0 shadow-[1px_2px_6px_rgba(0,0,0,0.3)] transition-opacity duration-100', { 'opacity-100': hovered })}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
style={{ left: `${volume * 100}%`, backgroundColor: _getAccentColor() }}
|
style={{ left: `${volume * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className='video-player__time'>
|
<span className='mx-[5px] inline-flex flex-[0_1_auto] overflow-hidden text-ellipsis'>
|
||||||
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
|
<span className='text-sm font-medium text-white/75'>{formatTime(Math.floor(currentTime))}</span>
|
||||||
{getDuration() && (<>
|
{getDuration() && (<>
|
||||||
<span className='video-player__time-sep'>/</span>
|
<span className='mx-1.5 inline-block text-sm font-medium text-white/75'>/</span>{/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
<span className='video-player__time-total'>{formatTime(Math.floor(getDuration()))}</span>
|
<span className='text-sm font-medium text-white/75'>{formatTime(Math.floor(getDuration()))}</span>
|
||||||
</>)}
|
</>)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='video-player__buttons right'>
|
<div className='flex min-w-[30px] flex-auto items-center truncate text-[16px]'>
|
||||||
<a
|
<a
|
||||||
title={intl.formatMessage(messages.download)}
|
title={intl.formatMessage(messages.download)}
|
||||||
aria-label={intl.formatMessage(messages.download)}
|
aria-label={intl.formatMessage(messages.download)}
|
||||||
className='video-player__download__icon player-button'
|
className={clsx('inline-block flex-none border-0 bg-transparent px-[6px] py-[5px] text-[16px] text-white/75 opacity-75 outline-none hover:text-white hover:opacity-100 active:text-white active:opacity-100 ')}
|
||||||
href={src}
|
href={src}
|
||||||
download
|
download
|
||||||
target='_blank'
|
target='_blank'
|
||||||
>
|
>
|
||||||
<Icon src={require('@tabler/icons/outline/download.svg')} />
|
<SvgIcon className='w-5' src={require('@tabler/icons/outline/download.svg')} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -9,7 +9,7 @@ import PasswordResetConfirm from './password-reset-confirm';
|
||||||
const TestableComponent = () => (
|
const TestableComponent = () => (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path='/edit' exact><PasswordResetConfirm /></Route>
|
<Route path='/edit' exact><PasswordResetConfirm /></Route>
|
||||||
<Route path='/' exact><span data-testid='home'>Homepage</span></Route>
|
<Route path='/' exact><span data-testid='home'>Homepage</span></Route> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -141,13 +141,19 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
|
||||||
/></p>}
|
/></p>}
|
||||||
</>);
|
</>);
|
||||||
|
|
||||||
|
const confirmationHeading = needsConfirmation
|
||||||
|
? intl.formatMessage(messages.needsConfirmationHeader)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const approvalHeading = needsApproval
|
||||||
|
? intl.formatMessage(messages.needsApprovalHeader)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const heading = confirmationHeading || approvalHeading;
|
||||||
|
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(openModal('CONFIRM', {
|
||||||
icon: require('@tabler/icons/outline/check.svg'),
|
icon: require('@tabler/icons/outline/check.svg'),
|
||||||
heading: needsConfirmation
|
heading: heading,
|
||||||
? intl.formatMessage(messages.needsConfirmationHeader)
|
|
||||||
: needsApproval
|
|
||||||
? intl.formatMessage(messages.needsApprovalHeader)
|
|
||||||
: undefined,
|
|
||||||
message,
|
message,
|
||||||
confirm: intl.formatMessage(messages.close),
|
confirm: intl.formatMessage(messages.close),
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -73,9 +73,9 @@ const AuthTokenList: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const tokens = useAppSelector(state => state.security.get('tokens').reverse());
|
const tokens = useAppSelector(state => state.security.get('tokens').reverse());
|
||||||
const currentTokenId = useAppSelector(state => {
|
|
||||||
const currentToken = state.auth.tokens.valueSeq().find((token) => token.me === state.auth.me);
|
|
||||||
|
|
||||||
|
const currentTokenId = useAppSelector(state => {
|
||||||
|
const currentToken = Object.values(state.auth.tokens).find((token) => token.me === state.auth.me);
|
||||||
return currentToken?.id;
|
return currentToken?.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -86,7 +86,7 @@ const AuthTokenList: React.FC = () => {
|
||||||
const body = tokens ? (
|
const body = tokens ? (
|
||||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||||
{tokens.map((token) => (
|
{tokens.map((token) => (
|
||||||
<AuthToken key={token.id} token={token} isCurrent={token.id === currentTokenId} />
|
<AuthToken key={token.id} token={token} isCurrent={token.id.toString() === currentTokenId} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : <Spinner />;
|
) : <Spinner />;
|
||||||
|
|
|
@ -88,12 +88,14 @@ const Backups = () => {
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
const body = showLoading ? <Spinner /> : backups.isEmpty() ? emptyMessage : (
|
const backupsContent = backups.isEmpty() ? emptyMessage : (
|
||||||
<div className='mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
<div className='mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||||
{backups.map((backup) => <Backup key={backup.id} backup={backup} />)}
|
{backups.map((backup) => <Backup key={backup.id} backup={backup} />)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const body = showLoading ? <Spinner /> : backupsContent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)}>
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
{body}
|
{body}
|
||||||
|
|
|
@ -84,7 +84,7 @@ const ChatListItem: React.FC<IChatListItemInterface> = ({ chat, onClick }) => {
|
||||||
|
|
||||||
<Stack alignItems='start' className='overflow-hidden'>
|
<Stack alignItems='start' className='overflow-hidden'>
|
||||||
<div className='flex w-full grow items-center space-x-1'>
|
<div className='flex w-full grow items-center space-x-1'>
|
||||||
<Text weight='bold' size='sm' align='left' truncate>{chat.account?.display_name || `@${chat.account.username}`}</Text>
|
<Text weight='bold' size='sm' align='left' truncate>{chat.account?.display_name || `@${chat.account.username}`}</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
{chat.account?.verified && <VerificationBadge />}
|
{chat.account?.verified && <VerificationBadge />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -74,13 +74,13 @@ const ChatMessageListIntro = () => {
|
||||||
<Text size='lg' align='center'>
|
<Text size='lg' align='center'>
|
||||||
{needsAcceptance ? (
|
{needsAcceptance ? (
|
||||||
<>
|
<>
|
||||||
<Text tag='span' weight='semibold'>@{chat.account.acct}</Text>
|
<Text tag='span' weight='semibold'>@{chat.account.acct}</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
{' '}
|
{' '}
|
||||||
<Text tag='span'>{intl.formatMessage(messages.intro)}</Text>
|
<Text tag='span'>{intl.formatMessage(messages.intro)}</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Link to={`/@${chat.account.acct}`}>
|
<Link to={`/@${chat.account.acct}`}>
|
||||||
<Text tag='span' theme='inherit' weight='semibold'>@{chat.account.acct}</Text>
|
<Text tag='span' theme='inherit' weight='semibold'>@{chat.account.acct}</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -195,7 +195,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||||
<>
|
<>
|
||||||
<Text tag='span'>{intl.formatMessage(messages.blockedBy)}</Text>
|
<Text tag='span'>{intl.formatMessage(messages.blockedBy)}</Text>
|
||||||
{' '}
|
{' '}
|
||||||
<Text tag='span' theme='primary'>@{chat.account.acct}</Text>
|
<Text tag='span' theme='primary'>@{chat.account.acct}</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</>
|
</>
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -134,7 +134,7 @@ const ChatPageMain = () => {
|
||||||
<div className='flex w-full grow items-center space-x-1'>
|
<div className='flex w-full grow items-center space-x-1'>
|
||||||
<Link to={`/@${chat.account.acct}`}>
|
<Link to={`/@${chat.account.acct}`}>
|
||||||
<Text weight='bold' size='sm' align='left' truncate>
|
<Text weight='bold' size='sm' align='left' truncate>
|
||||||
{chat.account.display_name || `@${chat.account.username}`}
|
{chat.account.display_name || `@${chat.account.username}`} {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
{chat.account.verified && <VerificationBadge />}
|
{chat.account.verified && <VerificationBadge />}
|
||||||
|
@ -173,7 +173,7 @@ const ChatPageMain = () => {
|
||||||
<Avatar src={chat.account.avatar_static} size={50} />
|
<Avatar src={chat.account.avatar_static} size={50} />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text weight='semibold'>{chat.account.display_name}</Text>
|
<Text weight='semibold'>{chat.account.display_name}</Text>
|
||||||
<Text size='sm' theme='primary'>@{chat.account.acct}</Text>
|
<Text size='sm' theme='primary'>@{chat.account.acct}</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</Stack>
|
</Stack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ describe('<ChatPaneHeader />', () => {
|
||||||
describe('when it is a node', () => {
|
describe('when it is a node', () => {
|
||||||
it('renders the title', () => {
|
it('renders the title', () => {
|
||||||
const title = (
|
const title = (
|
||||||
<div><p>hello world</p></div>
|
<div><p>hello world</p></div> // eslint-disable-line formatjs/no-literal-string-in-jsx
|
||||||
);
|
);
|
||||||
render(<ChatPaneHeader title={title} onToggle={vi.fn()} isOpen />);
|
render(<ChatPaneHeader title={title} onToggle={vi.fn()} isOpen />);
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ const renderComponent = () => render(
|
||||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
|
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
|
||||||
<ChatProvider>
|
<ChatProvider>
|
||||||
<ChatSearch />
|
<ChatSearch />
|
||||||
</ChatProvider>,
|
</ChatProvider>, {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</VirtuosoMockContext.Provider>,
|
</VirtuosoMockContext.Provider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -41,7 +41,7 @@ const Results = ({ accountSearchResult, onSelect }: IResults) => {
|
||||||
<Text weight='bold' size='sm' truncate>{account.display_name}</Text>
|
<Text weight='bold' size='sm' truncate>{account.display_name}</Text>
|
||||||
{account.verified && <VerificationBadge />}
|
{account.verified && <VerificationBadge />}
|
||||||
</div>
|
</div>
|
||||||
<Text size='sm' weight='medium' theme='muted' direction='ltr' truncate>@{account.acct}</Text>
|
<Text size='sm' weight='medium' theme='muted' direction='ltr' truncate>@{account.acct}</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</Stack>
|
</Stack>
|
||||||
</HStack>
|
</HStack>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -35,8 +35,10 @@ describe('<ChatWidget />', () => {
|
||||||
it('hides the widget', async () => {
|
it('hides the widget', async () => {
|
||||||
const App = () => (
|
const App = () => (
|
||||||
<Switch>
|
<Switch>
|
||||||
|
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||||
<Route path='/chats' exact><span>Chats page <ChatWidget /></span></Route>
|
<Route path='/chats' exact><span>Chats page <ChatWidget /></span></Route>
|
||||||
<Route path='/' exact><span data-testid='home'>Homepage <ChatWidget /></span></Route>
|
<Route path='/' exact><span data-testid='home'>Homepage <ChatWidget /></span></Route>
|
||||||
|
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -85,8 +87,10 @@ describe('<ChatWidget />', () => {
|
||||||
it('shows the widget', async () => {
|
it('shows the widget', async () => {
|
||||||
const App = () => (
|
const App = () => (
|
||||||
<Switch>
|
<Switch>
|
||||||
|
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||||
<Route path='/chats' exact><span>Chats page <ChatWidget /></span></Route>
|
<Route path='/chats' exact><span>Chats page <ChatWidget /></span></Route>
|
||||||
<Route path='/' exact><span data-testid='home'>Homepage <ChatWidget /></span></Route>
|
<Route path='/' exact><span data-testid='home'>Homepage <ChatWidget /></span></Route>
|
||||||
|
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -43,9 +43,11 @@ const ChatPaneHeader = (props: IChatPaneHeader) => {
|
||||||
|
|
||||||
{(typeof unreadCount !== 'undefined' && unreadCount > 0) && (
|
{(typeof unreadCount !== 'undefined' && unreadCount > 0) && (
|
||||||
<HStack alignItems='center' space={2}>
|
<HStack alignItems='center' space={2}>
|
||||||
|
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||||
<Text weight='semibold' data-testid='unread-count'>
|
<Text weight='semibold' data-testid='unread-count'>
|
||||||
({unreadCount})
|
({unreadCount})
|
||||||
</Text>
|
</Text>
|
||||||
|
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||||
|
|
||||||
<div className='size-2.5 rounded-full bg-accent-300' />
|
<div className='size-2.5 rounded-full bg-accent-300' />
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
|
@ -112,7 +112,7 @@ const ChatSettings = () => {
|
||||||
<Avatar src={chat.account.avatar_static} size={50} />
|
<Avatar src={chat.account.avatar_static} size={50} />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text weight='semibold'>{chat.account.display_name}</Text>
|
<Text weight='semibold'>{chat.account.display_name}</Text>
|
||||||
<Text size='sm' theme='primary'>@{chat.account.acct}</Text>
|
<Text size='sm' theme='primary'>@{chat.account.acct}</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</Stack>
|
</Stack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
|
|
|
@ -88,7 +88,7 @@ const ChatWindow = () => {
|
||||||
<Stack alignItems='start'>
|
<Stack alignItems='start'>
|
||||||
<LinkWrapper enabled={isOpen} to={`/@${chat.account.acct}`}>
|
<LinkWrapper enabled={isOpen} to={`/@${chat.account.acct}`}>
|
||||||
<div className='flex grow items-center space-x-1'>
|
<div className='flex grow items-center space-x-1'>
|
||||||
<Text size='sm' weight='bold' truncate>{chat.account.display_name || `@${chat.account.acct}`}</Text>
|
<Text size='sm' weight='bold' truncate>{chat.account.display_name || `@${chat.account.acct}`}</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
{chat.account.verified && <VerificationBadge />}
|
{chat.account.verified && <VerificationBadge />}
|
||||||
</div>
|
</div>
|
||||||
</LinkWrapper>
|
</LinkWrapper>
|
||||||
|
|
|
@ -74,7 +74,7 @@ const Option: React.FC<IOption> = ({
|
||||||
<HStack alignItems='center' justifyContent='between' space={4}>
|
<HStack alignItems='center' justifyContent='between' space={4}>
|
||||||
<HStack alignItems='center' space={2} grow>
|
<HStack alignItems='center' space={2} grow>
|
||||||
<div className='w-6'>
|
<div className='w-6'>
|
||||||
<Text weight='bold'>{index + 1}.</Text>
|
<Text weight='bold'>{index + 1}.</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AutosuggestInput
|
<AutosuggestInput
|
||||||
|
|
|
@ -55,7 +55,7 @@ const ReplyMentions: React.FC<IReplyMentions> = ({ composeId }) => {
|
||||||
const accounts = to.slice(0, 2).map((acct: string) => {
|
const accounts = to.slice(0, 2).map((acct: string) => {
|
||||||
const username = acct.split('@')[0];
|
const username = acct.split('@')[0];
|
||||||
return (
|
return (
|
||||||
<span className='reply-mentions__account'>
|
<span className='reply-mentions__account'> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
@{shortenNostr(username)}
|
@{shortenNostr(username)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
|
@ -160,6 +160,7 @@ const SearchResults = () => {
|
||||||
));
|
));
|
||||||
resultsIds = results.statuses;
|
resultsIds = results.statuses;
|
||||||
} else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) {
|
} else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) {
|
||||||
|
hasMore = !!nextTrendingStatuses;
|
||||||
searchResults = trendingStatuses.map((statusId: string) => (
|
searchResults = trendingStatuses.map((statusId: string) => (
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
<StatusContainer
|
<StatusContainer
|
||||||
|
@ -231,7 +232,7 @@ const SearchResults = () => {
|
||||||
scrollKey={`${selectedFilter}:${value}`}
|
scrollKey={`${selectedFilter}:${value}`}
|
||||||
isLoading={submitted && !loaded}
|
isLoading={submitted && !loaded}
|
||||||
showLoading={submitted && !loaded && searchResults?.isEmpty()}
|
showLoading={submitted && !loaded && searchResults?.isEmpty()}
|
||||||
hasMore={(!!nextTrendingStatuses) || hasMore}
|
hasMore={hasMore}
|
||||||
onLoadMore={handleLoadMore}
|
onLoadMore={handleLoadMore}
|
||||||
placeholderComponent={placeholderComponent}
|
placeholderComponent={placeholderComponent}
|
||||||
placeholderCount={20}
|
placeholderCount={20}
|
||||||
|
|
|
@ -96,8 +96,6 @@ const SearchZapSplit = (props: ISearchZapSplit) => {
|
||||||
|
|
||||||
const getAccount = (accountId: string) => (dispatch: any, getState: () => RootState) => {
|
const getAccount = (accountId: string) => (dispatch: any, getState: () => RootState) => {
|
||||||
const account = selectAccount(getState(), accountId);
|
const account = selectAccount(getState(), accountId);
|
||||||
console.log(account);
|
|
||||||
|
|
||||||
props.onChange(account!);
|
props.onChange(account!);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -41,11 +41,13 @@ const DevelopersChallenge = () => {
|
||||||
<Column label={intl.formatMessage(messages.heading)}>
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<Text>
|
<Text>
|
||||||
|
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='developers.challenge.message'
|
id='developers.challenge.message'
|
||||||
defaultMessage='What is the result of calling {function}?'
|
defaultMessage='What is the result of calling {function}?'
|
||||||
values={{ function: <span className='font-mono'>soapbox()</span> }}
|
values={{ function: <span className='font-mono'>soapbox()</span> }}
|
||||||
/>
|
/>
|
||||||
|
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text tag='pre' family='mono' theme='muted'>
|
<Text tag='pre' family='mono' theme='muted'>
|
||||||
|
|
|
@ -164,7 +164,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(openModal('CONFIRM', {
|
||||||
icon: require('@tabler/icons/outline/ban.svg'),
|
icon: require('@tabler/icons/outline/ban.svg'),
|
||||||
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.acct }} />,
|
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.acct }} />,
|
||||||
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.acct}</strong> }} />,
|
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>{/* eslint-disable-line formatjs/no-literal-string-in-jsx */}@{account.acct}</strong> }} />,
|
||||||
confirm: intl.formatMessage(messages.blockConfirm),
|
confirm: intl.formatMessage(messages.blockConfirm),
|
||||||
onConfirm: () => dispatch(blockAccount(account.id)),
|
onConfirm: () => dispatch(blockAccount(account.id)),
|
||||||
secondary: intl.formatMessage(messages.blockAndReport),
|
secondary: intl.formatMessage(messages.blockAndReport),
|
||||||
|
|
|
@ -50,7 +50,7 @@ const SuggestionItem: React.FC<ISuggestionItem> = ({ accountId }) => {
|
||||||
{account.verified && <VerificationBadge />}
|
{account.verified && <VerificationBadge />}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<Text theme='muted' align='center' size='sm' truncate>@{account.acct}</Text>
|
<Text theme='muted' align='center' size='sm' truncate>@{account.acct}</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -70,29 +70,33 @@ const Filters = () => {
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
itemClassName='pb-4 last:pb-0'
|
itemClassName='pb-4 last:pb-0'
|
||||||
>
|
>
|
||||||
{filters.map((filter) => (
|
{filters.map((filter) => {
|
||||||
|
|
||||||
|
const truthMessageFilter = filter.filter_action === 'hide' ?
|
||||||
|
<FormattedMessage id='filters.filters_list_hide_completely' defaultMessage='Hide content' /> :
|
||||||
|
<FormattedMessage id='filters.filters_list_warn' defaultMessage='Display warning' />;
|
||||||
|
|
||||||
|
const falseMessageFilter = (filter.filter_action === 'hide' ?
|
||||||
|
<FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /> :
|
||||||
|
<FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' />);
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={filter.id} className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
|
<div key={filter.id} className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
|
||||||
<Stack space={2}>
|
<Stack space={2}>
|
||||||
<Stack className='grow' space={1}>
|
<Stack className='grow' space={1}>
|
||||||
<Text weight='medium'>
|
<Text weight='medium'>
|
||||||
<FormattedMessage id='filters.filters_list_phrases_label' defaultMessage='Keywords or phrases:' />
|
<FormattedMessage id='filters.filters_list_phrases_label' defaultMessage='Keywords or phrases:' />
|
||||||
{' '}
|
{' '} {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
<Text theme='muted' tag='span'>{filter.keywords.map(keyword => keyword.keyword).join(', ')}</Text>
|
<Text theme='muted' tag='span'>{filter.keywords.map(keyword => keyword.keyword).join(', ')}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Text weight='medium'>
|
<Text weight='medium'>
|
||||||
<FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' />
|
<FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' />
|
||||||
{' '}
|
{' '} {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
<Text theme='muted' tag='span'>{filter.context.map(context => contexts[context] ? intl.formatMessage(contexts[context]) : context).join(', ')}</Text>
|
<Text theme='muted' tag='span'>{filter.context.map(context => contexts[context] ? intl.formatMessage(contexts[context]) : context).join(', ')}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<HStack space={4} wrap>
|
<HStack space={4} wrap>
|
||||||
<Text weight='medium'>
|
<Text weight='medium'>
|
||||||
{filtersV2 ? (
|
{filtersV2 ? truthMessageFilter : falseMessageFilter}
|
||||||
filter.filter_action === 'hide' ?
|
|
||||||
<FormattedMessage id='filters.filters_list_hide_completely' defaultMessage='Hide content' /> :
|
|
||||||
<FormattedMessage id='filters.filters_list_warn' defaultMessage='Display warning' />
|
|
||||||
) : (filter.filter_action === 'hide' ?
|
|
||||||
<FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /> :
|
|
||||||
<FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' />)}
|
|
||||||
</Text>
|
</Text>
|
||||||
{filter.expires_at && (
|
{filter.expires_at && (
|
||||||
<Text weight='medium'>
|
<Text weight='medium'>
|
||||||
|
@ -113,7 +117,9 @@ const Filters = () => {
|
||||||
</HStack>
|
</HStack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
</ScrollableList>
|
</ScrollableList>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|
|
@ -15,7 +15,7 @@ const GroupMemberCount = ({ group }: IGroupMemberCount) => {
|
||||||
<Link to={`/group/${group.slug}/members`} className='hover:underline'>
|
<Link to={`/group/${group.slug}/members`} className='hover:underline'>
|
||||||
<Text theme='inherit' tag='span' size='sm' weight='medium' data-testid='group-member-count'>
|
<Text theme='inherit' tag='span' size='sm' weight='medium' data-testid='group-member-count'>
|
||||||
{shortNumberFormat(group.members_count)}
|
{shortNumberFormat(group.members_count)}
|
||||||
{' '}
|
{' '} {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='groups.discover.search.results.member_count'
|
id='groups.discover.search.results.member_count'
|
||||||
defaultMessage='{members, plural, one {member} other {members}}'
|
defaultMessage='{members, plural, one {member} other {members}}'
|
||||||
|
|
|
@ -145,6 +145,7 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
|
||||||
>
|
>
|
||||||
<Link to={`/group/${group.slug}/tag/${tag.id}`} className='group grow'>
|
<Link to={`/group/${group.slug}/tag/${tag.id}`} className='group grow'>
|
||||||
<Stack>
|
<Stack>
|
||||||
|
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||||
<Text
|
<Text
|
||||||
weight='bold'
|
weight='bold'
|
||||||
theme={(tag.visible || !isOwner) ? 'default' : 'subtle'}
|
theme={(tag.visible || !isOwner) ? 'default' : 'subtle'}
|
||||||
|
@ -153,9 +154,12 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
|
||||||
>
|
>
|
||||||
#{tag.name}
|
#{tag.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||||
<Text size='sm' theme={(tag.visible || !isOwner) ? 'muted' : 'subtle'}>
|
<Text size='sm' theme={(tag.visible || !isOwner) ? 'muted' : 'subtle'}>
|
||||||
|
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||||
{intl.formatMessage(messages.total)}:
|
{intl.formatMessage(messages.total)}:
|
||||||
{' '}
|
{' '}
|
||||||
|
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||||
<Text size='sm' theme='inherit' weight='semibold' tag='span'>
|
<Text size='sm' theme='inherit' weight='semibold' tag='span'>
|
||||||
{shortNumberFormat(tag.uses)}
|
{shortNumberFormat(tag.uses)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -58,7 +58,7 @@ const GroupGridItem = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDiv
|
||||||
|
|
||||||
<HStack alignItems='center' space={1}>
|
<HStack alignItems='center' space={1}>
|
||||||
<GroupPrivacy group={group} />
|
<GroupPrivacy group={group} />
|
||||||
<span>•</span>
|
<span>•</span> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
<GroupMemberCount group={group} />
|
<GroupMemberCount group={group} />
|
||||||
</HStack>
|
</HStack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -52,10 +52,10 @@ const GroupListItem = (props: IGroupListItem) => {
|
||||||
|
|
||||||
{typeof group.members_count !== 'undefined' && (
|
{typeof group.members_count !== 'undefined' && (
|
||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>•</span> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||||
{shortNumberFormat(group.members_count)}
|
{shortNumberFormat(group.members_count)}
|
||||||
{' '}
|
{' '} {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='groups.discover.search.results.member_count'
|
id='groups.discover.search.results.member_count'
|
||||||
defaultMessage='{members, plural, one {member} other {members}}'
|
defaultMessage='{members, plural, one {member} other {members}}'
|
||||||
|
|
|
@ -19,8 +19,8 @@ describe('<Blankslate />', () => {
|
||||||
it('should render correctly', () => {
|
it('should render correctly', () => {
|
||||||
render(
|
render(
|
||||||
<Blankslate
|
<Blankslate
|
||||||
title={<span>Title</span>}
|
title={<span>Title</span>} /* eslint-disable-line formatjs/no-literal-string-in-jsx */
|
||||||
subtitle={<span>Subtitle</span>}
|
subtitle={<span>Subtitle</span>} /* eslint-disable-line formatjs/no-literal-string-in-jsx */
|
||||||
/>);
|
/>);
|
||||||
|
|
||||||
expect(screen.getByTestId('no-results')).toHaveTextContent('Title');
|
expect(screen.getByTestId('no-results')).toHaveTextContent('Title');
|
||||||
|
|
|
@ -23,17 +23,19 @@ const TagListItem = (props: ITagListItem) => {
|
||||||
<Text
|
<Text
|
||||||
weight='bold'
|
weight='bold'
|
||||||
className='group-hover:text-primary-600 group-hover:underline dark:group-hover:text-accent-blue'
|
className='group-hover:text-primary-600 group-hover:underline dark:group-hover:text-accent-blue'
|
||||||
>
|
> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
||||||
#{tag.name}
|
#{tag.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text size='sm' theme='muted' weight='medium'>
|
<Text size='sm' theme='muted' weight='medium'>
|
||||||
|
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='groups.discovery.tags.no_of_groups'
|
id='groups.discovery.tags.no_of_groups'
|
||||||
defaultMessage='Number of groups'
|
defaultMessage='Number of groups'
|
||||||
/>
|
/>
|
||||||
:{' '}
|
:{' '}
|
||||||
{tag.groups}
|
{tag.groups}
|
||||||
|
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { Button, Column, Form, FormActions, Stack } from 'soapbox/components/ui'
|
||||||
import { useNostr } from 'soapbox/contexts/nostr-context';
|
import { useNostr } from 'soapbox/contexts/nostr-context';
|
||||||
import { useNostrReq } from 'soapbox/features/nostr/hooks/useNostrReq';
|
import { useNostrReq } from 'soapbox/features/nostr/hooks/useNostrReq';
|
||||||
import { useOwnAccount } from 'soapbox/hooks';
|
import { useOwnAccount } from 'soapbox/hooks';
|
||||||
|
import { useSigner } from 'soapbox/hooks/nostr/useSigner';
|
||||||
|
|
||||||
import RelayEditor, { RelayData } from './components/relay-editor';
|
import RelayEditor, { RelayData } from './components/relay-editor';
|
||||||
|
|
||||||
|
@ -15,7 +16,8 @@ const messages = defineMessages({
|
||||||
const NostrRelays = () => {
|
const NostrRelays = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { account } = useOwnAccount();
|
const { account } = useOwnAccount();
|
||||||
const { relay, signer } = useNostr();
|
const { relay } = useNostr();
|
||||||
|
const { signer } = useSigner();
|
||||||
|
|
||||||
const { events } = useNostrReq(
|
const { events } = useNostrReq(
|
||||||
account?.nostr?.pubkey
|
account?.nostr?.pubkey
|
||||||
|
|
|
@ -0,0 +1,227 @@
|
||||||
|
import {
|
||||||
|
NRelay,
|
||||||
|
NostrConnectRequest,
|
||||||
|
NostrConnectResponse,
|
||||||
|
NostrEvent,
|
||||||
|
NostrFilter,
|
||||||
|
NostrSigner,
|
||||||
|
NSchema as n,
|
||||||
|
} from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
/** Options passed to `NBunker`. */
|
||||||
|
export interface NBunkerOpts {
|
||||||
|
/** Relay to subscribe to for NIP-46 requests. */
|
||||||
|
relay: NRelay;
|
||||||
|
/** Signer to complete the actual NIP-46 requests, such as `get_public_key`, `sign_event`, `nip44_encrypt` etc. */
|
||||||
|
userSigner: NostrSigner;
|
||||||
|
/** Signer to sign, encrypt, and decrypt the kind 24133 transport events events. */
|
||||||
|
bunkerSigner: NostrSigner;
|
||||||
|
/**
|
||||||
|
* Callback when a `connect` request has been received.
|
||||||
|
* This is a good place to call `bunker.authorize()` with the remote client's pubkey.
|
||||||
|
* It's up to the caller to verify the request parameters and secret, and then return a response object.
|
||||||
|
* All other methods are handled by the bunker automatically.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const bunker = new Bunker({
|
||||||
|
* ...opts,
|
||||||
|
* onConnect(request, event) {
|
||||||
|
* const [, secret] = request.params;
|
||||||
|
*
|
||||||
|
* if (secret === authorization.secret) {
|
||||||
|
* bunker.authorize(event.pubkey); // Authorize the pubkey for signer actions.
|
||||||
|
* return { id: request.id, result: 'ack' }; // Return a success response.
|
||||||
|
* } else {
|
||||||
|
* return { id: request.id, result: '', error: 'Invalid secret' };
|
||||||
|
* }
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
onConnect?(request: NostrConnectRequest, event: NostrEvent): Promise<NostrConnectResponse> | NostrConnectResponse;
|
||||||
|
/**
|
||||||
|
* Callback when an error occurs while parsing a request event.
|
||||||
|
* Client errors are not captured here, only errors that occur before arequest's `id` can be known,
|
||||||
|
* eg when decrypting the event content or parsing the request object.
|
||||||
|
*/
|
||||||
|
onError?(error: unknown, event: NostrEvent): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modular NIP-46 remote signer bunker class.
|
||||||
|
*
|
||||||
|
* Runs a remote signer against a given relay, using `bunkerSigner` to sign transport events,
|
||||||
|
* and `userSigner` to complete NIP-46 requests.
|
||||||
|
*/
|
||||||
|
export class NBunker {
|
||||||
|
|
||||||
|
private controller = new AbortController();
|
||||||
|
private authorizedPubkeys = new Set<string>();
|
||||||
|
|
||||||
|
/** Wait for the bunker to be ready before sending requests. */
|
||||||
|
public waitReady: Promise<void>;
|
||||||
|
private setReady!: () => void;
|
||||||
|
|
||||||
|
constructor(private opts: NBunkerOpts) {
|
||||||
|
this.waitReady = new Promise((resolve) => {
|
||||||
|
this.setReady = resolve;
|
||||||
|
});
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the signer subscription to the relay. */
|
||||||
|
private async open() {
|
||||||
|
const { relay, bunkerSigner, onError } = this.opts;
|
||||||
|
|
||||||
|
const signal = this.controller.signal;
|
||||||
|
const bunkerPubkey = await bunkerSigner.getPublicKey();
|
||||||
|
|
||||||
|
const filters: NostrFilter[] = [
|
||||||
|
{ kinds: [24133], '#p': [bunkerPubkey], limit: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sub = relay.req(filters, { signal });
|
||||||
|
this.setReady();
|
||||||
|
|
||||||
|
for await (const msg of sub) {
|
||||||
|
if (msg[0] === 'EVENT') {
|
||||||
|
const [,, event] = msg;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decrypted = await this.decrypt(event.pubkey, event.content);
|
||||||
|
const request = n.json().pipe(n.connectRequest()).parse(decrypted);
|
||||||
|
await this.handleRequest(request, event);
|
||||||
|
} catch (error) {
|
||||||
|
onError?.(error, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle NIP-46 requests.
|
||||||
|
*
|
||||||
|
* The `connect` method must be handled passing an `onConnect` option into the class
|
||||||
|
* and then calling `bunker.authorize()` within that callback to authorize the pubkey.
|
||||||
|
*
|
||||||
|
* All other methods are handled automatically, as long as the key is authorized,
|
||||||
|
* by invoking the appropriate method on the `userSigner`.
|
||||||
|
*/
|
||||||
|
private async handleRequest(request: NostrConnectRequest, event: NostrEvent): Promise<void> {
|
||||||
|
const { userSigner, onConnect } = this.opts;
|
||||||
|
const { pubkey } = event;
|
||||||
|
|
||||||
|
if (request.method === 'connect') {
|
||||||
|
if (onConnect) {
|
||||||
|
const response = await onConnect(request, event);
|
||||||
|
return this.sendResponse(pubkey, response);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent unauthorized access.
|
||||||
|
if (!this.authorizedPubkeys.has(pubkey)) {
|
||||||
|
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 userSigner.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 userSigner.getRelays?.() ?? []),
|
||||||
|
});
|
||||||
|
case 'get_public_key':
|
||||||
|
return this.sendResponse(pubkey, {
|
||||||
|
id: request.id,
|
||||||
|
result: await userSigner.getPublicKey(),
|
||||||
|
});
|
||||||
|
case 'nip04_encrypt':
|
||||||
|
return this.sendResponse(pubkey, {
|
||||||
|
id: request.id,
|
||||||
|
result: await userSigner.nip04!.encrypt(request.params[0], request.params[1]),
|
||||||
|
});
|
||||||
|
case 'nip04_decrypt':
|
||||||
|
return this.sendResponse(pubkey, {
|
||||||
|
id: request.id,
|
||||||
|
result: await userSigner.nip04!.decrypt(request.params[0], request.params[1]),
|
||||||
|
});
|
||||||
|
case 'nip44_encrypt':
|
||||||
|
return this.sendResponse(pubkey, {
|
||||||
|
id: request.id,
|
||||||
|
result: await userSigner.nip44!.encrypt(request.params[0], request.params[1]),
|
||||||
|
});
|
||||||
|
case 'nip44_decrypt':
|
||||||
|
return this.sendResponse(pubkey, {
|
||||||
|
id: request.id,
|
||||||
|
result: await userSigner.nip44!.decrypt(request.params[0], request.params[1]),
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return this.sendResponse(pubkey, {
|
||||||
|
id: request.id,
|
||||||
|
result: '',
|
||||||
|
error: `Unrecognized method: ${request.method}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt the response with the bunker key, then publish it to the relay. */
|
||||||
|
private async sendResponse(pubkey: string, response: NostrConnectResponse): Promise<void> {
|
||||||
|
const { bunkerSigner, relay } = this.opts;
|
||||||
|
|
||||||
|
const content = await bunkerSigner.nip04!.encrypt(pubkey, JSON.stringify(response));
|
||||||
|
|
||||||
|
const event = await bunkerSigner.signEvent({
|
||||||
|
kind: 24133,
|
||||||
|
content,
|
||||||
|
tags: [['p', pubkey]],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await relay.event(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-decrypt NIP-44 or NIP-04 ciphertext. */
|
||||||
|
private async decrypt(pubkey: string, ciphertext: string): Promise<string> {
|
||||||
|
const { bunkerSigner } = this.opts;
|
||||||
|
try {
|
||||||
|
return await bunkerSigner.nip44!.decrypt(pubkey, ciphertext);
|
||||||
|
} catch {
|
||||||
|
return await bunkerSigner.nip04!.decrypt(pubkey, ciphertext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorize the pubkey to perform signer actions (ie any other actions besides `connect`). */
|
||||||
|
authorize(pubkey: string): void {
|
||||||
|
this.authorizedPubkeys.add(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke authorization for the pubkey. */
|
||||||
|
revoke(pubkey: string): void {
|
||||||
|
this.authorizedPubkeys.delete(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the bunker and unsubscribe relay subscriptions. */
|
||||||
|
close(): void {
|
||||||
|
this.controller.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.dispose](): void {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,153 +0,0 @@
|
||||||
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;
|
|
||||||
onSubscribed(): void;
|
|
||||||
getSecret(): string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NConnect {
|
|
||||||
|
|
||||||
private relay: NRelay;
|
|
||||||
private signer: NostrSigner;
|
|
||||||
private authorizedPubkey: string | undefined;
|
|
||||||
private onAuthorize: (pubkey: string) => void;
|
|
||||||
private onSubscribed: () => 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.onSubscribed = opts.onSubscribed;
|
|
||||||
this.getSecret = opts.getSecret;
|
|
||||||
|
|
||||||
this.open();
|
|
||||||
}
|
|
||||||
|
|
||||||
async open() {
|
|
||||||
const pubkey = await this.signer.getPublicKey();
|
|
||||||
const signal = this.controller.signal;
|
|
||||||
|
|
||||||
const sub = this.relay.req([{ kinds: [24133], '#p': [pubkey], limit: 0 }], { signal });
|
|
||||||
this.onSubscribed();
|
|
||||||
|
|
||||||
for await (const msg of sub) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -8,7 +8,7 @@ import { z } from 'zod';
|
||||||
* When instantiated, it will lock the storage key to prevent tampering.
|
* When instantiated, it will lock the storage key to prevent tampering.
|
||||||
* Changes to the object will sync to storage.
|
* Changes to the object will sync to storage.
|
||||||
*/
|
*/
|
||||||
export class NKeyStorage implements ReadonlyMap<string, NostrSigner> {
|
export class NKeyring implements ReadonlyMap<string, NostrSigner> {
|
||||||
|
|
||||||
#keypairs = new Map<string, Uint8Array>();
|
#keypairs = new Map<string, Uint8Array>();
|
||||||
#storage: Storage;
|
#storage: Storage;
|
|
@ -5,7 +5,13 @@ import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNostr } from 'soapbox/contexts/nostr-context';
|
import { useNostr } from 'soapbox/contexts/nostr-context';
|
||||||
import { useForceUpdate } from 'soapbox/hooks/useForceUpdate';
|
import { useForceUpdate } from 'soapbox/hooks/useForceUpdate';
|
||||||
|
|
||||||
/** Streams events from the relay for the given filters. */
|
/**
|
||||||
|
* Streams events from the relay for the given filters.
|
||||||
|
*
|
||||||
|
* @deprecated Add a custom HTTP endpoint to Ditto instead.
|
||||||
|
* Integrating Nostr directly has too many problems.
|
||||||
|
* Soapbox should only connect to the Nostr relay to sign events, because it's required for Nostr to work.
|
||||||
|
*/
|
||||||
export function useNostrReq(filters: NostrFilter[]): { events: NostrEvent[]; eose: boolean; closed: boolean } {
|
export function useNostrReq(filters: NostrFilter[]): { events: NostrEvent[]; eose: boolean; closed: boolean } {
|
||||||
const { relay } = useNostr();
|
const { relay } = useNostr();
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { NKeyring } from './NKeyring';
|
||||||
|
|
||||||
|
export const keyring = new NKeyring(
|
||||||
|
localStorage,
|
||||||
|
'soapbox:nostr:keys',
|
||||||
|
);
|
|
@ -1,6 +0,0 @@
|
||||||
import { NKeyStorage } from './NKeyStorage';
|
|
||||||
|
|
||||||
export const NKeys = new NKeyStorage(
|
|
||||||
localStorage,
|
|
||||||
'soapbox:nostr:keys',
|
|
||||||
);
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { authLoggedIn, verifyCredentials } from 'soapbox/actions/auth';
|
||||||
|
import { obtainOAuthToken } from 'soapbox/actions/oauth';
|
||||||
|
import { Button, Form, Input, Spinner } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
export const NostrBunkerLogin: React.FC = () => {
|
||||||
|
const history = useHistory();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [uri, setUri] = React.useState<string>('');
|
||||||
|
const [loading, setLoading] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
const url = new URL(uri);
|
||||||
|
const params = new URLSearchParams(url.search);
|
||||||
|
|
||||||
|
// https://github.com/denoland/deno/issues/26440
|
||||||
|
const pubkey = url.hostname || url.pathname.slice(2);
|
||||||
|
const secret = params.get('secret');
|
||||||
|
const relays = params.getAll('relay');
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const token = await dispatch(obtainOAuthToken({
|
||||||
|
grant_type: 'nostr_bunker',
|
||||||
|
pubkey,
|
||||||
|
relays,
|
||||||
|
secret,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { access_token } = dispatch(authLoggedIn(token));
|
||||||
|
await dispatch(verifyCredentials(access_token as string));
|
||||||
|
|
||||||
|
history.push('/');
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={onSubmit}>
|
||||||
|
<Input value={uri} onChange={(e) => setUri(e.target.value)} placeholder='bunker://' />
|
||||||
|
<Button type='submit' theme='primary'>
|
||||||
|
<FormattedMessage id='login.log_in' defaultMessage='Log in' />
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NostrBunkerLogin;
|
|
@ -27,6 +27,7 @@ export default ({ withJoinAction = true }: { withJoinAction?: boolean }) => {
|
||||||
{generateText(6)}
|
{generateText(6)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
|
|
||||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||||
|
|
|
@ -78,13 +78,13 @@ const PlaceholderMediaGallery: React.FC<IPlaceholderMediaGallery> = ({ media, de
|
||||||
const float = dimensions.float as any || 'left';
|
const float = dimensions.float as any || 'left';
|
||||||
const position = dimensions.pos as any || 'relative';
|
const position = dimensions.pos as any || 'relative';
|
||||||
|
|
||||||
return <div key={i} className='media-gallery__item animate-pulse bg-primary-200' style={{ position, float, left, top, right, bottom, height, width }} />;
|
return <div key={i} className='relative float-left box-border block animate-pulse overflow-hidden rounded-sm border-0 bg-primary-200' style={{ position, float, left, top, right, bottom, height, width }} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeData = getSizeData(media.size);
|
const sizeData = getSizeData(media.size);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='media-gallery media-gallery--placeholder' style={sizeData.get('style')} ref={handleRef}>
|
<div className='relative isolate box-border h-auto w-full overflow-hidden rounded-lg' style={sizeData.get('style')} ref={handleRef}>
|
||||||
{media.take(4).map((_, i) => renderItem(sizeData.get('itemsDimensions')[i], i))}
|
{media.take(4).map((_, i) => renderItem(sizeData.get('itemsDimensions')[i], i))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -66,6 +66,7 @@ const OtpConfirmForm: React.FC = () => {
|
||||||
<Stack space={4}>
|
<Stack space={4}>
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<Stack>
|
<Stack>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<Text weight='semibold' size='lg'>
|
<Text weight='semibold' size='lg'>
|
||||||
1. <FormattedMessage id='mfa.mfa_setup_scan_title' defaultMessage='Scan' />
|
1. <FormattedMessage id='mfa.mfa_setup_scan_title' defaultMessage='Scan' />
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -78,6 +79,7 @@ const OtpConfirmForm: React.FC = () => {
|
||||||
<QRCode className='rounded-lg' value={state.qrCodeURI} includeMargin />
|
<QRCode className='rounded-lg' value={state.qrCodeURI} includeMargin />
|
||||||
{state.confirmKey}
|
{state.confirmKey}
|
||||||
|
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<Text weight='semibold' size='lg'>
|
<Text weight='semibold' size='lg'>
|
||||||
2. <FormattedMessage id='mfa.mfa_setup_verify_title' defaultMessage='Verify' />
|
2. <FormattedMessage id='mfa.mfa_setup_verify_title' defaultMessage='Verify' />
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -36,6 +36,7 @@ const SitePreview: React.FC<ISitePreview> = ({ soapbox }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={bodyClass}>
|
<div className={bodyClass}>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<style>{`.site-preview {${generateThemeCss(soapboxConfig)}}`}</style>
|
<style>{`.site-preview {${generateThemeCss(soapboxConfig)}}`}</style>
|
||||||
<BackgroundShapes position='absolute' />
|
<BackgroundShapes position='absolute' />
|
||||||
|
|
||||||
|
|
|
@ -42,8 +42,10 @@ const FundingPanel: React.FC = () => {
|
||||||
let ratioText;
|
let ratioText;
|
||||||
|
|
||||||
if (goalReached) {
|
if (goalReached) {
|
||||||
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
ratioText = <><strong>{moneyFormat(goal)}</strong> per month <span>— reached!</span></>;
|
ratioText = <><strong>{moneyFormat(goal)}</strong> per month <span>— reached!</span></>;
|
||||||
} else {
|
} else {
|
||||||
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
ratioText = <><strong>{moneyFormat(amount)} out of {moneyFormat(goal)}</strong> per month</>;
|
ratioText = <><strong>{moneyFormat(amount)} out of {moneyFormat(goal)}</strong> per month</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -121,7 +121,7 @@ const AccountModerationModal: React.FC<IAccountModerationModal> = ({ onClose, ac
|
||||||
</OutlineBox>
|
</OutlineBox>
|
||||||
|
|
||||||
<List>
|
<List>
|
||||||
{(ownAccount.admin && account.local) && (
|
{(ownAccount.admin && (account.local || features.nostr)) && (
|
||||||
<ListItem label={<FormattedMessage id='account_moderation_modal.fields.account_role' defaultMessage='Staff level' />}>
|
<ListItem label={<FormattedMessage id='account_moderation_modal.fields.account_role' defaultMessage='Staff level' />}>
|
||||||
<div className='w-auto'>
|
<div className='w-auto'>
|
||||||
<StaffRolePicker account={account} />
|
<StaffRolePicker account={account} />
|
||||||
|
|
|
@ -38,6 +38,7 @@ const BoostModal: React.FC<IBoostModal> = ({ status, onReblog, onClose }) => {
|
||||||
<ReplyIndicator status={status} hideActions />
|
<ReplyIndicator status={status} hideActions />
|
||||||
|
|
||||||
<Text>
|
<Text>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon className='inline-block align-middle' src={require('@tabler/icons/outline/repeat.svg')} /></span> }} />
|
<FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon className='inline-block align-middle' src={require('@tabler/icons/outline/repeat.svg')} /></span> }} />
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -22,6 +22,8 @@ const CaptchaModal: React.FC<ICaptchaModal> = ({ onClose }) => {
|
||||||
xPosition,
|
xPosition,
|
||||||
} = useCaptcha();
|
} = useCaptcha();
|
||||||
|
|
||||||
|
const messageButton = tryAgain ? <FormattedMessage id='nostr_signup.captcha_try_again_button' defaultMessage='Try again' /> : <FormattedMessage id='nostr_signup.captcha_check_button' defaultMessage='Check' />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={<FormattedMessage id='nostr_signup.captcha_title' defaultMessage='Human Verification' />} width='sm'
|
title={<FormattedMessage id='nostr_signup.captcha_title' defaultMessage='Human Verification' />} width='sm'
|
||||||
|
@ -46,10 +48,7 @@ const CaptchaModal: React.FC<ICaptchaModal> = ({ onClose }) => {
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<FormattedMessage id='nostr_signup.captcha_check_button.checking' defaultMessage='Checking…' />
|
<FormattedMessage id='nostr_signup.captcha_check_button.checking' defaultMessage='Checking…' />
|
||||||
) : (tryAgain ?
|
) : messageButton}
|
||||||
<FormattedMessage id='nostr_signup.captcha_try_again_button' defaultMessage='Try again' /> :
|
|
||||||
<FormattedMessage id='nostr_signup.captcha_check_button' defaultMessage='Check' />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={loadCaptcha}>
|
<Button onClick={loadCaptcha}>
|
||||||
<FormattedMessage id='nostr_signup.captcha_reset_button' defaultMessage='Reset puzzle' />
|
<FormattedMessage id='nostr_signup.captcha_reset_button' defaultMessage='Reset puzzle' />
|
||||||
|
|
|
@ -38,36 +38,44 @@ const HotkeysModal: React.FC<IHotkeysModal> = ({ onClose }) => {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>r</Hotkey></TableCell>
|
<TableCell><Hotkey>r</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.reply' defaultMessage='to reply' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.reply' defaultMessage='to reply' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>m</Hotkey></TableCell>
|
<TableCell><Hotkey>m</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.mention' defaultMessage='to mention author' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.mention' defaultMessage='to mention author' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>p</Hotkey></TableCell>
|
<TableCell><Hotkey>p</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.profile' defaultMessage="to open author's profile" /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.profile' defaultMessage="to open author's profile" /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>f</Hotkey></TableCell>
|
<TableCell><Hotkey>f</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.favourite' defaultMessage='to like' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.favourite' defaultMessage='to like' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
{features.emojiReacts && (
|
{features.emojiReacts && (
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>e</Hotkey></TableCell>
|
<TableCell><Hotkey>e</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.react' defaultMessage='to react' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.react' defaultMessage='to react' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>b</Hotkey></TableCell>
|
<TableCell><Hotkey>b</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to repost' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to repost' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>enter</Hotkey>, <Hotkey>o</Hotkey></TableCell>
|
<TableCell><Hotkey>enter</Hotkey>, <Hotkey>o</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open post' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open post' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>a</Hotkey></TableCell>
|
<TableCell><Hotkey>a</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.open_media' defaultMessage='to open media' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.open_media' defaultMessage='to open media' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -82,41 +90,50 @@ const HotkeysModal: React.FC<IHotkeysModal> = ({ onClose }) => {
|
||||||
<tbody>
|
<tbody>
|
||||||
{features.spoilers && (
|
{features.spoilers && (
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>x</Hotkey></TableCell>
|
<TableCell><Hotkey>x</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{features.spoilers && (
|
{features.spoilers && (
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>h</Hotkey></TableCell>
|
<TableCell><Hotkey>h</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>up</Hotkey>, <Hotkey>k</Hotkey></TableCell>
|
<TableCell><Hotkey>up</Hotkey>, <Hotkey>k</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>down</Hotkey>, <Hotkey>j</Hotkey></TableCell>
|
<TableCell><Hotkey>down</Hotkey>, <Hotkey>j</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>n</Hotkey></TableCell>
|
<TableCell><Hotkey>n</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='to open the compose textarea' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='to open the compose textarea' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>alt</Hotkey> + <Hotkey>n</Hotkey></TableCell>
|
<TableCell><Hotkey>alt</Hotkey> + <Hotkey>n</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a new post' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a new post' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>backspace</Hotkey></TableCell>
|
<TableCell><Hotkey>backspace</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>s</Hotkey>, <Hotkey>/</Hotkey></TableCell>
|
<TableCell><Hotkey>s</Hotkey>, <Hotkey>/</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>esc</Hotkey></TableCell>
|
<TableCell><Hotkey>esc</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -130,40 +147,49 @@ const HotkeysModal: React.FC<IHotkeysModal> = ({ onClose }) => {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>g</Hotkey> + <Hotkey>h</Hotkey></TableCell>
|
<TableCell><Hotkey>g</Hotkey> + <Hotkey>h</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.home' defaultMessage='to open home timeline' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.home' defaultMessage='to open home timeline' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>g</Hotkey> + <Hotkey>n</Hotkey></TableCell>
|
<TableCell><Hotkey>g</Hotkey> + <Hotkey>n</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.notifications' defaultMessage='to open notifications column' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.notifications' defaultMessage='to open notifications column' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>g</Hotkey> + <Hotkey>f</Hotkey></TableCell>
|
<TableCell><Hotkey>g</Hotkey> + <Hotkey>f</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.favourites' defaultMessage='to open likes list' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.favourites' defaultMessage='to open likes list' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>g</Hotkey> + <Hotkey>p</Hotkey></TableCell>
|
<TableCell><Hotkey>g</Hotkey> + <Hotkey>p</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.pinned' defaultMessage='to open pinned posts list' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.pinned' defaultMessage='to open pinned posts list' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>g</Hotkey> + <Hotkey>u</Hotkey></TableCell>
|
<TableCell><Hotkey>g</Hotkey> + <Hotkey>u</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.my_profile' defaultMessage='to open your profile' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.my_profile' defaultMessage='to open your profile' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>g</Hotkey> + <Hotkey>b</Hotkey></TableCell>
|
<TableCell><Hotkey>g</Hotkey> + <Hotkey>b</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.blocked' defaultMessage='to open blocked users list' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.blocked' defaultMessage='to open blocked users list' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>g</Hotkey> + <Hotkey>m</Hotkey></TableCell>
|
<TableCell><Hotkey>g</Hotkey> + <Hotkey>m</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.muted' defaultMessage='to open muted users list' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.muted' defaultMessage='to open muted users list' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
{features.followRequests && (
|
{features.followRequests && (
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>g</Hotkey> + <Hotkey>r</Hotkey></TableCell>
|
<TableCell><Hotkey>g</Hotkey> + <Hotkey>r</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.requests' defaultMessage='to open follow requests list' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.requests' defaultMessage='to open follow requests list' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<TableCell><Hotkey>?</Hotkey></TableCell>
|
<TableCell><Hotkey>?</Hotkey></TableCell>
|
||||||
<TableCell><FormattedMessage id='keyboard_shortcuts.legend' defaultMessage='to display this legend' /></TableCell>
|
<TableCell><FormattedMessage id='keyboard_shortcuts.legend' defaultMessage='to display this legend' /></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -59,6 +59,7 @@ const MuteModal = () => {
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='confirmations.mute.message'
|
id='confirmations.mute.message'
|
||||||
defaultMessage='Are you sure you want to mute {name}?'
|
defaultMessage='Are you sure you want to mute {name}?'
|
||||||
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
values={{ name: <strong className='break-words'>@{account.acct}</strong> }}
|
values={{ name: <strong className='break-words'>@{account.acct}</strong> }}
|
||||||
/>
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -93,6 +94,7 @@ const MuteModal = () => {
|
||||||
|
|
||||||
{duration !== 0 && (
|
{duration !== 0 && (
|
||||||
<Stack space={2}>
|
<Stack space={2}>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
<Text weight='medium'><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </Text>
|
<Text weight='medium'><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </Text>
|
||||||
|
|
||||||
<DurationSelector onDurationChange={handleChangeMuteDuration} />
|
<DurationSelector onDurationChange={handleChangeMuteDuration} />
|
||||||
|
|
|
@ -5,20 +5,23 @@ import { closeModal } from 'soapbox/actions/modals';
|
||||||
import { nostrExtensionLogIn } from 'soapbox/actions/nostr';
|
import { nostrExtensionLogIn } from 'soapbox/actions/nostr';
|
||||||
import Stack from 'soapbox/components/ui/stack/stack';
|
import Stack from 'soapbox/components/ui/stack/stack';
|
||||||
import Text from 'soapbox/components/ui/text/text';
|
import Text from 'soapbox/components/ui/text/text';
|
||||||
|
import { useNostr } from 'soapbox/contexts/nostr-context';
|
||||||
import { useAppDispatch } from 'soapbox/hooks';
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
const NostrExtensionIndicator: React.FC = () => {
|
const NostrExtensionIndicator: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const { relay } = useNostr();
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
dispatch(nostrExtensionLogIn());
|
if (relay) {
|
||||||
|
dispatch(nostrExtensionLogIn(relay));
|
||||||
dispatch(closeModal());
|
dispatch(closeModal());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function renderBody(): React.ReactNode {
|
||||||
|
if (window.nostr && window.nostr.nip44) {
|
||||||
return (
|
return (
|
||||||
<Stack space={2} className='flex items-center rounded-lg bg-gray-100 p-2 dark:bg-gray-800'>
|
|
||||||
<Text size='xs'>
|
|
||||||
{window.nostr ? (
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='nostr_extension.found'
|
id='nostr_extension.found'
|
||||||
defaultMessage='<link>Sign in</link> with browser extension.'
|
defaultMessage='<link>Sign in</link> with browser extension.'
|
||||||
|
@ -26,9 +29,28 @@ const NostrExtensionIndicator: React.FC = () => {
|
||||||
link: (node) => <button type='button' className='underline' onClick={onClick}>{node}</button>,
|
link: (node) => <button type='button' className='underline' onClick={onClick}>{node}</button>,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
);
|
||||||
<FormattedMessage id='nostr_extension.not_found' defaultMessage='Browser extension not found.' />
|
} else if (window.nostr) {
|
||||||
)}
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='nostr_extension.not_supported'
|
||||||
|
defaultMessage='Browser extension not supported. Please upgrade to the latest version.'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='nostr_extension.not_found'
|
||||||
|
defaultMessage='Browser extension not found.'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack space={2} className='flex items-center rounded-lg bg-gray-100 p-2 dark:bg-gray-800'>
|
||||||
|
<Text size='xs'>
|
||||||
|
{renderBody()}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue