Merge pull request #3445 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to 16c41e035b
This commit is contained in:
Claire
2026-03-17 18:00:23 +01:00
committed by GitHub
158 changed files with 2175 additions and 388 deletions

View File

@@ -26,9 +26,9 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
end end
def unknown_affected_account? def unknown_affected_account?
json = Oj.load(body, mode: :strict) json = JSON.parse(body)
json.is_a?(Hash) && %w(Delete Update).include?(json['type']) && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.exists?(uri: json['actor']) json.is_a?(Hash) && %w(Delete Update).include?(json['type']) && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.exists?(uri: json['actor'])
rescue Oj::ParseError rescue JSON::ParserError
false false
end end

View File

@@ -38,7 +38,7 @@ class Api::V1::AccountsController < Api::BaseController
headers.merge!(response.headers) headers.merge!(response.headers)
self.response_body = Oj.dump(response.body) self.response_body = response.body.to_json
self.status = response.status self.status = response.status
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
render json: ValidationErrorFormatter.new(e, 'account.username': :username, 'invite_request.text': :reason).as_json, status: 422 render json: ValidationErrorFormatter.new(e, 'account.username': :username, 'invite_request.text': :reason).as_json, status: 422

View File

@@ -35,7 +35,7 @@ class Api::V1::DonationCampaignsController < Api::BaseController
return if key.blank? return if key.blank?
campaign = Rails.cache.read("donation_campaign:#{key}", raw: true) campaign = Rails.cache.read("donation_campaign:#{key}", raw: true)
Oj.load(campaign) if campaign.present? JSON.parse(campaign) if campaign.present?
end end
def save_to_cache!(campaign) def save_to_cache!(campaign)
@@ -44,7 +44,7 @@ class Api::V1::DonationCampaignsController < Api::BaseController
Rails.cache.write_multi( Rails.cache.write_multi(
{ {
request_key => campaign_key(campaign), request_key => campaign_key(campaign),
"donation_campaign:#{campaign_key(campaign)}" => Oj.dump(campaign), "donation_campaign:#{campaign_key(campaign)}" => campaign.to_json,
}, },
expires_in: 1.hour, expires_in: 1.hour,
raw: true raw: true
@@ -57,7 +57,7 @@ class Api::V1::DonationCampaignsController < Api::BaseController
url.query_values = { platform: 'web', seed: seed, locale: locale, environment: Rails.configuration.x.donation_campaigns.environment }.compact url.query_values = { platform: 'web', seed: seed, locale: locale, environment: Rails.configuration.x.donation_campaigns.environment }.compact
Request.new(:get, url.to_s).perform do |res| Request.new(:get, url.to_s).perform do |res|
return Oj.load(res.body_with_limit, mode: :strict) if res.code == 200 return JSON.parse(res.body_with_limit) if res.code == 200
end end
end end
rescue *Mastodon::HTTP_CONNECTION_ERRORS, Oj::ParseError rescue *Mastodon::HTTP_CONNECTION_ERRORS, Oj::ParseError

View File

@@ -25,7 +25,9 @@ class Api::V1::ProfilesController < Api::BaseController
:display_name, :display_name,
:note, :note,
:avatar, :avatar,
:avatar_description,
:header, :header,
:header_description,
:locked, :locked,
:bot, :bot,
:discoverable, :discoverable,

View File

@@ -30,7 +30,7 @@ class Api::V1::Statuses::PinsController < Api::V1::Statuses::BaseController
adapter: ActivityPub::Adapter adapter: ActivityPub::Adapter
).as_json ).as_json
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account.id) ActivityPub::RawDistributionWorker.perform_async(json.to_json, current_account.id)
end end
def distribute_remove_activity! def distribute_remove_activity!
@@ -40,6 +40,6 @@ class Api::V1::Statuses::PinsController < Api::V1::Statuses::BaseController
adapter: ActivityPub::Adapter adapter: ActivityPub::Adapter
).as_json ).as_json
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account.id) ActivityPub::RawDistributionWorker.perform_async(json.to_json, current_account.id)
end end
end end

View File

@@ -309,7 +309,7 @@ module JsonLdHelper
end end
def body_to_json(body, compare_id: nil) def body_to_json(body, compare_id: nil)
json = body.is_a?(String) ? Oj.load(body, mode: :strict) : body json = body.is_a?(String) ? JSON.parse(body) : body
return if compare_id.present? && json['id'] != compare_id return if compare_id.present? && json['id'] != compare_id

View File

@@ -273,3 +273,7 @@ export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
export const setComposeQuotePolicy = createAction<ApiQuotePolicy>( export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(
'compose/setQuotePolicy', 'compose/setQuotePolicy',
); );
export const setDragUploadEnabled = createAction<boolean>(
'compose/setDragUploadEnabled',
);

View File

@@ -67,5 +67,11 @@ export const apiGetFamiliarFollowers = (id: string) =>
export const apiGetProfile = () => apiRequestGet<ApiProfileJSON>('v1/profile'); export const apiGetProfile = () => apiRequestGet<ApiProfileJSON>('v1/profile');
export const apiPatchProfile = (params: ApiProfileUpdateParams) => export const apiPatchProfile = (params: ApiProfileUpdateParams | FormData) =>
apiRequestPatch<ApiProfileJSON>('v1/profile', params); apiRequestPatch<ApiProfileJSON>('v1/profile', params);
export const apiDeleteProfileAvatar = () =>
apiRequestDelete('v1/profile/avatar');
export const apiDeleteProfileHeader = () =>
apiRequestDelete('v1/profile/header');

View File

@@ -27,6 +27,8 @@ export interface ApiProfileJSON {
export type ApiProfileUpdateParams = Partial< export type ApiProfileUpdateParams = Partial<
Pick< Pick<
ApiProfileJSON, ApiProfileJSON,
| 'avatar_description'
| 'header_description'
| 'display_name' | 'display_name'
| 'note' | 'note'
| 'locked' | 'locked'

View File

@@ -26,6 +26,7 @@ export const CharacterCounter = polymorphicForwardRef<
maxLength, maxLength,
as: Component = 'span', as: Component = 'span',
recommended = false, recommended = false,
className,
...props ...props
}, },
ref, ref,
@@ -39,6 +40,7 @@ export const CharacterCounter = polymorphicForwardRef<
{...props} {...props}
ref={ref} ref={ref}
className={classNames( className={classNames(
className,
classes.counter, classes.counter,
currentLength > maxLength && !recommended && classes.counterError, currentLength > maxLength && !recommended && classes.counterError,
)} )}

View File

@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Details } from './index';
const meta = {
component: Details,
title: 'Components/Details',
args: {
summary: 'Here is the summary title',
children: (
<p>
And here are the details that are hidden until you click the summary.
</p>
),
},
render(props) {
return (
<div style={{ width: '400px' }}>
<Details {...props} />
</div>
);
},
} satisfies Meta<typeof Details>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Plain: Story = {};

View File

@@ -0,0 +1,35 @@
import { forwardRef } from 'react';
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import classNames from 'classnames';
import ExpandArrowIcon from '@/material-icons/400-24px/expand_more.svg?react';
import { Icon } from '../icon';
import classes from './styles.module.scss';
export const Details = forwardRef<
HTMLDetailsElement,
{
summary: ReactNode;
children: ReactNode;
className?: string;
} & ComponentPropsWithoutRef<'details'>
>(({ summary, children, className, ...rest }, ref) => {
return (
<details
ref={ref}
className={classNames(classes.details, className)}
{...rest}
>
<summary>
{summary}
<Icon icon={ExpandArrowIcon} id='arrow' />
</summary>
{children}
</details>
);
});
Details.displayName = 'Details';

View File

@@ -0,0 +1,25 @@
.details {
color: var(--color-text-secondary);
font-size: 13px;
margin-top: 8px;
summary {
cursor: pointer;
font-weight: 600;
list-style: none;
margin-bottom: 8px;
text-decoration: underline;
text-decoration-style: dotted;
}
:global(.icon) {
width: 1.4em;
height: 1.4em;
vertical-align: middle;
transition: transform 0.2s ease-in-out;
}
&[open] :global(.icon) {
transform: rotate(-180deg);
}
}

View File

