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

Merge upstream changes up to d887790e86
This commit is contained in:
Claire
2025-06-06 23:42:55 +02:00
committed by GitHub
12 changed files with 169 additions and 224 deletions

View File

@@ -20,6 +20,7 @@ jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
if: github.repository == 'mastodon/mastodon'
steps:
- name: Checkout code
uses: actions/checkout@v4

View File

@@ -1,181 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { is } from 'immutable';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
const messages = defineMessages({
placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
});
class InlineAlert extends PureComponent {
static propTypes = {
show: PropTypes.bool,
};
state = {
mountMessage: false,
};
static TRANSITION_DELAY = 200;
UNSAFE_componentWillReceiveProps (nextProps) {
if (!this.props.show && nextProps.show) {
this.setState({ mountMessage: true });
} else if (this.props.show && !nextProps.show) {
setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY);
}
}
render () {
const { show } = this.props;
const { mountMessage } = this.state;
return (
<span aria-live='polite' role='status' className='inline-alert' style={{ opacity: show ? 1 : 0 }}>
{mountMessage && <FormattedMessage id='generic.saved' defaultMessage='Saved' />}
</span>
);
}
}
class AccountNote extends ImmutablePureComponent {
static propTypes = {
accountId: PropTypes.string.isRequired,
value: PropTypes.string,
onSave: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
value: null,
saving: false,
saved: false,
};
UNSAFE_componentWillMount () {
this._reset();
}
UNSAFE_componentWillReceiveProps (nextProps) {
const accountWillChange = !is(this.props.accountId, nextProps.accountId);
const newState = {};
if (accountWillChange && this._isDirty()) {
this._save(false);
}
if (accountWillChange || nextProps.value === this.state.value) {
newState.saving = false;
}
if (this.props.value !== nextProps.value) {
newState.value = nextProps.value;
}
this.setState(newState);
}
componentWillUnmount () {
if (this._isDirty()) {
this._save(false);
}
}
setTextareaRef = c => {
this.textarea = c;
};
handleChange = e => {
this.setState({ value: e.target.value, saving: false });
};
handleKeyDown = e => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (this.textarea) {
this.textarea.blur();
} else {
this._save();
}
} else if (e.keyCode === 27) {
e.preventDefault();
this._reset(() => {
if (this.textarea) {
this.textarea.blur();
}
});
}
};
handleBlur = () => {
if (this._isDirty()) {
this._save();
}
};
_save (showMessage = true) {
this.setState({ saving: true }, () => this.props.onSave(this.state.value));
if (showMessage) {
this.setState({ saved: true }, () => setTimeout(() => this.setState({ saved: false }), 2000));
}
}
_reset (callback) {
this.setState({ value: this.props.value }, callback);
}
_isDirty () {
return !this.state.saving && this.props.value !== null && this.state.value !== null && this.state.value !== this.props.value;
}
render () {
const { accountId, intl } = this.props;
const { value, saved } = this.state;
if (!accountId) {
return null;
}
return (
<div className='account__header__account-note'>
<label htmlFor={`account-note-${accountId}`}>
<FormattedMessage id='account.account_note_header' defaultMessage='Personal note' /> <InlineAlert show={saved} />
</label>
{this.props.value === undefined ? (
<div className='account__header__account-note__loading-indicator-wrapper'>
<LoadingIndicator />
</div>
) : (
<Textarea
id={`account-note-${accountId}`}
className='account__header__account-note__content'
disabled={value === null}
placeholder={intl.formatMessage(messages.placeholder)}
value={value || ''}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
onBlur={this.handleBlur}
ref={this.setTextareaRef}
/>
)}
</div>
);
}
}
export default injectIntl(AccountNote);

View File

