Merge remote-tracking branch 'origin/main' into dompurify

environments/review-dompurify-e55h9j/deployments/4392
Alex Gleason 2024-02-08 14:57:35 -06:00
commit c0325498c8
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
20 zmienionych plików z 211 dodań i 58 usunięć

Wyświetl plik

@ -16,6 +16,10 @@
"bugs": {
"url": "https://gitlab.com/soapbox-pub/soapbox/-/issues"
},
"funding": {
"type": "lightning",
"url": "lightning:alex@alexgleason.me"
},
"scripts": {
"start": "npx vite serve",
"dev": "${npm_execpath} run start",
@ -92,6 +96,7 @@
"@types/semver": "^7.3.9",
"@types/uuid": "^9.0.0",
"@vitejs/plugin-react": "^4.0.4",
"@webbtc/webln-types": "^3.0.0",
"autoprefixer": "^10.4.15",
"axios": "^1.2.2",
"axios-mock-adapter": "^1.22.0",

Wyświetl plik

@ -78,6 +78,10 @@ const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL';
const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL';
const ZAP_REQUEST = 'ZAP_REQUEST';
const ZAP_SUCCESS = 'ZAP_SUCCESS';
const ZAP_FAIL = 'ZAP_FAIL';
const messages = defineMessages({
bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' },
bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' },
@ -306,6 +310,38 @@ const undislikeFail = (status: StatusEntity, error: unknown) => ({
skipLoading: true,
});
const zap = (status: StatusEntity, amount: number) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(zapRequest(status));
api(getState).post(`/api/v1/statuses/${status.id}/zap`, { amount }).then(function(response) {
dispatch(zapSuccess(status));
}).catch(function(error) {
dispatch(zapFail(status, error));
});
};
const zapRequest = (status: StatusEntity) => ({
type: ZAP_REQUEST,
status: status,
skipLoading: true,
});
const zapSuccess = (status: StatusEntity) => ({
type: ZAP_SUCCESS,
status: status,
skipLoading: true,
});
const zapFail = (status: StatusEntity, error: unknown) => ({
type: ZAP_FAIL,
status: status,
error: error,
skipLoading: true,
});
const bookmark = (status: StatusEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(bookmarkRequest(status));
@ -732,6 +768,8 @@ export {
FAVOURITES_EXPAND_FAIL,
REBLOGS_EXPAND_SUCCESS,
REBLOGS_EXPAND_FAIL,
ZAP_REQUEST,
ZAP_FAIL,
reblog,
unreblog,
toggleReblog,
@ -801,4 +839,5 @@ export {
remoteInteractionRequest,
remoteInteractionSuccess,
remoteInteractionFail,
zap,
};

Wyświetl plik

@ -1,9 +1,10 @@
import { NiceRelay } from 'nostr-machina';
import { type Event } from 'nostr-tools';
import { useEffect, useMemo } from 'react';
import { nip04, signEvent } from 'soapbox/features/nostr/sign';
import { useInstance } from 'soapbox/hooks';
import { connectRequestSchema } from 'soapbox/schemas/nostr';
import { connectRequestSchema, nwcRequestSchema } from 'soapbox/schemas/nostr';
import { jsonSchema } from 'soapbox/schemas/utils';
function useSignerStream() {
@ -18,35 +19,62 @@ function useSignerStream() {
}
}, [relayUrl]);
useEffect(() => {
async function handleConnectEvent(event: Event) {
if (!relay || !pubkey) return;
const decrypted = await nip04.decrypt(pubkey, event.content);
const reqMsg = jsonSchema.pipe(connectRequestSchema).safeParse(decrypted);
if (!reqMsg.success) {
console.warn(decrypted);
console.warn(reqMsg.error);
return;
}
const respMsg = {
id: reqMsg.data.id,
result: await signEvent(reqMsg.data.params[0], reqMsg.data.params[1]),
};
const respEvent = await signEvent({
kind: 24133,
content: await nip04.encrypt(pubkey, JSON.stringify(respMsg)),
tags: [['p', pubkey]],
created_at: Math.floor(Date.now() / 1000),
});
relay.send(['EVENT', respEvent]);
}
async function handleWalletEvent(event: Event) {
if (!relay || !pubkey) return;
const sub = relay.req([{ kinds: [24133], authors: [pubkey], limit: 0 }]);
const decrypted = await nip04.decrypt(pubkey, event.content);
const reqMsg = jsonSchema.pipe(nwcRequestSchema).safeParse(decrypted);
if (!reqMsg.success) {
console.warn(decrypted);
console.warn(reqMsg.error);
return;
}
await window.webln?.enable();
await window.webln?.sendPayment(reqMsg.data.params.invoice);
}
useEffect(() => {
if (!relay || !pubkey) return;
const sub = relay.req([{ kinds: [24133, 23194], authors: [pubkey], limit: 0 }]);
const readEvents = async () => {
for await (const event of sub) {
const decrypted = await nip04.decrypt(pubkey, event.content);
const reqMsg = jsonSchema.pipe(connectRequestSchema).safeParse(decrypted);
if (!reqMsg.success) {
console.warn(decrypted);
console.warn(reqMsg.error);
return;
switch (event.kind) {
case 24133:
await handleConnectEvent(event);
break;
case 23194:
await handleWalletEvent(event);
break;
}
const respMsg = {
id: reqMsg.data.id,
result: await signEvent(reqMsg.data.params[0], reqMsg.data.params[1]),
};
const respEvent = await signEvent({
kind: 24133,
content: await nip04.encrypt(pubkey, JSON.stringify(respMsg)),
tags: [['p', pubkey]],
created_at: Math.floor(Date.now() / 1000),
});
relay.send(['EVENT', respEvent]);
}
};

Wyświetl plik

@ -7,7 +7,7 @@ import { useHistory } from 'react-router-dom';
import { closeDropdownMenu as closeDropdownMenuRedux, openDropdownMenu } from 'soapbox/actions/dropdown-menu';
import { closeModal, openModal } from 'soapbox/actions/modals';
import { useAppDispatch } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is-mobile';
import { userTouching } from 'soapbox/is-mobile';
import { IconButton, Portal } from '../ui';
@ -53,8 +53,6 @@ const DropdownMenu = (props: IDropdownMenu) => {
const arrowRef = useRef<HTMLDivElement>(null);
const isOnMobile = isUserTouching();
const { x, y, strategy, refs, middlewareData, placement } = useFloating<HTMLButtonElement>({
placement: initialPlacement,
middleware: [
@ -92,7 +90,7 @@ const DropdownMenu = (props: IDropdownMenu) => {
* On mobile screens, let's replace the Popper dropdown with a Modal.
*/
const handleOpen = () => {
if (isOnMobile) {
if (userTouching.matches) {
dispatch(
openModal('ACTIONS', {
status: filteredProps.status,
@ -113,7 +111,7 @@ const DropdownMenu = (props: IDropdownMenu) => {
const handleClose = () => {
(refs.reference.current as HTMLButtonElement)?.focus();
if (isOnMobile) {
if (userTouching.matches) {
dispatch(closeModal('ACTIONS'));
} else {
closeDropdownMenu();

Wyświetl plik

@ -6,7 +6,7 @@ import { blockAccount } from 'soapbox/actions/accounts';
import { launchChat } from 'soapbox/actions/chats';
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
import { editEvent } from 'soapbox/actions/events';
import { pinToGroup, toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog, unpinFromGroup } from 'soapbox/actions/interactions';
import { pinToGroup, toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog, unpinFromGroup, zap } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals';
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
import { initMuteModal } from 'soapbox/actions/mutes';
@ -103,6 +103,7 @@ const messages = defineMessages({
unmuteSuccess: { id: 'group.unmute.success', defaultMessage: 'Unmuted the group' },
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
unpinFromGroup: { id: 'status.unpin_to_group', defaultMessage: 'Unpin from Group' },
zap: { id: 'status.zap', defaultMessage: 'Zap' },
});
interface IStatusActionBar {
@ -188,6 +189,14 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
}
};
const handleZapClick: React.EventHandler<React.MouseEvent> = (e) => {
if (me) {
dispatch(zap(status, 1337));
} else {
onOpenUnauthorizedModal('ZAP');
}
};
const handleBookmarkClick: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(toggleBookmark(status));
};
@ -694,6 +703,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
}
const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group');
const acceptsZaps = status.account.ditto.accepts_zaps === true;
const spacing: {
[key: string]: React.ComponentProps<typeof HStack>['space'];
@ -781,6 +791,19 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
/>
)}
{(acceptsZaps && window.webln) && (
<StatusActionButton
title={intl.formatMessage(messages.zap)}
icon={require('@tabler/icons/bolt.svg')}
color='accent'
filled
onClick={handleZapClick}
active={status.zapped}
text={withLabels ? intl.formatMessage(messages.zap) : undefined}
theme={statusActionButtonTheme}
/>
)}
{canShare && (
<StatusActionButton
title={intl.formatMessage(messages.share)}

Wyświetl plik

@ -4,7 +4,7 @@ import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts';
import { openModal } from 'soapbox/actions/modals';
import { EmojiSelector, Portal } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is-mobile';
import { userTouching } from 'soapbox/is-mobile';
import { getReactForStatus } from 'soapbox/utils/emoji-reacts';
interface IStatusReactionWrapper {
@ -39,7 +39,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
clearTimeout(timeout.current);
}
if (!isUserTouching()) {
if (!userTouching.matches) {
setVisible(true);
}
};
@ -51,7 +51,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
// Unless the user is touching, delay closing the emoji selector briefly
// so the user can move the mouse diagonally to make a selection.
if (isUserTouching()) {
if (userTouching.matches) {
setVisible(false);
} else {
timeout.current = setTimeout(() => {
@ -73,7 +73,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
const handleClick: React.EventHandler<React.MouseEvent> = e => {
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji)?.name || '👍';
if (isUserTouching()) {
if (userTouching.matches) {
if (ownAccount) {
if (visible) {
handleReact(meEmojiReact);

Wyświetl plik

@ -11,7 +11,7 @@ import { closeModal, openModal } from 'soapbox/actions/modals';
import Icon from 'soapbox/components/icon';
import { IconButton } from 'soapbox/components/ui';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is-mobile';
import { userTouching } from 'soapbox/is-mobile';
import Motion from '../../ui/util/optional-motion';
@ -173,7 +173,7 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
const onModalClose = () => dispatch(closeModal('ACTIONS'));
const handleToggle: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (isUserTouching()) {
if (userTouching.matches) {
if (open) {
onModalClose();
} else {

Wyświetl plik

@ -0,0 +1,32 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import CopyableInput from 'soapbox/components/copyable-input';
import { Text, Stack, HStack, Emoji } from 'soapbox/components/ui';
export interface ILightningAddress {
address: string;
}
const LightningAddress: React.FC<ILightningAddress> = (props): JSX.Element => {
const { address } = props;
return (
<Stack>
<HStack alignItems='center' className='mb-1'>
<Emoji
className='mr-2.5 flex w-6 items-start justify-center rtl:ml-2.5 rtl:mr-0'
emoji='⚡'
/>
<Text weight='bold'>
<FormattedMessage id='crypto.lightning' defaultMessage='Lightning' />
</Text>
</HStack>
<CopyableInput value={address} />
</Stack>
);
};
export default LightningAddress;

Wyświetl plik

@ -15,7 +15,7 @@ import PlaceholderStatus from 'soapbox/features/placeholder/components/placehold
import Thread from 'soapbox/features/status/components/thread';
import Video from 'soapbox/features/video';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is-mobile';
import { userTouching } from 'soapbox/is-mobile';
import { makeGetStatus } from 'soapbox/selectors';
import ImageLoader from '../image-loader';
@ -104,7 +104,7 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
const getIndex = () => index !== null ? index : props.index;
const toggleNavigation = () => {
setNavigationHidden(value => !value && isUserTouching());
setNavigationHidden(value => !value && userTouching.matches);
};
const handleStatusClick: React.MouseEventHandler = e => {

Wyświetl plik

@ -4,12 +4,13 @@ import { defineMessages, useIntl, FormatDateOptions } from 'react-intl';
import Markup from 'soapbox/components/markup';
import { HStack, Icon } from 'soapbox/components/ui';
import { CryptoAddress } from 'soapbox/features/ui/util/async-components';
import { CryptoAddress, LightningAddress } from 'soapbox/features/ui/util/async-components';
import type { Account } from 'soapbox/schemas';
const getTicker = (value: string): string => (value.match(/\$([a-zA-Z]*)/i) || [])[1];
const isTicker = (value: string): boolean => Boolean(getTicker(value));
const isZapEmoji = (value: string) => /^\u26A1[\uFE00-\uFE0F]?$/.test(value);
const messages = defineMessages({
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
@ -39,6 +40,8 @@ const ProfileField: React.FC<IProfileField> = ({ field }) => {
address={field.value_plain}
/>
);
} else if (isZapEmoji(field.name)) {
return <LightningAddress address={field.value_plain} />;
}
return (

Wyświetl plik

@ -102,6 +102,7 @@ export const CryptoDonate = lazy(() => import('soapbox/features/crypto-donate'))
export const CryptoDonatePanel = lazy(() => import('soapbox/features/crypto-donate/components/crypto-donate-panel'));
export const CryptoAddress = lazy(() => import('soapbox/features/crypto-donate/components/crypto-address'));
export const CryptoDonateModal = lazy(() => import('soapbox/features/ui/components/modals/crypto-donate-modal'));
export const LightningAddress = lazy(() => import('soapbox/features/crypto-donate/components/lightning-address'));
export const ScheduledStatuses = lazy(() => import('soapbox/features/scheduled-statuses'));
export const UserIndex = lazy(() => import('soapbox/features/admin/user-index'));
export const FederationRestrictions = lazy(() => import('soapbox/features/federation-restrictions'));

Wyświetl plik

@ -1,5 +1,3 @@
import { supportsPassiveEvents } from 'detect-passive-events';
/** Breakpoint at which the application is considered "mobile". */
const LAYOUT_BREAKPOINT = 630;
@ -11,20 +9,7 @@ export function isMobile(width: number) {
/** Whether the device is iOS (best guess). */
const iOS: boolean = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream;
let userTouching = false;
const listenerOptions = supportsPassiveEvents ? { passive: true } as EventListenerOptions : false;
function touchListener(): void {
userTouching = true;
window.removeEventListener('touchstart', touchListener, listenerOptions);
}
window.addEventListener('touchstart', touchListener, listenerOptions);
/** Whether the user has touched the screen since the page loaded. */
export function isUserTouching(): boolean {
return userTouching;
}
export const userTouching = window.matchMedia('(pointer: coarse)');
/** Whether the device is iOS (best guess). */
export function isIOS(): boolean {

Wyświetl plik

@ -528,6 +528,7 @@
"confirmations.scheduled_status_delete.message": "Are you sure you want to discard this scheduled post?",
"confirmations.unfollow.confirm": "Unfollow",
"copy.success": "Copied to clipboard!",
"crypto.lightning": "Lightning",
"crypto_donate.explanation_box.message": "{siteTitle} accepts cryptocurrency donations. You may send a donation to any of the addresses below. Thank you for your support!",
"crypto_donate.explanation_box.title": "Sending cryptocurrency donations",
"crypto_donate_panel.actions.view": "Click to see {count, plural, one {# wallet} other {# wallets}}",
@ -1453,6 +1454,7 @@
"status.unmute_conversation": "Unmute Conversation",
"status.unpin": "Unpin from profile",
"status.unpin_to_group": "Unpin from Group",
"status.zap": "Zap",
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
"statuses.quote_tombstone": "Post is unavailable.",
"statuses.tombstone": "One or more posts are unavailable.",

Wyświetl plik

@ -82,6 +82,7 @@ export const StatusRecord = ImmutableRecord({
uri: '',
url: '',
visibility: 'public' as StatusVisibility,
zapped: false,
event: null as ReturnType<typeof EventRecord> | null,
// Internal fields

Wyświetl plik

@ -30,6 +30,8 @@ import {
DISLIKE_REQUEST,
UNDISLIKE_REQUEST,
DISLIKE_FAIL,
ZAP_REQUEST,
ZAP_FAIL,
} from '../actions/interactions';
import {
STATUS_CREATE_REQUEST,
@ -234,6 +236,18 @@ const simulateDislike = (
return state.set(statusId, updatedStatus);
};
/** Simulate zap of status for optimistic interactions */
const simulateZap = (state: State, statusId: string, zapped: boolean): State => {
const status = state.get(statusId);
if (!status) return state;
const updatedStatus = status.merge({
zapped,
});
return state.set(statusId, updatedStatus);
};
interface Translation {
content: string;
detected_source_language: string;
@ -288,6 +302,10 @@ export default function statuses(state = initialState, action: AnyAction): State
return state.get(action.status.id) === undefined ? state : state.setIn([action.status.id, 'favourited'], false);
case DISLIKE_FAIL:
return state.get(action.status.id) === undefined ? state : state.setIn([action.status.id, 'disliked'], false);
case ZAP_REQUEST:
return simulateZap(state, action.status.id, true);
case ZAP_FAIL:
return simulateZap(state, action.status.id, false);
case REBLOG_REQUEST:
return state.setIn([action.status.id, 'reblogged'], true);
case REBLOG_FAIL:

Wyświetl plik

@ -7,7 +7,7 @@ import { unescapeHTML } from 'soapbox/utils/html';
import { customEmojiSchema } from './custom-emoji';
import { relationshipSchema } from './relationship';
import { contentSchema, filteredArray, makeCustomEmojiMap } from './utils';
import { coerceObject, contentSchema, filteredArray, makeCustomEmojiMap } from './utils';
import type { Resolve } from 'soapbox/utils/types';
@ -30,6 +30,9 @@ const baseAccountSchema = z.object({
created_at: z.string().datetime().catch(new Date().toUTCString()),
discoverable: z.boolean().catch(false),
display_name: z.string().catch(''),
ditto: coerceObject({
accepts_zaps: z.boolean().catch(false),
}),
emojis: filteredArray(customEmojiSchema),
fields: filteredArray(fieldSchema),
followers_count: z.number().catch(0),

Wyświetl plik

@ -36,4 +36,12 @@ const connectRequestSchema = z.object({
params: z.tuple([eventTemplateSchema]).or(z.tuple([eventTemplateSchema, signEventOptsSchema])),
});
export { nostrIdSchema, kindSchema, eventSchema, signedEventSchema, connectRequestSchema };
/** NIP-47 signer response. */
const nwcRequestSchema = z.object({
method: z.literal('pay_invoice'),
params: z.object({
invoice: z.string(),
}),
});
export { nostrIdSchema, kindSchema, eventSchema, signedEventSchema, connectRequestSchema, nwcRequestSchema };

Wyświetl plik

@ -67,6 +67,7 @@ const baseStatusSchema = z.object({
uri: z.string().url().catch(''),
url: z.string().url().catch(''),
visibility: z.string().catch('public'),
zapped: z.coerce.boolean(),
});
type BaseStatus = z.infer<typeof baseStatusSchema>;

Wyświetl plik

@ -24,7 +24,8 @@
"types": [
"vite/client",
"vitest/globals",
"vite-plugin-compile-time/client"
"vite-plugin-compile-time/client",
"@webbtc/webln-types"
]
}
}

Wyświetl plik

@ -2914,6 +2914,11 @@
"@webassemblyjs/ast" "1.11.6"
"@xtuc/long" "4.2.2"
"@webbtc/webln-types@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@webbtc/webln-types/-/webln-types-3.0.0.tgz#448b2138423865087ba8859e9e6430fc2463b864"
integrity sha512-aXfTHLKz5lysd+6xTeWl+qHNh/p3qVYbeLo+yDN5cUDmhie2ZoGvkppfWxzbGkcFBzb6dJyQ2/i2cbmDHas+zQ==
"@xtuc/ieee754@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"