Merge remote-tracking branch 'origin/main' into fix-anchor-used-as-button
This commit is contained in:
commit
82d1ae01c6
|
@ -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;
|
||||||
|
|
|
@ -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');
|
|
||||||
}
|
|
||||||
|
|
||||||
const relay = getState().instance.nostr?.relay;
|
let authorizedPubkey: string | undefined;
|
||||||
|
|
||||||
// HACK: waits 1 second to ensure the relay subscription is open
|
const bunker = new NBunker({
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
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' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await bunker.waitReady;
|
||||||
|
|
||||||
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,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { access_token } = dispatch(authLoggedIn(token));
|
if (!authorizedPubkey) {
|
||||||
await dispatch(verifyCredentials(access_token as string));
|
throw new Error('Authorization failed');
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(setNostrPubkey(undefined));
|
const accessToken = dispatch(authLoggedIn(token)).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();
|
||||||
|
|
||||||
|
|
|
@ -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 { NBunker } from 'soapbox/features/nostr/NBunker';
|
|
||||||
|
|
||||||
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 bunker = new NBunker({
|
|
||||||
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 () => {
|
|
||||||
bunker.close();
|
|
||||||
};
|
|
||||||
}, [relay, signer, pubkey]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isSubscribed,
|
|
||||||
isSubscribing,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useSignerStream };
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
<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'> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
<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}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
|
@ -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) {
|
||||||
|
|
|
@ -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 } 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,37 +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 url = instance.nostr?.relay;
|
const relayUrl = instance.nostr?.relay;
|
||||||
const accountPubkey = useAppSelector(({ meta, auth }) => meta.pubkey ?? auth.users[auth.me!]?.id);
|
|
||||||
|
|
||||||
const signer = useMemo(
|
|
||||||
() => accountPubkey ? NKeys.get(accountPubkey) ?? window.nostr : undefined,
|
|
||||||
[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;
|
||||||
|
|
|
@ -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> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
<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}
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
<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>
|
||||||
|
|
|
@ -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!);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,73 +1,131 @@
|
||||||
import { NRelay, NostrConnectRequest, NostrConnectResponse, NostrEvent, NostrSigner, NSchema as n } from '@nostrify/nostrify';
|
import {
|
||||||
|
NRelay,
|
||||||
|
NostrConnectRequest,
|
||||||
|
NostrConnectResponse,
|
||||||
|
NostrEvent,
|
||||||
|
NostrFilter,
|
||||||
|
NostrSigner,
|
||||||
|
NSchema as n,
|
||||||
|
} from '@nostrify/nostrify';
|
||||||
|
|
||||||
interface NBunkerOpts {
|
/** Options passed to `NBunker`. */
|
||||||
|
export interface NBunkerOpts {
|
||||||
|
/** Relay to subscribe to for NIP-46 requests. */
|
||||||
relay: NRelay;
|
relay: NRelay;
|
||||||
signer: NostrSigner;
|
/** Signer to complete the actual NIP-46 requests, such as `get_public_key`, `sign_event`, `nip44_encrypt` etc. */
|
||||||
authorizedPubkey: string | undefined;
|
userSigner: NostrSigner;
|
||||||
onAuthorize(pubkey: string): void;
|
/** Signer to sign, encrypt, and decrypt the kind 24133 transport events events. */
|
||||||
onSubscribed(): void;
|
bunkerSigner: NostrSigner;
|
||||||
getSecret(): string;
|
/**
|
||||||
|
* 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 {
|
export class NBunker {
|
||||||
|
|
||||||
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();
|
private controller = new AbortController();
|
||||||
|
private authorizedPubkeys = new Set<string>();
|
||||||
|
|
||||||
constructor(opts: NBunkerOpts) {
|
/** Wait for the bunker to be ready before sending requests. */
|
||||||
this.relay = opts.relay;
|
public waitReady: Promise<void>;
|
||||||
this.signer = opts.signer;
|
private setReady!: () => void;
|
||||||
this.authorizedPubkey = opts.authorizedPubkey;
|
|
||||||
this.onAuthorize = opts.onAuthorize;
|
|
||||||
this.onSubscribed = opts.onSubscribed;
|
|
||||||
this.getSecret = opts.getSecret;
|
|
||||||
|
|
||||||
|
constructor(private opts: NBunkerOpts) {
|
||||||
|
this.waitReady = new Promise((resolve) => {
|
||||||
|
this.setReady = resolve;
|
||||||
|
});
|
||||||
this.open();
|
this.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
async open() {
|
/** Open the signer subscription to the relay. */
|
||||||
const pubkey = await this.signer.getPublicKey();
|
private async open() {
|
||||||
const signal = this.controller.signal;
|
const { relay, bunkerSigner, onError } = this.opts;
|
||||||
|
|
||||||
const sub = this.relay.req([{ kinds: [24133], '#p': [pubkey], limit: 0 }], { signal });
|
const signal = this.controller.signal;
|
||||||
this.onSubscribed();
|
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) {
|
for await (const msg of sub) {
|
||||||
if (msg[0] === 'EVENT') {
|
if (msg[0] === 'EVENT') {
|
||||||
const event = msg[2];
|
const [,, event] = msg;
|
||||||
this.handleEvent(event);
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleEvent(event: NostrEvent): Promise<void> {
|
/**
|
||||||
const decrypted = await this.signer.nip04!.decrypt(event.pubkey, event.content);
|
* Handle NIP-46 requests.
|
||||||
const request = n.json().pipe(n.connectRequest()).safeParse(decrypted);
|
*
|
||||||
|
* 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.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') {
|
if (request.method === 'connect') {
|
||||||
return this.handleConnect(pubkey, request as NostrConnectRequest & { method: 'connect' });
|
if (onConnect) {
|
||||||
|
const response = await onConnect(request, event);
|
||||||
|
return this.sendResponse(pubkey, response);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent unauthorized access.
|
// Prevent unauthorized access.
|
||||||
if (pubkey !== this.authorizedPubkey) {
|
if (!this.authorizedPubkeys.has(pubkey)) {
|
||||||
return;
|
return this.sendResponse(pubkey, {
|
||||||
|
id: request.id,
|
||||||
|
result: '',
|
||||||
|
error: 'Unauthorized',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorized methods.
|
// Authorized methods.
|
||||||
|
@ -75,7 +133,7 @@ export class NBunker {
|
||||||
case 'sign_event':
|
case 'sign_event':
|
||||||
return this.sendResponse(pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
id: request.id,
|
||||||
result: JSON.stringify(await this.signer.signEvent(JSON.parse(request.params[0]))),
|
result: JSON.stringify(await userSigner.signEvent(JSON.parse(request.params[0]))),
|
||||||
});
|
});
|
||||||
case 'ping':
|
case 'ping':
|
||||||
return this.sendResponse(pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
|
@ -85,32 +143,32 @@ export class NBunker {
|
||||||
case 'get_relays':
|
case 'get_relays':
|
||||||
return this.sendResponse(pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
id: request.id,
|
||||||
result: JSON.stringify(await this.signer.getRelays?.() ?? []),
|
result: JSON.stringify(await userSigner.getRelays?.() ?? []),
|
||||||
});
|
});
|
||||||
case 'get_public_key':
|
case 'get_public_key':
|
||||||
return this.sendResponse(pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
id: request.id,
|
||||||
result: await this.signer.getPublicKey(),
|
result: await userSigner.getPublicKey(),
|
||||||
});
|
});
|
||||||
case 'nip04_encrypt':
|
case 'nip04_encrypt':
|
||||||
return this.sendResponse(pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
id: request.id,
|
||||||
result: await this.signer.nip04!.encrypt(request.params[0], request.params[1]),
|
result: await userSigner.nip04!.encrypt(request.params[0], request.params[1]),
|
||||||
});
|
});
|
||||||
case 'nip04_decrypt':
|
case 'nip04_decrypt':
|
||||||
return this.sendResponse(pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
id: request.id,
|
||||||
result: await this.signer.nip04!.decrypt(request.params[0], request.params[1]),
|
result: await userSigner.nip04!.decrypt(request.params[0], request.params[1]),
|
||||||
});
|
});
|
||||||
case 'nip44_encrypt':
|
case 'nip44_encrypt':
|
||||||
return this.sendResponse(pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
id: request.id,
|
||||||
result: await this.signer.nip44!.encrypt(request.params[0], request.params[1]),
|
result: await userSigner.nip44!.encrypt(request.params[0], request.params[1]),
|
||||||
});
|
});
|
||||||
case 'nip44_decrypt':
|
case 'nip44_decrypt':
|
||||||
return this.sendResponse(pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
id: request.id,
|
||||||
result: await this.signer.nip44!.decrypt(request.params[0], request.params[1]),
|
result: await userSigner.nip44!.decrypt(request.params[0], request.params[1]),
|
||||||
});
|
});
|
||||||
default:
|
default:
|
||||||
return this.sendResponse(pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
|
@ -121,33 +179,49 @@ export class NBunker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleConnect(pubkey: string, request: NostrConnectRequest & { method: 'connect' }) {
|
/** Encrypt the response with the bunker key, then publish it to the relay. */
|
||||||
const [remotePubkey, secret] = request.params;
|
private async sendResponse(pubkey: string, response: NostrConnectResponse): Promise<void> {
|
||||||
|
const { bunkerSigner, relay } = this.opts;
|
||||||
|
|
||||||
if (secret === this.getSecret() && remotePubkey === await this.signer.getPublicKey()) {
|
const content = await bunkerSigner.nip04!.encrypt(pubkey, JSON.stringify(response));
|
||||||
this.authorizedPubkey = pubkey;
|
|
||||||
this.onAuthorize(pubkey);
|
|
||||||
|
|
||||||
await this.sendResponse(pubkey, {
|
const event = await bunkerSigner.signEvent({
|
||||||
id: request.id,
|
|
||||||
result: 'ack',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendResponse(pubkey: string, response: NostrConnectResponse) {
|
|
||||||
const event = await this.signer.signEvent({
|
|
||||||
kind: 24133,
|
kind: 24133,
|
||||||
content: await this.signer.nip04!.encrypt(pubkey, JSON.stringify(response)),
|
content,
|
||||||
tags: [['p', pubkey]],
|
tags: [['p', pubkey]],
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.relay.event(event);
|
await relay.event(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
/** 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();
|
this.controller.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Symbol.dispose](): void {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -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',
|
|
||||||
);
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,30 +5,52 @@ 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(closeModal());
|
dispatch(nostrExtensionLogIn(relay));
|
||||||
|
dispatch(closeModal());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function renderBody(): React.ReactNode {
|
||||||
|
if (window.nostr && window.nostr.nip44) {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='nostr_extension.found'
|
||||||
|
defaultMessage='<link>Sign in</link> with browser extension.'
|
||||||
|
values={{
|
||||||
|
link: (node) => <button type='button' className='underline' onClick={onClick}>{node}</button>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} 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 (
|
return (
|
||||||
<Stack space={2} className='flex items-center rounded-lg bg-gray-100 p-2 dark:bg-gray-800'>
|
<Stack space={2} className='flex items-center rounded-lg bg-gray-100 p-2 dark:bg-gray-800'>
|
||||||
<Text size='xs'>
|
<Text size='xs'>
|
||||||
{window.nostr ? (
|
{renderBody()}
|
||||||
<FormattedMessage
|
|
||||||
id='nostr_extension.found'
|
|
||||||
defaultMessage='<link>Sign in</link> with browser extension.'
|
|
||||||
values={{
|
|
||||||
link: (node) => <button type='button' className='underline' onClick={onClick}>{node}</button>,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FormattedMessage id='nostr_extension.not_found' defaultMessage='Browser extension not found.' />
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { openModal } from 'soapbox/actions/modals';
|
||||||
import { nostrExtensionLogIn } from 'soapbox/actions/nostr';
|
import { nostrExtensionLogIn } from 'soapbox/actions/nostr';
|
||||||
import EmojiGraphic from 'soapbox/components/emoji-graphic';
|
import EmojiGraphic from 'soapbox/components/emoji-graphic';
|
||||||
import { Button, Stack, Modal, Text, Divider, HStack } from 'soapbox/components/ui';
|
import { Button, Stack, Modal, Text, Divider, HStack } from 'soapbox/components/ui';
|
||||||
|
import { useNostr } from 'soapbox/contexts/nostr-context';
|
||||||
import { useAppDispatch, useInstance, useSoapboxConfig } from 'soapbox/hooks';
|
import { useAppDispatch, useInstance, useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
|
||||||
interface IExtensionStep {
|
interface IExtensionStep {
|
||||||
|
@ -18,6 +19,7 @@ const ExtensionStep: React.FC<IExtensionStep> = ({ isLogin, onClickAlt, onClose
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { instance } = useInstance();
|
const { instance } = useInstance();
|
||||||
const { logo } = useSoapboxConfig();
|
const { logo } = useSoapboxConfig();
|
||||||
|
const { relay } = useNostr();
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onClose();
|
onClose();
|
||||||
|
@ -25,8 +27,10 @@ const ExtensionStep: React.FC<IExtensionStep> = ({ isLogin, onClickAlt, onClose
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
dispatch(nostrExtensionLogIn());
|
if (relay) {
|
||||||
onClose();
|
dispatch(nostrExtensionLogIn(relay));
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -5,7 +5,8 @@ import { FormattedMessage } from 'react-intl';
|
||||||
import { logInNostr } from 'soapbox/actions/nostr';
|
import { logInNostr } from 'soapbox/actions/nostr';
|
||||||
import EmojiGraphic from 'soapbox/components/emoji-graphic';
|
import EmojiGraphic from 'soapbox/components/emoji-graphic';
|
||||||
import { Button, Stack, Modal, Input, FormGroup, Form, Divider } from 'soapbox/components/ui';
|
import { Button, Stack, Modal, Input, FormGroup, Form, Divider } from 'soapbox/components/ui';
|
||||||
import { NKeys } from 'soapbox/features/nostr/keys';
|
import { useNostr } from 'soapbox/contexts/nostr-context';
|
||||||
|
import { keyring } from 'soapbox/features/nostr/keyring';
|
||||||
import { useAppDispatch } from 'soapbox/hooks';
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
import NostrExtensionIndicator from '../components/nostr-extension-indicator';
|
import NostrExtensionIndicator from '../components/nostr-extension-indicator';
|
||||||
|
@ -19,6 +20,7 @@ const KeyAddStep: React.FC<IKeyAddStep> = ({ onClose }) => {
|
||||||
const [error, setError] = useState<string | undefined>();
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const { relay } = useNostr();
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setNsec(e.target.value);
|
setNsec(e.target.value);
|
||||||
|
@ -26,13 +28,13 @@ const KeyAddStep: React.FC<IKeyAddStep> = ({ onClose }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!relay) return;
|
||||||
try {
|
try {
|
||||||
const result = nip19.decode(nsec);
|
const result = nip19.decode(nsec);
|
||||||
if (result.type === 'nsec') {
|
if (result.type === 'nsec') {
|
||||||
const seckey = result.data;
|
const seckey = result.data;
|
||||||
const signer = NKeys.add(seckey);
|
const signer = keyring.add(seckey);
|
||||||
const pubkey = await signer.getPublicKey();
|
dispatch(logInNostr(signer, relay));
|
||||||
dispatch(logInNostr(pubkey));
|
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { closeSidebar } from 'soapbox/actions/sidebar';
|
||||||
import EmojiGraphic from 'soapbox/components/emoji-graphic';
|
import EmojiGraphic from 'soapbox/components/emoji-graphic';
|
||||||
import { Button, Stack, Modal, FormGroup, Text, Tooltip, HStack, Input } from 'soapbox/components/ui';
|
import { Button, Stack, Modal, FormGroup, Text, Tooltip, HStack, Input } from 'soapbox/components/ui';
|
||||||
import { useNostr } from 'soapbox/contexts/nostr-context';
|
import { useNostr } from 'soapbox/contexts/nostr-context';
|
||||||
import { NKeys } from 'soapbox/features/nostr/keys';
|
import { keyring } from 'soapbox/features/nostr/keyring';
|
||||||
import { useAppDispatch, useInstance } from 'soapbox/hooks';
|
import { useAppDispatch, useInstance } from 'soapbox/hooks';
|
||||||
import { useIsMobile } from 'soapbox/hooks/useIsMobile';
|
import { useIsMobile } from 'soapbox/hooks/useIsMobile';
|
||||||
import { download } from 'soapbox/utils/download';
|
import { download } from 'soapbox/utils/download';
|
||||||
|
@ -43,8 +43,9 @@ const KeygenStep: React.FC<IKeygenStep> = ({ onClose }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
const signer = NKeys.add(secretKey);
|
if (!relay) return;
|
||||||
const pubkey = await signer.getPublicKey();
|
|
||||||
|
const signer = keyring.add(secretKey);
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
const [kind0, ...events] = await Promise.all([
|
const [kind0, ...events] = await Promise.all([
|
||||||
|
@ -57,12 +58,12 @@ const KeygenStep: React.FC<IKeygenStep> = ({ onClose }) => {
|
||||||
signer.signEvent({ kind: 30078, content: '', tags: [['d', 'pub.ditto.pleroma_settings_store']], created_at: now }),
|
signer.signEvent({ kind: 30078, content: '', tags: [['d', 'pub.ditto.pleroma_settings_store']], created_at: now }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await relay?.event(kind0);
|
await relay.event(kind0);
|
||||||
await Promise.all(events.map((event) => relay?.event(event)));
|
await Promise.all(events.map((event) => relay.event(event)));
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
|
|
||||||
await dispatch(logInNostr(pubkey));
|
await dispatch(logInNostr(signer, relay));
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
dispatch(closeSidebar());
|
dispatch(closeSidebar());
|
||||||
|
|
|
@ -424,7 +424,7 @@ const UI: React.FC<IUI> = ({ children }) => {
|
||||||
|
|
||||||
if (account.staff) {
|
if (account.staff) {
|
||||||
dispatch(fetchReports({ resolved: false }));
|
dispatch(fetchReports({ resolved: false }));
|
||||||
dispatch(fetchUsers(['local', 'need_approval']));
|
dispatch(fetchUsers({ pending: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.admin) {
|
if (account.admin) {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import Blurhash from 'soapbox/components/blurhash';
|
import Blurhash from 'soapbox/components/blurhash';
|
||||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||||
|
import { useIsMobile } from 'soapbox/hooks/useIsMobile';
|
||||||
import { isPanoramic, isPortrait, minimumAspectRatio, maximumAspectRatio } from 'soapbox/utils/media-aspect-ratio';
|
import { isPanoramic, isPortrait, minimumAspectRatio, maximumAspectRatio } from 'soapbox/utils/media-aspect-ratio';
|
||||||
|
|
||||||
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
||||||
|
@ -129,11 +130,13 @@ const Video: React.FC<IVideo> = ({
|
||||||
blurhash,
|
blurhash,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const player = useRef<HTMLDivElement>(null);
|
const player = useRef<HTMLDivElement>(null);
|
||||||
const video = useRef<HTMLVideoElement>(null);
|
const video = useRef<HTMLVideoElement>(null);
|
||||||
const seek = useRef<HTMLDivElement>(null);
|
const seek = useRef<HTMLDivElement>(null);
|
||||||
const slider = useRef<HTMLDivElement>(null);
|
const slider = useRef<HTMLDivElement>(null);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
|
@ -144,6 +147,7 @@ const Video: React.FC<IVideo> = ({
|
||||||
const [containerWidth, setContainerWidth] = useState(width);
|
const [containerWidth, setContainerWidth] = useState(width);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const [volumeHovered, setVolumeHovered] = useState(false);
|
||||||
const [seekHovered, setSeekHovered] = useState(false);
|
const [seekHovered, setSeekHovered] = useState(false);
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
const [buffer, setBuffer] = useState(0);
|
const [buffer, setBuffer] = useState(0);
|
||||||
|
@ -222,6 +226,9 @@ const Video: React.FC<IVideo> = ({
|
||||||
|
|
||||||
if (video.current) {
|
if (video.current) {
|
||||||
video.current.volume = slideamt;
|
video.current.volume = slideamt;
|
||||||
|
const isMuted = slideamt <= 0;
|
||||||
|
video.current.muted = isMuted;
|
||||||
|
setMuted(isMuted);
|
||||||
}
|
}
|
||||||
|
|
||||||
setVolume(slideamt);
|
setVolume(slideamt);
|
||||||
|
@ -382,13 +389,6 @@ const Video: React.FC<IVideo> = ({
|
||||||
setFullscreen(isFullscreen());
|
setFullscreen(isFullscreen());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
setHovered(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
setHovered(false);
|
|
||||||
};
|
|
||||||
const handleSeekEnter = () => {
|
const handleSeekEnter = () => {
|
||||||
setSeekHovered(true);
|
setSeekHovered(true);
|
||||||
};
|
};
|
||||||
|
@ -397,10 +397,43 @@ const Video: React.FC<IVideo> = ({
|
||||||
setSeekHovered(false);
|
setSeekHovered(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVolumeEnter = (e: React.MouseEvent) => {
|
||||||
|
if (isMobile) return;
|
||||||
|
|
||||||
|
setVolumeHovered(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeLeave = (e: React.MouseEvent) => {
|
||||||
|
if (isMobile) return;
|
||||||
|
|
||||||
|
setVolumeHovered(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickStart = () => {
|
||||||
|
setHovered(true);
|
||||||
|
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setHovered(false);
|
||||||
|
}, 2 * 1000);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnMouseMove = () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
handleClickStart();
|
||||||
|
};
|
||||||
|
|
||||||
const toggleMute = () => {
|
const toggleMute = () => {
|
||||||
if (video.current) {
|
if (video.current) {
|
||||||
const muted = !video.current.muted;
|
const muted = !video.current.muted;
|
||||||
setMuted(!muted);
|
setMuted(muted);
|
||||||
video.current.muted = muted;
|
video.current.muted = muted;
|
||||||
|
|
||||||
if (muted) {
|
if (muted) {
|
||||||
|
@ -434,9 +467,17 @@ const Video: React.FC<IVideo> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTogglePlay = () => {
|
||||||
|
if (!isMobile || paused || hovered) togglePlay();
|
||||||
|
};
|
||||||
|
|
||||||
const progress = (currentTime / duration) * 100;
|
const progress = (currentTime / duration) * 100;
|
||||||
const playerStyle: React.CSSProperties = {};
|
const playerStyle: React.CSSProperties = {};
|
||||||
|
|
||||||
|
const startTimeout = () => {
|
||||||
|
timeoutRef.current = setTimeout(() => setHovered(false), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
if (inline && containerWidth) {
|
if (inline && containerWidth) {
|
||||||
width = containerWidth;
|
width = containerWidth;
|
||||||
const minSize = containerWidth / (16 / 9);
|
const minSize = containerWidth / (16 / 9);
|
||||||
|
@ -481,10 +522,13 @@ const Video: React.FC<IVideo> = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role='menuitem'
|
role='menuitem'
|
||||||
className={clsx('relative box-border max-w-full overflow-hidden rounded-[10px] bg-black text-white focus:outline-0', { detailed, 'w-full h-full m-0': fullscreen })}
|
className={clsx('relative box-border flex max-w-full overflow-hidden rounded-[10px] bg-black text-white focus:outline-0', { 'w-full h-full m-0': fullscreen })}
|
||||||
style={playerStyle}
|
style={playerStyle}
|
||||||
ref={player}
|
ref={player}
|
||||||
onClick={handleClickRoot}
|
onClick={handleClickRoot}
|
||||||
|
onMouseMove={handleOnMouseMove}
|
||||||
|
onMouseOut={startTimeout}
|
||||||
|
onBlur={startTimeout}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
|
@ -506,7 +550,7 @@ const Video: React.FC<IVideo> = ({
|
||||||
})}
|
})}
|
||||||
width={width}
|
width={width}
|
||||||
height={height || DEFAULT_HEIGHT}
|
height={height || DEFAULT_HEIGHT}
|
||||||
onClick={togglePlay}
|
onClick={handleTogglePlay}
|
||||||
onKeyDown={handleVideoKeyDown}
|
onKeyDown={handleVideoKeyDown}
|
||||||
onPlay={handlePlay}
|
onPlay={handlePlay}
|
||||||
onPause={handlePause}
|
onPause={handlePause}
|
||||||
|
@ -516,7 +560,9 @@ const Video: React.FC<IVideo> = ({
|
||||||
onVolumeChange={handleVolumeChange}
|
onVolumeChange={handleVolumeChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={clsx('absolute inset-x-0 bottom-0 z-20 box-border bg-gradient-to-t from-black/70 to-transparent px-[15px] opacity-0 transition-opacity duration-100 ease-linear', { 'opacity-100': paused || hovered })}>
|
<div
|
||||||
|
className={clsx('absolute inset-x-0 bottom-0 z-20 box-border bg-gradient-to-t from-black/70 to-transparent px-[15px] opacity-0 transition-opacity duration-100 ease-linear', { 'opacity-100': paused || hovered || volumeHovered })}
|
||||||
|
>
|
||||||
<div className='relative h-6 cursor-pointer' onMouseDown={handleMouseDown} onMouseEnter={handleSeekEnter} onMouseLeave={handleSeekLeave} ref={seek}>
|
<div className='relative h-6 cursor-pointer' onMouseDown={handleMouseDown} onMouseEnter={handleSeekEnter} onMouseLeave={handleSeekLeave} ref={seek}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
@ -559,8 +605,8 @@ const Video: React.FC<IVideo> = ({
|
||||||
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)}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleVolumeEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleVolumeLeave}
|
||||||
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 focus:text-white focus:opacity-100 active:text-white active:opacity-100 '
|
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 focus:text-white focus:opacity-100 active:text-white active:opacity-100 '
|
||||||
, { 'py-[10px]': fullscreen })}
|
, { 'py-[10px]': fullscreen })}
|
||||||
onClick={toggleMute}
|
onClick={toggleMute}
|
||||||
|
@ -569,9 +615,9 @@ const Video: React.FC<IVideo> = ({
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
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}
|
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]': volumeHovered })} onMouseDown={handleVolumeMouseDown} ref={slider}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleVolumeEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleVolumeLeave}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx({ 'bottom-[27px]': fullscreen || detailed })}
|
className={clsx({ 'bottom-[27px]': fullscreen || detailed })}
|
||||||
|
@ -590,7 +636,7 @@ const Video: React.FC<IVideo> = ({
|
||||||
/>
|
/>
|
||||||
<div className={clsx('absolute left-0 top-1/2 block h-1 -translate-y-1/2 rounded-md bg-accent-500', { 'bottom-[27px]': fullscreen || detailed })} style={{ width: `${volume * 100}%` }} />
|
<div className={clsx('absolute left-0 top-1/2 block h-1 -translate-y-1/2 rounded-md bg-accent-500', { 'bottom-[27px]': fullscreen || detailed })} style={{ width: `${volume * 100}%` }} />
|
||||||
<span
|
<span
|
||||||
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, 'bottom-[23px]': fullscreen || detailed })}
|
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': volumeHovered, 'bottom-[23px]': fullscreen || detailed })}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
style={{ left: `${volume * 100}%` }}
|
style={{ left: `${volume * 100}%` }}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useNostr } from 'soapbox/contexts/nostr-context';
|
||||||
|
import { NBunker } from 'soapbox/features/nostr/NBunker';
|
||||||
|
import { useSigner } from 'soapbox/hooks/nostr/useSigner';
|
||||||
|
|
||||||
|
function useBunker() {
|
||||||
|
const { relay } = useNostr();
|
||||||
|
const { signer: userSigner, bunkerSigner, authorizedPubkey } = useSigner();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!relay || !userSigner || !bunkerSigner || !authorizedPubkey) return;
|
||||||
|
|
||||||
|
const bunker = new NBunker({
|
||||||
|
relay,
|
||||||
|
userSigner,
|
||||||
|
bunkerSigner,
|
||||||
|
onError(error, event) {
|
||||||
|
console.warn('Bunker error:', error, event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
bunker.authorize(authorizedPubkey);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bunker.close();
|
||||||
|
};
|
||||||
|
}, [relay, userSigner, bunkerSigner, authorizedPubkey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useBunker };
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { NSchema as n } from '@nostrify/nostrify';
|
||||||
|
import { produce } from 'immer';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
// eslint-disable-next-line import/extensions
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
import { filteredArray, jsonSchema } from 'soapbox/schemas/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bunker connection maps an OAuth token from Mastodon API to a user pubkey and bunker keypair.
|
||||||
|
* The user pubkey is used to determine whether to use keys from localStorage or a browser extension,
|
||||||
|
* and the bunker keypair is used to sign and encrypt NIP-46 messages.
|
||||||
|
*/
|
||||||
|
interface BunkerConnection {
|
||||||
|
/** User pubkey. Events will be signed by this pubkey. */
|
||||||
|
pubkey: string;
|
||||||
|
/** Mastodon API access token associated with this connection. */
|
||||||
|
accessToken: string;
|
||||||
|
/** Pubkey of the app authorized to sign events with this connection. */
|
||||||
|
authorizedPubkey: string;
|
||||||
|
/** Pubkey for this connection. Secret key is stored in the keyring. NIP-46 responses will be signed by this key. */
|
||||||
|
bunkerPubkey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionSchema: z.ZodType<BunkerConnection> = z.object({
|
||||||
|
pubkey: n.id(),
|
||||||
|
accessToken: z.string(),
|
||||||
|
authorizedPubkey: n.id(),
|
||||||
|
bunkerPubkey: n.id(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface BunkerState {
|
||||||
|
connections: BunkerConnection[];
|
||||||
|
connect(connection: BunkerConnection): void;
|
||||||
|
revoke(accessToken: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBunkerStore = create<BunkerState>()(
|
||||||
|
persist(
|
||||||
|
(setState) => ({
|
||||||
|
connections: [],
|
||||||
|
|
||||||
|
/** Connect to a bunker using the authorization secret. */
|
||||||
|
connect(connection: BunkerConnection): void {
|
||||||
|
setState((state) => {
|
||||||
|
return produce(state, (draft) => {
|
||||||
|
draft.connections.push(connection);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Revoke any connections associated with the access token. */
|
||||||
|
revoke(accessToken: string): void {
|
||||||
|
setState((state) => {
|
||||||
|
return produce(state, (draft) => {
|
||||||
|
draft.connections = draft.connections.filter((conn) => conn.accessToken !== accessToken);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'soapbox:bunker',
|
||||||
|
storage: {
|
||||||
|
getItem(name) {
|
||||||
|
const value = localStorage.getItem(name);
|
||||||
|
|
||||||
|
const connections = jsonSchema()
|
||||||
|
.pipe(filteredArray(connectionSchema))
|
||||||
|
.catch([])
|
||||||
|
.parse(value);
|
||||||
|
|
||||||
|
return { state: { connections } };
|
||||||
|
},
|
||||||
|
setItem(name, { state }) {
|
||||||
|
localStorage.setItem(name, JSON.stringify(state.connections));
|
||||||
|
},
|
||||||
|
removeItem(name) {
|
||||||
|
localStorage.removeItem(name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { keyring } from 'soapbox/features/nostr/keyring';
|
||||||
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore';
|
||||||
|
|
||||||
|
export function useSigner() {
|
||||||
|
const { connections } = useBunkerStore();
|
||||||
|
|
||||||
|
const connection = useAppSelector(({ auth }) => {
|
||||||
|
const accessToken = auth.me ? auth.users[auth.me]?.access_token : undefined;
|
||||||
|
if (accessToken) {
|
||||||
|
return connections.find((conn) => conn.accessToken === accessToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { pubkey, bunkerPubkey, authorizedPubkey } = connection ?? {};
|
||||||
|
|
||||||
|
const { data: signer, ...rest } = useQuery({
|
||||||
|
queryKey: ['nostr', 'signer', pubkey ?? ''],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!pubkey) return null;
|
||||||
|
|
||||||
|
const signer = keyring.get(pubkey);
|
||||||
|
if (signer) return signer;
|
||||||
|
|
||||||
|
if (window.nostr && await window.nostr.getPublicKey() === pubkey) {
|
||||||
|
return window.nostr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
enabled: !!pubkey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bunkerSigner = useMemo(() => {
|
||||||
|
if (bunkerPubkey) {
|
||||||
|
return keyring.get(bunkerPubkey);
|
||||||
|
}
|
||||||
|
}, [bunkerPubkey]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
signer: signer ?? undefined,
|
||||||
|
bunkerSigner,
|
||||||
|
authorizedPubkey,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
|
@ -3,7 +3,6 @@ import { IntlProvider } from 'react-intl';
|
||||||
|
|
||||||
import { fetchMe } from 'soapbox/actions/me';
|
import { fetchMe } from 'soapbox/actions/me';
|
||||||
import { loadSoapboxConfig } from 'soapbox/actions/soapbox';
|
import { loadSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||||
import { useSignerStream } from 'soapbox/api/hooks/nostr/useSignerStream';
|
|
||||||
import LoadingScreen from 'soapbox/components/loading-screen';
|
import LoadingScreen from 'soapbox/components/loading-screen';
|
||||||
import { useNostr } from 'soapbox/contexts/nostr-context';
|
import { useNostr } from 'soapbox/contexts/nostr-context';
|
||||||
import {
|
import {
|
||||||
|
@ -13,6 +12,8 @@ import {
|
||||||
useLocale,
|
useLocale,
|
||||||
useInstance,
|
useInstance,
|
||||||
} from 'soapbox/hooks';
|
} from 'soapbox/hooks';
|
||||||
|
import { useBunker } from 'soapbox/hooks/nostr/useBunker';
|
||||||
|
import { useSigner } from 'soapbox/hooks/nostr/useSigner';
|
||||||
import MESSAGES from 'soapbox/messages';
|
import MESSAGES from 'soapbox/messages';
|
||||||
|
|
||||||
/** Load initial data from the backend */
|
/** Load initial data from the backend */
|
||||||
|
@ -44,10 +45,12 @@ const SoapboxLoad: React.FC<ISoapboxLoad> = ({ children }) => {
|
||||||
const [localeLoading, setLocaleLoading] = useState(true);
|
const [localeLoading, setLocaleLoading] = useState(true);
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
const { hasNostr, isRelayOpen, signer } = useNostr();
|
const nostr = useNostr();
|
||||||
const { isSubscribed } = useSignerStream();
|
const signer = useSigner();
|
||||||
|
|
||||||
const nostrLoading = Boolean(hasNostr && signer && (!isRelayOpen || !isSubscribed));
|
const nostrLoading = Boolean(nostr.isRelayLoading || signer.isLoading);
|
||||||
|
|
||||||
|
useBunker();
|
||||||
|
|
||||||
/** Whether to display a loading indicator. */
|
/** Whether to display a loading indicator. */
|
||||||
const showLoading = [
|
const showLoading = [
|
||||||
|
|
|
@ -2,12 +2,11 @@ import React, { Suspense, useEffect } from 'react';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { BrowserRouter, Switch, Redirect, Route } from 'react-router-dom';
|
import { BrowserRouter, Switch, Redirect, Route } from 'react-router-dom';
|
||||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||||
// @ts-ignore: it doesn't have types
|
|
||||||
import { ScrollContext } from 'react-router-scroll-4';
|
|
||||||
|
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import * as BuildConfig from 'soapbox/build-config';
|
import * as BuildConfig from 'soapbox/build-config';
|
||||||
import LoadingScreen from 'soapbox/components/loading-screen';
|
import LoadingScreen from 'soapbox/components/loading-screen';
|
||||||
|
import { ScrollContext } from 'soapbox/components/scroll-context';
|
||||||
import SiteErrorBoundary from 'soapbox/components/site-error-boundary';
|
import SiteErrorBoundary from 'soapbox/components/site-error-boundary';
|
||||||
import {
|
import {
|
||||||
ModalContainer,
|
ModalContainer,
|
||||||
|
@ -51,16 +50,11 @@ const SoapboxMount = () => {
|
||||||
|
|
||||||
const { redirectRootNoLogin, gdpr } = soapboxConfig;
|
const { redirectRootNoLogin, gdpr } = soapboxConfig;
|
||||||
|
|
||||||
// @ts-ignore: I don't actually know what these should be, lol
|
|
||||||
const shouldUpdateScroll = (prevRouterProps, { location }) => {
|
|
||||||
return !(location.state?.soapboxModalKey && location.state?.soapboxModalKey !== prevRouterProps?.location?.state?.soapboxModalKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteErrorBoundary>
|
<SiteErrorBoundary>
|
||||||
<BrowserRouter basename={BuildConfig.FE_SUBDIRECTORY}>
|
<BrowserRouter basename={BuildConfig.FE_SUBDIRECTORY}>
|
||||||
<CompatRouter>
|
<CompatRouter>
|
||||||
<ScrollContext shouldUpdateScroll={shouldUpdateScroll}>
|
<ScrollContext>
|
||||||
<Switch>
|
<Switch>
|
||||||
{(!isLoggedIn && redirectRootNoLogin) && (
|
{(!isLoggedIn && redirectRootNoLogin) && (
|
||||||
<Redirect exact from='/' to={redirectRootNoLogin} />
|
<Redirect exact from='/' to={redirectRootNoLogin} />
|
||||||
|
|
|
@ -1167,6 +1167,7 @@
|
||||||
"new_group_panel.title": "Create Group",
|
"new_group_panel.title": "Create Group",
|
||||||
"nostr_extension.found": "<link>Sign in</link> with browser extension.",
|
"nostr_extension.found": "<link>Sign in</link> with browser extension.",
|
||||||
"nostr_extension.not_found": "Browser extension not found.",
|
"nostr_extension.not_found": "Browser extension not found.",
|
||||||
|
"nostr_extension.not_supported": "Browser extension not supported. Please upgrade to the latest version.",
|
||||||
"nostr_login.siwe.action": "Log in with extension",
|
"nostr_login.siwe.action": "Log in with extension",
|
||||||
"nostr_login.siwe.alt": "Log in with key",
|
"nostr_login.siwe.alt": "Log in with key",
|
||||||
"nostr_login.siwe.sign_up": "Sign Up",
|
"nostr_login.siwe.sign_up": "Sign Up",
|
||||||
|
|
|
@ -1154,6 +1154,8 @@
|
||||||
"new_group_panel.title": "Crear un Grupo",
|
"new_group_panel.title": "Crear un Grupo",
|
||||||
"nostr_extension.found": "<link>Iniciar sesión</link> con la extensión del navegador.",
|
"nostr_extension.found": "<link>Iniciar sesión</link> con la extensión del navegador.",
|
||||||
"nostr_extension.not_found": "No se ha encontrado la extensión del navegador.",
|
"nostr_extension.not_found": "No se ha encontrado la extensión del navegador.",
|
||||||
|
"nostr_extension.not_supported": "La extensión del navegador no es compatible. Actualice a la última versión.",
|
||||||
|
"nostr_login.siwe.action": "Iniciar sesión con la extensión",
|
||||||
"nostr_panel.message": "Conéctese con cualquier cliente de Nostr.",
|
"nostr_panel.message": "Conéctese con cualquier cliente de Nostr.",
|
||||||
"nostr_panel.title": "Relés de Nostr",
|
"nostr_panel.title": "Relés de Nostr",
|
||||||
"nostr_relays.read_only": "Solo lectura",
|
"nostr_relays.read_only": "Solo lectura",
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { enableMapSet } from 'immer';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
@ -14,7 +15,7 @@ import '@fontsource/inter/700.css';
|
||||||
import '@fontsource/inter/900.css';
|
import '@fontsource/inter/900.css';
|
||||||
import '@fontsource/roboto-mono/400.css';
|
import '@fontsource/roboto-mono/400.css';
|
||||||
import 'line-awesome/dist/font-awesome-line-awesome/css/all.css';
|
import 'line-awesome/dist/font-awesome-line-awesome/css/all.css';
|
||||||
import 'soapbox/features/nostr/keys';
|
import 'soapbox/features/nostr/keyring';
|
||||||
|
|
||||||
import './iframe';
|
import './iframe';
|
||||||
import './styles/i18n/arabic.css';
|
import './styles/i18n/arabic.css';
|
||||||
|
@ -25,6 +26,8 @@ import './styles/tailwind.css';
|
||||||
import ready from './ready';
|
import ready from './ready';
|
||||||
import { registerSW, lockSW } from './utils/sw';
|
import { registerSW, lockSW } from './utils/sw';
|
||||||
|
|
||||||
|
enableMapSet();
|
||||||
|
|
||||||
if (BuildConfig.NODE_ENV === 'production') {
|
if (BuildConfig.NODE_ENV === 'production') {
|
||||||
printConsoleWarning();
|
printConsoleWarning();
|
||||||
registerSW('/sw.js');
|
registerSW('/sw.js');
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import {
|
import {
|
||||||
Map as ImmutableMap,
|
Map as ImmutableMap,
|
||||||
List as ImmutableList,
|
List as ImmutableList,
|
||||||
Set as ImmutableSet,
|
|
||||||
Record as ImmutableRecord,
|
Record as ImmutableRecord,
|
||||||
OrderedSet as ImmutableOrderedSet,
|
OrderedSet as ImmutableOrderedSet,
|
||||||
fromJS,
|
fromJS,
|
||||||
is,
|
|
||||||
} from 'immutable';
|
} from 'immutable';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -64,12 +62,8 @@ type SetKeys = keyof FilterConditionally<State, ImmutableOrderedSet<string>>;
|
||||||
type APIReport = { id: string; state: string; statuses: any[] };
|
type APIReport = { id: string; state: string; statuses: any[] };
|
||||||
type APIUser = { id: string; email: string; nickname: string; registration_reason: string };
|
type APIUser = { id: string; email: string; nickname: string; registration_reason: string };
|
||||||
|
|
||||||
type Filter = 'local' | 'need_approval' | 'active';
|
type Filters = Record<string, boolean>;
|
||||||
|
|
||||||
const FILTER_UNAPPROVED: Filter[] = ['local', 'need_approval'];
|
|
||||||
const FILTER_LATEST: Filter[] = ['local', 'active'];
|
|
||||||
|
|
||||||
const filtersMatch = (f1: string[], f2: string[]) => is(ImmutableSet(f1), ImmutableSet(f2));
|
|
||||||
const toIds = (items: any[]) => items.map(item => item.id);
|
const toIds = (items: any[]) => items.map(item => item.id);
|
||||||
|
|
||||||
const mergeSet = (state: State, key: SetKeys, users: APIUser[]): State => {
|
const mergeSet = (state: State, key: SetKeys, users: APIUser[]): State => {
|
||||||
|
@ -82,16 +76,16 @@ const replaceSet = (state: State, key: SetKeys, users: APIUser[]): State => {
|
||||||
return state.set(key, ImmutableOrderedSet(newIds));
|
return state.set(key, ImmutableOrderedSet(newIds));
|
||||||
};
|
};
|
||||||
|
|
||||||
const maybeImportUnapproved = (state: State, users: APIUser[], filters: Filter[]): State => {
|
const maybeImportUnapproved = (state: State, users: APIUser[], filters: Filters): State => {
|
||||||
if (filtersMatch(FILTER_UNAPPROVED, filters)) {
|
if (filters.pending) {
|
||||||
return mergeSet(state, 'awaitingApproval', users);
|
return mergeSet(state, 'awaitingApproval', users);
|
||||||
} else {
|
} else {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const maybeImportLatest = (state: State, users: APIUser[], filters: Filter[], page: number): State => {
|
const maybeImportLatest = (state: State, users: APIUser[], filters: Filters, page: number): State => {
|
||||||
if (page === 1 && filtersMatch(FILTER_LATEST, filters)) {
|
if (page === 1 && !filters.pending) {
|
||||||
return replaceSet(state, 'latestUsers', users);
|
return replaceSet(state, 'latestUsers', users);
|
||||||
} else {
|
} else {
|
||||||
return state;
|
return state;
|
||||||
|
@ -110,7 +104,7 @@ const fixUser = (user: APIEntity): ReducerAdminAccount => {
|
||||||
}) as ReducerAdminAccount;
|
}) as ReducerAdminAccount;
|
||||||
};
|
};
|
||||||
|
|
||||||
function importUsers(state: State, users: APIUser[], filters: Filter[], page: number): State {
|
function importUsers(state: State, users: APIUser[], filters: Filters, page: number): State {
|
||||||
return state.withMutations(state => {
|
return state.withMutations(state => {
|
||||||
maybeImportUnapproved(state, users, filters);
|
maybeImportUnapproved(state, users, filters);
|
||||||
maybeImportLatest(state, users, filters, page);
|
maybeImportLatest(state, users, filters, page);
|
||||||
|
@ -202,7 +196,7 @@ export default function admin(state: State = ReducerRecord(), action: AnyAction)
|
||||||
case ADMIN_REPORTS_PATCH_SUCCESS:
|
case ADMIN_REPORTS_PATCH_SUCCESS:
|
||||||
return handleReportDiffs(state, action.reports);
|
return handleReportDiffs(state, action.reports);
|
||||||
case ADMIN_USERS_FETCH_SUCCESS:
|
case ADMIN_USERS_FETCH_SUCCESS:
|
||||||
return importUsers(state, action.users, action.filters, action.page);
|
return importUsers(state, action.accounts, action.filters, action.page);
|
||||||
case ADMIN_USERS_DELETE_REQUEST:
|
case ADMIN_USERS_DELETE_REQUEST:
|
||||||
case ADMIN_USERS_DELETE_SUCCESS:
|
case ADMIN_USERS_DELETE_SUCCESS:
|
||||||
case ADMIN_USERS_REJECT_REQUEST:
|
case ADMIN_USERS_REJECT_REQUEST:
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { AxiosError } from 'axios';
|
||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { keyring } from 'soapbox/features/nostr/keyring';
|
||||||
|
import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore';
|
||||||
import { Account, accountSchema } from 'soapbox/schemas';
|
import { Account, accountSchema } from 'soapbox/schemas';
|
||||||
import { Application, applicationSchema } from 'soapbox/schemas/application';
|
import { Application, applicationSchema } from 'soapbox/schemas/application';
|
||||||
import { AuthUser, SoapboxAuth, soapboxAuthSchema } from 'soapbox/schemas/soapbox/soapbox-auth';
|
import { AuthUser, SoapboxAuth, soapboxAuthSchema } from 'soapbox/schemas/soapbox/soapbox-auth';
|
||||||
|
@ -24,6 +26,17 @@ import type { UnknownAction } from 'redux';
|
||||||
const STORAGE_KEY = 'soapbox:auth';
|
const STORAGE_KEY = 'soapbox:auth';
|
||||||
const SESSION_KEY = 'soapbox:auth:me';
|
const SESSION_KEY = 'soapbox:auth:me';
|
||||||
|
|
||||||
|
// Log out legacy Nostr/Ditto users.
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
|
||||||
|
if (key && /^soapbox:nostr:auth:[0-9a-f]{64}$/.test(key)) {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Get current user's URL from session storage. */
|
/** Get current user's URL from session storage. */
|
||||||
function getSessionUser(): string | undefined {
|
function getSessionUser(): string | undefined {
|
||||||
const value = sessionStorage.getItem(SESSION_KEY);
|
const value = sessionStorage.getItem(SESSION_KEY);
|
||||||
|
@ -37,7 +50,7 @@ function getSessionUser(): string | undefined {
|
||||||
/** Retrieve state from browser storage. */
|
/** Retrieve state from browser storage. */
|
||||||
function getLocalState(): SoapboxAuth | undefined {
|
function getLocalState(): SoapboxAuth | undefined {
|
||||||
const data = localStorage.getItem(STORAGE_KEY);
|
const data = localStorage.getItem(STORAGE_KEY);
|
||||||
const result = jsonSchema.pipe(soapboxAuthSchema).safeParse(data);
|
const result = jsonSchema().pipe(soapboxAuthSchema).safeParse(data);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -105,7 +118,26 @@ function importCredentials(auth: SoapboxAuth, accessToken: string, account: Acco
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete Nostr credentials when an access token is revoked. */
|
||||||
|
// TODO: Rework auth so this can all be conrolled from one place.
|
||||||
|
function revokeNostr(accessToken: string): void {
|
||||||
|
const { connections, revoke } = useBunkerStore.getState();
|
||||||
|
|
||||||
|
for (const conn of connections) {
|
||||||
|
if (conn.accessToken === accessToken) {
|
||||||
|
// Revoke the Bunker connection.
|
||||||
|
revoke(accessToken);
|
||||||
|
// Revoke the user's private key.
|
||||||
|
keyring.delete(conn.pubkey);
|
||||||
|
// Revoke the bunker's private key.
|
||||||
|
keyring.delete(conn.bunkerPubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function deleteToken(auth: SoapboxAuth, accessToken: string): SoapboxAuth {
|
function deleteToken(auth: SoapboxAuth, accessToken: string): SoapboxAuth {
|
||||||
|
revokeNostr(accessToken);
|
||||||
|
|
||||||
return produce(auth, draft => {
|
return produce(auth, draft => {
|
||||||
delete draft.tokens[accessToken];
|
delete draft.tokens[accessToken];
|
||||||
|
|
||||||
|
|
|
@ -56,7 +56,7 @@ import trending_statuses from './trending-statuses';
|
||||||
import trends from './trends';
|
import trends from './trends';
|
||||||
import user_lists from './user-lists';
|
import user_lists from './user-lists';
|
||||||
|
|
||||||
const reducers = {
|
export default combineReducers({
|
||||||
accounts_meta,
|
accounts_meta,
|
||||||
admin,
|
admin,
|
||||||
aliases,
|
aliases,
|
||||||
|
@ -111,6 +111,4 @@ const reducers = {
|
||||||
trending_statuses,
|
trending_statuses,
|
||||||
trends,
|
trends,
|
||||||
user_lists,
|
user_lists,
|
||||||
};
|
});
|
||||||
|
|
||||||
export default combineReducers(reducers);
|
|
|
@ -30,14 +30,16 @@ function makeCustomEmojiMap(customEmojis: CustomEmoji[]) {
|
||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonSchema = z.string().transform((value, ctx) => {
|
function jsonSchema(reviver?: (this: any, key: string, value: any) => any) {
|
||||||
try {
|
return z.string().transform((value, ctx) => {
|
||||||
return JSON.parse(value) as unknown;
|
try {
|
||||||
} catch (_e) {
|
return JSON.parse(value, reviver) as unknown;
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON' });
|
} catch (_e) {
|
||||||
return z.NEVER;
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON' });
|
||||||
}
|
return z.NEVER;
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** MIME schema, eg `image/png`. */
|
/** MIME schema, eg `image/png`. */
|
||||||
const mimeSchema = z.string().regex(/^\w+\/[-+.\w]+$/);
|
const mimeSchema = z.string().regex(/^\w+\/[-+.\w]+$/);
|
||||||
|
|
|
@ -988,6 +988,7 @@ const getInstanceFeatures = (instance: InstanceV1 | InstanceV2) => {
|
||||||
v.software === ICESHRIMP,
|
v.software === ICESHRIMP,
|
||||||
v.software === MASTODON && gte(v.version, '2.8.0'),
|
v.software === MASTODON && gte(v.version, '2.8.0'),
|
||||||
v.software === PLEROMA && gte(v.version, '1.0.0'),
|
v.software === PLEROMA && gte(v.version, '1.0.0'),
|
||||||
|
v.software === DITTO,
|
||||||
]),
|
]),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
28
yarn.lock
28
yarn.lock
|
@ -5516,13 +5516,6 @@ intl-pluralrules@^2.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/intl-pluralrules/-/intl-pluralrules-2.0.1.tgz#de16c3df1e09437635829725e88ea70c9ad79569"
|
resolved "https://registry.yarnpkg.com/intl-pluralrules/-/intl-pluralrules-2.0.1.tgz#de16c3df1e09437635829725e88ea70c9ad79569"
|
||||||
integrity sha512-astxTLzIdXPeN0K9Rumi6LfMpm3rvNO0iJE+h/k8Kr/is+wPbRe4ikyDjlLr6VTh/mEfNv8RjN+gu3KwDiuhqg==
|
integrity sha512-astxTLzIdXPeN0K9Rumi6LfMpm3rvNO0iJE+h/k8Kr/is+wPbRe4ikyDjlLr6VTh/mEfNv8RjN+gu3KwDiuhqg==
|
||||||
|
|
||||||
invariant@^2.2.4:
|
|
||||||
version "2.2.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
|
|
||||||
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
|
|
||||||
dependencies:
|
|
||||||
loose-envify "^1.0.0"
|
|
||||||
|
|
||||||
is-array-buffer@^3.0.1, is-array-buffer@^3.0.2:
|
is-array-buffer@^3.0.1, is-array-buffer@^3.0.2:
|
||||||
version "3.0.2"
|
version "3.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.2.tgz#f2653ced8412081638ecb0ebbd0c41c6e0aecbbe"
|
resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.2.tgz#f2653ced8412081638ecb0ebbd0c41c6e0aecbbe"
|
||||||
|
@ -7363,14 +7356,6 @@ react-router-dom@^5.3.0:
|
||||||
tiny-invariant "^1.0.2"
|
tiny-invariant "^1.0.2"
|
||||||
tiny-warning "^1.0.0"
|
tiny-warning "^1.0.0"
|
||||||
|
|
||||||
react-router-scroll-4@^1.0.0-beta.2:
|
|
||||||
version "1.0.0-beta.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/react-router-scroll-4/-/react-router-scroll-4-1.0.0-beta.2.tgz#d887063ec0f66124aaf450158dd158ff7d3dc279"
|
|
||||||
integrity sha512-K67Dnm75naSBs/WYc2CDNxqU+eE8iA3I0wSCArgGSHb0xR/7AUcgUEXtCxrQYVTogXvjVK60gmwYvOyRQ6fuBA==
|
|
||||||
dependencies:
|
|
||||||
scroll-behavior "^0.9.1"
|
|
||||||
warning "^3.0.0"
|
|
||||||
|
|
||||||
react-router@5.2.1:
|
react-router@5.2.1:
|
||||||
version "5.2.1"
|
version "5.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.1.tgz#4d2e4e9d5ae9425091845b8dbc6d9d276239774d"
|
resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.1.tgz#4d2e4e9d5ae9425091845b8dbc6d9d276239774d"
|
||||||
|
@ -7817,14 +7802,6 @@ schema-utils@^4.0.0:
|
||||||
ajv-formats "^2.1.1"
|
ajv-formats "^2.1.1"
|
||||||
ajv-keywords "^5.0.0"
|
ajv-keywords "^5.0.0"
|
||||||
|
|
||||||
scroll-behavior@^0.9.1:
|
|
||||||
version "0.9.12"
|
|
||||||
resolved "https://registry.yarnpkg.com/scroll-behavior/-/scroll-behavior-0.9.12.tgz#1c22d273ec4ce6cd4714a443fead50227da9424c"
|
|
||||||
integrity sha512-18sirtyq1P/VsBX6O/vgw20Np+ngduFXEMO4/NDFXabdOKBL2kjPVUpz1y0+jm99EWwFJafxf5/tCyMeXt9Xyg==
|
|
||||||
dependencies:
|
|
||||||
dom-helpers "^3.4.0"
|
|
||||||
invariant "^2.2.4"
|
|
||||||
|
|
||||||
semver-compare@^1.0.0:
|
semver-compare@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
|
resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
|
||||||
|
@ -9315,3 +9292,8 @@ zod@^3.23.4, zod@^3.23.5:
|
||||||
version "3.23.5"
|
version "3.23.5"
|
||||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.5.tgz#c7b7617d017d4a2f21852f533258d26a9a5ae09f"
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.5.tgz#c7b7617d017d4a2f21852f533258d26a9a5ae09f"
|
||||||
integrity sha512-fkwiq0VIQTksNNA131rDOsVJcns0pfVUjHzLrNBiF/O/Xxb5lQyEXkhZWcJ7npWsYlvs+h0jFWXXy4X46Em1JA==
|
integrity sha512-fkwiq0VIQTksNNA131rDOsVJcns0pfVUjHzLrNBiF/O/Xxb5lQyEXkhZWcJ7npWsYlvs+h0jFWXXy4X46Em1JA==
|
||||||
|
|
||||||
|
zustand@^5.0.0:
|
||||||
|
version "5.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.0.tgz#71f8aaecf185592a3ba2743d7516607361899da9"
|
||||||
|
integrity sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==
|
||||||
|
|
Loading…
Reference in New Issue