@@ -0,0 +1,131 @@
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
import { useState, useRef, useCallback, useId } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import Textarea from 'react-textarea-autosize';
import { submitAccountNote } from '@/flavours/glitch/actions/account_notes';
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
const messages = defineMessages({
placeholder: {
id: 'account_note.placeholder',
defaultMessage: 'Click to add a note',
},
});
const AccountNoteUI: React.FC<{
initialValue: string | undefined;
onSubmit: (newNote: string) => void;
wasSaved: boolean;
}> = ({ initialValue, onSubmit, wasSaved }) => {
const intl = useIntl();
const uniqueId = useId();
const [value, setValue] = useState(initialValue ?? '');
const isLoading = initialValue === undefined;
const canSubmitOnBlurRef = useRef(true);
const handleChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>(
(e) => {
setValue(e.target.value);
},
[],
);
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
(e) => {
if (e.key === 'Escape') {
e.preventDefault();
setValue(initialValue ?? '');
canSubmitOnBlurRef.current = false;
e.currentTarget.blur();
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onSubmit(value);
canSubmitOnBlurRef.current = false;
e.currentTarget.blur();
}
},
[initialValue, onSubmit, value],
);
const handleBlur = useCallback(() => {
if (initialValue !== value && canSubmitOnBlurRef.current) {
onSubmit(value);
}
canSubmitOnBlurRef.current = true;
}, [initialValue, onSubmit, value]);
return (
<div className='account__header__account-note'>
<label htmlFor={`account-note-${uniqueId}`}>
<FormattedMessage
id='account.account_note_header'
defaultMessage='Personal note'
/>{' '}
<span
aria-live='polite'
role='status'
className='inline-alert'
style={{ opacity: wasSaved ? 1 : 0 }}
>
{wasSaved && (
<FormattedMessage id='generic.saved' defaultMessage='Saved' />
)}
</span>
</label>
{isLoading ? (
<div className='account__header__account-note__loading-indicator-wrapper'>
<LoadingIndicator />
</div>
) : (
<Textarea
id={`account-note-${uniqueId}`}
className='account__header__account-note__content'
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
)}
</div>
);
};
export const AccountNote: React.FC<{
accountId: string;
}> = ({ accountId }) => {
const dispatch = useAppDispatch();
const initialValue = useAppSelector((state) =>
state.relationships.get(accountId)?.get('note'),
);
const [wasSaved, setWasSaved] = useState(false);
const handleSubmit = useCallback(
(note: string) => {
setWasSaved(true);
void dispatch(submitAccountNote({ accountId, note }));
setTimeout(() => {
setWasSaved(false);
}, 2000);
},
[dispatch, accountId],
);
return (
<AccountNoteUI
key={`${accountId}-${initialValue}`}
initialValue={initialValue}
wasSaved={wasSaved}
onSubmit={handleSubmit}
/>
);
};

View File