@@ -12,11 +12,9 @@ import { openModal } from '@/flavours/glitch/actions/modal';
import { Dropdown } from '@/flavours/glitch/components/dropdown_menu'; import { Dropdown } from '@/flavours/glitch/components/dropdown_menu';
import { IconButton } from '@/flavours/glitch/components/icon_button'; import { IconButton } from '@/flavours/glitch/components/icon_button';
import type { MenuItem } from '@/flavours/glitch/models/dropdown_menu'; import type { MenuItem } from '@/flavours/glitch/models/dropdown_menu';
import { import type { ImageLocation } from '@/flavours/glitch/reducers/slices/profile_edit';
createAppSelector, import { selectImageInfo } from '@/flavours/glitch/reducers/slices/profile_edit';
useAppDispatch, import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
useAppSelector,
} from '@/flavours/glitch/store';
import AddIcon from '@/material-icons/400-24px/add.svg?react'; import AddIcon from '@/material-icons/400-24px/add.svg?react';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react'; import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import EditIcon from '@/material-icons/400-24px/edit.svg?react';
@@ -50,36 +48,15 @@ const messages = defineMessages({
}, },
}); });
export type ImageLocation = 'avatar' | 'header';
const selectImageInfo = createAppSelector(
[
(state) => state.profileEdit.profile,
(_, location: ImageLocation) => location,
],
(profile, location) => {
if (!profile) {
return {
hasImage: false,
hasAlt: false,
};
}
return {
hasImage: !!profile[`${location}Static`],
hasAlt: !!profile[`${location}Description`],
};
},
);
export const AccountImageEdit: FC<{ export const AccountImageEdit: FC<{
className?: string; className?: string;
location: ImageLocation; location: ImageLocation;
}> = ({ className, location }) => { }> = ({ className, location }) => {
const intl = useIntl(); const intl = useIntl();
const { hasAlt, hasImage } = useAppSelector((state) => const { alt, src } = useAppSelector((state) =>
selectImageInfo(state, location), selectImageInfo(state, location),
); );
const hasAlt = !!alt;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleModal = useCallback( const handleModal = useCallback(
@@ -125,7 +102,7 @@ export const AccountImageEdit: FC<{
const iconClassName = classNames(classes.imageButton, className); const iconClassName = classNames(classes.imageButton, className);
if (!hasImage) { if (!src) {
return ( return (
<IconButton <IconButton
title={intl.formatMessage(messages.add)} title={intl.formatMessage(messages.add)}

View File

@@ -7,6 +7,7 @@ import { useHistory } from 'react-router-dom';
import type { ModalType } from '@/flavours/glitch/actions/modal'; import type { ModalType } from '@/flavours/glitch/actions/modal';
import { openModal } from '@/flavours/glitch/actions/modal'; import { openModal } from '@/flavours/glitch/actions/modal';
import { AccountBio } from '@/flavours/glitch/components/account_bio';
import { Avatar } from '@/flavours/glitch/components/avatar'; import { Avatar } from '@/flavours/glitch/components/avatar';
import { Button } from '@/flavours/glitch/components/button'; import { Button } from '@/flavours/glitch/components/button';
import { DismissibleCallout } from '@/flavours/glitch/components/callout/dismissible'; import { DismissibleCallout } from '@/flavours/glitch/components/callout/dismissible';
@@ -201,7 +202,11 @@ export const AccountEdit: FC = () => {
/> />
} }
> >
<EmojiHTML htmlString={profile.bio} {...htmlHandlers} /> <AccountBio
showDropdown
accountId={profile.id}
className={classes.bio}
/>
</AccountEditSection> </AccountEditSection>
<AccountEditSection <AccountEditSection

View File

@@ -1,11 +1,147 @@
import type { FC } from 'react'; import type { ChangeEventHandler, FC } from 'react';
import { useCallback, useState } from 'react';
import { DialogModal } from '../../ui/components/dialog_modal'; import { FormattedMessage } from 'react-intl';
import { CharacterCounter } from '@/flavours/glitch/components/character_counter';
import { Details } from '@/flavours/glitch/components/details';
import { TextAreaField } from '@/flavours/glitch/components/form_fields';
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
import { patchProfile } from '@/flavours/glitch/reducers/slices/profile_edit';
import type { ImageLocation } from '@/flavours/glitch/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import { ConfirmationModal } from '../../ui/components/confirmation_modals';
import type { DialogModalProps } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal';
import type { ImageLocation } from '../components/image_edit';
import classes from './styles.module.scss';
export const ImageAltModal: FC< export const ImageAltModal: FC<
DialogModalProps & { location: ImageLocation } DialogModalProps & { location: ImageLocation }
> = ({ onClose }) => { > = ({ onClose, location }) => {
return <DialogModal title='TODO' onClose={onClose} />; const { profile, isPending } = useAppSelector((state) => state.profileEdit);
const initialAlt = profile?.[`${location}Description`];
const imageSrc = profile?.[`${location}Static`];
const [altText, setAltText] = useState(initialAlt ?? '');
const dispatch = useAppDispatch();
const handleSave = useCallback(() => {
void dispatch(
patchProfile({
[`${location}_description`]: altText,
}),
).then(onClose);
}, [altText, dispatch, location, onClose]);
if (!imageSrc) {
return <LoadingIndicator />;
}
return (
<ConfirmationModal
title={
initialAlt ? (
<FormattedMessage
id='account_edit.image_alt_modal.edit_title'
defaultMessage='Edit alt text'
/>
) : (
<FormattedMessage
id='account_edit.image_alt_modal.add_title'
defaultMessage='Add alt text'
/>
)
}
onClose={onClose}
onConfirm={handleSave}
confirm={
<FormattedMessage
id='account_edit.upload_modal.done'
defaultMessage='Done'
/>
}
updating={isPending}
>
<div className={classes.wrapper}>
<ImageAltTextField
imageSrc={imageSrc}
altText={altText}
onChange={setAltText}
/>
</div>
</ConfirmationModal>
);
};
export const ImageAltTextField: FC<{
imageSrc: string;
altText: string;
onChange: (altText: string) => void;
}> = ({ imageSrc, altText, onChange }) => {
const altLimit = useAppSelector(
(state) =>
state.server.getIn(
['server', 'configuration', 'media_attachments', 'description_limit'],
150,
) as number,
);
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
onChange(event.currentTarget.value);
},
[onChange],
);
return (
<>
<img src={imageSrc} alt='' className={classes.altImage} />
<div>
<TextAreaField
label={
<FormattedMessage
id='account_edit.image_alt_modal.text_label'
defaultMessage='Alt text'
/>
}
hint={
<FormattedMessage
id='account_edit.image_alt_modal.text_hint'
defaultMessage='Alt text helps screen reader users to understand your content.'
/>
}
onChange={handleChange}
value={altText}
/>
<CharacterCounter
currentString={altText}
maxLength={altLimit}
className={classes.altCounter}
/>
</div>
<Details
summary={
<FormattedMessage
id='account_edit.image_alt_modal.details_title'
defaultMessage='Tips: Alt text for profile photos'
/>
}
className={classes.altHint}
>
<FormattedMessage
id='account_edit.image_alt_modal.details_content'
defaultMessage='DO: <ul> <li>Describe yourself as pictured</li> <li>Use third person language (e.g. “Alex” instead of “me”)</li> <li>Be succinct a few words is often enough</li> </ul> DONT: <ul> <li>Start with “Photo of” its redundant for screen readers</li> </ul> EXAMPLE: <ul> <li>“Alex wearing a green shirt and glasses”</li> </ul>'
values={{
ul: (chunks) => <ul>{chunks}</ul>,
li: (chunks) => <li>{chunks}</li>,
}}
tagName='div'
/>
</Details>
</>
);
}; };

View File

@@ -1,11 +1,48 @@
import { useCallback } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { Button } from '@/flavours/glitch/components/button';
import { deleteImage } from '@/flavours/glitch/reducers/slices/profile_edit';
import type { ImageLocation } from '@/flavours/glitch/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import { DialogModal } from '../../ui/components/dialog_modal'; import { DialogModal } from '../../ui/components/dialog_modal';
import type { DialogModalProps } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal';
import type { ImageLocation } from '../components/image_edit';
export const ImageDeleteModal: FC< export const ImageDeleteModal: FC<
DialogModalProps & { location: ImageLocation } DialogModalProps & { location: ImageLocation }
> = ({ onClose }) => { > = ({ onClose, location }) => {
return <DialogModal title='TODO' onClose={onClose} />; const isPending = useAppSelector((state) => state.profileEdit.isPending);
const dispatch = useAppDispatch();
const handleDelete = useCallback(() => {
void dispatch(deleteImage({ location })).then(onClose);
}, [dispatch, location, onClose]);
return (
<DialogModal
onClose={onClose}
title={
<FormattedMessage
id='account_edit.image_delete_modal.title'
defaultMessage='Delete image?'
/>
}
buttons={
<Button dangerous onClick={handleDelete} disabled={isPending}>
<FormattedMessage
id='account_edit.image_delete_modal.delete_button'
defaultMessage='Delete'
/>
</Button>
}
>
<FormattedMessage
id='account_edit.image_delete_modal.confirm'
defaultMessage='Are you sure you want to delete this image? This action cant be undone.'
tagName='p'
/>
</DialogModal>
);
}; };

View File

@@ -1,11 +1,438 @@
import type { FC } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import type { Area } from 'react-easy-crop';
import Cropper from 'react-easy-crop';
import { setDragUploadEnabled } from '@/flavours/glitch/actions/compose_typed';
import { Button } from '@/flavours/glitch/components/button';
import { RangeInput } from '@/flavours/glitch/components/form_fields/range_input_field';
import {
selectImageInfo,
uploadImage,
} from '@/flavours/glitch/reducers/slices/profile_edit';
import type { ImageLocation } from '@/flavours/glitch/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import { DialogModal } from '../../ui/components/dialog_modal'; import { DialogModal } from '../../ui/components/dialog_modal';
import type { DialogModalProps } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal';
import type { ImageLocation } from '../components/image_edit';
import { ImageAltTextField } from './image_alt';
import classes from './styles.module.scss';
import 'react-easy-crop/react-easy-crop.css';
export const ImageUploadModal: FC< export const ImageUploadModal: FC<
DialogModalProps & { location: ImageLocation } DialogModalProps & { location: ImageLocation }
> = ({ onClose }) => { > = ({ onClose, location }) => {
return <DialogModal title='TODO' onClose={onClose} />; const { src: oldSrc } = useAppSelector((state) =>
selectImageInfo(state, location),
);
const hasImage = !!oldSrc;
const [step, setStep] = useState<'select' | 'crop' | 'alt'>('select');
// State for individual steps.
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [imageBlob, setImageBlob] = useState<Blob | null>(null);
const handleFile = useCallback((file: File) => {
const reader = new FileReader();
reader.addEventListener('load', () => {
const result = reader.result;
if (typeof result === 'string' && result.length > 0) {
setImageSrc(result);
setStep('crop');
}
});
reader.readAsDataURL(file);
}, []);
const handleCrop = useCallback(
(crop: Area) => {
if (!imageSrc) {
setStep('select');
return;
}
void calculateCroppedImage(imageSrc, crop).then((blob) => {
setImageBlob(blob);
setStep('alt');
});
},
[imageSrc],
);
const dispatch = useAppDispatch();
const handleSave = useCallback(
(altText: string) => {
if (!imageBlob) {
setStep('crop');
return;
}
void dispatch(uploadImage({ location, imageBlob, altText })).then(
onClose,
);
},
[dispatch, imageBlob, location, onClose],
);
const handleCancel = useCallback(() => {
switch (step) {
case 'crop':
setImageSrc(null);
setStep('select');
break;
case 'alt':
setImageBlob(null);
setStep('crop');
break;
default:
onClose();
}
}, [onClose, step]);
return (
<DialogModal
title={
hasImage ? (
<FormattedMessage
id='account_edit.upload_modal.title_replace'
defaultMessage='Replace profile photo'
/>
) : (
<FormattedMessage
id='account_edit.upload_modal.title_add'
defaultMessage='Add profile photo'
/>
)
}
onClose={onClose}
wrapperClassName={classes.uploadWrapper}
noCancelButton
>
{step === 'select' && (
<StepUpload location={location} onFile={handleFile} />
)}
{step === 'crop' && imageSrc && (
<StepCrop
src={imageSrc}
location={location}
onCancel={handleCancel}
onComplete={handleCrop}
/>
)}
{step === 'alt' && imageBlob && (
<StepAlt
imageBlob={imageBlob}
onCancel={handleCancel}
onComplete={handleSave}
/>
)}
</DialogModal>
);
}; };
// Taken from app/models/concerns/account/header.rb and app/models/concerns/account/avatar.rb
const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
];
const StepUpload: FC<{
location: ImageLocation;
onFile: (file: File) => void;
}> = ({ location, onFile }) => {
const inputRef = useRef<HTMLInputElement>(null);
const handleUploadClick = useCallback(() => {
inputRef.current?.click();
}, []);
const handleFileChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
const file = event.currentTarget.files?.[0];
if (!file || !ALLOWED_MIME_TYPES.includes(file.type)) {
return;
}
onFile(file);
},
[onFile],
);
// Handle drag and drop
const [isDragging, setDragging] = useState(false);
const handleDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
if (!event.dataTransfer?.types.includes('Files')) {
return;
}
const items = Array.from(event.dataTransfer.items);
if (
!items.some(
(item) =>
item.kind === 'file' && ALLOWED_MIME_TYPES.includes(item.type),
)
) {
return;
}
setDragging(true);
}, []);
const handleDragDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
setDragging(false);
if (!event.dataTransfer?.files) {
return;
}
const file = Array.from(event.dataTransfer.files).find((f) =>
ALLOWED_MIME_TYPES.includes(f.type),
);
if (!file) {
return;
}
onFile(file);
},
[onFile],
);
const handleDragLeave = useCallback((event: DragEvent) => {
event.preventDefault();
setDragging(false);
}, []);
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setDragUploadEnabled(false));
document.addEventListener('dragover', handleDragOver);
document.addEventListener('drop', handleDragDrop);
document.addEventListener('dragleave', handleDragLeave);
return () => {
document.removeEventListener('dragover', handleDragOver);
document.removeEventListener('drop', handleDragDrop);
document.removeEventListener('dragleave', handleDragLeave);
dispatch(setDragUploadEnabled(true));
};
}, [handleDragLeave, handleDragDrop, handleDragOver, dispatch]);
if (isDragging) {
return (
<div className={classes.uploadStepSelect}>
<FormattedMessage
id='account_edit.upload_modal.step_upload.dragging'
defaultMessage='Drop to upload'
tagName='h2'
/>
</div>
);
}
return (
<div className={classes.uploadStepSelect}>
<FormattedMessage
id='account_edit.upload_modal.step_upload.header'
defaultMessage='Choose an image'
tagName='h2'
/>
<FormattedMessage
id='account_edit.upload_modal.step_upload.hint'
defaultMessage='WEBP, PNG, GIF or JPG format, up to {limit}MB.{br}Image will be scaled to {width}x{height}px.'
description='Guideline for avatar and header images.'
values={{
br: <br />,
limit: 8,
width: location === 'avatar' ? 400 : 1500,
height: location === 'avatar' ? 400 : 500,
}}
tagName='p'
/>
<Button
onClick={handleUploadClick}
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is the main input, so auto-focus on it.
autoFocus
>
<FormattedMessage
id='account_edit.upload_modal.step_upload.button'
defaultMessage='Browse files'
/>
</Button>
<input
hidden
type='file'
ref={inputRef}
accept={ALLOWED_MIME_TYPES.join(',')}
onChange={handleFileChange}
/>
</div>
);
};
const zoomLabel = defineMessage({
id: 'account_edit.upload_modal.step_crop.zoom',
defaultMessage: 'Zoom',
});
const StepCrop: FC<{
src: string;
location: ImageLocation;
onCancel: () => void;
onComplete: (crop: Area) => void;
}> = ({ src, location, onCancel, onComplete }) => {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [croppedArea, setCroppedArea] = useState<Area | null>(null);
const [zoom, setZoom] = useState(1);
const intl = useIntl();
const handleZoomChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
setZoom(event.currentTarget.valueAsNumber);
},
[],
);
const handleCropComplete = useCallback((_: Area, croppedAreaPixels: Area) => {
setCroppedArea(croppedAreaPixels);
}, []);
const handleNext = useCallback(() => {
if (croppedArea) {
onComplete(croppedArea);
}
}, [croppedArea, onComplete]);
return (
<>
<div className={classes.cropContainer}>
<Cropper
image={src}
crop={crop}
zoom={zoom}
onCropChange={setCrop}
onCropComplete={handleCropComplete}
aspect={location === 'avatar' ? 1 : 3 / 1}
disableAutomaticStylesInjection
/>
</div>
<div className={classes.cropActions}>
<RangeInput
min={1}
max={3}
step={0.1}
value={zoom}
onChange={handleZoomChange}
className={classes.zoomControl}
aria-label={intl.formatMessage(zoomLabel)}
/>
<Button onClick={onCancel} secondary>
<FormattedMessage
id='account_edit.upload_modal.back'
defaultMessage='Back'
/>
</Button>
<Button onClick={handleNext} disabled={!croppedArea}>
<FormattedMessage
id='account_edit.upload_modal.next'
defaultMessage='Next'
/>
</Button>
</div>
</>
);
};
const StepAlt: FC<{
imageBlob: Blob;
onCancel: () => void;
onComplete: (altText: string) => void;
}> = ({ imageBlob, onCancel, onComplete }) => {
const [altText, setAltText] = useState('');
const handleComplete = useCallback(() => {
onComplete(altText);
}, [altText, onComplete]);
const imageSrc = useMemo(() => URL.createObjectURL(imageBlob), [imageBlob]);
return (
<>
<ImageAltTextField
imageSrc={imageSrc}
altText={altText}
onChange={setAltText}
/>
<div className={classes.cropActions}>
<Button onClick={onCancel} secondary>
<FormattedMessage
id='account_edit.upload_modal.back'
defaultMessage='Back'
/>
</Button>
<Button onClick={handleComplete}>
<FormattedMessage
id='account_edit.upload_modal.done'
defaultMessage='Done'
/>
</Button>
</div>
</>
);
};
async function calculateCroppedImage(
imageSrc: string,
crop: Area,
): Promise<Blob> {
const image = await dataUriToImage(imageSrc);
const canvas = new OffscreenCanvas(crop.width, crop.height);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
ctx.imageSmoothingQuality = 'high';
// Draw the image
ctx.drawImage(
image,
crop.x,
crop.y,
crop.width,
crop.height,
0,
0,
crop.width,
crop.height,
);
return canvas.convertToBlob({
quality: 0.7,
type: 'image/jpeg',
});
}
function dataUriToImage(dataUri: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => {
resolve(image);
});
image.addEventListener('error', (event) => {
if (event.error instanceof Error) {
reject(event.error);
} else {
reject(new Error('Failed to load image'));
}
});
image.src = dataUri;
});
}

View File

@@ -1,7 +1,7 @@
export * from './bio_modal'; export * from './bio_modal';
export * from './fields_modals'; export * from './fields_modals';
export * from './fields_reorder_modal'; export * from './fields_reorder_modal';
export * from './image_alt'; export { ImageAltModal } from './image_alt';
export * from './image_delete'; export * from './image_delete';
export * from './image_upload'; export * from './image_upload';
export * from './name_modal'; export * from './name_modal';

View File

