+
{shareButton}
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index 46770930f..b53bf1d4e 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -210,7 +210,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 e5e065987..087316401 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
@@ -3,6 +3,7 @@ import DetailedStatus from '../components/detailed_status';
import { makeGetStatus } from 'flavours/glitch/selectors';
import {
replyCompose,
+ quoteCompose,
mentionCompose,
directCompose,
} from 'flavours/glitch/actions/compose';
@@ -33,6 +34,8 @@ import { showAlertForError } from 'flavours/glitch/actions/alerts';
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? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
@@ -68,6 +71,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(status, privacy));
},
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index aaa9c7928..28023b607 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -23,6 +23,7 @@ import {
} from 'flavours/glitch/actions/interactions';
import {
replyCompose,
+ quoteCompose,
mentionCompose,
directCompose,
} from 'flavours/glitch/actions/compose';
@@ -321,6 +322,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;
@@ -679,6 +695,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 1edc70add..5e5b039f9 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -6,6 +6,8 @@ import {
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
COMPOSE_DIRECT,
+ COMPOSE_QUOTE,
+ COMPOSE_QUOTE_CANCEL,
COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST,
COMPOSE_SUBMIT_SUCCESS,
@@ -85,6 +87,7 @@ const initialState = ImmutableMap({
caretPosition: null,
preselectDate: null,
in_reply_to: null,
+ quote_id: null,
is_submitting: false,
is_uploading: false,
is_changing_upload: false,
@@ -173,6 +176,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,52 @@ 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'));
+ }
+ }
+ });
+};
+
+
export default function compose(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
@@ -407,41 +457,15 @@ export default function compose(state = initialState, action) {
return state
.set('elefriend', (state.get('elefriend') + 1) % totalElefriends);
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());
-
- if (action.status.get('language')) {
- map.set('language', action.status.get('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);
+ case COMPOSE_QUOTE_CANCEL:
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/compose_form.scss b/app/javascript/flavours/glitch/styles/components/compose_form.scss
index 72d3aad1d..00d395ce9 100644
--- a/app/javascript/flavours/glitch/styles/components/compose_form.scss
+++ b/app/javascript/flavours/glitch/styles/components/compose_form.scss
@@ -123,6 +123,7 @@
}
}
+.quote-indicator,
.reply-indicator {
margin: 0 0 10px;
border-radius: 4px;
@@ -133,6 +134,7 @@
flex: 0 2 auto;
}
+.quote-indicator__header,
.reply-indicator__header {
margin-bottom: 5px;
overflow: hidden;
@@ -140,11 +142,13 @@
& > .account.small { color: $inverted-text-color; }
}
+.quote-indicator__cancel,
.reply-indicator__cancel {
float: right;
line-height: 24px;
}
+.quote-indicator__content,
.reply-indicator__content {
position: relative;
margin: 10px 0;
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index 054110e41..b19c72cbe 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -77,6 +77,11 @@
}
}
+ .status__quote {
+ padding-bottom: 0.5em;
+ }
+
+ .status__quote,
.status__content__text,
.e-content {
overflow: hidden;
@@ -123,6 +128,11 @@
font-style: italic;
}
+ i[role=img] {
+ font-style: normal;
+ padding-right: 0.25em;
+ }
+
sub {
font-size: smaller;
vertical-align: sub;
@@ -686,6 +696,7 @@
}
a.status__display-name,
+.quote-indicator__display-name,
.reply-indicator__display-name,
.detailed-status__display-name,
.account__display-name {
diff --git a/app/javascript/flavours/glitch/styles/contrast/diff.scss b/app/javascript/flavours/glitch/styles/contrast/diff.scss
index 4fa1a0361..97b7aa9fe 100644
--- a/app/javascript/flavours/glitch/styles/contrast/diff.scss
+++ b/app/javascript/flavours/glitch/styles/contrast/diff.scss
@@ -14,6 +14,7 @@
.status__content a,
.link-footer a,
+.quote-indicator__content a,
.reply-indicator__content a,
.status__content__read-more-button {
text-decoration: underline;
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
index 6489c2f80..660ea707c 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
@@ -257,6 +257,7 @@ html {
}
// 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;
@@ -662,6 +663,7 @@ html {
}
}
+.quote-indicator,
.reply-indicator {
background: transparent;
border: 1px solid lighten($ui-base-color, 8%);
@@ -673,6 +675,7 @@ html {
}
.status__content,
+.quote-indicator__content,
.reply-indicator__content {
a {
color: $highlight-text-color;
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index ebae12973..4fb3e3dc9 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -126,6 +126,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
conversation: conversation_from_uri(@object['conversation']),
media_attachment_ids: process_attachments.take(4).map(&:id),
poll: process_poll,
+ quote: process_quote,
}
end
end
@@ -426,4 +427,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/case_transform.rb b/app/lib/activitypub/case_transform.rb
index 7f716f862..ef0c6e1f9 100644
--- a/app/lib/activitypub/case_transform.rb
+++ b/app/lib/activitypub/case_transform.rb
@@ -14,6 +14,8 @@ module ActivityPub::CaseTransform
when String
camel_lower_cache[value] ||= if value.start_with?('_:')
'_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower)
+ elsif value.start_with?('_')
+ value
else
value.underscore.camelize(:lower)
end
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index 3d6b28ef5..a8e29d204 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -77,11 +77,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 044816be7..c08f0c55c 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -28,6 +28,7 @@
# edited_at :datetime
# trendable :boolean
# ordered_media_attachment_ids :bigint(8) is an Array
+# quote_id :bigint(8)
#
class Status < ApplicationRecord
@@ -61,6 +62,7 @@ class Status < ApplicationRecord
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true
+ belongs_to :quote, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote, optional: true
has_many :favourites, inverse_of: :status, dependent: :destroy
has_many :bookmarks, inverse_of: :status, dependent: :destroy
@@ -70,6 +72,7 @@ class Status < ApplicationRecord
has_many :mentions, dependent: :destroy, inverse_of: :status
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
has_many :media_attachments, dependent: :nullify
+ has_many :quoted, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote, dependent: :nullify
has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards
@@ -86,6 +89,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
@@ -134,6 +138,17 @@ class Status < ApplicationRecord
account: [:account_stat, :user],
active_mentions: { account: :account_stat },
],
+ quote: [
+ :application,
+ :tags,
+ :preview_cards,
+ :media_attachments,
+ :conversation,
+ :status_stat,
+ :preloadable_poll,
+ account: [:account_stat, :user],
+ active_mentions: { account: :account_stat },
+ ],
thread: { account: :account_stat }
delegate :domain, to: :account, prefix: true
@@ -195,6 +210,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
@@ -259,7 +282,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 c2330c04f..c4778d908 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 ca067ed9b..dc810be95 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?
@@ -149,6 +151,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 659c45b83..f33f499a5 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -184,3 +184,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 e5b5b730e..e5c044892 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -74,7 +74,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 36592a531..2e52aec66 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -21,6 +21,7 @@ class PostStatusService < BaseService
# @option [Doorkeeper::Application] :application
# @option [String] :idempotency Optional idempotency key
# @option [Boolean] :with_rate_limit
+ # @option [String] :quote_id
# @return [Status]
def call(account, options = {})
@account = account
@@ -179,6 +180,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 bf498e33d..f824752f7 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -15,6 +15,10 @@
= 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<
diff --git a/app/views/statuses/_quote_status.html.haml b/app/views/statuses/_quote_status.html.haml
new file mode 100644
index 000000000..6fdaa9a84
--- /dev/null
+++ b/app/views/statuses/_quote_status.html.haml
@@ -0,0 +1,35 @@
+.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' do
+ .status__avatar
+ %div
+ = image_tag status.account.avatar_static_url, width: 18, height: 18, alt: '', class: 'u-photo account__avatar'
+ %span.display-name
+ %bdi
+ %strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true)
+
+ %span.display-name__account
+ = acct(status.account)
+ = fa_icon('lock') if status.account.locked?
+
+ .status__content.emojify<
+ - if status.spoiler_text?
+ %p{ :style => ('margin-bottom: 0' unless current_account&.user&.setting_expand_spoilers) }<
+ %span.p-summary> #{Formatter.instance.format_spoiler(status)}
+ %button.status__content__spoiler-link= t('statuses.show_more')
+ .e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}" }
+ = Formatter.instance.format_in_quote(status, custom_emojify: true)
+
+ - if !status.media_attachments.empty?
+ - if status.media_attachments.first.video?
+ - video = status.media_attachments.first
+ = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description, quote: true do
+ = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
+ - elsif status.media_attachments.first.audio?
+ - audio = status.media_attachments.first
+ = react_component :audio, src: audio.file.url(:original), height: 60, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do
+ = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
+ - else
+ = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }, quote: true do
+ = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
+ - elsif status.preview_card
+ = react_component :card, maxDescription: 10, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json, quote: true
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index ecbabf34c..78d16a6a9 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -27,6 +27,10 @@
%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<
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 000000000..b01f6520a
--- /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 000000000..2a51daf88
--- /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 0bc7a7460..2ecb1c21c 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -943,6 +943,7 @@ ActiveRecord::Schema.define(version: 2022_11_04_133904) do
t.datetime "edited_at"
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)"
@@ -950,6 +951,7 @@ ActiveRecord::Schema.define(version: 2022_11_04_133904) 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 946543868..be4781870 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(' ')
diff --git a/quote-scss.diff b/quote-scss.diff
new file mode 100644
index 000000000..0cbb8345d
--- /dev/null
+++ b/quote-scss.diff
@@ -0,0 +1,110 @@
+diff --git a/app/javascript/flavours/glitch/styles/components/compose_form.scss b/app/javascript/flavours/glitch/styles/components/compose_form.scss
+index 72d3aad1d..00d395ce9 100644
+--- a/app/javascript/flavours/glitch/styles/components/compose_form.scss
++++ b/app/javascript/flavours/glitch/styles/components/compose_form.scss
+@@ -123,6 +123,7 @@
+ }
+ }
+
++.quote-indicator,
+ .reply-indicator {
+ margin: 0 0 10px;
+ border-radius: 4px;
+@@ -133,6 +134,7 @@
+ flex: 0 2 auto;
+ }
+
++.quote-indicator__header,
+ .reply-indicator__header {
+ margin-bottom: 5px;
+ overflow: hidden;
+@@ -140,11 +142,13 @@
+ & > .account.small { color: $inverted-text-color; }
+ }
+
++.quote-indicator__cancel,
+ .reply-indicator__cancel {
+ float: right;
+ line-height: 24px;
+ }
+
++.quote-indicator__content,
+ .reply-indicator__content {
+ position: relative;
+ margin: 10px 0;
+diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
+index 054110e41..b19c72cbe 100644
+--- a/app/javascript/flavours/glitch/styles/components/status.scss
++++ b/app/javascript/flavours/glitch/styles/components/status.scss
+@@ -77,6 +77,11 @@
+ }
+ }
+
++ .status__quote {
++ padding-bottom: 0.5em;
++ }
++
++ .status__quote,
+ .status__content__text,
+ .e-content {
+ overflow: hidden;
+@@ -123,6 +128,11 @@
+ font-style: italic;
+ }
+
++ i[role=img] {
++ font-style: normal;
++ padding-right: 0.25em;
++ }
++
+ sub {
+ font-size: smaller;
+ vertical-align: sub;
+@@ -686,6 +696,7 @@
+ }
+
+ a.status__display-name,
++.quote-indicator__display-name,
+ .reply-indicator__display-name,
+ .detailed-status__display-name,
+ .account__display-name {
+diff --git a/app/javascript/flavours/glitch/styles/contrast/diff.scss b/app/javascript/flavours/glitch/styles/contrast/diff.scss
+index 4fa1a0361..97b7aa9fe 100644
+--- a/app/javascript/flavours/glitch/styles/contrast/diff.scss
++++ b/app/javascript/flavours/glitch/styles/contrast/diff.scss
+@@ -14,6 +14,7 @@
+
+ .status__content a,
+ .link-footer a,
++.quote-indicator__content a,
+ .reply-indicator__content a,
+ .status__content__read-more-button {
+ text-decoration: underline;
+diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
+index 6489c2f80..660ea707c 100644
+--- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
++++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
+@@ -257,6 +257,7 @@ html {
+ }
+
+ // 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;
+@@ -662,6 +663,7 @@ html {
+ }
+ }
+
++.quote-indicator,
+ .reply-indicator {
+ background: transparent;
+ border: 1px solid lighten($ui-base-color, 8%);
+@@ -673,6 +675,7 @@ html {
+ }
+
+ .status__content,
++.quote-indicator__content,
+ .reply-indicator__content {
+ a {
+ color: $highlight-text-color;