@@ -1,19 +0,0 @@
import { connect } from 'react-redux';
import { submitAccountNote } from 'flavours/glitch/actions/account_notes';
import AccountNote from '../components/account_note';
const mapStateToProps = (state, { accountId }) => ({
value: state.relationships.getIn([accountId, 'note']),
});
const mapDispatchToProps = (dispatch, { accountId }) => ({
onSave (value) {
dispatch(submitAccountNote({ accountId: accountId, note: value }));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);

View File

@@ -42,8 +42,8 @@ import { FollowButton } from 'flavours/glitch/components/follow_button';
import { FormattedDateWrapper } from 'flavours/glitch/components/formatted_date';
import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { AccountNote } from 'flavours/glitch/features/account/components/account_note';
import { DomainPill } from 'flavours/glitch/features/account/components/domain_pill';
import AccountNoteContainer from 'flavours/glitch/features/account/containers/account_note_container';
import FollowRequestNoteContainer from 'flavours/glitch/features/account/containers/follow_request_note_container';
import { useLinks } from 'flavours/glitch/hooks/useLinks';
import { useIdentity } from 'flavours/glitch/identity_context';
@@ -927,7 +927,7 @@ export const AccountHeader: React.FC<{
onClickCapture={handleLinkClick}
>
{account.id !== me && signedIn && (
<AccountNoteContainer accountId={accountId} />
<AccountNote accountId={accountId} />
)}
{account.note.length > 0 && account.note !== '<p></p>' && (

View File

@@ -1,4 +1,4 @@
import { useCallback, useState, useRef } from 'react';
import { useCallback, useState, useRef, useEffect } from 'react';
import {
defineMessages,
@@ -72,6 +72,10 @@ export const Search: React.FC<{
const [expanded, setExpanded] = useState(false);
const [selectedOption, setSelectedOption] = useState(-1);
const [quickActions, setQuickActions] = useState<SearchOption[]>([]);
useEffect(() => {
setValue(initialValue ?? '');
setQuickActions([]);
}, [initialValue]);
const searchOptions: SearchOption[] = [];
if (searchEnabled) {

View File

@@ -1948,16 +1948,18 @@ body > [data-popper-placement] {
}
.status__quote {
--quote-margin: 36px;
position: relative;
margin-block-start: 16px;
margin-inline-start: 36px;
margin-inline-start: calc(var(--quote-margin) + var(--thread-margin, 0px));
border-radius: 8px;
color: var(--nested-card-text);
background: var(--nested-card-background);
border: var(--nested-card-border);
@media screen and (min-width: $mobile-breakpoint) {
margin-inline-start: 56px;
@container (width > 460px) {
--quote-margin: 56px;
}
}
@@ -2017,7 +2019,7 @@ body > [data-popper-placement] {
transform: translateY(-50%);
}
@media screen and (min-width: $mobile-breakpoint) {
@container (width > 460px) {
inset-inline-start: -50px;
}
}
@@ -2943,6 +2945,7 @@ a.account__display-name {
display: flex;
flex-direction: column;
contain: inline-size layout paint style;
container: column / inline-size;
@media screen and (min-width: $no-gap-breakpoint) {
max-width: 600px;

View File

@@ -1,4 +1,4 @@
import { useCallback, useState, useRef } from 'react';
import { useCallback, useState, useRef, useEffect } from 'react';
import {
defineMessages,
@@ -72,6 +72,10 @@ export const Search: React.FC<{
const [expanded, setExpanded] = useState(false);
const [selectedOption, setSelectedOption] = useState(-1);
const [quickActions, setQuickActions] = useState<SearchOption[]>([]);
useEffect(() => {
setValue(initialValue ?? '');
setQuickActions([]);
}, [initialValue]);
const searchOptions: SearchOption[] = [];
if (searchEnabled) {

View File

@@ -1882,16 +1882,18 @@ body > [data-popper-placement] {
}
.status__quote {
--quote-margin: 36px;
position: relative;
margin-block-start: 16px;
margin-inline-start: 36px;
margin-inline-start: calc(var(--quote-margin) + var(--thread-margin, 0px));
border-radius: 8px;
color: var(--nested-card-text);
background: var(--nested-card-background);
border: var(--nested-card-border);
@media screen and (min-width: $mobile-breakpoint) {
margin-inline-start: 56px;
@container (width > 460px) {
--quote-margin: 56px;
}
}
@@ -1951,7 +1953,7 @@ body > [data-popper-placement] {
transform: translateY(-50%);
}
@media screen and (min-width: $mobile-breakpoint) {
@container (width > 460px) {
inset-inline-start: -50px;
}
}
@@ -2878,6 +2880,7 @@ a.account__display-name {
display: flex;
flex-direction: column;
contain: inline-size layout paint style;
container: column / inline-size;
@media screen and (min-width: $no-gap-breakpoint) {
max-width: 600px;

View File

@@ -8,6 +8,7 @@ class Admin::SystemCheck::SidekiqProcessCheck < Admin::SystemCheck::BaseCheck
pull
scheduler
ingress
fasp
).freeze
def skip?

View File

@@ -16,7 +16,7 @@ module Status::SafeReblogInsert
# The code is kept similar to ActiveRecord::Persistence code and calls it
# directly when we are not handling a reblog.
#
# https://github.com/rails/rails/blob/v7.2.1.1/activerecord/lib/active_record/persistence.rb#L238-L263
# https://github.com/rails/rails/blob/v8.0.2/activerecord/lib/active_record/persistence.rb#L238-L261
def _insert_record(connection, values, returning)
return super unless values.is_a?(Hash) && values['reblog_of_id']&.value.present?
@@ -36,15 +36,13 @@ module Status::SafeReblogInsert
# Instead, we use a custom builder when a reblog is happening:
im = _compile_reblog_insert(values)
with_connection do |_c|
connection.insert(
im, "#{self} Create", primary_key || false, primary_key_value,
returning: returning
).tap do |result|
# Since we are using SELECT instead of VALUES, a non-error `nil` return is possible.
# For our purposes, it's equivalent to a foreign key constraint violation
raise ActiveRecord::InvalidForeignKey, "(reblog_of_id)=(#{values['reblog_of_id'].value}) is not present in table \"statuses\"" if result.nil?
end
connection.insert(
im, "#{self} Create", primary_key || false, primary_key_value,
returning: returning
).tap do |result|
# Since we are using SELECT instead of VALUES, a non-error `nil` return is possible.
# For our purposes, it's equivalent to a foreign key constraint violation
raise ActiveRecord::InvalidForeignKey, "(reblog_of_id)=(#{values['reblog_of_id'].value}) is not present in table \"statuses\"" if result.nil?
end
end

View File

@@ -35,11 +35,11 @@ RSpec.describe Admin::SystemCheck::SidekiqProcessCheck do
describe 'message' do
it 'sends values to message instance' do
allow(Admin::SystemCheck::Message).to receive(:new).with(:sidekiq_process_check, 'default, push, mailers, pull, scheduler, ingress')
allow(Admin::SystemCheck::Message).to receive(:new).with(:sidekiq_process_check, 'default, push, mailers, pull, scheduler, ingress, fasp')
check.message
expect(Admin::SystemCheck::Message).to have_received(:new).with(:sidekiq_process_check, 'default, push, mailers, pull, scheduler, ingress')
expect(Admin::SystemCheck::Message).to have_received(:new).with(:sidekiq_process_check, 'default, push, mailers, pull, scheduler, ingress, fasp')
end
end
end