@@ -80,6 +80,66 @@
} }
} }
.uploadWrapper {
min-height: min(400px, 70vh);
justify-content: center;
}
.uploadStepSelect {
text-align: center;
h2 {
color: var(--color-text-primary);
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
}
button {
margin-top: 16px;
}
}
.cropContainer {
position: relative;
width: 100%;
height: 300px;
overflow: hidden;
}
.cropActions {
margin-top: 8px; // 16px gap from DialogModal, plus 8px = 24px to look like normal action buttons.
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
.zoomControl {
width: min(100%, 200px);
margin-right: auto;
}
}
.altImage {
max-height: 150px;
object-fit: contain;
align-self: flex-start;
border: 1px solid var(--color-border-primary);
border-radius: var(--avatar-border-radius);
}
.altCounter {
color: var(--color-text-secondary);
}
.altHint {
ul {
padding-left: 1em;
list-style: disc;
margin-bottom: 1em;
}
}
.verifiedSteps { .verifiedSteps {
font-size: 15px; font-size: 15px;
@@ -108,29 +168,3 @@
} }
} }
} }
.details {
color: var(--color-text-secondary);
font-size: 13px;
margin-top: 8px;
summary {
cursor: pointer;
font-weight: 600;
list-style: none;
margin-bottom: 8px;
text-decoration: underline;
text-decoration-style: dotted;
}
:global(.icon) {
width: 1.4em;
height: 1.4em;
vertical-align: middle;
transition: transform 0.2s ease-in-out;
}
&[open] :global(.icon) {
transform: rotate(-180deg);
}
}

View File

@@ -2,10 +2,9 @@ import type { FC } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Details } from '@/flavours/glitch/components/details';
import { CopyLinkField } from '@/flavours/glitch/components/form_fields/copy_link_field'; import { CopyLinkField } from '@/flavours/glitch/components/form_fields/copy_link_field';
import { Icon } from '@/flavours/glitch/components/icon';
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store'; import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
import ExpandArrowIcon from '@/material-icons/400-24px/expand_more.svg?react';
import type { DialogModalProps } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal';
import { DialogModal } from '../../ui/components/dialog_modal'; import { DialogModal } from '../../ui/components/dialog_modal';
@@ -53,20 +52,20 @@ export const VerifiedModal: FC<DialogModalProps> = ({ onClose }) => {
} }
value={`<a rel="me" href="${accountUrl}">Mastodon</a>`} value={`<a rel="me" href="${accountUrl}">Mastodon</a>`}
/> />
<details className={classes.details}> <Details
<summary> summary={
<FormattedMessage <FormattedMessage
id='account_edit.verified_modal.invisible_link.summary' id='account_edit.verified_modal.invisible_link.summary'
defaultMessage='How do I make the link invisible?' defaultMessage='How do I make the link invisible?'
/> />
<Icon icon={ExpandArrowIcon} id='arrow' /> }
</summary> >
<FormattedMessage <FormattedMessage
id='account_edit.verified_modal.invisible_link.details' id='account_edit.verified_modal.invisible_link.details'
defaultMessage='Add the link to your header. The important part is rel="me" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.' defaultMessage='Add the link to your header. The important part is rel="me" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.'
values={{ tag: <code>&lt;a&gt;</code> }} values={{ tag: <code>&lt;a&gt;</code> }}
/> />
</details> </Details>
</li> </li>
<li> <li>
<FormattedMessage <FormattedMessage

View File

