diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index fe4279302..381bb2eb9 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -8,8 +8,12 @@ class Api::V1::Accounts::StatusesController < Api::BaseController def index cache_if_unauthenticated! - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + accounts = Account.where(id: account_ids) + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(accounts, current_user&.account_id) end private diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb index 498eb16f4..2d3c0a561 100644 --- a/app/controllers/api/v1/bookmarks_controller.rb +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -6,8 +6,12 @@ class Api::V1::BookmarksController < Api::BaseController after_action :insert_pagination_headers def index - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + accounts = Account.where(id: account_ids) + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(accounts, current_user&.account_id) end private diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index faf1bda96..4c34445eb 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -6,8 +6,12 @@ class Api::V1::FavouritesController < Api::BaseController after_action :insert_pagination_headers def index - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + accounts = Account.where(id: account_ids) + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(accounts, current_user&.account_id) end private diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 064e7632a..bc7d7076b 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -47,10 +47,14 @@ class Api::V1::StatusesController < Api::BaseController loaded_ancestors = cache_collection(ancestors_results, Status) loaded_descendants = cache_collection(descendants_results, Status) - @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) - statuses = [@status] + @context.ancestors + @context.descendants + @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) + statuses = [@status] + @context.ancestors + @context.descendants + account_ids = statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + accounts = Account.where(id: account_ids) - render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) + render json: @context, serializer: REST::ContextSerializer, + relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(accounts, current_user&.account_id) end def create @@ -68,7 +72,8 @@ class Api::V1::StatusesController < Api::BaseController poll: status_params[:poll], allowed_mentions: status_params[:allowed_mentions], idempotency: request.headers['Idempotency-Key'], - with_rate_limit: true + with_rate_limit: true, + quote_id: status_params[:quote_id].presence ) render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer @@ -138,6 +143,7 @@ class Api::V1::StatusesController < Api::BaseController :visibility, :language, :scheduled_at, + :quote_id, allowed_mentions: [], media_ids: [], media_attributes: [ diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index 36fdbea64..199d04e16 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -10,11 +10,16 @@ class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController with_read_replica do @statuses = load_statuses @relationships = StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + accounts = Account.where(id: account_ids) + + @account_relationships = AccountRelationshipsPresenter.new(accounts, current_user&.account_id) end render json: @statuses, each_serializer: REST::StatusSerializer, relationships: @relationships, + account_relationships: @account_relationships, status: account_home_feed.regenerating? ? 206 : 200 end diff --git a/app/controllers/api/v1/timelines/list_controller.rb b/app/controllers/api/v1/timelines/list_controller.rb index 14b884ecd..7e42e6490 100644 --- a/app/controllers/api/v1/timelines/list_controller.rb +++ b/app/controllers/api/v1/timelines/list_controller.rb @@ -9,9 +9,13 @@ class Api::V1::Timelines::ListController < Api::V1::Timelines::BaseController PERMITTED_PARAMS = %i(limit).freeze def show + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + accounts = Account.where(id: account_ids) + render json: @statuses, each_serializer: REST::StatusSerializer, - relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id) + relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id), + account_relationships: AccountRelationshipsPresenter.new(accounts, current_user&.account_id) end private diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index 35af8dc4b..a37b56110 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -7,8 +7,13 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController def show cache_if_unauthenticated! - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + accounts = Account.where(id: account_ids) + + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(accounts, current_user&.account_id) end private diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 4ba439dbb..97e245b09 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -8,8 +8,13 @@ class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController def show cache_if_unauthenticated! - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + accounts = Account.where(id: account_ids) + + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(accounts, current_user&.account_id) end private diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index 8440df6b7..00e148efc 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -11,6 +11,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio before_action :set_last_used_at_by_app, only: :index, unless: -> { request.format == :json } + before_action :set_last_used_at_by_app, only: :index, unless: -> { request.format == :json } + skip_before_action :require_functional! include Localized diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 945ef9b91..704b0c736 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -32,6 +32,7 @@ module ContextHelper 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, + quoteUrl: { 'quoteUrl' => 'as:quoteUrl' }, }.freeze def full_context diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb index 7d1423e52..3715fc27d 100644 --- a/app/helpers/formatting_helper.rb +++ b/app/helpers/formatting_helper.rb @@ -19,7 +19,12 @@ module FormattingHelper module_function :extract_status_plain_text def status_content_format(status) - html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [])) + html_aware_format( + status.text, + status.local?, + preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), + quote: status.respond_to?(:quote) && status.quote + ) end def rss_status_content_format(status) diff --git a/app/javascript/icons/favicon-16x16.png b/app/javascript/icons/favicon-16x16.png index eed8e0035..35593c443 100644 Binary files a/app/javascript/icons/favicon-16x16.png and b/app/javascript/icons/favicon-16x16.png differ diff --git a/app/javascript/icons/favicon-32x32.png b/app/javascript/icons/favicon-32x32.png index 9165746bc..acdeb7246 100644 Binary files a/app/javascript/icons/favicon-32x32.png and b/app/javascript/icons/favicon-32x32.png differ diff --git a/app/javascript/icons/favicon-48x48.png b/app/javascript/icons/favicon-48x48.png index 259676c0a..47a2493aa 100644 Binary files a/app/javascript/icons/favicon-48x48.png and b/app/javascript/icons/favicon-48x48.png differ diff --git a/app/javascript/images/y-zu-logo.svg b/app/javascript/images/y-zu-logo.svg new file mode 100644 index 000000000..1069b47fc --- /dev/null +++ b/app/javascript/images/y-zu-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/mastodon/actions/UtilBtns.js b/app/javascript/mastodon/actions/UtilBtns.js new file mode 100644 index 000000000..38dcb1e50 --- /dev/null +++ b/app/javascript/mastodon/actions/UtilBtns.js @@ -0,0 +1,89 @@ +import { changeCompose } from '../actions/compose'; + +export const UTILBTNS_GOJI = 'UTILBTNS_GOJI'; +export const UTILBTNS_HARUKIN = 'UTILBTNS_HARUKIN'; +export const UTILBTNS_RISA = 'UTILBTNS_RISA'; + +export function submitGoji (textarea) { + return function (dispatch, getState) { + if (!textarea.value) { + let text = [ + "#ゴジモリィィィィイイ", + ":goji:" + ].join("\r\n"); + + dispatch(submitGojiRequest()); + dispatch(changeCompose(text)); + + textarea.focus(); + } + } +} + +export function submitGojiRequest () { + return { + type: UTILBTNS_GOJI + } +} + +export function submitHarukin (textarea) { + return function (dispatch, getState) { + const HARUKINS = [":harukin: ", ":harukin_old: ", ":harukin_ika: ", ":harukin_tako: "]; + const MAX = 6; + + if (!textarea.value) { + let text = ""; + + let quantity = Math.round(Math.random() * MAX + 1); + let type = Math.round(Math.random() * (HARUKINS.length - 1)); + + let harukin = HARUKINS[type]; + + switch (quantity) { + default: + text = [ + harukin.repeat(quantity), + "🔥 ".repeat(quantity) + ].join("\r\n"); + + break; + + case MAX + 1: + text = `${harukin}💕\r\n`.repeat(6); + break; + } + + dispatch(submitHarukinRequest()); + dispatch(changeCompose(text)); + + textarea.focus(); + } + } +} + +export function submitHarukinRequest () { + return { + type: UTILBTNS_HARUKIN + } +} + +export function submitRisa (textarea) { + return function (dispatch, getState) { + if (!textarea.value) { + let text = [ + "@risa2 " + ].join("\r\n"); + + dispatch(submitRisaRequest()); + dispatch(changeCompose(text)); + + textarea.focus(); + } + } +} + +export function submitRisaRequest () { + return { + type: UTILBTNS_RISA + } +} \ No newline at end of file diff --git a/app/javascript/mastodon/actions/bookmarks.js b/app/javascript/mastodon/actions/bookmarks.js index 0b16f61e6..b19bd12f1 100644 --- a/app/javascript/mastodon/actions/bookmarks.js +++ b/app/javascript/mastodon/actions/bookmarks.js @@ -1,6 +1,8 @@ import api, { getLinks } from '../api'; +import { fetchRelationships } from './accounts'; import { importFetchedStatuses } from './importer'; +import { uniq } from '../utils/uniq'; export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST'; export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS'; @@ -21,6 +23,7 @@ export function fetchBookmarkedStatuses() { api(getState).get('/api/v1/bookmarks').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(fetchBookmarkedStatusesFail(error)); @@ -62,6 +65,7 @@ export function expandBookmarkedStatuses() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandBookmarkedStatusesFail(error)); diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 6abfd6157..cce3f46bb 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -24,6 +24,8 @@ export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; export const COMPOSE_REPLY = 'COMPOSE_REPLY'; export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; +export const COMPOSE_QUOTE = 'COMPOSE_QUOTE'; +export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL'; export const COMPOSE_DIRECT = 'COMPOSE_DIRECT'; export const COMPOSE_MENTION = 'COMPOSE_MENTION'; export const COMPOSE_RESET = 'COMPOSE_RESET'; @@ -126,6 +128,23 @@ export function cancelReplyCompose() { }; } +export function quoteCompose(status, routerHistory) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_QUOTE, + status: status, + }); + + ensureComposeIsVisible(getState, routerHistory); + }; +}; + +export function cancelQuoteCompose() { + return { + type: COMPOSE_QUOTE_CANCEL, + }; +}; + export function resetCompose() { return { type: COMPOSE_RESET, @@ -208,6 +227,7 @@ export function submitCompose(routerHistory) { visibility: getState().getIn(['compose', 'privacy']), poll: getState().getIn(['compose', 'poll'], null), language: getState().getIn(['compose', 'language']), + quote_id: getState().getIn(['compose', 'quote_from'], null), }, headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), diff --git a/app/javascript/mastodon/actions/favourites.js b/app/javascript/mastodon/actions/favourites.js index 2d4d4e620..9ca025325 100644 --- a/app/javascript/mastodon/actions/favourites.js +++ b/app/javascript/mastodon/actions/favourites.js @@ -1,6 +1,8 @@ import api, { getLinks } from '../api'; +import { fetchRelationships } from './accounts'; import { importFetchedStatuses } from './importer'; +import { uniq } from '../utils/uniq'; export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; @@ -21,6 +23,7 @@ export function fetchFavouritedStatuses() { api(getState).get('/api/v1/favourites').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(fetchFavouritedStatusesFail(error)); @@ -65,6 +68,7 @@ export function expandFavouritedStatuses() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandFavouritedStatusesFail(error)); diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 16f191b58..4edccd908 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -72,6 +72,10 @@ export function importFetchedStatuses(statuses) { processStatus(status.reblog); } + if (status.quote && status.quote.id) { + processStatus(status.quote); + } + if (status.poll && status.poll.id) { pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id]))); } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index b5a30343e..0b1f024a4 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -49,6 +49,8 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); normalStatus.spoiler_text = normalOldStatus.get('spoiler_text'); normalStatus.hidden = normalOldStatus.get('hidden'); + normalStatus.quote = normalOldStatus.get('quote'); + normalStatus.quote_hidden = normalOldStatus.get('quote_hidden'); if (normalOldStatus.get('translation')) { normalStatus.translation = normalOldStatus.get('translation'); @@ -69,6 +71,11 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; + + if (status.quote) { + normalStatus.quote = normalizeStatus(status.quote, null); + normalStatus.quote_hidden = normalStatus.quote.hidden; + } } if (normalOldStatus) { diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 3aed80735..cc28df31f 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -28,6 +28,9 @@ export const STATUS_REVEAL = 'STATUS_REVEAL'; export const STATUS_HIDE = 'STATUS_HIDE'; export const STATUS_COLLAPSE = 'STATUS_COLLAPSE'; +export const QUOTE_REVEAL = 'QUOTE_REVEAL'; +export const QUOTE_HIDE = 'QUOTE_HIDE'; + export const REDRAFT = 'REDRAFT'; export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST'; @@ -348,3 +351,25 @@ export const undoStatusTranslation = (id, pollId) => ({ id, pollId, }); + +export function hideQuote(ids) { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: QUOTE_HIDE, + ids, + }; +}; + +export function revealQuote(ids) { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: QUOTE_REVEAL, + ids, + }; +}; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 4ce7c3cf8..7c9ea8a59 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -4,6 +4,9 @@ import api, { getLinks } from 'mastodon/api'; import { compareId } from 'mastodon/compare_id'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; +import { uniq } from '../utils/uniq'; + +import { fetchRelationships } from './accounts'; import { importFetchedStatus, importFetchedStatuses } from './importer'; import { submitMarkers } from './markers'; @@ -45,6 +48,7 @@ export function updateTimeline(timeline, status, accept) { } dispatch(importFetchedStatus(status)); + dispatch(fetchRelationships([status.reblog ? status.reblog.account.id : status.account.id, status.quote ? status.quote.account.id : null].filter(x => x))); dispatch({ type: TIMELINE_UPDATE, @@ -118,6 +122,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => [item.reblog ? item.reblog.account.id : item.account.id, item.quote ? item.quote.account.id : null]).flat().filter(x => x)))); dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); if (timelineId === 'home' && !isLoadingMore && !isLoadingRecent) { diff --git a/app/javascript/mastodon/components/logo.tsx b/app/javascript/mastodon/components/logo.tsx index b7f8bd669..607560ce8 100644 --- a/app/javascript/mastodon/components/logo.tsx +++ b/app/javascript/mastodon/components/logo.tsx @@ -1,4 +1,4 @@ -import logo from '@/images/logo.svg'; +import logo from '@/images/y-zu-logo.svg'; export const WordmarkLogo: React.FC = () => ( @@ -9,4 +9,4 @@ export const WordmarkLogo: React.FC = () => ( export const SymbolLogo: React.FC = () => ( Mastodon -); +); \ No newline at end of file diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx index 91459a128..27a35a08e 100644 --- a/app/javascript/mastodon/components/media_gallery.jsx +++ b/app/javascript/mastodon/components/media_gallery.jsx @@ -221,6 +221,7 @@ class MediaGallery extends PureComponent { visible: PropTypes.bool, autoplay: PropTypes.bool, onToggleVisibility: PropTypes.func, + quote: PropTypes.bool, }; state = { @@ -291,7 +292,7 @@ class MediaGallery extends PureComponent { } render () { - const { media, lang, intl, sensitive, defaultWidth, autoplay } = this.props; + const { media, lang, intl, sensitive, defaultWidth, autoplay, quote } = this.props; const { visible } = this.state; const width = this.state.width || defaultWidth; @@ -305,6 +306,14 @@ class MediaGallery extends PureComponent { style.aspectRatio = '3 / 2'; } + if (quote && style.height) { + style.height /= 2; + } + + if (quote && style.height) { + style.height /= 2; + } + const size = media.take(4).size; const uncached = media.every(attachment => attachment.get('type') === 'unknown'); diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 1171351c5..479f1f920 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -35,6 +35,29 @@ import { VisibilityIcon } from './visibility_icon'; const domParser = new DOMParser(); +export const mapStateToProps = (state, props) => { + let status = props.status; + + if (status === null) { + return null; + } + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + status = status.get('reblog'); + } + + if (status.get('quote', null) === null) { + return { + quoteMuted: !!status.get('quote_id', null), + }; + } + + const id = status.getIn(['quote', 'account', 'id'], null); + return { + quoteMuted: id !== null && (state.getIn(['relationships', id, 'muting']) || state.getIn(['relationships', id, 'blocking']) || state.getIn(['relationships', id, 'blocked_by']) || state.getIn(['relationships', id, 'domain_blocking']) || status.getIn(['quote', 'muted'])), + }; +}; + export const textForScreenReader = (intl, status, rebloggedByText = false) => { const displayName = status.getIn(['account', 'display_name']); @@ -76,6 +99,60 @@ const messages = defineMessages({ edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, }); +export const quote = (status, muted, quoteMuted, handleQuoteClick, handleExpandedQuoteToggle, identity, media, router, contextType = 'home') => { + const quoteStatus = status.get('quote', null); + + if (!quoteStatus) { + return null; + } + + const quoteInner = (() => { + const hideUnlisted = quoteStatus.get('visibility') === 'unlisted' + && ['public', 'community', 'hashtag'].includes(contextType); + if (quoteMuted || hideUnlisted) { + const content = (() => { + if (quoteMuted) { + return ( + + ); + } + + return ( + + ); + })(); + + return ( +
+ {content} +
+ ); + } + + return ( +
+
+ {identity(quoteStatus, null, null, true)} +
+ + {media(quoteStatus, true)} +
+ ); + })(); + + return ( +
+ {quoteInner} +
+ ); +}; + class Status extends ImmutablePureComponent { static propTypes = { @@ -102,7 +179,9 @@ class Status extends ImmutablePureComponent { onToggleCollapsed: PropTypes.func, onTranslate: PropTypes.func, onInteractionModal: PropTypes.func, + onQuoteToggleHidden: PropTypes.func, muted: PropTypes.bool, + quoteMuted: PropTypes.bool, hidden: PropTypes.bool, unread: PropTypes.bool, onMoveUp: PropTypes.func, @@ -118,6 +197,7 @@ class Status extends ImmutablePureComponent { inUse: PropTypes.bool, available: PropTypes.bool, }), + contextType: PropTypes.string, ...WithOptionalRouterPropTypes, }; @@ -130,10 +210,12 @@ class Status extends ImmutablePureComponent { 'hidden', 'unread', 'pictureInPicture', + 'quoteMuted', ]; state = { showMedia: defaultMediaVisibility(this.props.status), + showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null), statusId: undefined, forceFilter: undefined, }; @@ -142,6 +224,7 @@ class Status extends ImmutablePureComponent { if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) { return { showMedia: defaultMediaVisibility(nextProps.status), + showQuoteMedia: defaultMediaVisibility(nextProps.status ? nextProps.status.get('quote', null) : null), statusId: nextProps.status.get('id'), }; } else { @@ -153,6 +236,10 @@ class Status extends ImmutablePureComponent { this.setState({ showMedia: !this.state.showMedia }); }; + handleToggleQuoteMediaVisibility = () => { + this.setState({ showQuoteMedia: !this.state.showQuoteMedia }); + } + handleClick = e => { if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) { return; @@ -182,6 +269,24 @@ class Status extends ImmutablePureComponent { this._openProfile(proper); }; + handleQuoteClick = () => { + if (!this.props) { + return; + } + + const { status } = this.props; + this.props.history.push(`/statuses/${status.getIn(['reblog', 'quote', 'id'], status.getIn(['quote', 'id']))}`); + } + + handleQuoteUserClick = () =>{ + if (!this.props) { + return; + } + + const { status } = this.props; + this.location.href(`/@${status.getIn(['account', 'acct'])}`); + } + handleExpandedToggle = () => { this.props.onToggleHidden(this._properStatus()); }; @@ -194,6 +299,10 @@ class Status extends ImmutablePureComponent { this.props.onTranslate(this._properStatus()); }; + handleExpandedQuoteToggle = () => { + this.props.onQuoteToggleHidden(this._properStatus()); + } + getAttachmentAspectRatio () { const attachments = this._properStatus().get('media_attachments'); @@ -348,7 +457,7 @@ class Status extends ImmutablePureComponent { }; render () { - const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props; + const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, quoteMuted, nextInReplyToId, rootId, contextType } = this.props; let { status, account, ...other } = this.props; @@ -370,7 +479,7 @@ class Status extends ImmutablePureComponent { openMedia: this.handleHotkeyOpenMedia, }; - let media, statusAvatar, prepend, rebloggedByText; + let prepend, rebloggedByText; if (hidden) { return ( @@ -445,99 +554,117 @@ class Status extends ImmutablePureComponent { ); } + const media = (status, quote = false) => { + if (pictureInPicture.get('inUse')) { + return ; + } else if (status.get('media_attachments').size > 0) { + const language = status.getIn(['translation', 'language']) || status.get('language'); - if (pictureInPicture.get('inUse')) { - media = ; - } else if (status.get('media_attachments').size > 0) { - const language = status.getIn(['translation', 'language']) || status.get('language'); + if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); - if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { - const attachment = status.getIn(['media_attachments', 0]); - const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); + return ( + + {Component => ( + + )} + + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + const attachment = status.getIn(['media_attachments', 0]); + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); - media = ( - - {Component => ( - - )} - - ); - } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - const attachment = status.getIn(['media_attachments', 0]); - const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); - - media = ( - - {Component => ( - - )} - - ); - } else { - media = ( - - {Component => ( - - )} - + return ( + + {Component => ( + + )} + + ); + } else { + return ( + + {Component => ( + + )} + + ); + } + } else if (status.get('spoiler_text').length === 0 && status.get('card')) { + return ( + ); } - } else if (status.get('spoiler_text').length === 0 && status.get('card')) { - media = ( - - ); - } + return null; + }; - if (account === undefined || account === null) { - statusAvatar = ; - } else { - statusAvatar = ; - } + const statusAvatar = (status, account) => { + if (account === undefined || account === null) { + return ; + } else { + return ; + } + }; + + const identity = (status, account, _0, quote = false) => ( + +
+ {statusAvatar(status, account)} +
+ + +
+ ); const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0; @@ -557,13 +684,15 @@ class Status extends ImmutablePureComponent { {status.get('edited_at') && *} - + {/*
{statusAvatar}
-
+ */} + + {identity(status, account, false)} - {media} + {media(status)} + {quote(status, this.props.muted, quoteMuted, this.handleQuoteClick, this.handleExpandedQuoteToggle, identity, media, this.props, contextType)} + {expanded && hashtagBar} @@ -590,4 +721,4 @@ class Status extends ImmutablePureComponent { } -export default withOptionalRouter(injectIntl(Status)); +export default withOptionalRouter(injectIntl(Status)); \ No newline at end of file diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index b111a6538..3a989ea42 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -16,6 +16,7 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; +import QuoteIcon from '@material-symbols/400-24px/format_quote.svg?react'; import StarBorderIcon from '@/material-icons/400-24px/star.svg?react'; import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react'; import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react'; @@ -46,6 +47,8 @@ const messages = defineMessages({ reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + quote: { id: 'status.quote', defaultMessage: 'Quote' }, + cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favorite' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' }, @@ -85,6 +88,7 @@ class StatusActionBar extends ImmutablePureComponent { onReply: PropTypes.func, onFavourite: PropTypes.func, onReblog: PropTypes.func, + onQuote: PropTypes.func, onDelete: PropTypes.func, onDirect: PropTypes.func, onMention: PropTypes.func, @@ -155,6 +159,10 @@ class StatusActionBar extends ImmutablePureComponent { } }; + handleQuoteClick = () => { + this.props.onQuote(this.props.status, this.props.history); + } + handleBookmarkClick = () => { this.props.onBookmark(this.props.status); }; @@ -248,6 +256,14 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onFilter(); }; + static quoteTitle = (intl, messages, publicStatus) => { + if (publicStatus) { + return intl.formatMessage(messages.quote); + } else { + return intl.formatMessage(messages.cannot_quote); + } + } + render () { const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props; const { signedIn, permissions } = this.context.identity; @@ -391,6 +407,7 @@ class StatusActionBar extends ImmutablePureComponent { + {filterButton} diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 4a7ba941e..ccbaf940c 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -15,6 +15,7 @@ import PollContainer from 'mastodon/containers/poll_container'; import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state'; const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) +const QUOTE_MAX_HEIGHT = 112; // 22px * 5 (+ 2px padding at the top) /** * @@ -83,6 +84,7 @@ class StatusContent extends PureComponent { onCollapsedToggle: PropTypes.func, languages: ImmutablePropTypes.map, intl: PropTypes.object, + quote: PropTypes.bool, // from react-router match: PropTypes.object.isRequired, location: PropTypes.object.isRequired, @@ -130,12 +132,12 @@ class StatusContent extends PureComponent { } if (status.get('collapsed', null) === null && onCollapsedToggle) { - const { collapsible, onClick } = this.props; + const { collapsible, onClick, quote } = this.props; const collapsed = collapsible && onClick - && node.clientHeight > MAX_HEIGHT + && node.clientHeight > (quote ? QUOTE_MAX_HEIGHT : MAX_HEIGHT) && status.get('spoiler_text').length === 0; onCollapsedToggle(collapsed); @@ -239,7 +241,7 @@ class StatusContent extends PureComponent { }; render () { - const { status, intl, statusContent } = this.props; + const { status, intl, statusContent, quote } = this.props; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; const renderReadMore = this.props.onClick && status.get('collapsed'); @@ -270,6 +272,12 @@ class StatusContent extends PureComponent { ); + if (quote) { + const doc = new DOMParser().parseFromString(content.__html, 'text/html').documentElement; + Array.from(doc.querySelectorAll('br')).forEach(nl => nl.replaceWith(' ')); + content.__html = doc.outerHTML; + } + if (status.get('spoiler_text').length > 0) { let mentionsPlaceholder = ''; diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index 7a7cd9880..fea108c23 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -11,6 +11,7 @@ import { initBlockModal } from '../actions/blocks'; import { initBoostModal } from '../actions/boosts'; import { replyCompose, + quoteCompose, mentionCompose, directCompose, } from '../actions/compose'; @@ -45,6 +46,8 @@ import { editStatus, translateStatus, undoStatusTranslation, + hideQuote, + revealQuote, } from '../actions/statuses'; import Status from '../components/status'; import { boostModal, deleteModal } from '../initial_state'; @@ -57,6 +60,8 @@ const messages = defineMessages({ 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' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + 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?' }, editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' }, editMessage: { id: 'confirmations.edit.message', defaultMessage: 'Editing now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' }, @@ -111,6 +116,22 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + 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)); + } + }); + }, + onFavourite (status) { if (status.get('favourited')) { dispatch(unfavourite(status)); @@ -253,6 +274,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ dispatch(toggleStatusCollapse(status.get('id'), isCollapsed)); }, + onQuoteToggleHidden (status) { + if (status.get('quote_hidden')) { + dispatch(revealQuote(status.get('id'))); + } else { + dispatch(hideQuote(status.get('id'))); + } + }, + onBlockDomain (domain) { dispatch(openModal({ modalType: 'CONFIRM', diff --git a/app/javascript/mastodon/features/audio/index.jsx b/app/javascript/mastodon/features/audio/index.jsx index fdc1b0be0..01b3601f4 100644 --- a/app/javascript/mastodon/features/audio/index.jsx +++ b/app/javascript/mastodon/features/audio/index.jsx @@ -61,6 +61,7 @@ class Audio extends PureComponent { volume: PropTypes.number, muted: PropTypes.bool, deployPictureInPicture: PropTypes.func, + quote: PropTypes.bool, }; state = { @@ -105,7 +106,11 @@ class Audio extends PureComponent { _setDimensions () { const width = this.player.offsetWidth; - const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9)); + let height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9)); + + if (this.props.quote) { + height /= 2; + } if (this.props.cacheWidth) { this.props.cacheWidth(width); diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index b93bac9d1..bfd193708 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -39,6 +39,9 @@ const messages = defineMessages({ publish: { id: 'compose_form.publish', defaultMessage: 'Post' }, saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Update' }, reply: { id: 'compose_form.reply', defaultMessage: 'Reply' }, + utilBtns_goji: { id: 'compose_form.utilBtns_goji', defaultMessage: 'Typo!!!' }, + utilBtns_harukin: { id: 'compose_form.utilBtns_harukin', defaultMessage: 'Burn Harukin' }, + utilBtns_risa: { id: 'compose_form.utilBtns_risa', defaultMessage: 'Risa' } }); class ComposeForm extends ImmutablePureComponent { @@ -71,6 +74,9 @@ class ComposeForm extends ImmutablePureComponent { singleColumn: PropTypes.bool, lang: PropTypes.string, maxChars: PropTypes.number, + onGojiSubmit: PropTypes.func.isRequired, + onHarukinSubmit: PropTypes.func.isRequired, + onRisaSubmit: PropTypes.func.isRequired, ...WithOptionalRouterPropTypes }; @@ -224,6 +230,10 @@ class ComposeForm extends ImmutablePureComponent { this.props.onPickEmoji(position, data, needsSpace); }; + handleOnGojiSubmit = () => this.props.onGojiSubmit(this.textareaRef.current); + handleOnHarukinSubmit = () => this.props.onHarukinSubmit(this.textareaRef.current); + handleOnRisaSubmit = () => this.props.onRisaSubmit(this.textareaRef.current); + render () { const { intl, onPaste, autoFocus, withoutNavigation, maxChars } = this.props; const { highlighted } = this.state; @@ -311,6 +321,10 @@ class ComposeForm extends ImmutablePureComponent { +
+
); } diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.jsx b/app/javascript/mastodon/features/compose/components/reply_indicator.jsx index b7959e211..baf763370 100644 --- a/app/javascript/mastodon/features/compose/components/reply_indicator.jsx +++ b/app/javascript/mastodon/features/compose/components/reply_indicator.jsx @@ -45,4 +45,4 @@ export const ReplyIndicator = () => { ); -}; +}; \ No newline at end of file diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index b5e530033..34542b17a 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -10,6 +10,11 @@ import { insertEmojiCompose, uploadCompose, } from '../../../actions/compose'; +import { + submitGoji, + submitHarukin, + submitRisa +} from '../../../actions/UtilBtns'; import ComposeForm from '../components/compose_form'; const mapStateToProps = state => ({ @@ -65,6 +70,18 @@ const mapDispatchToProps = (dispatch) => ({ dispatch(insertEmojiCompose(position, data, needsSpace)); }, + onRisaSubmit (textarea) { + dispatch(submitRisa(textarea)); + }, + + onGojiSubmit (textarea) { + dispatch(submitGoji(textarea)); + }, + + onHarukinSubmit (textarea) { + dispatch(submitHarukin(textarea)); + }, + }); export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm); diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js new file mode 100644 index 000000000..2eea64f96 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js @@ -0,0 +1,36 @@ +import { connect } from 'react-redux'; +import { cancelReplyCompose, cancelQuoteCompose } from '../../../actions/compose'; +import { makeGetStatus } from '../../../selectors'; +import ReplyIndicator from '../components/reply_indicator'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => { + let statusId = state.getIn(['compose', 'id'], null); + let editing = true; + + if (statusId === null) { + statusId = state.getIn(['compose', props.quote ? 'quote_from' : 'in_reply_to']); + editing = false; + } + + return { + status: getStatus(state, { id: statusId }), + quote: props.quote, + editing, + }; + }; + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + + onCancel (quote) { + dispatch(quote ? cancelQuoteCompose() : cancelReplyCompose()); + }, + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator); diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx b/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx index 8dfbf54cb..191133d05 100644 --- a/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx +++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx @@ -15,7 +15,7 @@ import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react'; import StarIcon from '@/material-icons/400-24px/star.svg?react'; import { initBoostModal } from 'mastodon/actions/boosts'; -import { replyCompose } from 'mastodon/actions/compose'; +import { replyCompose, quoteCompose } from 'mastodon/actions/compose'; import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions'; import { openModal } from 'mastodon/actions/modal'; import { IconButton } from 'mastodon/components/icon_button'; @@ -30,9 +30,13 @@ const messages = defineMessages({ reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + quote: { id: 'status.quote', defaultMessage: 'Quote' }, + cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favorite' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + 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?' }, open: { id: 'status.open', defaultMessage: 'Expand this status' }, }); @@ -154,6 +158,31 @@ class Footer extends ImmutablePureComponent { } }; + _performQuote = () => { + const { dispatch, status, onClose } = this.props; + const { router } = this.context; + + if (onClose) { + onClose(); + } + + dispatch(quoteCompose(status, router.history)); + } + + handleQuoteClick = () => { + const { dispatch, askReplyConfirmation, intl } = this.props; + + if (askReplyConfirmation) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.quoteMessage), + confirm: intl.formatMessage(messages.quoteConfirm), + onConfirm: this._performQuote, + })); + } else { + this._performQuote(); + } + } + handleOpenClick = e => { if (e.button !== 0 || !history) { return; diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index c243a4912..8530f8976 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -16,6 +16,7 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; +import QuoteIcon from '@/material-icons/400-24px/format_quote.svg?react'; import StarBorderIcon from '@/material-icons/400-24px/star.svg?react'; import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react'; import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react'; @@ -25,6 +26,7 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/ import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { IconButton } from '../../../components/icon_button'; +import StatusActionBar from '../../../components/status_action_bar'; import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; import { me } from '../../../initial_state'; @@ -39,6 +41,8 @@ const messages = defineMessages({ reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + quote: { id: 'status.quote', defaultMessage: 'Quote' }, + cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favorite' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, more: { id: 'status.more', defaultMessage: 'More' }, @@ -77,6 +81,7 @@ class ActionBar extends PureComponent { relationship: ImmutablePropTypes.map, onReply: PropTypes.func.isRequired, onReblog: PropTypes.func.isRequired, + onQuote: PropTypes.func, onFavourite: PropTypes.func.isRequired, onBookmark: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, @@ -105,6 +110,10 @@ class ActionBar extends PureComponent { this.props.onReblog(this.props.status, e); }; + handleQuoteClick = () => { + this.props.onQuote(this.props.status, this.props.history); + } + handleFavouriteClick = () => { this.props.onFavourite(this.props.status); }; @@ -315,8 +324,8 @@ class ActionBar extends PureComponent {
+
-
diff --git a/app/javascript/mastodon/features/status/components/card.jsx b/app/javascript/mastodon/features/status/components/card.jsx index f37b558c4..f64d489bb 100644 --- a/app/javascript/mastodon/features/status/components/card.jsx +++ b/app/javascript/mastodon/features/status/components/card.jsx @@ -62,6 +62,7 @@ export default class Card extends PureComponent { card: ImmutablePropTypes.map, onOpenMedia: PropTypes.func.isRequired, sensitive: PropTypes.bool, + quote: PropTypes.bool, }; state = { @@ -121,7 +122,7 @@ export default class Card extends PureComponent { } render () { - const { card } = this.props; + const { card, quote } = this.props; const { embedded, revealed } = this.state; if (card === null) { diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx index 437e9e86b..259f9864b 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.jsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx @@ -22,6 +22,7 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { Avatar } from '../../../components/avatar'; import { DisplayName } from '../../../components/display_name'; import MediaGallery from '../../../components/media_gallery'; +import { quote } from '../../../components/status'; import StatusContent from '../../../components/status_content'; import Audio from '../../audio'; import scheduleIdleTask from '../../ui/util/schedule_idle_task'; @@ -36,17 +37,21 @@ class DetailedStatus extends ImmutablePureComponent { onOpenMedia: PropTypes.func.isRequired, onOpenVideo: PropTypes.func.isRequired, onToggleHidden: PropTypes.func.isRequired, + onQuoteToggleHidden: PropTypes.func.isRequired, onTranslate: PropTypes.func.isRequired, measureHeight: PropTypes.bool, onHeightChange: PropTypes.func, domain: PropTypes.string.isRequired, compact: PropTypes.bool, + quoteMuted: PropTypes.bool, showMedia: PropTypes.bool, + showQuoteMedia: PropTypes.bool, pictureInPicture: ImmutablePropTypes.contains({ inUse: PropTypes.bool, available: PropTypes.bool, }), onToggleMediaVisibility: PropTypes.func, + onQuoteToggleMediaVisibility: PropTypes.func, ...WithRouterPropTypes, }; @@ -71,6 +76,28 @@ class DetailedStatus extends ImmutablePureComponent { this.props.onToggleHidden(this.props.status); }; + handleExpandedQuoteToggle = () => { + this.props.onQuoteToggleHidden(this.props.status); + } + + handleQuoteClick = () => { + if (!this.props) { + return; + } + + const { status } = this.props; + this.props.history.push(`/statuses/${status.getIn(['quote', 'id'])}`); + } + + handleQuoteUserClick = () =>{ + if (!this.props) { + return; + } + + const { status } = this.props; + this.location.href(`/@${status.getIn(['account', 'acct'])}`); + } + _measureHeight (heightJustChanged) { if (this.props.measureHeight && this.node) { scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 })); @@ -134,13 +161,12 @@ class DetailedStatus extends ImmutablePureComponent { render () { const status = this._properStatus(); const outerStyle = { boxSizing: 'border-box' }; - const { compact, pictureInPicture } = this.props; + const { compact, pictureInPicture, quoteMuted } = this.props; if (!status) { return null; } - let media = ''; let applicationLink = ''; let reblogLink = ''; const reblogIcon = 'retweet'; @@ -154,69 +180,82 @@ class DetailedStatus extends ImmutablePureComponent { const language = status.getIn(['translation', 'language']) || status.get('language'); - if (pictureInPicture.get('inUse')) { - media = ; - } else if (status.get('media_attachments').size > 0) { - if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { - const attachment = status.getIn(['media_attachments', 0]); - const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); + const identity = (status, _0, _1, quote = false) => ( + +
+ +
+ ); - media = ( -