+
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx
index 2db9fa6d3a..4d55eafac3 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx
@@ -221,7 +221,7 @@ class DetailedStatus extends ImmutablePureComponent {
);
mediaIcons.push('picture-o');
}
- } else if (status.get('card')) {
+ } else if (!status.get('quote') && status.get('card')) {
media.push(
);
mediaIcons.push('link');
}
diff --git a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
index 7cb9735cfe..8058e04f3d 100644
--- a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
+++ b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
@@ -6,6 +6,7 @@ import { showAlertForError } from '../../../actions/alerts';
import { initBlockModal } from '../../../actions/blocks';
import {
replyCompose,
+ quoteCompose,
mentionCompose,
directCompose,
} from '../../../actions/compose';
@@ -32,6 +33,8 @@ import DetailedStatus from '../components/detailed_status';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+ quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
+ quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favorites and boosts will be lost, and replies to the original post will be orphaned.' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
@@ -70,6 +73,21 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
});
},
+ onQuote (status, router) {
+ dispatch((_, getState) => {
+ let state = getState();
+ if (state.getIn(['compose', 'text']).trim().length !== 0) {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.quoteMessage),
+ confirm: intl.formatMessage(messages.quoteConfirm),
+ onConfirm: () => dispatch(quoteCompose(status, router)),
+ }));
+ } else {
+ dispatch(quoteCompose(status, router));
+ }
+ });
+ },
+
onModalReblog (status, privacy) {
dispatch(reblog({ statusId: status.get('id'), visibility: privacy }));
},
diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx
index 00639a667e..2c8fb89d32 100644
--- a/app/javascript/flavours/glitch/features/status/index.jsx
+++ b/app/javascript/flavours/glitch/features/status/index.jsx
@@ -28,6 +28,7 @@ import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import { initBlockModal } from '../../actions/blocks';
import {
replyCompose,
+ quoteCompose,
mentionCompose,
directCompose,
} from '../../actions/compose';
@@ -342,6 +343,21 @@ class Status extends ImmutablePureComponent {
}
};
+ handleQuoteClick = (status) => {
+ const { dispatch } = this.props;
+ const { signedIn } = this.context.identity;
+
+ if (signedIn) {
+ dispatch(quoteCompose(status, this.context.router.history));
+ } else {
+ dispatch(openModal('INTERACTION', {
+ type: 'reply',
+ accountId: status.getIn(['account', 'id']),
+ url: status.get('url'),
+ }));
+ }
+ }
+
handleModalReblog = (status, privacy) => {
const { dispatch } = this.props;
@@ -760,6 +776,7 @@ class Status extends ImmutablePureComponent {
onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick}
onBookmark={this.handleBookmarkClick}
+ onQuote={this.handleQuoteClick}
onDelete={this.handleDeleteClick}
onEdit={this.handleEditClick}
onDirect={this.handleDirectClick}
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index 211402ab2a..33dade4f1e 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -9,6 +9,8 @@ import {
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
COMPOSE_DIRECT,
+ COMPOSE_QUOTE,
+ COMPOSE_QUOTE_CANCEL,
COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST,
COMPOSE_SUBMIT_SUCCESS,
@@ -80,6 +82,7 @@ const initialState = ImmutableMap({
caretPosition: null,
preselectDate: null,
in_reply_to: null,
+ quote_id: null,
is_composing: false,
is_submitting: false,
is_changing_upload: false,
@@ -169,6 +172,7 @@ function clearAll(state) {
map.set('is_submitting', false);
map.set('is_changing_upload', false);
map.set('in_reply_to', null);
+ map.set('quote_id', null);
map.update(
'advanced_options',
map => map.mergeWith(overwrite, state.get('default_advanced_options')),
@@ -358,6 +362,51 @@ const updateSuggestionTags = (state, token) => {
});
};
+const updateWithReply = (state, action) => {
+ // doesn't support QT&reply
+ const isQuote = action.type === COMPOSE_QUOTE;
+ const parentStatusId = action.status.get('id');
+
+ return state.withMutations(map => {
+ map.set('id', null);
+ map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
+ map.update(
+ 'advanced_options',
+ map => map.merge(new ImmutableMap({ do_not_federate: !!action.status.get('local_only') })),
+ );
+ map.set('focusDate', new Date());
+ map.set('caretPosition', null);
+ map.set('preselectDate', new Date());
+ map.set('idempotencyKey', uuid());
+
+ if (action.status.get('spoiler_text').length > 0) {
+ let spoilerText = action.status.get('spoiler_text');
+ if (action.prependCWRe && !spoilerText.match(/^(re|qt)[: ]/i)) {
+ spoilerText = isQuote ? `QT: ${spoilerText}` : `re: ${spoilerText}`;
+ }
+ map.set('spoiler', true);
+ map.set('spoiler_text', spoilerText);
+ } else {
+ map.set('spoiler', false);
+ map.set('spoiler_text', '');
+ }
+
+ if (isQuote) {
+ map.set('in_reply_to', null);
+ map.set('quote_id', parentStatusId);
+ map.set('text', '');
+ } else {
+ map.set('in_reply_to', parentStatusId);
+ map.set('quote_id', null);
+ map.set('text', statusToTextMentions(state, action.status));
+
+ if (action.status.get('language')) {
+ map.set('language', action.status.get('language'));
+ }
+ }
+ });
+};
+
const updatePoll = (state, index, value, maxOptions) => state.updateIn(['poll', 'options'], options => {
const tmp = options.set(index, value).filterNot(x => x.trim().length === 0);
@@ -420,46 +469,17 @@ export default function compose(state = initialState, action) {
case COMPOSE_COMPOSING_CHANGE:
return state.set('is_composing', action.value);
case COMPOSE_REPLY:
- return state.withMutations(map => {
- map.set('id', null);
- map.set('in_reply_to', action.status.get('id'));
- map.set('text', statusToTextMentions(state, action.status));
- map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
- map.update(
- 'advanced_options',
- map => map.merge(new ImmutableMap({ do_not_federate: !!action.status.get('local_only') })),
- );
- map.set('focusDate', new Date());
- map.set('caretPosition', null);
- map.set('preselectDate', new Date());
- map.set('idempotencyKey', uuid());
-
- map.update('media_attachments', list => list.filter(media => media.get('unattached')));
-
- if (action.status.get('language') && !action.status.has('translation')) {
- map.set('language', action.status.get('language'));
- } else {
- map.set('language', state.get('default_language'));
- }
-
- if (action.status.get('spoiler_text').length > 0) {
- let spoiler_text = action.status.get('spoiler_text');
- if (action.prependCWRe && !spoiler_text.match(/^re[: ]/i)) {
- spoiler_text = 're: '.concat(spoiler_text);
- }
- map.set('spoiler', true);
- map.set('spoiler_text', spoiler_text);
- } else {
- map.set('spoiler', false);
- map.set('spoiler_text', '');
- }
- });
+ case COMPOSE_QUOTE:
+ return updateWithReply(state, action);
case COMPOSE_REPLY_CANCEL:
state = state.setIn(['advanced_options', 'threaded_mode'], false);
// eslint-disable-next-line no-fallthrough -- fall-through to `COMPOSE_RESET` is intended
+ case COMPOSE_QUOTE_CANCEL:
+ // eslint-disable-next-line no-fallthrough -- fall-through to `COMPOSE_RESET` is intended
case COMPOSE_RESET:
return state.withMutations(map => {
map.set('in_reply_to', null);
+ map.set('quote_id', null);
if (defaultContentType) map.set('content_type', defaultContentType);
map.set('text', '');
map.set('spoiler', false);
diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss
index 5c2a130cb9..9138522c7b 100644
--- a/app/javascript/flavours/glitch/styles/components.scss
+++ b/app/javascript/flavours/glitch/styles/components.scss
@@ -1009,6 +1009,50 @@ body > [data-popper-placement] {
}
}
+.quote-indicator,
+.reply-indicator {
+ border-radius: 4px;
+ margin-bottom: 10px;
+ background: $ui-primary-color;
+ padding: 10px;
+ min-height: 23px;
+ overflow-y: auto;
+ flex: 0 2 auto;
+}
+
+.quote-indicator__header,
+.reply-indicator__header {
+ margin-bottom: 5px;
+ overflow: hidden;
+}
+
+.quote-indicator__cancel,
+.reply-indicator__cancel {
+ float: right;
+ line-height: 24px;
+}
+
+.quote-indicator__display-name,
+.reply-indicator__display-name {
+ color: $inverted-text-color;
+ display: block;
+ max-width: 100%;
+ line-height: 24px;
+ overflow: hidden;
+ text-decoration: none;
+
+ & > .display-name {
+ line-height: unset;
+ height: unset;
+ }
+}
+
+.quote-indicator__display-avatar,
+.reply-indicator__display-avatar {
+ float: left;
+ margin-inline-end: 5px;
+}
+
.status__content--with-action {
cursor: pointer;
}
@@ -1019,6 +1063,7 @@ body > [data-popper-placement] {
.status__content,
.edit-indicator__content,
+.quote-indicator__content,
.reply-indicator__content {
position: relative;
word-wrap: break-word;
@@ -1041,7 +1086,8 @@ body > [data-popper-placement] {
}
p,
- pre {
+ pre,
+ blockquote {
margin-bottom: 20px;
white-space: pre-wrap;
unicode-bidi: plaintext;
@@ -1419,6 +1465,7 @@ body > [data-popper-placement] {
}
.focusable {
+ &:hover,
&:focus {
outline: 0;
background: rgba($ui-highlight-color, 0.05);
@@ -2279,7 +2326,9 @@ a .account__avatar {
}
}
-.status__display-name,
+a.status__display-name,
+.quote-indicator__display-name,
+.reply-indicator__display-name,
.detailed-status__display-name,
a.account__display-name {
&:hover .display-name strong {
diff --git a/app/javascript/flavours/glitch/styles/contrast/diff.scss b/app/javascript/flavours/glitch/styles/contrast/diff.scss
index ae607f484a..321526c9ef 100644
--- a/app/javascript/flavours/glitch/styles/contrast/diff.scss
+++ b/app/javascript/flavours/glitch/styles/contrast/diff.scss
@@ -1,7 +1,21 @@
+.compose-form {
+ .compose-form__modifiers {
+ .compose-form__upload {
+ &-description {
+ input {
+ &::placeholder {
+ opacity: 1;
+ }
+ }
+ }
+ }
+ }
+}
+
.status__content a,
-.reply-indicator__content a,
-.edit-indicator__content a,
.link-footer a,
+.quote-indicator__content a,
+.reply-indicator__content a,
.status__content__read-more-button,
.status__content__translate-button {
text-decoration: underline;
@@ -29,9 +43,7 @@
}
}
-.status__content a,
-.reply-indicator__content a,
-.edit-indicator__content a {
+.status__content a {
color: $highlight-text-color;
}
@@ -39,10 +51,24 @@
color: $darker-text-color;
}
-.report-dialog-modal__textarea::placeholder {
+.compose-form__poll-wrapper .button.button-secondary,
+.compose-form .autosuggest-textarea__textarea::placeholder,
+.compose-form .spoiler-input__input::placeholder,
+.report-dialog-modal__textarea::placeholder,
+.language-dropdown__dropdown__results__item__common-name,
+.compose-form .icon-button {
color: $inverted-text-color;
}
+.text-icon-button.active {
+ color: $ui-highlight-color;
+}
+
+.language-dropdown__dropdown__results__item.active {
+ background: $ui-highlight-color;
+ font-weight: 500;
+}
+
.link-button:disabled {
cursor: not-allowed;
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
index 4040ee0fe0..447abf0fad 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
@@ -21,6 +21,25 @@ html {
}
// Change default background colors of columns
+.column > .scrollable,
+.getting-started,
+.column-inline-form,
+.regeneration-indicator {
+ background: $white;
+ border: 1px solid lighten($ui-base-color, 8%);
+ border-top: 0;
+}
+
+.error-column {
+ border: 1px solid lighten($ui-base-color, 8%);
+}
+
+.column > .scrollable.about {
+ border-top: 1px solid lighten($ui-base-color, 8%);
+}
+
+.about__meta,
+.about__section__title,
.interaction-modal {
background: $white;
border: 1px solid lighten($ui-base-color, 8%);
@@ -34,6 +53,29 @@ html {
background: lighten($ui-base-color, 12%);
}
+.filter-form {
+ background: $white;
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+}
+
+.column-back-button,
+.column-header {
+ background: $white;
+ border: 1px solid lighten($ui-base-color, 8%);
+
+ @media screen and (max-width: $no-gap-breakpoint) {
+ border-top: 0;
+ }
+
+ &--slim-button {
+ top: -50px;
+ right: 0;
+ }
+}
+
+.column-header__back-button,
+.column-header__button,
+.column-header__button.active,
.account__header {
background: $white;
}
@@ -45,6 +87,7 @@ html {
&:active,
&:focus {
color: $ui-highlight-color;
+ background: $white;
}
}
@@ -70,6 +113,25 @@ html {
}
}
+.column-subheading {
+ background: darken($ui-base-color, 4%);
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+}
+
+.getting-started,
+.scrollable {
+ .column-link {
+ background: $white;
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+ &:hover,
+ &:active,
+ &:focus {
+ background: $ui-base-color;
+ }
+ }
+}
+
.getting-started .navigation-bar {
border-top: 1px solid lighten($ui-base-color, 8%);
border-bottom: 1px solid lighten($ui-base-color, 8%);
@@ -79,8 +141,11 @@ html {
}
}
+.compose-form__autosuggest-wrapper,
+.poll__option input[type='text'],
+.compose-form .spoiler-input__input,
+.compose-form__poll-wrapper select,
.search__input,
-.search__popout,
.setting-text,
.report-dialog-modal__textarea,
.audio-player {
@@ -103,6 +168,86 @@ html {
border-bottom: 0;
}
+.compose-form__poll-wrapper select {
+ background: $simple-background-color
+ url("data:image/svg+xml;utf8,
")
+ no-repeat right 8px center / auto 16px;
+}
+
+.compose-form__poll-wrapper,
+.compose-form__poll-wrapper .poll__footer {
+ border-top-color: lighten($ui-base-color, 8%);
+}
+
+.notification__filter-bar {
+ border: 1px solid lighten($ui-base-color, 8%);
+ border-top: 0;
+}
+
+.compose-form .compose-form__buttons-wrapper {
+ background: $ui-base-color;
+ border: 1px solid lighten($ui-base-color, 8%);
+ border-top: 0;
+}
+
+.drawer__header,
+.drawer__inner {
+ background: $white;
+ border: 1px solid lighten($ui-base-color, 8%);
+}
+
+.drawer__inner__mastodon {
+ background: $white
+ url('data:image/svg+xml;utf8,
')
+ no-repeat bottom / 100% auto;
+}
+
+// Change the colors used in compose-form
+.compose-form {
+ .compose-form__modifiers {
+ .compose-form__upload__actions .icon-button,
+ .compose-form__upload__warning .icon-button {
+ color: lighten($white, 7%);
+
+ &:active,
+ &:focus,
+ &:hover {
+ color: $white;
+ }
+ }
+ }
+
+ .compose-form__buttons-wrapper {
+ background: darken($ui-base-color, 6%);
+ }
+
+ .autosuggest-textarea__suggestions {
+ background: darken($ui-base-color, 6%);
+ }
+
+ .autosuggest-textarea__suggestions__item {
+ &:hover,
+ &:focus,
+ &:active,
+ &.selected {
+ background: lighten($ui-base-color, 4%);
+ }
+ }
+}
+
+.emoji-mart-bar {
+ border-color: lighten($ui-base-color, 4%);
+
+ &:first-child {
+ background: darken($ui-base-color, 6%);
+ }
+}
+
+.emoji-mart-search input {
+ background: rgba($ui-base-color, 0.3);
+ border-color: $ui-base-color;
+}
+
.upload-progress__backdrop {
background: $ui-base-color;
}
@@ -112,7 +257,13 @@ html {
background: lighten($white, 4%);
}
+.detailed-status,
+.detailed-status__action-bar {
+ background: $white;
+}
+
// Change the background colors of status__content__spoiler-link
+.quote-indicator__content .status__content__spoiler-link,
.reply-indicator__content .status__content__spoiler-link,
.status__content .status__content__spoiler-link {
background: $ui-base-color;
@@ -123,11 +274,52 @@ html {
}
}
+// Change the background colors of media and video spoilers
+.media-spoiler,
+.video-player__spoiler {
+ background: $ui-base-color;
+}
+
+.privacy-dropdown.active .privacy-dropdown__value.active .icon-button {
+ color: $white;
+}
+
.account-gallery__item a {
background-color: $ui-base-color;
}
+// Change the colors used in the dropdown menu
+.dropdown-menu {
+ background: $white;
+
+ &__arrow::before {
+ background-color: $white;
+ }
+
+ &__item {
+ color: $darker-text-color;
+
+ &--dangerous {
+ color: $error-value-color;
+ }
+
+ a,
+ button {
+ background: $white;
+ }
+ }
+}
+
// Change the text colors on inverted background
+.privacy-dropdown__option.active,
+.privacy-dropdown__option:hover,
+.privacy-dropdown__option.active .privacy-dropdown__option__content,
+.privacy-dropdown__option.active .privacy-dropdown__option__content strong,
+.privacy-dropdown__option:hover .privacy-dropdown__option__content,
+.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,
+.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:active,
+.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:focus,
+.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:hover,
.actions-modal ul li:not(:empty) a.active,
.actions-modal ul li:not(:empty) a.active button,
.actions-modal ul li:not(:empty) a:active,
@@ -136,6 +328,7 @@ html {
.actions-modal ul li:not(:empty) a:focus button,
.actions-modal ul li:not(:empty) a:hover,
.actions-modal ul li:not(:empty) a:hover button,
+.language-dropdown__dropdown__results__item.active,
.admin-wrapper .sidebar ul .simple-navigation-active-leaf a,
.simple_form .block-button,
.simple_form .button,
@@ -143,6 +336,19 @@ html {
color: $white;
}
+.language-dropdown__dropdown__results__item
+ .language-dropdown__dropdown__results__item__common-name {
+ color: lighten($ui-base-color, 8%);
+}
+
+.language-dropdown__dropdown__results__item.active
+ .language-dropdown__dropdown__results__item__common-name {
+ color: darken($ui-base-color, 12%);
+}
+
+.dropdown-menu__separator,
+.dropdown-menu__item.edited-timestamp__history__item,
+.dropdown-menu__container__header,
.compare-history-modal .report-modal__target,
.report-dialog-modal .poll__option.dialog-option {
border-bottom-color: lighten($ui-base-color, 4%);
@@ -176,7 +382,10 @@ html {
.reactions-bar__item:hover,
.reactions-bar__item:focus,
-.reactions-bar__item:active {
+.reactions-bar__item:active,
+.language-dropdown__dropdown__results__item:hover,
+.language-dropdown__dropdown__results__item:focus,
+.language-dropdown__dropdown__results__item:active {
background-color: $ui-base-color;
}
@@ -214,7 +423,7 @@ html {
.column-header__collapsible-inner {
background: darken($ui-base-color, 4%);
border: 1px solid lighten($ui-base-color, 8%);
- border-bottom: 0;
+ border-top: 0;
}
.column-settings__hashtags .column-select__option {
@@ -264,11 +473,11 @@ html {
}
.react-toggle-track {
- background: $ui-primary-color;
+ background: $ui-secondary-color;
}
.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
- background: lighten($ui-primary-color, 10%);
+ background: darken($ui-secondary-color, 10%);
}
.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled)
@@ -419,7 +628,14 @@ html {
}
}
+.quote-indicator,
+.reply-indicator {
+ background: transparent;
+ border: 1px solid lighten($ui-base-color, 8%);
+}
+
.status__content,
+.quote-indicator__content,
.reply-indicator__content {
a {
color: $highlight-text-color;
@@ -440,8 +656,7 @@ html {
.directory__tag > div,
.card > a,
.page-header,
-.compose-form,
-.compose-form__warning {
+.compose-form .compose-form__warning {
box-shadow: none;
}
@@ -513,6 +728,13 @@ html {
}
}
+.status.collapsed .status__content::after {
+ background: linear-gradient(
+ rgba(darken($ui-base-color, 13%), 0),
+ rgba(darken($ui-base-color, 13%), 1)
+ );
+}
+
.drawer__inner__mastodon {
background: $white
url('data:image/svg+xml;utf8,
')
@@ -522,47 +744,3 @@ html {
filter: contrast(75%) brightness(75%) !important;
}
}
-
-.compose-form__actions .icon-button.active,
-.dropdown-button.active,
-.privacy-dropdown__option.active,
-.privacy-dropdown__option:focus,
-.language-dropdown__dropdown__results__item:focus,
-.language-dropdown__dropdown__results__item.active,
-.privacy-dropdown__option:focus .privacy-dropdown__option__content,
-.privacy-dropdown__option:focus .privacy-dropdown__option__content strong,
-.privacy-dropdown__option.active .privacy-dropdown__option__content,
-.privacy-dropdown__option.active .privacy-dropdown__option__content strong,
-.language-dropdown__dropdown__results__item:focus
- .language-dropdown__dropdown__results__item__common-name,
-.language-dropdown__dropdown__results__item.active
- .language-dropdown__dropdown__results__item__common-name {
- color: $white;
-}
-
-.compose-form .spoiler-input__input {
- color: lighten($ui-highlight-color, 8%);
-}
-
-.compose-form .autosuggest-textarea__textarea,
-.compose-form__highlightable,
-.search__input,
-.search__popout,
-.emoji-mart-search input,
-.language-dropdown__dropdown .emoji-mart-search input,
-.poll__option input[type='text'] {
- background: darken($ui-base-color, 10%);
-}
-
-.inline-follow-suggestions {
- background-color: rgba($ui-highlight-color, 0.1);
- border-bottom-color: rgba($ui-highlight-color, 0.3);
-}
-
-.inline-follow-suggestions__body__scrollable__card {
- background: $white;
-}
-
-.inline-follow-suggestions__body__scroll-button__icon {
- color: $white;
-}
diff --git a/app/javascript/flavours/glitch/styles/rich_text.scss b/app/javascript/flavours/glitch/styles/rich_text.scss
index 266a09eb8e..892aca457e 100644
--- a/app/javascript/flavours/glitch/styles/rich_text.scss
+++ b/app/javascript/flavours/glitch/styles/rich_text.scss
@@ -1,6 +1,8 @@
+.status__quote,
.status__content__text,
.e-content,
.edit-indicator__content,
+.quote-indicator__content,
.reply-indicator__content {
pre,
blockquote {
@@ -91,3 +93,11 @@
list-style-type: decimal;
}
}
+
+.quote-indicator__content,
+.reply-indicator__content {
+ blockquote {
+ border-inline-start-color: $inverted-text-color;
+ color: $inverted-text-color;
+ }
+}
diff --git a/app/javascript/material-icons/400-24px/format_quote-fill.svg b/app/javascript/material-icons/400-24px/format_quote-fill.svg
new file mode 100644
index 0000000000..f4afa3ed17
--- /dev/null
+++ b/app/javascript/material-icons/400-24px/format_quote-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/javascript/material-icons/400-24px/format_quote.svg b/app/javascript/material-icons/400-24px/format_quote.svg
new file mode 100644
index 0000000000..c354385ea9
--- /dev/null
+++ b/app/javascript/material-icons/400-24px/format_quote.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index c384505232..f091f0d201 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -130,6 +130,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
media_attachment_ids: attachment_ids,
ordered_media_attachment_ids: attachment_ids,
poll: process_poll,
+ quote: process_quote,
}
end
@@ -430,4 +431,28 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
poll.reload
retry
end
+
+ def guess_quote_url
+ if @object["quoteUri"] && !@object["quoteUri"].empty?
+ @object["quoteUri"]
+ elsif @object["quoteUrl"] && !@object["quoteUrl"].empty?
+ @object["quoteUrl"]
+ elsif @object["quoteURL"] && !@object["quoteURL"].empty?
+ @object["quoteURL"]
+ elsif @object["_misskey_quote"] && !@object["_misskey_quote"].empty?
+ @object["_misskey_quote"]
+ else
+ nil
+ end
+ end
+
+ def process_quote
+ url = guess_quote_url
+ return nil if url.nil?
+
+ quote = ResolveURLService.new.call(url)
+ status_from_uri(quote.uri) if quote
+ rescue
+ nil
+ end
end
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index 8643286317..e6d9d607d9 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -83,11 +83,15 @@ class ActivityPub::TagManager
# Unlisted and private statuses go out primarily to the followers collection
# Others go out only to the people they mention
def to(status)
+ to = []
+
+ to << uri_for(status.quote.account) if status.quote?
+
case status.visibility
when 'public'
- [COLLECTIONS[:public]]
+ to << COLLECTIONS[:public]
when 'unlisted', 'private'
- [account_followers_url(status.account)]
+ to << account_followers_url(status.account)
when 'direct', 'limited'
if status.account.silenced?
# Only notify followers if the account is locally silenced
diff --git a/app/models/status.rb b/app/models/status.rb
index a6ea7bb90b..3eaad9bb6a 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -29,6 +29,7 @@
# edited_at :datetime
# trendable :boolean
# ordered_media_attachment_ids :bigint(8) is an Array
+# quote_id :bigint(8)
#
class Status < ApplicationRecord
@@ -66,6 +67,7 @@ class Status < ApplicationRecord
with_options class_name: 'Status', optional: true do
belongs_to :thread, foreign_key: 'in_reply_to_id', inverse_of: :replies
belongs_to :reblog, foreign_key: 'reblog_of_id', inverse_of: :reblogs
+ belongs_to :quote, foreign_key: 'quote_id', inverse_of: :quoted
end
has_many :favourites, inverse_of: :status, dependent: :destroy
@@ -76,6 +78,7 @@ class Status < ApplicationRecord
has_many :mentions, dependent: :destroy, inverse_of: :status
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
has_many :media_attachments, dependent: :nullify
+ has_many :quoted, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote, dependent: :nullify
# The `dependent` option is enabled by the initial `mentions` association declaration
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status # rubocop:disable Rails/HasManyOrHasOneDependent
@@ -102,6 +105,7 @@ class Status < ApplicationRecord
validates :reblog, uniqueness: { scope: :account }, if: :reblog?
validates :visibility, exclusion: { in: %w(direct limited) }, if: :reblog?
validates :content_type, inclusion: { in: %w(text/plain text/markdown text/html) }, allow_nil: true
+ validates :quote_visibility, inclusion: { in: %w(public unlisted) }, if: :quote?
accepts_nested_attributes_for :poll
@@ -178,6 +182,17 @@ class Status < ApplicationRecord
account: [:account_stat, user: :role],
active_mentions: :account,
],
+ quote: [
+ :application,
+ :tags,
+ :media_attachments,
+ :conversation,
+ :status_stat,
+ :preloadable_poll,
+ preview_cards_status: [:preview_card],
+ account: [:account_stat, :user],
+ active_mentions: { account: :account_stat },
+ ],
thread: :account
delegate :domain, to: :account, prefix: true
@@ -212,6 +227,14 @@ class Status < ApplicationRecord
!reblog_of_id.nil?
end
+ def quote?
+ !quote_id.nil? && quote
+ end
+
+ def quote_visibility
+ quote&.visibility
+ end
+
def within_realtime_window?
created_at >= REAL_TIME_WINDOW.ago
end
@@ -284,7 +307,7 @@ class Status < ApplicationRecord
fields = [spoiler_text, text]
fields += preloadable_poll.options unless preloadable_poll.nil?
- @emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
+ @emojis = CustomEmoji.from_text(fields.join(' '), account.domain) + (quote? ? CustomEmoji.from_text([quote.spoiler_text, quote.text].join(' '), quote.account.domain) : [])
end
def ordered_media_attachments
diff --git a/app/models/status_edit.rb b/app/models/status_edit.rb
index e5d7cb46ea..810a2f9b7d 100644
--- a/app/models/status_edit.rb
+++ b/app/models/status_edit.rb
@@ -62,6 +62,10 @@ class StatusEdit < ApplicationRecord
end
end
+ def quote?
+ status.quote?
+ end
+
def proper
self
end
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 52ffaf7170..28122393c2 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -3,7 +3,7 @@
class ActivityPub::NoteSerializer < ActivityPub::Serializer
include FormattingHelper
- context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :direct_message
+ context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :direct_message, :quote_uri
attributes :id, :type, :summary,
:in_reply_to, :published, :url,
@@ -11,6 +11,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
:atom_uri, :in_reply_to_atom_uri,
:conversation
+ attribute :quote_uri, if: -> { object.quote? }
+
attribute :content
attribute :content_map, if: :language?
attribute :updated, if: :edited?
@@ -150,6 +152,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end
end
+ def quote_uri
+ ActivityPub::TagManager.instance.uri_for(object.quote) if object.quote?
+ end
+
def local?
object.account.local?
end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 96bdd600cf..3638e871b1 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -202,3 +202,13 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
end
end
+
+class REST::QuoteStatusSerializer < REST::StatusSerializer
+ attribute :quote do
+ nil
+ end
+end
+
+class REST::StatusSerializer < ActiveModel::Serializer
+ belongs_to :quote, serializer: REST::QuoteStatusSerializer
+end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 8bc9f912c5..c9018b7d85 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -78,7 +78,7 @@ class FetchLinkCardService < BaseService
@status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
else
document = Nokogiri::HTML(@status.text)
- links = document.css('a')
+ links = document.css(':not(.quote-inline) > a')
links.filter_map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.filter_map(&:normalize)
end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index f67064c9a1..8c38f00ae3 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -31,6 +31,7 @@ class PostStatusService < BaseService
# @option [String] :idempotency Optional idempotency key
# @option [Boolean] :with_rate_limit
# @option [Enumerable] :allowed_mentions Optional array of expected mentioned account IDs, raises `UnexpectedMentionsError` if unexpected accounts end up in mentions
+ # @option [String] :quote_id
# @return [Status]
def call(account, options = {})
@account = account
@@ -210,6 +211,7 @@ class PostStatusService < BaseService
application: @options[:application],
content_type: @options[:content_type] || @account.user&.setting_default_content_type,
rate_limit: @options[:with_rate_limit],
+ quote_id: @options[:quote_id],
}.compact
end
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index c55dff5d96..39b6d4824b 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -15,7 +15,11 @@
= account_action_button(status.account)
+ - if status.quote?
+ = render partial: "statuses/quote_status", locals: {status: status.quote}
+
.status__content.emojify{ data: ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
+
- if status.spoiler_text?
%p<
%span.p-summary> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}
diff --git a/app/views/statuses/_quote_status.html.haml b/app/views/statuses/_quote_status.html.haml
new file mode 100644
index 0000000000..514e04800c
--- /dev/null
+++ b/app/views/statuses/_quote_status.html.haml
@@ -0,0 +1,38 @@
+.status.quote-status{ dataurl: ActivityPub::TagManager.instance.url_for(status) }
+ = link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener noreferrer' do
+ .status__avatar
+ %div
+ - if prefers_autoplay?
+ = image_tag status.account.avatar_original_url, alt: '', class: 'u-photo account__avatar'
+ - else
+ = image_tag status.account.avatar_static_url, alt: '', class: 'u-photo account__avatar'
+ %span.display-name
+ %bdi
+ %strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true, autoplay: prefers_autoplay?)
+
+ %span.display-name__account
+ = acct(status.account)
+ = fa_icon('lock') if status.account.locked?
+
+ .status__content.emojify{ data: ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
+
+ - if status.spoiler_text?
+ %p<
+ %span.p-summary> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}
+ %button.status__content__spoiler-link= t('statuses.show_more')
+ .e-content{ lang: status.language }<
+ = prerender_custom_emojis(status_content_format(status), status.emojis)
+
+ - if status.preloadable_poll
+ = render_poll_component(status)
+
+ - if !status.ordered_media_attachments.empty?
+ - if status.ordered_media_attachments.first.video?
+ = render_video_component(status, width: 610, height: 343)
+ - elsif status.ordered_media_attachments.first.audio?
+ = render_audio_component(status, width: 610, height: 343)
+ - else
+ = render_media_gallery_component(status, height: 343)
+ - elsif status.preview_card
+ = render_card_component(status)
+
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index 6aa4d23dab..31ad335aa2 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -27,7 +27,12 @@
%span.display-name__account
= acct(status.account)
= fa_icon('lock') if status.account.locked?
+
+ - if status.quote?
+ = render partial: "statuses/quote_status", locals: {status: status.quote}
+
.status__content.emojify{ data: ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
+
- if status.spoiler_text?
%p<
%span.p-summary> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}
diff --git a/db/migrate/20221224204906_add_quote_id_to_statuses.rb b/db/migrate/20221224204906_add_quote_id_to_statuses.rb
new file mode 100644
index 0000000000..b01f6520a2
--- /dev/null
+++ b/db/migrate/20221224204906_add_quote_id_to_statuses.rb
@@ -0,0 +1,5 @@
+class AddQuoteIdToStatuses < ActiveRecord::Migration[6.1]
+ def change
+ add_column :statuses, :quote_id, :bigint, null: true, default: nil
+ end
+end
diff --git a/db/migrate/20221224220348_add_index_to_statuses_quote_id.rb b/db/migrate/20221224220348_add_index_to_statuses_quote_id.rb
new file mode 100644
index 0000000000..2a51daf883
--- /dev/null
+++ b/db/migrate/20221224220348_add_index_to_statuses_quote_id.rb
@@ -0,0 +1,7 @@
+class AddIndexToStatusesQuoteId < ActiveRecord::Migration[6.1]
+ disable_ddl_transaction!
+
+ def change
+ add_index :statuses, :quote_id, algorithm: :concurrently
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index a3c4f0408a..7ca144d605 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -1087,6 +1087,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_06_07_094856) do
t.datetime "edited_at", precision: nil
t.boolean "trendable"
t.bigint "ordered_media_attachment_ids", array: true
+ t.bigint "quote_id"
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
t.index ["account_id"], name: "index_statuses_on_account_id"
t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)"
@@ -1094,6 +1095,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_06_07_094856) do
t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id", where: "(in_reply_to_account_id IS NOT NULL)"
t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id", where: "(in_reply_to_id IS NOT NULL)"
+ t.index ["quote_id"], name: "index_statuses_on_quote_id"
t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id"
t.index ["uri"], name: "index_statuses_on_uri", unique: true, opclass: :text_pattern_ops, where: "(uri IS NOT NULL)"
end
diff --git a/lib/sanitize_ext/sanitize_config.rb b/lib/sanitize_ext/sanitize_config.rb
index 5bc6da6ebe..bff81804be 100644
--- a/lib/sanitize_ext/sanitize_config.rb
+++ b/lib/sanitize_ext/sanitize_config.rb
@@ -31,6 +31,7 @@ class Sanitize
next true if /^(h|p|u|dt|e)-/.match?(e) # microformats classes
next true if /^(mention|hashtag)$/.match?(e) # semantic classes
next true if /^(ellipsis|invisible)$/.match?(e) # link formatting classes
+ next true if /^quote-inline$/.match?(e) # quote inline classes
end
node['class'] = class_list.join(' ')