@@ -40,6 +40,19 @@
} }
} }
.bio {
unicode-bidi: plaintext;
p:not(:last-child) {
margin-bottom: 20px;
}
a {
color: inherit;
text-decoration: underline;
}
}
.field { .field {
padding: 12px 0; padding: 12px 0;
display: flex; display: flex;

View File

@@ -211,6 +211,7 @@ export const AccountHeader: React.FC<{
))} ))}
<AccountBio <AccountBio
showDropdown
accountId={accountId} accountId={accountId}
className={classNames( className={classNames(
'account__header__content', 'account__header__content',

View File

@@ -105,7 +105,11 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
layout: state.getIn(['meta', 'layout']), layout: state.getIn(['meta', 'layout']),
hasComposingContents: state.getIn(['compose', 'text']).trim().length !== 0 || state.getIn(['compose', 'media_attachments']).size > 0 || state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'quoted_status_id']) !== null, hasComposingContents: state.getIn(['compose', 'text']).trim().length !== 0 || state.getIn(['compose', 'media_attachments']).size > 0 || state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'quoted_status_id']) !== null,
canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4, canUploadMore:
!state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type')))
&& state.getIn(['compose', 'media_attachments']).size < state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']),
isUploadEnabled:
state.getIn(['compose', 'isDragDisabled']) !== true,
isWide: state.getIn(['local_settings', 'stretch']), isWide: state.getIn(['local_settings', 'stretch']),
fullWidthColumns: state.getIn(['local_settings', 'fullwidth_columns']), fullWidthColumns: state.getIn(['local_settings', 'fullwidth_columns']),
unreadNotifications: selectUnreadNotificationGroupsCount(state), unreadNotifications: selectUnreadNotificationGroupsCount(state),
@@ -339,6 +343,9 @@ class UI extends PureComponent {
}; };
handleDragEnter = (e) => { handleDragEnter = (e) => {
if (!this.props.isUploadEnabled) {
return;
}
e.preventDefault(); e.preventDefault();
if (!this.dragTargets) { if (!this.dragTargets) {
@@ -355,6 +362,9 @@ class UI extends PureComponent {
}; };
handleDragOver = (e) => { handleDragOver = (e) => {
if (!this.props.isUploadEnabled) {
return;
}
if (this.dataTransferIsText(e.dataTransfer)) return false; if (this.dataTransferIsText(e.dataTransfer)) return false;
e.preventDefault(); e.preventDefault();
@@ -370,6 +380,9 @@ class UI extends PureComponent {
}; };
handleDrop = (e) => { handleDrop = (e) => {
if (!this.props.isUploadEnabled) {
return;
}
if (this.dataTransferIsText(e.dataTransfer)) return; if (this.dataTransferIsText(e.dataTransfer)) return;
e.preventDefault(); e.preventDefault();
@@ -442,7 +455,6 @@ class UI extends PureComponent {
document.addEventListener('dragover', this.handleDragOver, false); document.addEventListener('dragover', this.handleDragOver, false);
document.addEventListener('drop', this.handleDrop, false); document.addEventListener('drop', this.handleDrop, false);
document.addEventListener('dragleave', this.handleDragLeave, false); document.addEventListener('dragleave', this.handleDragLeave, false);
document.addEventListener('dragend', this.handleDragEnd, false);
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
@@ -502,7 +514,6 @@ class UI extends PureComponent {
document.removeEventListener('dragover', this.handleDragOver); document.removeEventListener('dragover', this.handleDragOver);
document.removeEventListener('drop', this.handleDrop); document.removeEventListener('drop', this.handleDrop);
document.removeEventListener('dragleave', this.handleDragLeave); document.removeEventListener('dragleave', this.handleDragLeave);
document.removeEventListener('dragend', this.handleDragEnd);
} }
setRef = c => { setRef = c => {

View File

@@ -8,6 +8,7 @@ import {
setComposeQuotePolicy, setComposeQuotePolicy,
pasteLinkCompose, pasteLinkCompose,
cancelPasteLinkCompose, cancelPasteLinkCompose,
setDragUploadEnabled,
} from '@/flavours/glitch/actions/compose_typed'; } from '@/flavours/glitch/actions/compose_typed';
import { timelineDelete } from 'flavours/glitch/actions/timelines_typed'; import { timelineDelete } from 'flavours/glitch/actions/timelines_typed';
@@ -86,6 +87,7 @@ const initialState = ImmutableMap({
is_submitting: false, is_submitting: false,
is_changing_upload: false, is_changing_upload: false,
is_uploading: false, is_uploading: false,
isDragDisabled: false,
should_redirect_to_compose_page: false, should_redirect_to_compose_page: false,
progress: 0, progress: 0,
isUploadingThumbnail: false, isUploadingThumbnail: false,
@@ -184,6 +186,7 @@ function clearAll(state) {
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
map.set('quoted_status_id', null); map.set('quoted_status_id', null);
map.set('quote_policy', state.get('default_quote_policy')); map.set('quote_policy', state.get('default_quote_policy'));
map.set('isDragDisabled', false);
}); });
} }
@@ -438,6 +441,8 @@ export const composeReducer = (state = initialState, action) => {
return action.meta.requestId === state.get('fetching_link') ? state.set('fetching_link', null) : state; return action.meta.requestId === state.get('fetching_link') ? state.set('fetching_link', null) : state;
} else if (cancelPasteLinkCompose.match(action)) { } else if (cancelPasteLinkCompose.match(action)) {
return state.set('fetching_link', null); return state.set('fetching_link', null);
} else if (setDragUploadEnabled.match(action)) {
return state.set('isDragDisabled', !action.payload);
} }
switch(action.type) { switch(action.type) {

View File

@@ -3,8 +3,11 @@ import { createSlice } from '@reduxjs/toolkit';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { fetchAccount } from '@/flavours/glitch/actions/accounts';
import { import {
apiDeleteFeaturedTag, apiDeleteFeaturedTag,
apiDeleteProfileAvatar,
apiDeleteProfileHeader,
apiGetCurrentFeaturedTags, apiGetCurrentFeaturedTags,
apiGetProfile, apiGetProfile,
apiGetTagSuggestions, apiGetTagSuggestions,
@@ -109,6 +112,27 @@ const profileEditSlice = createSlice({
state.isPending = false; state.isPending = false;
}); });
builder.addCase(uploadImage.pending, (state) => {
state.isPending = true;
});
builder.addCase(uploadImage.rejected, (state) => {
state.isPending = false;
});
builder.addCase(uploadImage.fulfilled, (state, action) => {
state.profile = action.payload;
state.isPending = false;
});
builder.addCase(deleteImage.pending, (state) => {
state.isPending = true;
});
builder.addCase(deleteImage.rejected, (state) => {
state.isPending = false;
});
builder.addCase(deleteImage.fulfilled, (state) => {
state.isPending = false;
});
builder.addCase(addFeaturedTag.pending, (state) => { builder.addCase(addFeaturedTag.pending, (state) => {
state.isPending = true; state.isPending = true;
}); });
@@ -220,7 +244,10 @@ export const fetchProfile = createDataLoadingThunk(
export const patchProfile = createDataLoadingThunk( export const patchProfile = createDataLoadingThunk(
`${profileEditSlice.name}/patchProfile`, `${profileEditSlice.name}/patchProfile`,
(params: Partial<ApiProfileUpdateParams>) => apiPatchProfile(params), (params: Partial<ApiProfileUpdateParams>) => apiPatchProfile(params),
transformProfile, (response, { dispatch }) => {
dispatch(fetchAccount(response.id));
return transformProfile(response);
},
{ {
useLoadingBar: false, useLoadingBar: false,
condition(_, { getState }) { condition(_, { getState }) {
@@ -229,6 +256,67 @@ export const patchProfile = createDataLoadingThunk(
}, },
); );
export type ImageLocation = 'avatar' | 'header';
export const selectImageInfo = createAppSelector(
[
(state) => state.profileEdit.profile,
(_, location: ImageLocation) => location,
],
(profile, location) => {
if (!profile) {
return {};
}
return {
src: profile[location],
static: profile[`${location}Static`],
alt: profile[`${location}Description`],
};
},
);
export const uploadImage = createDataLoadingThunk(
`${profileEditSlice.name}/uploadImage`,
(arg: { location: ImageLocation; imageBlob: Blob; altText: string }) => {
const formData = new FormData();
formData.append(arg.location, arg.imageBlob);
if (arg.altText) {
formData.append(`${arg.location}_description`, arg.altText);
}
return apiPatchProfile(formData);
},
(response, { dispatch }) => {
dispatch(fetchAccount(response.id));
return transformProfile(response);
},
{
useLoadingBar: false,
},
);
export const deleteImage = createDataLoadingThunk(
`${profileEditSlice.name}/deleteImage`,
(arg: { location: ImageLocation }) => {
if (arg.location === 'avatar') {
return apiDeleteProfileAvatar();
} else {
return apiDeleteProfileHeader();
}
},
async (_, { dispatch, getState }) => {
await dispatch(fetchProfile());
const accountId = getState().profileEdit.profile?.id;
if (accountId) {
dispatch(fetchAccount(accountId));
}
},
{
useLoadingBar: false,
},
);
export const selectFieldById = createAppSelector( export const selectFieldById = createAppSelector(
[(state) => state.profileEdit.profile?.fields, (_, id?: string) => id], [(state) => state.profileEdit.profile?.fields, (_, id?: string) => id],
(fields, fieldId) => { (fields, fieldId) => {

View File

@@ -273,3 +273,7 @@ export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
export const setComposeQuotePolicy = createAction<ApiQuotePolicy>( export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(
'compose/setQuotePolicy', 'compose/setQuotePolicy',
); );
export const setDragUploadEnabled = createAction<boolean>(
'compose/setDragUploadEnabled',
);

View File

@@ -67,5 +67,11 @@ export const apiGetFamiliarFollowers = (id: string) =>
export const apiGetProfile = () => apiRequestGet<ApiProfileJSON>('v1/profile'); export const apiGetProfile = () => apiRequestGet<ApiProfileJSON>('v1/profile');
export const apiPatchProfile = (params: ApiProfileUpdateParams) => export const apiPatchProfile = (params: ApiProfileUpdateParams | FormData) =>
apiRequestPatch<ApiProfileJSON>('v1/profile', params); apiRequestPatch<ApiProfileJSON>('v1/profile', params);
export const apiDeleteProfileAvatar = () =>
apiRequestDelete('v1/profile/avatar');
export const apiDeleteProfileHeader = () =>
apiRequestDelete('v1/profile/header');

View File

@@ -27,6 +27,8 @@ export interface ApiProfileJSON {
export type ApiProfileUpdateParams = Partial< export type ApiProfileUpdateParams = Partial<
Pick< Pick<
ApiProfileJSON, ApiProfileJSON,
| 'avatar_description'
| 'header_description'
| 'display_name' | 'display_name'
| 'note' | 'note'
| 'locked' | 'locked'

View File

@@ -26,6 +26,7 @@ export const CharacterCounter = polymorphicForwardRef<
maxLength, maxLength,
as: Component = 'span', as: Component = 'span',
recommended = false, recommended = false,
className,
...props ...props
}, },
ref, ref,
@@ -39,6 +40,7 @@ export const CharacterCounter = polymorphicForwardRef<
{...props} {...props}
ref={ref} ref={ref}
className={classNames( className={classNames(
className,
classes.counter, classes.counter,
currentLength > maxLength && !recommended && classes.counterError, currentLength > maxLength && !recommended && classes.counterError,
)} )}

View File

@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Details } from './index';
const meta = {
component: Details,
title: 'Components/Details',
args: {
summary: 'Here is the summary title',
children: (
<p>
And here are the details that are hidden until you click the summary.
</p>
),
},
render(props) {
return (
<div style={{ width: '400px' }}>
<Details {...props} />
</div>
);
},
} satisfies Meta<typeof Details>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Plain: Story = {};

View File

@@ -0,0 +1,35 @@
import { forwardRef } from 'react';
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import classNames from 'classnames';
import ExpandArrowIcon from '@/material-icons/400-24px/expand_more.svg?react';
import { Icon } from '../icon';
import classes from './styles.module.scss';
export const Details = forwardRef<
HTMLDetailsElement,
{
summary: ReactNode;
children: ReactNode;
className?: string;
} & ComponentPropsWithoutRef<'details'>
>(({ summary, children, className, ...rest }, ref) => {
return (
<details
ref={ref}
className={classNames(classes.details, className)}
{...rest}
>
<summary>
{summary}
<Icon icon={ExpandArrowIcon} id='arrow' />
</summary>
{children}
</details>
);
});
Details.displayName = 'Details';

View File

@@ -0,0 +1,25 @@
.details {
color: var(--color-text-secondary);
font-size: 13px;
margin-top: 8px;
summary {
cursor: pointer;
font-weight: 600;
list-style: none;
margin-bottom: 8px;
text-decoration: underline;
text-decoration-style: dotted;
}
:global(.icon) {
width: 1.4em;
height: 1.4em;
vertical-align: middle;
transition: transform 0.2s ease-in-out;
}
&[open] :global(.icon) {
transform: rotate(-180deg);
}
}

View File

@@ -12,11 +12,9 @@ import { openModal } from '@/mastodon/actions/modal';
import { Dropdown } from '@/mastodon/components/dropdown_menu'; import { Dropdown } from '@/mastodon/components/dropdown_menu';
import { IconButton } from '@/mastodon/components/icon_button'; import { IconButton } from '@/mastodon/components/icon_button';
import type { MenuItem } from '@/mastodon/models/dropdown_menu'; import type { MenuItem } from '@/mastodon/models/dropdown_menu';
import { import type { ImageLocation } from '@/mastodon/reducers/slices/profile_edit';
createAppSelector, import { selectImageInfo } from '@/mastodon/reducers/slices/profile_edit';
useAppDispatch, import { useAppDispatch, useAppSelector } from '@/mastodon/store';
useAppSelector,
} from '@/mastodon/store';
import AddIcon from '@/material-icons/400-24px/add.svg?react'; import AddIcon from '@/material-icons/400-24px/add.svg?react';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react'; import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import EditIcon from '@/material-icons/400-24px/edit.svg?react';
@@ -50,36 +48,15 @@ const messages = defineMessages({
}, },
}); });
export type ImageLocation = 'avatar' | 'header';
const selectImageInfo = createAppSelector(
[
(state) => state.profileEdit.profile,
(_, location: ImageLocation) => location,
],
(profile, location) => {
if (!profile) {
return {
hasImage: false,
hasAlt: false,
};
}
return {
hasImage: !!profile[`${location}Static`],
hasAlt: !!profile[`${location}Description`],
};
},
);
export const AccountImageEdit: FC<{ export const AccountImageEdit: FC<{
className?: string; className?: string;
location: ImageLocation; location: ImageLocation;
}> = ({ className, location }) => { }> = ({ className, location }) => {
const intl = useIntl(); const intl = useIntl();
const { hasAlt, hasImage } = useAppSelector((state) => const { alt, src } = useAppSelector((state) =>
selectImageInfo(state, location), selectImageInfo(state, location),
); );
const hasAlt = !!alt;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleModal = useCallback( const handleModal = useCallback(
@@ -125,7 +102,7 @@ export const AccountImageEdit: FC<{
const iconClassName = classNames(classes.imageButton, className); const iconClassName = classNames(classes.imageButton, className);
if (!hasImage) { if (!src) {
return ( return (
<IconButton <IconButton
title={intl.formatMessage(messages.add)} title={intl.formatMessage(messages.add)}

View File

@@ -7,6 +7,7 @@ import { useHistory } from 'react-router-dom';
import type { ModalType } from '@/mastodon/actions/modal'; import type { ModalType } from '@/mastodon/actions/modal';
import { openModal } from '@/mastodon/actions/modal'; import { openModal } from '@/mastodon/actions/modal';
import { AccountBio } from '@/mastodon/components/account_bio';
import { Avatar } from '@/mastodon/components/avatar'; import { Avatar } from '@/mastodon/components/avatar';
import { Button } from '@/mastodon/components/button'; import { Button } from '@/mastodon/components/button';
import { DismissibleCallout } from '@/mastodon/components/callout/dismissible'; import { DismissibleCallout } from '@/mastodon/components/callout/dismissible';
@@ -201,7 +202,11 @@ export const AccountEdit: FC = () => {
/> />
} }
> >
<EmojiHTML htmlString={profile.bio} {...htmlHandlers} /> <AccountBio
showDropdown
accountId={profile.id}
className={classes.bio}
/>
</AccountEditSection> </AccountEditSection>
<AccountEditSection <AccountEditSection

View File

@@ -1,11 +1,147 @@
import type { FC } from 'react'; import type { ChangeEventHandler, FC } from 'react';
import { useCallback, useState } from 'react';
import { DialogModal } from '../../ui/components/dialog_modal'; import { FormattedMessage } from 'react-intl';
import { CharacterCounter } from '@/mastodon/components/character_counter';
import { Details } from '@/mastodon/components/details';
import { TextAreaField } from '@/mastodon/components/form_fields';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { patchProfile } from '@/mastodon/reducers/slices/profile_edit';
import type { ImageLocation } from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { ConfirmationModal } from '../../ui/components/confirmation_modals';
import type { DialogModalProps } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal';
import type { ImageLocation } from '../components/image_edit';
import classes from './styles.module.scss';
export const ImageAltModal: FC< export const ImageAltModal: FC<
DialogModalProps & { location: ImageLocation } DialogModalProps & { location: ImageLocation }
> = ({ onClose }) => { > = ({ onClose, location }) => {
return <DialogModal title='TODO' onClose={onClose} />; const { profile, isPending } = useAppSelector((state) => state.profileEdit);
const initialAlt = profile?.[`${location}Description`];
const imageSrc = profile?.[`${location}Static`];
const [altText, setAltText] = useState(initialAlt ?? '');
const dispatch = useAppDispatch();
const handleSave = useCallback(() => {
void dispatch(
patchProfile({
[`${location}_description`]: altText,
}),
).then(onClose);
}, [altText, dispatch, location, onClose]);
if (!imageSrc) {
return <LoadingIndicator />;
}
return (
<ConfirmationModal
title={
initialAlt ? (
<FormattedMessage
id='account_edit.image_alt_modal.edit_title'
defaultMessage='Edit alt text'
/>
) : (
<FormattedMessage
id='account_edit.image_alt_modal.add_title'
defaultMessage='Add alt text'
/>
)
}
onClose={onClose}
onConfirm={handleSave}
confirm={
<FormattedMessage
id='account_edit.upload_modal.done'
defaultMessage='Done'
/>
}
updating={isPending}
>
<div className={classes.wrapper}>
<ImageAltTextField
imageSrc={imageSrc}
altText={altText}
onChange={setAltText}
/>
</div>
</ConfirmationModal>
);
};
export const ImageAltTextField: FC<{
imageSrc: string;
altText: string;
onChange: (altText: string) => void;
}> = ({ imageSrc, altText, onChange }) => {
const altLimit = useAppSelector(
(state) =>
state.server.getIn(
['server', 'configuration', 'media_attachments', 'description_limit'],
150,
) as number,
);
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
onChange(event.currentTarget.value);
},
[onChange],
);
return (
<>
<img src={imageSrc} alt='' className={classes.altImage} />
<div>
<TextAreaField
label={
<FormattedMessage
id='account_edit.image_alt_modal.text_label'
defaultMessage='Alt text'
/>
}
hint={
<FormattedMessage
id='account_edit.image_alt_modal.text_hint'
defaultMessage='Alt text helps screen reader users to understand your content.'
/>
}
onChange={handleChange}
value={altText}
/>
<CharacterCounter
currentString={altText}
maxLength={altLimit}
className={classes.altCounter}
/>
</div>
<Details
summary={
<FormattedMessage
id='account_edit.image_alt_modal.details_title'
defaultMessage='Tips: Alt text for profile photos'
/>
}
className={classes.altHint}
>
<FormattedMessage
id='account_edit.image_alt_modal.details_content'
defaultMessage='DO: <ul> <li>Describe yourself as pictured</li> <li>Use third person language (e.g. “Alex” instead of “me”)</li> <li>Be succinct a few words is often enough</li> </ul> DONT: <ul> <li>Start with “Photo of” its redundant for screen readers</li> </ul> EXAMPLE: <ul> <li>“Alex wearing a green shirt and glasses”</li> </ul>'
values={{
ul: (chunks) => <ul>{chunks}</ul>,
li: (chunks) => <li>{chunks}</li>,
}}
tagName='div'
/>
</Details>
</>
);
}; };

View File

@@ -1,11 +1,48 @@
import { useCallback } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { Button } from '@/mastodon/components/button';
import { deleteImage } from '@/mastodon/reducers/slices/profile_edit';
import type { ImageLocation } from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { DialogModal } from '../../ui/components/dialog_modal'; import { DialogModal } from '../../ui/components/dialog_modal';
import type { DialogModalProps } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal';
import type { ImageLocation } from '../components/image_edit';
export const ImageDeleteModal: FC< export const ImageDeleteModal: FC<
DialogModalProps & { location: ImageLocation } DialogModalProps & { location: ImageLocation }
> = ({ onClose }) => { > = ({ onClose, location }) => {
return <DialogModal title='TODO' onClose={onClose} />; const isPending = useAppSelector((state) => state.profileEdit.isPending);
const dispatch = useAppDispatch();
const handleDelete = useCallback(() => {
void dispatch(deleteImage({ location })).then(onClose);
}, [dispatch, location, onClose]);
return (
<DialogModal
onClose={onClose}
title={
<FormattedMessage
id='account_edit.image_delete_modal.title'
defaultMessage='Delete image?'
/>
}
buttons={
<Button dangerous onClick={handleDelete} disabled={isPending}>
<FormattedMessage
id='account_edit.image_delete_modal.delete_button'
defaultMessage='Delete'
/>
</Button>
}
>
<FormattedMessage
id='account_edit.image_delete_modal.confirm'
defaultMessage='Are you sure you want to delete this image? This action cant be undone.'
tagName='p'
/>
</DialogModal>
);
}; };

View File

@@ -1,11 +1,438 @@
import type { FC } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import type { Area } from 'react-easy-crop';
import Cropper from 'react-easy-crop';
import { setDragUploadEnabled } from '@/mastodon/actions/compose_typed';
import { Button } from '@/mastodon/components/button';
import { RangeInput } from '@/mastodon/components/form_fields/range_input_field';
import {
selectImageInfo,
uploadImage,
} from '@/mastodon/reducers/slices/profile_edit';
import type { ImageLocation } from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { DialogModal } from '../../ui/components/dialog_modal'; import { DialogModal } from '../../ui/components/dialog_modal';
import type { DialogModalProps } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal';
import type { ImageLocation } from '../components/image_edit';
import { ImageAltTextField } from './image_alt';
import classes from './styles.module.scss';
import 'react-easy-crop/react-easy-crop.css';
export const ImageUploadModal: FC< export const ImageUploadModal: FC<
DialogModalProps & { location: ImageLocation } DialogModalProps & { location: ImageLocation }
> = ({ onClose }) => { > = ({ onClose, location }) => {
return <DialogModal title='TODO' onClose={onClose} />; const { src: oldSrc } = useAppSelector((state) =>
selectImageInfo(state, location),
);
const hasImage = !!oldSrc;
const [step, setStep] = useState<'select' | 'crop' | 'alt'>('select');
// State for individual steps.
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [imageBlob, setImageBlob] = useState<Blob | null>(null);
const handleFile = useCallback((file: File) => {
const reader = new FileReader();
reader.addEventListener('load', () => {
const result = reader.result;
if (typeof result === 'string' && result.length > 0) {
setImageSrc(result);
setStep('crop');
}
});
reader.readAsDataURL(file);
}, []);
const handleCrop = useCallback(
(crop: Area) => {
if (!imageSrc) {
setStep('select');
return;
}
void calculateCroppedImage(imageSrc, crop).then((blob) => {
setImageBlob(blob);
setStep('alt');
});
},
[imageSrc],
);
const dispatch = useAppDispatch();
const handleSave = useCallback(
(altText: string) => {
if (!imageBlob) {
setStep('crop');
return;
}
void dispatch(uploadImage({ location, imageBlob, altText })).then(
onClose,
);
},
[dispatch, imageBlob, location, onClose],
);
const handleCancel = useCallback(() => {
switch (step) {
case 'crop':
setImageSrc(null);
setStep('select');
break;
case 'alt':
setImageBlob(null);
setStep('crop');
break;
default:
onClose();
}
}, [onClose, step]);
return (
<DialogModal
title={
hasImage ? (
<FormattedMessage
id='account_edit.upload_modal.title_replace'
defaultMessage='Replace profile photo'
/>
) : (
<FormattedMessage
id='account_edit.upload_modal.title_add'
defaultMessage='Add profile photo'
/>
)
}
onClose={onClose}
wrapperClassName={classes.uploadWrapper}
noCancelButton
>
{step === 'select' && (
<StepUpload location={location} onFile={handleFile} />
)}
{step === 'crop' && imageSrc && (
<StepCrop
src={imageSrc}
location={location}
onCancel={handleCancel}
onComplete={handleCrop}
/>
)}
{step === 'alt' && imageBlob && (
<StepAlt
imageBlob={imageBlob}
onCancel={handleCancel}
onComplete={handleSave}
/>
)}
</DialogModal>
);
}; };
// Taken from app/models/concerns/account/header.rb and app/models/concerns/account/avatar.rb
const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
];
const StepUpload: FC<{
location: ImageLocation;
onFile: (file: File) => void;
}> = ({ location, onFile }) => {
const inputRef = useRef<HTMLInputElement>(null);
const handleUploadClick = useCallback(() => {
inputRef.current?.click();
}, []);
const handleFileChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
const file = event.currentTarget.files?.[0];
if (!file || !ALLOWED_MIME_TYPES.includes(file.type)) {
return;
}
onFile(file);
},
[onFile],
);
// Handle drag and drop
const [isDragging, setDragging] = useState(false);
const handleDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
if (!event.dataTransfer?.types.includes('Files')) {
return;
}
const items = Array.from(event.dataTransfer.items);
if (
!items.some(
(item) =>
item.kind === 'file' && ALLOWED_MIME_TYPES.includes(item.type),
)
) {
return;
}
setDragging(true);
}, []);
const handleDragDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
setDragging(false);
if (!event.dataTransfer?.files) {
return;
}
const file = Array.from(event.dataTransfer.files).find((f) =>
ALLOWED_MIME_TYPES.includes(f.type),
);
if (!file) {
return;
}
onFile(file);
},
[onFile],
);
const handleDragLeave = useCallback((event: DragEvent) => {
event.preventDefault();
setDragging(false);
}, []);
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setDragUploadEnabled(false));
document.addEventListener('dragover', handleDragOver);
document.addEventListener('drop', handleDragDrop);
document.addEventListener('dragleave', handleDragLeave);
return () => {
document.removeEventListener('dragover', handleDragOver);
document.removeEventListener('drop', handleDragDrop);
document.removeEventListener('dragleave', handleDragLeave);
dispatch(setDragUploadEnabled(true));
};
}, [handleDragLeave, handleDragDrop, handleDragOver, dispatch]);
if (isDragging) {
return (
<div className={classes.uploadStepSelect}>
<FormattedMessage
id='account_edit.upload_modal.step_upload.dragging'
defaultMessage='Drop to upload'
tagName='h2'
/>
</div>
);
}
return (
<div className={classes.uploadStepSelect}>
<FormattedMessage
id='account_edit.upload_modal.step_upload.header'
defaultMessage='Choose an image'
tagName='h2'
/>
<FormattedMessage
id='account_edit.upload_modal.step_upload.hint'
defaultMessage='WEBP, PNG, GIF or JPG format, up to {limit}MB.{br}Image will be scaled to {width}x{height}px.'
description='Guideline for avatar and header images.'
values={{
br: <br />,
limit: 8,
width: location === 'avatar' ? 400 : 1500,
height: location === 'avatar' ? 400 : 500,
}}
tagName='p'
/>
<Button
onClick={handleUploadClick}
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is the main input, so auto-focus on it.
autoFocus
>
<FormattedMessage
id='account_edit.upload_modal.step_upload.button'
defaultMessage='Browse files'
/>
</Button>
<input
hidden
type='file'
ref={inputRef}
accept={ALLOWED_MIME_TYPES.join(',')}
onChange={handleFileChange}
/>
</div>
);
};
const zoomLabel = defineMessage({
id: 'account_edit.upload_modal.step_crop.zoom',
defaultMessage: 'Zoom',
});
const StepCrop: FC<{
src: string;
location: ImageLocation;
onCancel: () => void;
onComplete: (crop: Area) => void;
}> = ({ src, location, onCancel, onComplete }) => {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [croppedArea, setCroppedArea] = useState<Area | null>(null);
const [zoom, setZoom] = useState(1);
const intl = useIntl();
const handleZoomChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
setZoom(event.currentTarget.valueAsNumber);
},
[],
);
const handleCropComplete = useCallback((_: Area, croppedAreaPixels: Area) => {
setCroppedArea(croppedAreaPixels);
}, []);
const handleNext = useCallback(() => {
if (croppedArea) {
onComplete(croppedArea);
}
}, [croppedArea, onComplete]);
return (
<>
<div className={classes.cropContainer}>
<Cropper
image={src}
crop={crop}
zoom={zoom}
onCropChange={setCrop}
onCropComplete={handleCropComplete}
aspect={location === 'avatar' ? 1 : 3 / 1}
disableAutomaticStylesInjection
/>
</div>
<div className={classes.cropActions}>
<RangeInput
min={1}
max={3}
step={0.1}
value={zoom}
onChange={handleZoomChange}
className={classes.zoomControl}
aria-label={intl.formatMessage(zoomLabel)}
/>
<Button onClick={onCancel} secondary>
<FormattedMessage
id='account_edit.upload_modal.back'
defaultMessage='Back'
/>
</Button>
<Button onClick={handleNext} disabled={!croppedArea}>
<FormattedMessage
id='account_edit.upload_modal.next'
defaultMessage='Next'
/>
</Button>
</div>
</>
);
};
const StepAlt: FC<{
imageBlob: Blob;
onCancel: () => void;
onComplete: (altText: string) => void;
}> = ({ imageBlob, onCancel, onComplete }) => {
const [altText, setAltText] = useState('');
const handleComplete = useCallback(() => {
onComplete(altText);
}, [altText, onComplete]);
const imageSrc = useMemo(() => URL.createObjectURL(imageBlob), [imageBlob]);
return (
<>
<ImageAltTextField
imageSrc={imageSrc}
altText={altText}
onChange={setAltText}
/>
<div className={classes.cropActions}>
<Button onClick={onCancel} secondary>
<FormattedMessage
id='account_edit.upload_modal.back'
defaultMessage='Back'
/>
</Button>
<Button onClick={handleComplete}>
<FormattedMessage
id='account_edit.upload_modal.done'
defaultMessage='Done'
/>
</Button>
</div>
</>
);
};
async function calculateCroppedImage(
imageSrc: string,
crop: Area,
): Promise<Blob> {
const image = await dataUriToImage(imageSrc);
const canvas = new OffscreenCanvas(crop.width, crop.height);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
ctx.imageSmoothingQuality = 'high';
// Draw the image
ctx.drawImage(
image,
crop.x,
crop.y,
crop.width,
crop.height,
0,
0,
crop.width,
crop.height,
);
return canvas.convertToBlob({
quality: 0.7,
type: 'image/jpeg',
});
}
function dataUriToImage(dataUri: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => {
resolve(image);
});
image.addEventListener('error', (event) => {
if (event.error instanceof Error) {
reject(event.error);
} else {
reject(new Error('Failed to load image'));
}
});
image.src = dataUri;
});
}

View File

@@ -1,7 +1,7 @@
export * from './bio_modal'; export * from './bio_modal';
export * from './fields_modals'; export * from './fields_modals';
export * from './fields_reorder_modal'; export * from './fields_reorder_modal';
export * from './image_alt'; export { ImageAltModal } from './image_alt';
export * from './image_delete'; export * from './image_delete';
export * from './image_upload'; export * from './image_upload';
export * from './name_modal'; export * from './name_modal';

View File

@@ -80,6 +80,66 @@
} }
} }
.uploadWrapper {
min-height: min(400px, 70vh);
justify-content: center;
}
.uploadStepSelect {
text-align: center;
h2 {
color: var(--color-text-primary);
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
}
button {
margin-top: 16px;
}
}
.cropContainer {
position: relative;
width: 100%;
height: 300px;
overflow: hidden;
}
.cropActions {
margin-top: 8px; // 16px gap from DialogModal, plus 8px = 24px to look like normal action buttons.
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
.zoomControl {
width: min(100%, 200px);
margin-right: auto;
}
}
.altImage {
max-height: 150px;
object-fit: contain;
align-self: flex-start;
border: 1px solid var(--color-border-primary);
border-radius: var(--avatar-border-radius);
}
.altCounter {
color: var(--color-text-secondary);
}
.altHint {
ul {
padding-left: 1em;
list-style: disc;
margin-bottom: 1em;
}
}
.verifiedSteps { .verifiedSteps {
font-size: 15px; font-size: 15px;
@@ -108,29 +168,3 @@
} }
} }
} }
.details {
color: var(--color-text-secondary);
font-size: 13px;
margin-top: 8px;
summary {
cursor: pointer;
font-weight: 600;
list-style: none;
margin-bottom: 8px;
text-decoration: underline;
text-decoration-style: dotted;
}
:global(.icon) {
width: 1.4em;
height: 1.4em;
vertical-align: middle;
transition: transform 0.2s ease-in-out;
}
&[open] :global(.icon) {
transform: rotate(-180deg);
}
}

View File

@@ -2,10 +2,9 @@ import type { FC } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Details } from '@/mastodon/components/details';
import { CopyLinkField } from '@/mastodon/components/form_fields/copy_link_field'; import { CopyLinkField } from '@/mastodon/components/form_fields/copy_link_field';
import { Icon } from '@/mastodon/components/icon';
import { createAppSelector, useAppSelector } from '@/mastodon/store'; import { createAppSelector, useAppSelector } from '@/mastodon/store';
import ExpandArrowIcon from '@/material-icons/400-24px/expand_more.svg?react';
import type { DialogModalProps } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal';
import { DialogModal } from '../../ui/components/dialog_modal'; import { DialogModal } from '../../ui/components/dialog_modal';
@@ -53,20 +52,20 @@ export const VerifiedModal: FC<DialogModalProps> = ({ onClose }) => {
} }
value={`<a rel="me" href="${accountUrl}">Mastodon</a>`} value={`<a rel="me" href="${accountUrl}">Mastodon</a>`}
/> />
<details className={classes.details}> <Details
<summary> summary={
<FormattedMessage <FormattedMessage
id='account_edit.verified_modal.invisible_link.summary' id='account_edit.verified_modal.invisible_link.summary'
defaultMessage='How do I make the link invisible?' defaultMessage='How do I make the link invisible?'
/> />
<Icon icon={ExpandArrowIcon} id='arrow' /> }
</summary> >
<FormattedMessage <FormattedMessage
id='account_edit.verified_modal.invisible_link.details' id='account_edit.verified_modal.invisible_link.details'
defaultMessage='Add the link to your header. The important part is rel="me" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.' defaultMessage='Add the link to your header. The important part is rel="me" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.'
values={{ tag: <code>&lt;a&gt;</code> }} values={{ tag: <code>&lt;a&gt;</code> }}
/> />
</details> </Details>
</li> </li>
<li> <li>
<FormattedMessage <FormattedMessage

View File

@@ -40,6 +40,19 @@
} }
} }
.bio {
unicode-bidi: plaintext;
p:not(:last-child) {
margin-bottom: 20px;
}
a {
color: inherit;
text-decoration: underline;
}
}
.field { .field {
padding: 12px 0; padding: 12px 0;
display: flex; display: flex;

View File

@@ -211,6 +211,7 @@ export const AccountHeader: React.FC<{
))} ))}
<AccountBio <AccountBio
showDropdown
accountId={accountId} accountId={accountId}
className={classNames( className={classNames(
'account__header__content', 'account__header__content',

View File

@@ -103,7 +103,11 @@ const mapStateToProps = state => ({
layout: state.getIn(['meta', 'layout']), layout: state.getIn(['meta', 'layout']),
isComposing: state.getIn(['compose', 'is_composing']), isComposing: state.getIn(['compose', 'is_composing']),
hasComposingContents: state.getIn(['compose', 'text']).trim().length !== 0 || state.getIn(['compose', 'media_attachments']).size > 0 || state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'quoted_status_id']) !== null, hasComposingContents: state.getIn(['compose', 'text']).trim().length !== 0 || state.getIn(['compose', 'media_attachments']).size > 0 || state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'quoted_status_id']) !== null,
canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']), canUploadMore:
!state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type')))
&& state.getIn(['compose', 'media_attachments']).size < state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']),
isUploadEnabled:
state.getIn(['compose', 'isDragDisabled']) !== true,
firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION, firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
newAccount: !state.getIn(['accounts', me, 'note']) && !state.getIn(['accounts', me, 'bot']) && state.getIn(['accounts', me, 'following_count'], 0) === 0 && state.getIn(['accounts', me, 'statuses_count'], 0) === 0, newAccount: !state.getIn(['accounts', me, 'note']) && !state.getIn(['accounts', me, 'bot']) && state.getIn(['accounts', me, 'following_count'], 0) === 0 && state.getIn(['accounts', me, 'statuses_count'], 0) === 0,
username: state.getIn(['accounts', me, 'username']), username: state.getIn(['accounts', me, 'username']),
@@ -324,6 +328,9 @@ class UI extends PureComponent {
}; };
handleDragEnter = (e) => { handleDragEnter = (e) => {
if (!this.props.isUploadEnabled) {
return;
}
e.preventDefault(); e.preventDefault();
if (!this.dragTargets) { if (!this.dragTargets) {
@@ -340,6 +347,9 @@ class UI extends PureComponent {
}; };
handleDragOver = (e) => { handleDragOver = (e) => {
if (!this.props.isUploadEnabled) {
return;
}
if (this.dataTransferIsText(e.dataTransfer)) return false; if (this.dataTransferIsText(e.dataTransfer)) return false;
e.preventDefault(); e.preventDefault();
@@ -355,6 +365,9 @@ class UI extends PureComponent {
}; };
handleDrop = (e) => { handleDrop = (e) => {
if (!this.props.isUploadEnabled) {
return;
}
if (this.dataTransferIsText(e.dataTransfer)) return; if (this.dataTransferIsText(e.dataTransfer)) return;
e.preventDefault(); e.preventDefault();
@@ -429,7 +442,6 @@ class UI extends PureComponent {
document.addEventListener('dragover', this.handleDragOver, false); document.addEventListener('dragover', this.handleDragOver, false);
document.addEventListener('drop', this.handleDrop, false); document.addEventListener('drop', this.handleDrop, false);
document.addEventListener('dragleave', this.handleDragLeave, false); document.addEventListener('dragleave', this.handleDragLeave, false);
document.addEventListener('dragend', this.handleDragEnd, false);
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
@@ -456,7 +468,6 @@ class UI extends PureComponent {
document.removeEventListener('dragover', this.handleDragOver); document.removeEventListener('dragover', this.handleDragOver);
document.removeEventListener('drop', this.handleDrop); document.removeEventListener('drop', this.handleDrop);
document.removeEventListener('dragleave', this.handleDragLeave); document.removeEventListener('dragleave', this.handleDragLeave);
document.removeEventListener('dragend', this.handleDragEnd);
} }
setRef = c => { setRef = c => {

View File

@@ -184,6 +184,15 @@
"account_edit.field_reorder_modal.drag_start": "Picked up field \"{item}\".", "account_edit.field_reorder_modal.drag_start": "Picked up field \"{item}\".",
"account_edit.field_reorder_modal.handle_label": "Drag field \"{item}\"", "account_edit.field_reorder_modal.handle_label": "Drag field \"{item}\"",
"account_edit.field_reorder_modal.title": "Rearrange fields", "account_edit.field_reorder_modal.title": "Rearrange fields",
"account_edit.image_alt_modal.add_title": "Add alt text",
"account_edit.image_alt_modal.details_content": "DO: <ul> <li>Describe yourself as pictured</li> <li>Use third person language (e.g. “Alex” instead of “me”)</li> <li>Be succinct a few words is often enough</li> </ul> DONT: <ul> <li>Start with “Photo of” its redundant for screen readers</li> </ul> EXAMPLE: <ul> <li>“Alex wearing a green shirt and glasses”</li> </ul>",
"account_edit.image_alt_modal.details_title": "Tips: Alt text for profile photos",
"account_edit.image_alt_modal.edit_title": "Edit alt text",
"account_edit.image_alt_modal.text_hint": "Alt text helps screen reader users to understand your content.",
"account_edit.image_alt_modal.text_label": "Alt text",
"account_edit.image_delete_modal.confirm": "Are you sure you want to delete this image? This action cant be undone.",
"account_edit.image_delete_modal.delete_button": "Delete",
"account_edit.image_delete_modal.title": "Delete image?",
"account_edit.image_edit.add_button": "Add image", "account_edit.image_edit.add_button": "Add image",
"account_edit.image_edit.alt_add_button": "Add alt text", "account_edit.image_edit.alt_add_button": "Add alt text",
"account_edit.image_edit.alt_edit_button": "Edit alt text", "account_edit.image_edit.alt_edit_button": "Edit alt text",
@@ -203,6 +212,16 @@
"account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.", "account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.",
"account_edit.profile_tab.title": "Profile tab settings", "account_edit.profile_tab.title": "Profile tab settings",
"account_edit.save": "Save", "account_edit.save": "Save",
"account_edit.upload_modal.back": "Back",
"account_edit.upload_modal.done": "Done",
"account_edit.upload_modal.next": "Next",
"account_edit.upload_modal.step_crop.zoom": "Zoom",
"account_edit.upload_modal.step_upload.button": "Browse files",
"account_edit.upload_modal.step_upload.dragging": "Drop to upload",
"account_edit.upload_modal.step_upload.header": "Choose an image",
"account_edit.upload_modal.step_upload.hint": "WEBP, PNG, GIF or JPG format, up to {limit}MB.{br}Image will be scaled to {width}x{height}px.",
"account_edit.upload_modal.title_add": "Add profile photo",
"account_edit.upload_modal.title_replace": "Replace profile photo",
"account_edit.verified_modal.details": "Add credibility to your Mastodon profile by verifying links to personal websites. Heres how it works:", "account_edit.verified_modal.details": "Add credibility to your Mastodon profile by verifying links to personal websites. Heres how it works:",
"account_edit.verified_modal.invisible_link.details": "Add the link to your header. The important part is rel=\"me\" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.", "account_edit.verified_modal.invisible_link.details": "Add the link to your header. The important part is rel=\"me\" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.",
"account_edit.verified_modal.invisible_link.summary": "How do I make the link invisible?", "account_edit.verified_modal.invisible_link.summary": "How do I make the link invisible?",

View File

@@ -8,6 +8,7 @@ import {
setComposeQuotePolicy, setComposeQuotePolicy,
pasteLinkCompose, pasteLinkCompose,
cancelPasteLinkCompose, cancelPasteLinkCompose,
setDragUploadEnabled,
} from '@/mastodon/actions/compose_typed'; } from '@/mastodon/actions/compose_typed';
import { timelineDelete } from 'mastodon/actions/timelines_typed'; import { timelineDelete } from 'mastodon/actions/timelines_typed';
@@ -75,6 +76,7 @@ const initialState = ImmutableMap({
is_submitting: false, is_submitting: false,
is_changing_upload: false, is_changing_upload: false,
is_uploading: false, is_uploading: false,
isDragDisabled: false,
should_redirect_to_compose_page: false, should_redirect_to_compose_page: false,
progress: 0, progress: 0,
isUploadingThumbnail: false, isUploadingThumbnail: false,
@@ -132,6 +134,7 @@ function clearAll(state) {
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
map.set('quoted_status_id', null); map.set('quoted_status_id', null);
map.set('quote_policy', state.get('default_quote_policy')); map.set('quote_policy', state.get('default_quote_policy'));
map.set('isDragDisabled', false);
}); });
} }
@@ -359,6 +362,8 @@ export const composeReducer = (state = initialState, action) => {
return action.meta.requestId === state.get('fetching_link') ? state.set('fetching_link', null) : state; return action.meta.requestId === state.get('fetching_link') ? state.set('fetching_link', null) : state;
} else if (cancelPasteLinkCompose.match(action)) { } else if (cancelPasteLinkCompose.match(action)) {
return state.set('fetching_link', null); return state.set('fetching_link', null);
} else if (setDragUploadEnabled.match(action)) {
return state.set('isDragDisabled', !action.payload);
} }
switch(action.type) { switch(action.type) {

View File

@@ -3,8 +3,11 @@ import { createSlice } from '@reduxjs/toolkit';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { fetchAccount } from '@/mastodon/actions/accounts';
import { import {
apiDeleteFeaturedTag, apiDeleteFeaturedTag,
apiDeleteProfileAvatar,
apiDeleteProfileHeader,
apiGetCurrentFeaturedTags, apiGetCurrentFeaturedTags,
apiGetProfile, apiGetProfile,
apiGetTagSuggestions, apiGetTagSuggestions,
@@ -109,6 +112,27 @@ const profileEditSlice = createSlice({
state.isPending = false; state.isPending = false;
}); });
builder.addCase(uploadImage.pending, (state) => {
state.isPending = true;
});
builder.addCase(uploadImage.rejected, (state) => {
state.isPending = false;
});
builder.addCase(uploadImage.fulfilled, (state, action) => {
state.profile = action.payload;
state.isPending = false;
});
builder.addCase(deleteImage.pending, (state) => {
state.isPending = true;
});
builder.addCase(deleteImage.rejected, (state) => {
state.isPending = false;
});
builder.addCase(deleteImage.fulfilled, (state) => {
state.isPending = false;
});
builder.addCase(addFeaturedTag.pending, (state) => { builder.addCase(addFeaturedTag.pending, (state) => {
state.isPending = true; state.isPending = true;
}); });
@@ -220,7 +244,10 @@ export const fetchProfile = createDataLoadingThunk(
export const patchProfile = createDataLoadingThunk( export const patchProfile = createDataLoadingThunk(
`${profileEditSlice.name}/patchProfile`, `${profileEditSlice.name}/patchProfile`,
(params: Partial<ApiProfileUpdateParams>) => apiPatchProfile(params), (params: Partial<ApiProfileUpdateParams>) => apiPatchProfile(params),
transformProfile, (response, { dispatch }) => {
dispatch(fetchAccount(response.id));
return transformProfile(response);
},
{ {
useLoadingBar: false, useLoadingBar: false,
condition(_, { getState }) { condition(_, { getState }) {
@@ -229,6 +256,67 @@ export const patchProfile = createDataLoadingThunk(
}, },
); );
export type ImageLocation = 'avatar' | 'header';
export const selectImageInfo = createAppSelector(
[
(state) => state.profileEdit.profile,
(_, location: ImageLocation) => location,
],
(profile, location) => {
if (!profile) {
return {};
}
return {
src: profile[location],
static: profile[`${location}Static`],
alt: profile[`${location}Description`],
};
},
);
export const uploadImage = createDataLoadingThunk(
`${profileEditSlice.name}/uploadImage`,
(arg: { location: ImageLocation; imageBlob: Blob; altText: string }) => {
const formData = new FormData();
formData.append(arg.location, arg.imageBlob);
if (arg.altText) {
formData.append(`${arg.location}_description`, arg.altText);
}
return apiPatchProfile(formData);
},
(response, { dispatch }) => {
dispatch(fetchAccount(response.id));
return transformProfile(response);
},
{
useLoadingBar: false,
},
);
export const deleteImage = createDataLoadingThunk(
`${profileEditSlice.name}/deleteImage`,
(arg: { location: ImageLocation }) => {
if (arg.location === 'avatar') {
return apiDeleteProfileAvatar();
} else {
return apiDeleteProfileHeader();
}
},
async (_, { dispatch, getState }) => {
await dispatch(fetchProfile());
const accountId = getState().profileEdit.profile?.id;
if (accountId) {
dispatch(fetchAccount(accountId));
}
},
{
useLoadingBar: false,
},
);
export const selectFieldById = createAppSelector( export const selectFieldById = createAppSelector(
[(state) => state.profileEdit.profile?.fields, (_, id?: string) => id], [(state) => state.profileEdit.profile?.fields, (_, id?: string) => id],
(fields, fieldId) => { (fields, fieldId) => {

View File

@@ -24,6 +24,6 @@ module AccessTokenExtension
end end
def push_to_streaming_api def push_to_streaming_api
redis.publish("timeline:access_token:#{id}", Oj.dump(event: :kill)) if revoked? || destroyed? redis.publish("timeline:access_token:#{id}", { event: :kill }.to_json) if revoked? || destroyed?
end end
end end

View File

@@ -39,7 +39,7 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity
end end
def reject_follow_request!(target_account) def reject_follow_request!(target_account)
json = Oj.dump(serialize_payload(FollowRequest.new(account: @account, target_account: target_account, uri: @json['id']), ActivityPub::RejectFollowSerializer)) json = serialize_payload(FollowRequest.new(account: @account, target_account: target_account, uri: @json['id']), ActivityPub::RejectFollowSerializer).to_json
ActivityPub::DeliveryWorker.perform_async(json, target_account.id, @account.inbox_url) ActivityPub::DeliveryWorker.perform_async(json, target_account.id, @account.inbox_url)
end end
end end

View File

@@ -31,7 +31,7 @@ class ActivityPub::Activity::QuoteRequest < ActivityPub::Activity
status.quote.update!(activity_uri: @json['id']) status.quote.update!(activity_uri: @json['id'])
status.quote.accept! status.quote.accept!
json = Oj.dump(serialize_payload(status.quote, ActivityPub::AcceptQuoteRequestSerializer)) json = serialize_payload(status.quote, ActivityPub::AcceptQuoteRequestSerializer).to_json
ActivityPub::DeliveryWorker.perform_async(json, quoted_status.account_id, @account.inbox_url) ActivityPub::DeliveryWorker.perform_async(json, quoted_status.account_id, @account.inbox_url)
# Ensure the user is notified # Ensure the user is notified
@@ -60,7 +60,7 @@ class ActivityPub::Activity::QuoteRequest < ActivityPub::Activity
account: @account, account: @account,
activity_uri: @json['id'] activity_uri: @json['id']
) )
json = Oj.dump(serialize_payload(quote, ActivityPub::RejectQuoteRequestSerializer)) json = serialize_payload(quote, ActivityPub::RejectQuoteRequestSerializer).to_json
ActivityPub::DeliveryWorker.perform_async(json, quoted_status.account_id, @account.inbox_url) ActivityPub::DeliveryWorker.perform_async(json, quoted_status.account_id, @account.inbox_url)
end end

View File

@@ -80,7 +80,7 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
def ffmpeg_version def ffmpeg_version
version_output = Terrapin::CommandLine.new(Rails.configuration.x.ffprobe_binary, '-show_program_version -v 0 -of json').run version_output = Terrapin::CommandLine.new(Rails.configuration.x.ffprobe_binary, '-show_program_version -v 0 -of json').run
version = Oj.load(version_output, mode: :strict, symbol_keys: true).dig(:program_version, :version) version = JSON.parse(version_output, symbolize_names: true).dig(:program_version, :version)
{ {
key: 'ffmpeg', key: 'ffmpeg',

View File

@@ -35,7 +35,7 @@ module ApplicationExtension
def close_streaming_sessions(resource_owner = nil) def close_streaming_sessions(resource_owner = nil)
# TODO: #28793 Combine into a single topic # TODO: #28793 Combine into a single topic
payload = Oj.dump(event: :kill) payload = { event: :kill }.to_json
scope = access_tokens scope = access_tokens
scope = scope.where(resource_owner_id: resource_owner.id) unless resource_owner.nil? scope = scope.where(resource_owner_id: resource_owner.id) unless resource_owner.nil?
scope.in_batches do |tokens| scope.in_batches do |tokens|

View File

@@ -92,7 +92,7 @@ class FeedManager
def unpush_from_home(account, status, update: false) def unpush_from_home(account, status, update: false)
return false unless remove_from_feed(:home, account.id, status, aggregate_reblogs: account.user&.aggregates_reblogs?) return false unless remove_from_feed(:home, account.id, status, aggregate_reblogs: account.user&.aggregates_reblogs?)
redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update redis.publish("timeline:#{account.id}", { event: :delete, payload: status.id.to_s }.to_json) unless update
true true
end end
@@ -119,7 +119,7 @@ class FeedManager
def unpush_from_list(list, status, update: false) def unpush_from_list(list, status, update: false)
return false unless remove_from_feed(:list, list.id, status, aggregate_reblogs: list.account.user&.aggregates_reblogs?) return false unless remove_from_feed(:list, list.id, status, aggregate_reblogs: list.account.user&.aggregates_reblogs?)
redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update redis.publish("timeline:list:#{list.id}", { event: :delete, payload: status.id.to_s }.to_json) unless update
true true
end end

View File

@@ -101,7 +101,7 @@ class LinkDetailsExtractor
end end
def json def json
@json ||= root_array(Oj.load(@data)).compact.find { |obj| SUPPORTED_TYPES.include?(obj['@type']) } || {} @json ||= root_array(JSON.parse(@data)).compact.find { |obj| SUPPORTED_TYPES.include?(obj['@type']) } || {}
end end
end end
@@ -265,7 +265,7 @@ class LinkDetailsExtractor
next unless structured_data.valid? next unless structured_data.valid?
structured_data structured_data
rescue Oj::ParseError, EncodingError rescue JSON::ParserError, EncodingError
Rails.logger.debug { "Invalid JSON-LD in #{@original_url}" } Rails.logger.debug { "Invalid JSON-LD in #{@original_url}" }
next next
end.first end.first

View File

@@ -31,7 +31,7 @@ class TranslationService::DeepL < TranslationService
def fetch_languages(type) def fetch_languages(type)
request(:get, "/v2/languages?type=#{type}") do |res| request(:get, "/v2/languages?type=#{type}") do |res|
Oj.load(res.body_with_limit).map { |language| normalize_language(language['language']) } JSON.parse(res.body_with_limit).map { |language| normalize_language(language['language']) }
end end
end end
@@ -68,7 +68,7 @@ class TranslationService::DeepL < TranslationService
end end
def transform_response(json) def transform_response(json)
data = Oj.load(json, mode: :strict) data = JSON.parse(json)
raise UnexpectedResponseError unless data.is_a?(Hash) raise UnexpectedResponseError unless data.is_a?(Hash)
data['translations'].map do |translation| data['translations'].map do |translation|

View File

@@ -9,7 +9,7 @@ class TranslationService::LibreTranslate < TranslationService
end end
def translate(texts, source_language, target_language) def translate(texts, source_language, target_language)
body = Oj.dump(q: texts, source: source_language.presence || 'auto', target: target_language, format: 'html', api_key: @api_key) body = { q: texts, source: source_language.presence || 'auto', target: target_language, format: 'html', api_key: @api_key }.to_json
request(:post, '/translate', body: body) do |res| request(:post, '/translate', body: body) do |res|
transform_response(res.body_with_limit, source_language) transform_response(res.body_with_limit, source_language)
end end
@@ -17,7 +17,7 @@ class TranslationService::LibreTranslate < TranslationService
def languages def languages
request(:get, '/languages') do |res| request(:get, '/languages') do |res|
languages = Oj.load(res.body_with_limit).to_h do |language| languages = JSON.parse(res.body_with_limit).to_h do |language|
[language['code'], language['targets'].without(language['code'])] [language['code'], language['targets'].without(language['code'])]
end end
languages[nil] = languages.values.flatten.uniq.sort languages[nil] = languages.values.flatten.uniq.sort
@@ -45,7 +45,7 @@ class TranslationService::LibreTranslate < TranslationService
end end
def transform_response(json, source_language) def transform_response(json, source_language)
data = Oj.load(json, mode: :strict) data = JSON.parse(json)
raise UnexpectedResponseError unless data.is_a?(Hash) raise UnexpectedResponseError unless data.is_a?(Hash)
data['translatedText'].map.with_index do |text, index| data['translatedText'].map.with_index do |text, index|

View File

@@ -6,7 +6,7 @@ class UserSettingsSerializer
if value.blank? if value.blank?
{} {}
else else
Oj.load(value, symbol_keys: true) JSON.parse(value, symbolize_names: true)
end end
end end
@@ -14,6 +14,6 @@ class UserSettingsSerializer
end end
def self.dump(value) def self.dump(value)
Oj.dump(value.as_json) JSON.generate(value.as_json)
end end
end end

View File

@@ -6,7 +6,7 @@ class VideoMetadataExtractor
def initialize(path) def initialize(path)
@path = path @path = path
@metadata = Oj.load(ffmpeg_command_output, mode: :strict, symbol_keys: true) @metadata = JSON.parse(ffmpeg_command_output, symbolize_names: true)
parse_metadata parse_metadata
rescue Terrapin::ExitStatusError, Oj::ParseError rescue Terrapin::ExitStatusError, Oj::ParseError

View File

@@ -12,7 +12,7 @@ class Webfinger
def initialize(uri, body) def initialize(uri, body)
@uri = uri @uri = uri
@json = Oj.load(body, mode: :strict) @json = JSON.parse(body)
validate_response! validate_response!
end end

View File

@@ -58,7 +58,7 @@ class Webhooks::PayloadRenderer
/iox /iox
def initialize(json) def initialize(json)
@document = DocumentTraverser.new(Oj.load(json)) @document = DocumentTraverser.new(JSON.parse(json))
end end
def render(template) def render(template)

View File

@@ -35,7 +35,7 @@ module Account::Suspensions
# This terminates all connections for the given account with the streaming # This terminates all connections for the given account with the streaming
# server: # server:
redis.publish("timeline:system:#{id}", Oj.dump(event: :kill)) if local? redis.publish("timeline:system:#{id}", { event: :kill }.to_json) if local?
end end
def unsuspend! def unsuspend!

View File

@@ -115,8 +115,8 @@ class CustomFilter < ApplicationRecord
@should_invalidate_cache = false @should_invalidate_cache = false
Rails.cache.delete("filters:v3:#{account_id}") Rails.cache.delete("filters:v3:#{account_id}")
redis.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed)) redis.publish("timeline:#{account_id}", { event: :filters_changed }.to_json)
redis.publish("timeline:system:#{account_id}", Oj.dump(event: :filters_changed)) redis.publish("timeline:system:#{account_id}", { event: :filters_changed }.to_json)
end end
private private

View File

@@ -31,7 +31,7 @@ class Relay < ApplicationRecord
def enable! def enable!
activity_id = ActivityPub::TagManager.instance.generate_uri_for(nil) activity_id = ActivityPub::TagManager.instance.generate_uri_for(nil)
payload = Oj.dump(follow_activity(activity_id)) payload = follow_activity(activity_id).to_json
update!(state: :pending, follow_activity_id: activity_id) update!(state: :pending, follow_activity_id: activity_id)
reset_delivery_tracker reset_delivery_tracker
@@ -40,7 +40,7 @@ class Relay < ApplicationRecord
def disable! def disable!
activity_id = ActivityPub::TagManager.instance.generate_uri_for(nil) activity_id = ActivityPub::TagManager.instance.generate_uri_for(nil)
payload = Oj.dump(unfollow_activity(activity_id)) payload = unfollow_activity(activity_id).to_json
update!(state: :idle, follow_activity_id: nil) update!(state: :idle, follow_activity_id: nil)
reset_delivery_tracker reset_delivery_tracker

View File

@@ -173,7 +173,7 @@ class User < ApplicationRecord
# This terminates all connections for the given account with the streaming # This terminates all connections for the given account with the streaming
# server: # server:
redis.publish("timeline:system:#{account.id}", Oj.dump(event: :kill)) redis.publish("timeline:system:#{account.id}", { event: :kill }.to_json)
end end
def enable! def enable!
@@ -347,7 +347,7 @@ class User < ApplicationRecord
# Revoke each access token for the Streaming API, since `update_all`` # Revoke each access token for the Streaming API, since `update_all``
# doesn't trigger ActiveRecord Callbacks: # doesn't trigger ActiveRecord Callbacks:
# TODO: #28793 Combine into a single topic # TODO: #28793 Combine into a single topic
payload = Oj.dump(event: :kill) payload = { event: :kill }.to_json
redis.pipelined do |pipeline| redis.pipelined do |pipeline|
batch.ids.each do |id| batch.ids.each do |id|
pipeline.publish("timeline:access_token:#{id}", payload) pipeline.publish("timeline:access_token:#{id}", payload)

View File

@@ -6,7 +6,7 @@ class ActivityPub::ProcessCollectionService < BaseService
def call(body, actor, **options) def call(body, actor, **options)
@account = actor @account = actor
@json = original_json = Oj.load(body, mode: :strict) @json = original_json = JSON.parse(body)
@options = options @options = options
return unless @json.is_a?(Hash) return unless @json.is_a?(Hash)

View File

@@ -62,7 +62,7 @@ class ActivityPub::SynchronizeFollowersService < BaseService
end end
def build_undo_follow_json(follow) def build_undo_follow_json(follow)
Oj.dump(serialize_payload(follow, ActivityPub::UndoFollowSerializer)) serialize_payload(follow, ActivityPub::UndoFollowSerializer).to_json
end end
# Only returns true if the whole collection has been processed # Only returns true if the whole collection has been processed

View File

@@ -54,7 +54,7 @@ class AfterBlockDomainFromAccountService < BaseService
return unless follow.account.activitypub? return unless follow.account.activitypub?
ActivityPub::DeliveryWorker.perform_async(Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)), @account.id, follow.account.inbox_url) ActivityPub::DeliveryWorker.perform_async(serialize_payload(follow, ActivityPub::RejectFollowSerializer).to_json, @account.id, follow.account.inbox_url)
end end
def notify_of_severed_relationships! def notify_of_severed_relationships!

View File

@@ -22,6 +22,6 @@ class AuthorizeFollowService < BaseService
end end
def build_json(follow_request) def build_json(follow_request)
Oj.dump(serialize_payload(follow_request, ActivityPub::AcceptFollowSerializer)) serialize_payload(follow_request, ActivityPub::AcceptFollowSerializer).to_json
end end
end end

View File

@@ -23,7 +23,7 @@ class BackupService < BaseService
skeleton = serialize(collection_presenter, ActivityPub::CollectionSerializer) skeleton = serialize(collection_presenter, ActivityPub::CollectionSerializer)
skeleton[:@context] = full_context skeleton[:@context] = full_context
skeleton[:orderedItems] = ['!PLACEHOLDER!'] skeleton[:orderedItems] = ['!PLACEHOLDER!']
skeleton = Oj.dump(skeleton) skeleton = JSON.generate(skeleton)
prepend, append = skeleton.split('"!PLACEHOLDER!"') prepend, append = skeleton.split('"!PLACEHOLDER!"')
add_comma = false add_comma = false
@@ -44,7 +44,7 @@ class BackupService < BaseService
end end
end end
Oj.dump(item) JSON.generate(item)
end.join(',')) end.join(','))
GC.start GC.start
@@ -107,7 +107,7 @@ class BackupService < BaseService
download_to_zip(zipfile, account.avatar, "avatar#{File.extname(account.avatar.path)}") if account.avatar.exists? download_to_zip(zipfile, account.avatar, "avatar#{File.extname(account.avatar.path)}") if account.avatar.exists?
download_to_zip(zipfile, account.header, "header#{File.extname(account.header.path)}") if account.header.exists? download_to_zip(zipfile, account.header, "header#{File.extname(account.header.path)}") if account.header.exists?
json = Oj.dump(actor) json = JSON.generate(actor)
zipfile.get_output_stream('actor.json') do |io| zipfile.get_output_stream('actor.json') do |io|
io.write(json) io.write(json)
@@ -118,7 +118,7 @@ class BackupService < BaseService
skeleton = serialize(ActivityPub::CollectionPresenter.new(id: 'likes.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer) skeleton = serialize(ActivityPub::CollectionPresenter.new(id: 'likes.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer)
skeleton.delete(:totalItems) skeleton.delete(:totalItems)
skeleton[:orderedItems] = ['!PLACEHOLDER!'] skeleton[:orderedItems] = ['!PLACEHOLDER!']
skeleton = Oj.dump(skeleton) skeleton = JSON.generate(skeleton)
prepend, append = skeleton.split('"!PLACEHOLDER!"') prepend, append = skeleton.split('"!PLACEHOLDER!"')
zipfile.get_output_stream('likes.json') do |io| zipfile.get_output_stream('likes.json') do |io|
@@ -131,7 +131,7 @@ class BackupService < BaseService
add_comma = true add_comma = true
io.write(statuses.map do |status| io.write(statuses.map do |status|
Oj.dump(ActivityPub::TagManager.instance.uri_for(status)) JSON.generate(ActivityPub::TagManager.instance.uri_for(status))
end.join(',')) end.join(','))
GC.start GC.start
@@ -145,7 +145,7 @@ class BackupService < BaseService
skeleton = serialize(ActivityPub::CollectionPresenter.new(id: 'bookmarks.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer) skeleton = serialize(ActivityPub::CollectionPresenter.new(id: 'bookmarks.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer)
skeleton.delete(:totalItems) skeleton.delete(:totalItems)
skeleton[:orderedItems] = ['!PLACEHOLDER!'] skeleton[:orderedItems] = ['!PLACEHOLDER!']
skeleton = Oj.dump(skeleton) skeleton = JSON.generate(skeleton)
prepend, append = skeleton.split('"!PLACEHOLDER!"') prepend, append = skeleton.split('"!PLACEHOLDER!"')
zipfile.get_output_stream('bookmarks.json') do |io| zipfile.get_output_stream('bookmarks.json') do |io|
@@ -157,7 +157,7 @@ class BackupService < BaseService
add_comma = true add_comma = true
io.write(statuses.map do |status| io.write(statuses.map do |status|
Oj.dump(ActivityPub::TagManager.instance.uri_for(status)) JSON.generate(ActivityPub::TagManager.instance.uri_for(status))
end.join(',')) end.join(','))
GC.start GC.start

View File

@@ -85,7 +85,7 @@ class BatchedRemoveStatusService < BaseService
def unpush_from_public_timelines(status, pipeline) def unpush_from_public_timelines(status, pipeline)
return unless status.public_visibility? && status.id > @status_id_cutoff return unless status.public_visibility? && status.id > @status_id_cutoff
payload = Oj.dump(event: :delete, payload: status.id.to_s) payload = { event: :delete, payload: status.id.to_s }.to_json
pipeline.publish('timeline:public', payload) pipeline.publish('timeline:public', payload)
pipeline.publish(status.local? ? 'timeline:public:local' : 'timeline:public:remote', payload) pipeline.publish(status.local? ? 'timeline:public:local' : 'timeline:public:remote', payload)

View File

@@ -26,6 +26,6 @@ class BlockService < BaseService
end end
def build_json(block) def build_json(block)
Oj.dump(serialize_payload(block, ActivityPub::BlockSerializer)) serialize_payload(block, ActivityPub::BlockSerializer).to_json
end end
end end

View File

@@ -26,6 +26,6 @@ class CreateFeaturedTagService < BaseService
private private
def build_json(featured_tag) def build_json(featured_tag)
Oj.dump(serialize_payload(featured_tag, ActivityPub::AddHashtagSerializer, signer: @account)) serialize_payload(featured_tag, ActivityPub::AddHashtagSerializer, signer: @account).to_json
end end
end end

View File

@@ -114,7 +114,7 @@ class DeleteAccountService < BaseService
# we have to force it to unfollow them. # we have to force it to unfollow them.
ActivityPub::DeliveryWorker.push_bulk(Follow.where(account: @account)) do |follow| ActivityPub::DeliveryWorker.push_bulk(Follow.where(account: @account)) do |follow|
[Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)), follow.target_account_id, @account.inbox_url] [serialize_payload(follow, ActivityPub::RejectFollowSerializer).to_json, follow.target_account_id, @account.inbox_url]
end end
end end
@@ -126,7 +126,7 @@ class DeleteAccountService < BaseService
# if the remote account gets un-suspended. # if the remote account gets un-suspended.
ActivityPub::DeliveryWorker.push_bulk(Follow.where(target_account: @account)) do |follow| ActivityPub::DeliveryWorker.push_bulk(Follow.where(target_account: @account)) do |follow|
[Oj.dump(serialize_payload(follow, ActivityPub::UndoFollowSerializer)), follow.account_id, @account.inbox_url] [serialize_payload(follow, ActivityPub::UndoFollowSerializer).to_json, follow.account_id, @account.inbox_url]
end end
end end
@@ -285,7 +285,7 @@ class DeleteAccountService < BaseService
end end
def delete_actor_json def delete_actor_json
@delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account, always_sign: true)) @delete_actor_json ||= serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account, always_sign: true).to_json
end end
def delivery_inboxes def delivery_inboxes

View File

@@ -179,10 +179,10 @@ class FanOutOnWriteService < BaseService
end end
def anonymous_payload def anonymous_payload
@anonymous_payload ||= Oj.dump( @anonymous_payload ||= JSON.generate({
event: update? ? :'status.update' : :update, event: update? ? :'status.update' : :update,
payload: rendered_status payload: rendered_status,
) }.as_json)
end end
def rendered_status def rendered_status

View File

@@ -42,6 +42,6 @@ class FavouriteService < BaseService
end end
def build_json(favourite) def build_json(favourite)
Oj.dump(serialize_payload(favourite, ActivityPub::LikeSerializer)) serialize_payload(favourite, ActivityPub::LikeSerializer).to_json
end end
end end

View File

@@ -90,7 +90,7 @@ class FollowService < BaseService
end end
def build_json(follow_request) def build_json(follow_request)
Oj.dump(serialize_payload(follow_request, ActivityPub::FollowSerializer)) serialize_payload(follow_request, ActivityPub::FollowSerializer).to_json
end end
def follow_options def follow_options

View File

@@ -259,7 +259,7 @@ class NotifyService < BaseService
end end
def push_to_streaming_api! def push_to_streaming_api!
redis.publish("timeline:#{@recipient.id}:notifications", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification))) redis.publish("timeline:#{@recipient.id}:notifications", { event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification) }.to_json)
end end
def subscribed_to_streaming_api? def subscribed_to_streaming_api?

View File

@@ -17,6 +17,6 @@ class RejectFollowService < BaseService
end end
def build_json(follow_request) def build_json(follow_request)
Oj.dump(serialize_payload(follow_request, ActivityPub::RejectFollowSerializer)) serialize_payload(follow_request, ActivityPub::RejectFollowSerializer).to_json
end end
end end

View File

@@ -18,6 +18,6 @@ class RemoveDomainsFromFollowersService < BaseService
end end
def build_json(follow) def build_json(follow)
Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)) serialize_payload(follow, ActivityPub::RejectFollowSerializer).to_json
end end
end end

View File

@@ -26,6 +26,6 @@ class RemoveFeaturedTagService < BaseService
private private
def build_json(featured_tag) def build_json(featured_tag)
Oj.dump(serialize_payload(featured_tag, ActivityPub::RemoveHashtagSerializer, signer: @account)) serialize_payload(featured_tag, ActivityPub::RemoveHashtagSerializer, signer: @account).to_json
end end
end end

View File

@@ -18,6 +18,6 @@ class RemoveFromFollowersService < BaseService
end end
def build_json(follow) def build_json(follow)
Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)) serialize_payload(follow, ActivityPub::RejectFollowSerializer).to_json
end end
end end

View File

@@ -14,7 +14,7 @@ class RemoveStatusService < BaseService
# @option [Boolean] :original_removed # @option [Boolean] :original_removed
# @option [Boolean] :skip_streaming # @option [Boolean] :skip_streaming
def call(status, **options) def call(status, **options)
@payload = Oj.dump(event: :delete, payload: status.id.to_s) @payload = { event: :delete, payload: status.id.to_s }.to_json
@status = status @status = status
@account = status.account @account = status.account
@options = options @options = options
@@ -105,7 +105,7 @@ class RemoveStatusService < BaseService
end end
def signed_activity_json def signed_activity_json
@signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteNoteSerializer, signer: @account, always_sign: true)) @signed_activity_json ||= serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteNoteSerializer, signer: @account, always_sign: true).to_json
end end
def remove_reblogs def remove_reblogs

View File

@@ -98,7 +98,7 @@ class ReportService < BaseService
end end
def payload def payload
Oj.dump(serialize_payload(@report, ActivityPub::FlagSerializer, account: some_local_account)) serialize_payload(@report, ActivityPub::FlagSerializer, account: some_local_account).to_json
end end
def some_local_account def some_local_account

View File

@@ -19,6 +19,6 @@ class RevokeCollectionItemService < BaseService
end end
def signed_activity_json def signed_activity_json
@signed_activity_json ||= Oj.dump(serialize_payload(@collection_item, ActivityPub::DeleteFeatureAuthorizationSerializer, signer: @account, always_sign: true)) @signed_activity_json ||= serialize_payload(@collection_item, ActivityPub::DeleteFeatureAuthorizationSerializer, signer: @account, always_sign: true).to_json
end end
end end

View File

@@ -39,6 +39,6 @@ class RevokeQuoteService < BaseService
end end
def signed_activity_json def signed_activity_json
@signed_activity_json ||= Oj.dump(serialize_payload(@quote, ActivityPub::DeleteQuoteAuthorizationSerializer, signer: @account, always_sign: true, force_approval_id: true)) @signed_activity_json ||= serialize_payload(@quote, ActivityPub::DeleteQuoteAuthorizationSerializer, signer: @account, always_sign: true, force_approval_id: true).to_json
end end
end end

View File

@@ -20,7 +20,7 @@ class SoftwareUpdateCheckService < BaseService
def fetch_update_notices def fetch_update_notices
Request.new(:get, "#{api_url}?version=#{version}").add_headers('Accept' => 'application/json', 'User-Agent' => 'Mastodon update checker').perform do |res| Request.new(:get, "#{api_url}?version=#{version}").add_headers('Accept' => 'application/json', 'User-Agent' => 'Mastodon update checker').perform do |res|
return Oj.load(res.body_with_limit, mode: :strict) if res.code == 200 return JSON.parse(res.body_with_limit) if res.code == 200
end end
rescue *Mastodon::HTTP_CONNECTION_ERRORS, Oj::ParseError rescue *Mastodon::HTTP_CONNECTION_ERRORS, Oj::ParseError
nil nil

View File

@@ -34,7 +34,7 @@ class SuspendAccountService < BaseService
Follow.where(account: @account).find_in_batches do |follows| Follow.where(account: @account).find_in_batches do |follows|
ActivityPub::DeliveryWorker.push_bulk(follows) do |follow| ActivityPub::DeliveryWorker.push_bulk(follows) do |follow|
[Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)), follow.target_account_id, @account.inbox_url] [serialize_payload(follow, ActivityPub::RejectFollowSerializer).to_json, follow.target_account_id, @account.inbox_url]
end end
follows.each(&:destroy) follows.each(&:destroy)
@@ -72,6 +72,6 @@ class SuspendAccountService < BaseService
end end
def signed_activity_json def signed_activity_json
@signed_activity_json ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateActorSerializer, signer: @account)) @signed_activity_json ||= serialize_payload(@account, ActivityPub::UpdateActorSerializer, signer: @account).to_json
end end
end end

View File

@@ -18,6 +18,6 @@ class UnblockService < BaseService
end end
def build_json(unblock) def build_json(unblock)
Oj.dump(serialize_payload(unblock, ActivityPub::UndoBlockSerializer)) serialize_payload(unblock, ActivityPub::UndoBlockSerializer).to_json
end end
end end

View File

@@ -18,6 +18,6 @@ class UnfavouriteService < BaseService
end end
def build_json(favourite) def build_json(favourite)
Oj.dump(serialize_payload(favourite, ActivityPub::UndoLikeSerializer)) serialize_payload(favourite, ActivityPub::UndoLikeSerializer).to_json
end end
end end

View File

@@ -68,10 +68,10 @@ class UnfollowService < BaseService
end end
def build_json(follow) def build_json(follow)
Oj.dump(serialize_payload(follow, ActivityPub::UndoFollowSerializer)) serialize_payload(follow, ActivityPub::UndoFollowSerializer).to_json
end end
def build_reject_json(follow) def build_reject_json(follow)
Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)) serialize_payload(follow, ActivityPub::RejectFollowSerializer).to_json
end end
end end

View File

@@ -63,6 +63,6 @@ class UnsuspendAccountService < BaseService
end end
def signed_activity_json def signed_activity_json
@signed_activity_json ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateActorSerializer, signer: @account)) @signed_activity_json ||= serialize_payload(@account, ActivityPub::UpdateActorSerializer, signer: @account).to_json
end end
end end

View File

@@ -65,7 +65,7 @@ class VoteService < BaseService
end end
def build_json(vote) def build_json(vote)
Oj.dump(serialize_payload(vote, ActivityPub::VoteSerializer)) serialize_payload(vote, ActivityPub::VoteSerializer).to_json
end end
def increment_voters_count! def increment_voters_count!

View File

@@ -17,6 +17,8 @@ class WebhookService < BaseService
end end
def serialize_event def serialize_event
Oj.dump(ActiveModelSerializers::SerializableResource.new(@event, serializer: REST::Admin::WebhookEventSerializer, scope: nil, scope_name: :current_user).as_json) ActiveModelSerializers::SerializableResource
.new(@event, serializer: REST::Admin::WebhookEventSerializer, scope: nil, scope_name: :current_user)
.to_json
end end
end end

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class ReactionValidator < ActiveModel::Validator class ReactionValidator < ActiveModel::Validator
SUPPORTED_EMOJIS = Oj.load_file(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json').to_s).keys.freeze SUPPORTED_EMOJIS = JSON.load_file(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json').to_s).keys.freeze
LIMIT = 8 LIMIT = 8

View File

@@ -8,7 +8,7 @@
= render_initial_state = render_initial_state
= flavoured_vite_typescript_tag 'application.ts', crossorigin: 'anonymous' = flavoured_vite_typescript_tag 'application.ts', crossorigin: 'anonymous'
.notranslate.app-holder#mastodon{ data: { props: Oj.dump(default_props) } } .notranslate.app-holder#mastodon{ data: { props: default_props.to_json } }
%noscript %noscript
= image_tag frontend_asset_path('images/logo.svg'), alt: 'Mastodon' = image_tag frontend_asset_path('images/logo.svg'), alt: 'Mastodon'

View File

@@ -2,4 +2,4 @@
= render_initial_state = render_initial_state
= flavoured_vite_typescript_tag 'share.tsx', crossorigin: 'anonymous' = flavoured_vite_typescript_tag 'share.tsx', crossorigin: 'anonymous'
#mastodon-compose{ data: { props: Oj.dump(default_props) } } #mastodon-compose{ data: { props: default_props.to_json } }

View File

@@ -1 +1 @@
#mastodon-status{ data: { props: Oj.dump(default_props.merge(id: @status.id.to_s)) } } #mastodon-status{ data: { props: default_props.merge(id: @status.id.to_s).to_json } }

View File

@@ -43,7 +43,7 @@ class ActivityPub::DistributePollUpdateWorker
end end
def payload def payload
@payload ||= Oj.dump(serialize_payload(@status, ActivityPub::UpdatePollSerializer, signer: @account)) @payload ||= serialize_payload(@status, ActivityPub::UpdatePollSerializer, signer: @account).to_json
end end
def relay! def relay!

View File

@@ -24,7 +24,7 @@ class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker
end end
def payload def payload
@payload ||= Oj.dump(serialize_payload(@status, activity_serializer, serializer_options.merge(signer: @account))) @payload ||= serialize_payload(@status, activity_serializer, serializer_options.merge(signer: @account)).to_json
end end
def activity_serializer def activity_serializer

View File

@@ -17,6 +17,6 @@ class ActivityPub::FeatureRequestWorker < ActivityPub::RawDistributionWorker
end end
def payload def payload
@payload ||= Oj.dump(serialize_payload(@collection_item, ActivityPub::FeatureRequestSerializer, signer: @account)) @payload ||= serialize_payload(@collection_item, ActivityPub::FeatureRequestSerializer, signer: @account).to_json
end end
end end

Some files were not shown because too many files have changed in this diff Show More