diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 000000000..fd6f74689
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,32 @@
+# CODEOWNERS for mastodon/mastodon
+
+# Translators
+# To add translator, copy these lines, replace `fr` with appropriate language code and replace `@żelipapą` with user's GitHub nickname preceded by `@` sign or e-mail address.
+# /app/javascript/mastodon/locales/fr.json @żelipapą
+# /app/views/user_mailer/*.fr.html.erb @żelipapą
+# /app/views/user_mailer/*.fr.text.erb @żelipapą
+# /config/locales/*.fr.yml @żelipapą
+# /config/locales/fr.yml @żelipapą
+
+# Polish
+/app/javascript/mastodon/locales/pl.json @m4sk1n
+/app/views/user_mailer/*.pl.html.erb @m4sk1n
+/app/views/user_mailer/*.pl.text.erb @m4sk1n
+/config/locales/*.pl.yml @m4sk1n
+/config/locales/pl.yml @m4sk1n
+
+# French
+/app/javascript/mastodon/locales/fr.json @aldarone
+/app/javascript/mastodon/locales/whitelist_fr.json @aldarone
+/app/views/user_mailer/*.fr.html.erb @aldarone
+/app/views/user_mailer/*.fr.text.erb @aldarone
+/config/locales/*.fr.yml @aldarone
+/config/locales/fr.yml @aldarone
+
+# Dutch
+/app/javascript/mastodon/locales/nl.json @jeroenpraat
+/app/javascript/mastodon/locales/whitelist_nl.json @jeroenpraat
+/app/views/user_mailer/*.nl.html.erb @jeroenpraat
+/app/views/user_mailer/*.nl.text.erb @jeroenpraat
+/config/locales/*.nl.yml @jeroenpraat
+/config/locales/nl.yml @jeroenpraat
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index bb1fccf7c..000000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-# To get started with Dependabot version updates, you'll need to specify which
-# package ecosystems to update and where the package manifests are located.
-# Please see the documentation for all configuration options:
-# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
-
-version: 2
-updates:
- - package-ecosystem: npm
- directory: '/'
- schedule:
- interval: weekly
- open-pull-requests-limit: 99
- allow:
- - dependency-type: direct
-
- - package-ecosystem: bundler
- directory: '/'
- schedule:
- interval: weekly
- open-pull-requests-limit: 99
- allow:
- - dependency-type: direct
diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml
index 75c7b54a6..60fd2ae1f 100644
--- a/.github/workflows/build-image.yml
+++ b/.github/workflows/build-image.yml
@@ -5,11 +5,7 @@ on:
branches:
- 'main'
tags:
- - '*'
- pull_request:
- paths:
- - .github/workflows/build-image.yml
- - Dockerfile
+ - "*"
jobs:
build-image:
runs-on: ubuntu-latest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd0ccc5f7..34b0236b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1695,7 +1695,7 @@ All notable changes to this project will be documented in this file.
- Change Docker image to use Ubuntu with jemalloc ([Sir-Boops](https://github.com/mastodon/mastodon/pull/10100), [BenLubar](https://github.com/mastodon/mastodon/pull/10212))
- Change public pages to be cacheable by proxies ([BenLubar](https://github.com/mastodon/mastodon/pull/9059))
- Change the 410 gone response for suspended accounts to be cacheable by proxies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10339))
-- Change web UI to not empty timeline of blocked users on block ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10359))
+- Change web UI to not not empty timeline of blocked users on block ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10359))
- Change JSON serializer to remove unused `@context` values ([Gargron](https://github.com/mastodon/mastodon/pull/10378))
- Change GIFV file size limit to be the same as for other videos ([rinsuki](https://github.com/mastodon/mastodon/pull/9924))
- Change Webpack to not use @babel/preset-env to compile node_modules ([ykzts](https://github.com/mastodon/mastodon/pull/10289))
diff --git a/Gemfile b/Gemfile
index b29bf6818..35fc2b145 100644
--- a/Gemfile
+++ b/Gemfile
@@ -98,6 +98,8 @@ gem 'json-ld'
gem 'json-ld-preloaded', '~> 3.2'
gem 'rdf-normalize', '~> 0.5'
+gem 'redcarpet', "~> 3.4.0"
+
group :development, :test do
gem 'fabrication', '~> 2.27'
gem 'fuubar', '~> 2.5'
diff --git a/Gemfile.lock b/Gemfile.lock
index d8b684ce8..1b92ad188 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -522,6 +522,14 @@ GEM
rdf-normalize (0.5.0)
rdf (~> 3.2)
redis (4.5.1)
+ redcarpet (3.4.0)
+ redis-actionpack (5.2.0)
+ actionpack (>= 5, < 7)
+ redis-rack (>= 2.1.0, < 3)
+ redis-store (>= 1.1.0, < 2)
+ redis-activesupport (5.2.0)
+ activesupport (>= 3, < 7)
+ redis-store (>= 1.3, < 2)
redis-namespace (1.8.2)
redis (>= 3.0.4)
regexp_parser (2.2.1)
@@ -807,6 +815,7 @@ DEPENDENCIES
rails-i18n (~> 6.0)
rails-settings-cached (~> 0.6)
rdf-normalize (~> 0.5)
+ redcarpet (~> 3.4.0)
redis (~> 4.5)
redis-namespace (~> 1.8)
rexml (~> 3.2)
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 38c9f5a20..33364c71d 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -7,8 +7,9 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) }
def index
- @statuses = load_statuses
- render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+ @statuses = load_statuses
+ accountIds = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+ render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), account_relationships: AccountRelationshipsPresenter.new(accountIds, 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 aa3fb88f0..f666a1d8d 100644
--- a/app/controllers/api/v1/bookmarks_controller.rb
+++ b/app/controllers/api/v1/bookmarks_controller.rb
@@ -6,8 +6,9 @@ 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
+ accountIds = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+ render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), account_relationships: AccountRelationshipsPresenter.new(accountIds, 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 21836bc17..651a057d5 100644
--- a/app/controllers/api/v1/favourites_controller.rb
+++ b/app/controllers/api/v1/favourites_controller.rb
@@ -6,8 +6,9 @@ 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
+ accountIds = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+ render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), account_relationships: AccountRelationshipsPresenter.new(accountIds, 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 3fe137bfd..35396ecde 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -31,8 +31,9 @@ class Api::V1::StatusesController < Api::BaseController
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.descendants
+ accountIds = statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
- 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(accountIds, current_user&.account_id)
end
def create
@@ -49,7 +50,8 @@ class Api::V1::StatusesController < Api::BaseController
application: doorkeeper_token.application,
poll: status_params[:poll],
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
@@ -108,6 +110,7 @@ class Api::V1::StatusesController < Api::BaseController
:visibility,
:language,
:scheduled_at,
+ :quote_id,
media_ids: [],
poll: [
:multiple,
diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb
index ae6dbcb8b..c07e1a820 100644
--- a/app/controllers/api/v1/timelines/home_controller.rb
+++ b/app/controllers/api/v1/timelines/home_controller.rb
@@ -6,11 +6,13 @@ class Api::V1::Timelines::HomeController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
- @statuses = load_statuses
+ @statuses = load_statuses
+ accountIds = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
render json: @statuses,
each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
+ account_relationships: AccountRelationshipsPresenter.new(accountIds, current_user&.account_id),
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 a15eae468..bc13ed5ec 100644
--- a/app/controllers/api/v1/timelines/list_controller.rb
+++ b/app/controllers/api/v1/timelines/list_controller.rb
@@ -9,9 +9,12 @@ class Api::V1::Timelines::ListController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
+ accountIds = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+
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(accountIds, 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 d253b744f..1a778012b 100644
--- a/app/controllers/api/v1/timelines/public_controller.rb
+++ b/app/controllers/api/v1/timelines/public_controller.rb
@@ -5,8 +5,10 @@ class Api::V1::Timelines::PublicController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
- @statuses = load_statuses
- render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+ @statuses = load_statuses
+ accountIds = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+
+ render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), account_relationships: AccountRelationshipsPresenter.new(accountIds, 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 64a1db58d..c7afd4580 100644
--- a/app/controllers/api/v1/timelines/tag_controller.rb
+++ b/app/controllers/api/v1/timelines/tag_controller.rb
@@ -5,8 +5,9 @@ class Api::V1::Timelines::TagController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
- @statuses = load_statuses
- render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+ @statuses = load_statuses
+ accountIds = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+ render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), account_relationships: AccountRelationshipsPresenter.new(accountIds, current_user&.account_id)
end
private
diff --git a/app/controllers/api/v2/media_controller.rb b/app/controllers/api/v2/media_controller.rb
index 0c1baf01d..de2c72313 100644
--- a/app/controllers/api/v2/media_controller.rb
+++ b/app/controllers/api/v2/media_controller.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+class Api::V2::MediaController < Api::V1::MediaController
+end
+__END__
class Api::V2::MediaController < Api::V1::MediaController
def create
@media_attachment = current_account.media_attachments.create!({ delay_processing: true }.merge(media_attachment_params))
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index c7492700c..bcfdb805b 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -54,6 +54,8 @@ class Settings::PreferencesController < Settings::BaseController
:setting_use_pending_items,
:setting_trends,
:setting_crop_images,
+ :setting_place_tab_bar_at_bottom,
+ :setting_show_tab_bar_label,
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag),
interactions: %i(must_be_follower must_be_following must_be_following_dm)
)
diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb
index 08cfa9c6d..9303add35 100644
--- a/app/helpers/context_helper.rb
+++ b/app/helpers/context_helper.rb
@@ -23,6 +23,7 @@ module ContextHelper
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, '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 e11156999..9bd603922 100644
--- a/app/helpers/formatting_helper.rb
+++ b/app/helpers/formatting_helper.rb
@@ -5,6 +5,10 @@ module FormattingHelper
HtmlAwareFormatter.new(text, local, options).to_s
end
+ def quotify(html, status, text, local, options = {})
+ QuoteFormatter.new(html, status, text, local, options).to_s
+ end
+
def linkify(text, options = {})
TextFormatter.new(text, options).to_s
end
diff --git a/app/javascript/fonts/MaterialIcons-Regular.ttf b/app/javascript/fonts/MaterialIcons-Regular.ttf
new file mode 100644
index 000000000..e50801b3b
Binary files /dev/null and b/app/javascript/fonts/MaterialIcons-Regular.ttf differ
diff --git a/app/javascript/images/logo.svg b/app/javascript/images/logo.svg
index 034a9c221..82a6025a7 100644
--- a/app/javascript/images/logo.svg
+++ b/app/javascript/images/logo.svg
@@ -1 +1,17 @@
-
+
+
+
diff --git a/app/javascript/images/logo_alt.svg b/app/javascript/images/logo_alt.svg
index 102d4c787..4c04c93b8 100644
--- a/app/javascript/images/logo_alt.svg
+++ b/app/javascript/images/logo_alt.svg
@@ -1 +1,16 @@
-
+
+
+
diff --git a/app/javascript/mastodon/actions/UtilBtns.js b/app/javascript/mastodon/actions/UtilBtns.js
new file mode 100644
index 000000000..52ccc6877
--- /dev/null
+++ b/app/javascript/mastodon/actions/UtilBtns.js
@@ -0,0 +1,71 @@
+import { changeCompose } from '../actions/compose';
+
+
+
+export const UTILBTNS_GOJI = 'UTILBTNS_GOJI';
+export const UTILBTNS_HARUKIN = 'UTILBTNS_HARUKIN';
+
+
+
+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
+ }
+}
diff --git a/app/javascript/mastodon/actions/bookmarks.js b/app/javascript/mastodon/actions/bookmarks.js
index 544ed2ff2..b3e0a2ab4 100644
--- a/app/javascript/mastodon/actions/bookmarks.js
+++ b/app/javascript/mastodon/actions/bookmarks.js
@@ -1,5 +1,7 @@
+import { fetchRelationships } from './accounts';
import api, { getLinks } from '../api';
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';
@@ -20,6 +22,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));
@@ -61,6 +64,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 b0a217550..1e99e5afd 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -21,6 +21,8 @@ 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_DIRECT = 'COMPOSE_DIRECT';
+export const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
+export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_RESET = 'COMPOSE_RESET';
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
@@ -118,6 +120,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,
@@ -169,6 +188,7 @@ export function submitCompose(routerHistory) {
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
+ 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 9448b1efe..9b28ac4c4 100644
--- a/app/javascript/mastodon/actions/favourites.js
+++ b/app/javascript/mastodon/actions/favourites.js
@@ -1,5 +1,7 @@
+import { fetchRelationships } from './accounts';
import api, { getLinks } from '../api';
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';
@@ -20,6 +22,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));
@@ -64,6 +67,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 f4372fb31..35032fc70 100644
--- a/app/javascript/mastodon/actions/importer/index.js
+++ b/app/javascript/mastodon/actions/importer/index.js
@@ -70,6 +70,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));
}
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index ca76e3494..934bf74f4 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -63,6 +63,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');
} else {
// If the status has a CW but no contents, treat the CW as if it were the
// status' contents, to avoid having a CW toggle with seemingly no effect.
@@ -79,6 +81,29 @@ 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 && status.quote.id) {
+ const quote_spoilerText = status.quote.spoiler_text || '';
+ const quote_searchContent = [quote_spoilerText, status.quote.content].join('\n\n').replace(/
/g, '\n').replace(/<\/p>
/g, '\n\n');
+
+ const quote_emojiMap = makeEmojiMap(normalStatus.quote);
+
+ const quote_account_emojiMap = makeEmojiMap(status.quote.account);
+ const displayName = normalStatus.quote.account.display_name.length === 0 ? normalStatus.quote.account.username : normalStatus.quote.account.display_name;
+ normalStatus.quote.account.display_name_html = emojify(escapeTextContentForBrowser(displayName), quote_account_emojiMap);
+ normalStatus.quote.search_index = domParser.parseFromString(quote_searchContent, 'text/html').documentElement.textContent;
+ let docElem = domParser.parseFromString(normalStatus.quote.content, 'text/html').documentElement;
+ Array.from(docElem.querySelectorAll('p,br'), line => {
+ let parentNode = line.parentNode;
+ if (line.nextSibling) {
+ parentNode.insertBefore(document.createTextNode(' '), line.nextSibling);
+ }
+ });
+ let _contentHtml = docElem.textContent;
+ normalStatus.quote.contentHtml = '
'+emojify(_contentHtml.substr(0, 150), quote_emojiMap) + (_contentHtml.substr(150) ? '...' : '')+'
';
+ normalStatus.quote.spoilerHtml = emojify(escapeTextContentForBrowser(quote_spoilerText), quote_emojiMap);
+ normalStatus.quote_hidden = expandSpoilers ? false : quote_spoilerText.length > 0 || normalStatus.quote.sensitive;
+ }
}
return normalStatus;
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index adc24eabf..77edd45d7 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -30,6 +30,9 @@ export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
export const REDRAFT = 'REDRAFT';
+export const QUOTE_REVEAL = 'QUOTE_REVEAL';
+export const QUOTE_HIDE = 'QUOTE_HIDE';
+
export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST';
export const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS';
export const STATUS_FETCH_SOURCE_FAIL = 'STATUS_FETCH_SOURCE_FAIL';
@@ -310,3 +313,25 @@ export function toggleStatusCollapse(id, isCollapsed) {
isCollapsed,
};
}
+
+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 31ae09e4a..d82bf1d0d 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -1,9 +1,11 @@
+import { fetchRelationships } from './accounts';
import { importFetchedStatus, importFetchedStatuses } from './importer';
import { submitMarkers } from './markers';
import api, { getLinks } from 'mastodon/api';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from 'mastodon/compare_id';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
+import { uniq } from '../utils/uniq';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
@@ -39,6 +41,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(function(e){return e})));
dispatch({
type: TIMELINE_UPDATE,
@@ -111,6 +114,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
api(getState).get(path, { params }).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).concat(response.data.map(item => item.quote ? item.quote.account.id : null)).filter(function(e){return e}))));
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
if (timelineId === 'home') {
diff --git a/app/javascript/mastodon/components/announcement_icon_button.js b/app/javascript/mastodon/components/announcement_icon_button.js
new file mode 100644
index 000000000..0de58c6f7
--- /dev/null
+++ b/app/javascript/mastodon/components/announcement_icon_button.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import Motion from 'react-motion/lib/Motion';
+import spring from 'react-motion/lib/spring';
+import PropTypes from 'prop-types';
+
+class IconButton extends React.PureComponent {
+
+ static propTypes = {
+ className: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ icon: PropTypes.string.isRequired,
+ onClick: PropTypes.func,
+ size: PropTypes.number,
+ active: PropTypes.bool,
+ style: PropTypes.object,
+ activeStyle: PropTypes.object,
+ disabled: PropTypes.bool,
+ inverted: PropTypes.bool,
+ animate: PropTypes.bool,
+ overlay: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ size: 18,
+ active: false,
+ disabled: false,
+ animate: false,
+ overlay: false,
+ };
+
+ handleClick = (e) => {
+ e.preventDefault();
+
+ if (!this.props.disabled) {
+ this.props.onClick(e);
+ }
+ }
+
+ render () {
+ const style = {
+ fontSize: `${this.props.size}px`,
+ width: `${this.props.size * 1.28571429}px`,
+ height: `${this.props.size * 1.28571429}px`,
+ lineHeight: `${this.props.size}px`,
+ ...this.props.style,
+ ...(this.props.active ? this.props.activeStyle : {}),
+ };
+
+ const classes = ['icon-button'];
+
+ if (this.props.active) {
+ classes.push('active');
+ }
+
+ if (this.props.disabled) {
+ classes.push('disabled');
+ }
+
+ if (this.props.inverted) {
+ classes.push('inverted');
+ }
+
+ if (this.props.overlay) {
+ classes.push('overlayed');
+ }
+
+ if (this.props.className) {
+ classes.push(this.props.className);
+ }
+
+ return (
+
+ {({ rotate }) =>
+
+ }
+
+ );
+ }
+
+}
+
+export default IconButton;
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index 2e7ce2e60..5c303a1a0 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -236,10 +236,12 @@ class MediaGallery extends React.PureComponent {
visible: PropTypes.bool,
autoplay: PropTypes.bool,
onToggleVisibility: PropTypes.func,
+ quote: PropTypes.bool,
};
static defaultProps = {
standalone: false,
+ quote: false,
};
state = {
@@ -310,7 +312,7 @@ class MediaGallery extends React.PureComponent {
}
render () {
- const { media, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props;
+ const { media, intl, sensitive, height, defaultWidth, standalone, autoplay, quote } = this.props;
const { visible } = this.state;
const width = this.state.width || defaultWidth;
@@ -332,6 +334,10 @@ class MediaGallery extends React.PureComponent {
const size = media.take(4).size;
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
+ if (quote && style.height) {
+ style.height /= 2;
+ }
+
if (standalone && this.isFullSizeEligible()) {
children = ;
} else {
diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js
index 85aa28816..c5ddcb165 100644
--- a/app/javascript/mastodon/components/poll.js
+++ b/app/javascript/mastodon/components/poll.js
@@ -49,6 +49,9 @@ class Poll extends ImmutablePureComponent {
static getDerivedStateFromProps (props, state) {
const { poll, intl } = props;
+ if (!poll) {
+ return null;
+ }
const expires_at = poll.get('expires_at');
const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now();
return (expired === state.expired) ? null : { expired };
@@ -69,7 +72,7 @@ class Poll extends ImmutablePureComponent {
_setupTimer () {
const { poll, intl } = this.props;
clearTimeout(this._timer);
- if (!this.state.expired) {
+ if (!this.state.expired && !!poll) {
const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now();
this._timer = setTimeout(() => {
this.setState({ expired: true });
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index fb370ca71..f3ce17c68 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -1,4 +1,5 @@
import React from 'react';
+import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from './avatar';
@@ -23,6 +24,29 @@ import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle';
+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 {
+ quote_muted: status.get('quote_id', null) ? true : false,
+ };
+ }
+ const id = status.getIn(['quote', 'account', 'id'], null);
+
+ return {
+ quote_muted: 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', 'quote_muted']),
+ };
+};
+
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
const displayName = status.getIn(['account', 'display_name']);
@@ -71,6 +95,7 @@ class Status extends ImmutablePureComponent {
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map,
otherAccounts: ImmutablePropTypes.list,
+ quote_muted: PropTypes.bool,
onClick: PropTypes.func,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
@@ -86,6 +111,7 @@ class Status extends ImmutablePureComponent {
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
onToggleCollapsed: PropTypes.func,
+ onQuoteToggleHidden: PropTypes.func,
muted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
@@ -102,6 +128,7 @@ class Status extends ImmutablePureComponent {
inUse: PropTypes.bool,
available: PropTypes.bool,
}),
+ contextType: PropTypes.string,
};
// Avoid checking props that are functions (and whose equality will always
@@ -113,10 +140,12 @@ class Status extends ImmutablePureComponent {
'hidden',
'unread',
'pictureInPicture',
+ 'quote_muted',
];
state = {
showMedia: defaultMediaVisibility(this.props.status),
+ showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
statusId: undefined,
};
@@ -124,6 +153,7 @@ class Status extends ImmutablePureComponent {
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
return {
showMedia: defaultMediaVisibility(nextProps.status),
+ showQuoteMedia: defaultMediaVisibility(nextProps.status.get('quote', null)),
statusId: nextProps.status.get('id'),
};
} else {
@@ -135,6 +165,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;
@@ -152,10 +186,20 @@ class Status extends ImmutablePureComponent {
}
handleAccountClick = (e, proper = true) => {
- if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
+ if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
+ return;
+ }
+ }
+ handleQuoteClick = () => {
+ if (!this.context.router) {
return;
}
+ const { status } = this.props;
+ this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'quote', 'id'], status.getIn(['quote', 'id']))}`);
+ }
+
+ handleAccountClick = (e) => {
if (e) {
e.preventDefault();
}
@@ -171,15 +215,19 @@ class Status extends ImmutablePureComponent {
this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
}
- renderLoadingMediaGallery () {
+ handleExpandedQuoteToggle = () => {
+ this.props.onQuoteToggleHidden(this._properStatus());
+ }
+
+ renderLoadingMediaGallery = () => {
return ;
}
- renderLoadingVideoPlayer () {
+ renderLoadingVideoPlayer = () => {
return ;
}
- renderLoadingAudioPlayer () {
+ renderLoadingAudioPlayer = () => {
return ;
}
@@ -188,10 +236,19 @@ class Status extends ImmutablePureComponent {
this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
}
+ handleOpenVideoQuote = (options) => {
+ const status = this._properQuoteStatus();
+ this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
+ }
+
handleOpenMedia = (media, index) => {
this.props.onOpenMedia(this._properStatus().get('id'), media, index);
}
+ handleOpenMediaQuote = (media, index) => {
+ this.props.onOpenMedia(this._properQuoteStatus().get('id'), media, index);
+ }
+
handleHotkeyOpenMedia = e => {
const { onOpenMedia, onOpenVideo } = this.props;
const status = this._properStatus();
@@ -279,7 +336,7 @@ class Status extends ImmutablePureComponent {
this.handleToggleMediaVisibility();
}
- _properStatus () {
+ _properStatus = () => {
const { status } = this.props;
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
@@ -289,15 +346,25 @@ class Status extends ImmutablePureComponent {
}
}
+ _properQuoteStatus = () => {
+ const status = this._properStatus();
+
+ if (status.get('quote', null) !== null && typeof status.get('quote') === 'object') {
+ return status.get('quote');
+ } else {
+ return status;
+ }
+ }
+
handleRef = c => {
this.node = c;
}
- render () {
+ render = () => {
let media = null;
let statusAvatar, prepend, rebloggedByText;
- const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture } = this.props;
+ const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, contextType, quote_muted } = this.props;
let { status, account, ...other } = this.props;
@@ -365,13 +432,13 @@ class Status extends ImmutablePureComponent {
rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: status.getIn(['account', 'acct']) });
account = status.get('account');
- status = status.get('reblog');
+ status = status.get('reblog');
}
- if (pictureInPicture.get('inUse')) {
- media = ;
- } else if (status.get('media_attachments').size > 0) {
- if (this.props.muted) {
+ if (status.get('media_attachments').size > 0) {
+ if (pictureInPicture.get('inUse')) {
+ media = ;
+ } else if (this.props.muted) {
media = (
0) {
+ if (pictureInPicture.get('inUse')) {
+ quote_media = ;
+ } else if (this.props.muted) {
+ quote_media = (
+
+ );
+ } else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+ const attachment = quote_status.getIn(['media_attachments', 0]);
+
+ quote_media = (
+
+ {Component => (
+
+ )}
+
+ );
+ } else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ const attachment = quote_status.getIn(['media_attachments', 0]);
+
+ quote_media = (
+
+ {Component => (
+
+ )}
+
+ );
+ } else {
+ quote_media = (
+
+ {Component => (
+
+ )}
+
+ );
+ }
+ }
+
+ if (quote_muted) {
+ quote = (
+
+ );
+ } else if (quote_status.get('visibility') === 'unlisted' && !!contextType && ['public', 'community', 'hashtag'].includes(contextType.split(':', 2)[0])) {
+ quote = (
+
+ );
+ } else {
+ quote = (
+
+ );
+ }
+ } else if (quote_muted) {
+ quote = (
+
+ );
+ }
+
return (
@@ -498,6 +691,7 @@ class Status extends ImmutablePureComponent {
+ {quote}
{media}
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 1d8fe23da..c0ed844b1 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -24,7 +24,9 @@ const messages = defineMessages({
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+ cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ quote: { id: 'status.quote', defaultMessage: 'Quote' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
@@ -62,6 +64,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,
@@ -123,6 +126,14 @@ class StatusActionBar extends ImmutablePureComponent {
}
}
+ handleBookmarkClick = () => {
+ this.props.onBookmark(this.props.status);
+ }
+
+ handleReblogClick = (e) => {
+ this.props.onReblog(this.props.status, e);
+ }
+
_openInteractionDialog = type => {
window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
}
@@ -131,6 +142,10 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onBookmark(this.props.status);
}
+ handleQuoteClick = () => {
+ this.props.onQuote(this.props.status, this.context.router.history);
+ }
+
handleDeleteClick = () => {
this.props.onDelete(this.props.status, this.context.router.history);
}
@@ -334,6 +349,7 @@ class StatusActionBar extends ImmutablePureComponent {
+
{shareButton}
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index d01365afb..716e576f5 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -24,6 +24,7 @@ export default class StatusContent extends React.PureComponent {
onClick: PropTypes.func,
collapsable: PropTypes.bool,
onCollapsedToggle: PropTypes.func,
+ quote: PropTypes.bool,
};
state = {
@@ -125,6 +126,15 @@ export default class StatusContent extends React.PureComponent {
}
}
+ onQuoteClick = (statusId, e) => {
+ let statusUrl = `/statuses/${statusId}`;
+
+ if (this.context.router && e.button === 0) {
+ e.preventDefault();
+ this.context.router.history.push(statusUrl);
+ }
+ }
+
handleMouseDown = (e) => {
this.startXY = [e.clientX, e.clientY];
}
@@ -168,11 +178,12 @@ export default class StatusContent extends React.PureComponent {
}
render () {
- const { status } = this.props;
+ const { status, quote } = this.props;
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const renderReadMore = this.props.onClick && status.get('collapsed');
const renderViewThread = this.props.showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
+ const renderShowPoll = !!status.get('poll');
const content = { __html: status.get('contentHtml') };
const spoilerContent = { __html: status.get('spoilerHtml') };
@@ -194,6 +205,16 @@ export default class StatusContent extends React.PureComponent {
);
+ const showPollButton = (
+
+ );
+
+ const pollContainer = (
+
+ );
+
if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = '';
@@ -221,7 +242,7 @@ export default class StatusContent extends React.PureComponent {
- {!hidden && !!status.get('poll') &&
}
+ {!hidden && renderShowPoll && quote ? showPollButton : pollContainer}
{renderViewThread && showThreadButton}
@@ -231,7 +252,7 @@ export default class StatusContent extends React.PureComponent {
- {!!status.get('poll') &&
}
+ {renderShowPoll && quote ? showPollButton : pollContainer}
{renderViewThread && showThreadButton}
,
@@ -247,7 +268,7 @@ export default class StatusContent extends React.PureComponent {
- {!!status.get('poll') &&
}
+ {renderShowPoll && quote ? showPollButton : pollContainer}
{renderViewThread && showThreadButton}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index ef0aca13a..de7bd0b93 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -4,6 +4,7 @@ import Status from '../components/status';
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
import {
replyCompose,
+ quoteCompose,
mentionCompose,
directCompose,
} from '../actions/compose';
@@ -24,6 +25,8 @@ import {
hideStatus,
revealStatus,
toggleStatusCollapse,
+ hideQuote,
+ revealQuote,
editStatus,
} from '../actions/statuses';
import {
@@ -51,6 +54,8 @@ const messages = defineMessages({
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' },
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?' },
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
});
@@ -100,6 +105,22 @@ 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));
+ }
+ });
+ },
+
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
@@ -220,6 +241,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
},
+ onQuoteToggleHidden (status) {
+ if (status.get('quote_hidden')) {
+ dispatch(revealQuote(status.get('id')));
+ } else {
+ dispatch(hideQuote(status.get('id')));
+ }
+ },
+
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js
index ed8095f90..768169458 100644
--- a/app/javascript/mastodon/containers/timeline_container.js
+++ b/app/javascript/mastodon/containers/timeline_container.js
@@ -47,7 +47,9 @@ export default class TimelineContainer extends React.PureComponent {
- {timeline}
+
+ {timeline}
+
{ReactDOM.createPortal(
,
diff --git a/app/javascript/mastodon/features/compose/components/announcements.js b/app/javascript/mastodon/features/compose/components/announcements.js
new file mode 100644
index 000000000..ad7043e89
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/announcements.js
@@ -0,0 +1,127 @@
+import React from 'react';
+import Immutable from 'immutable';
+import PropTypes from 'prop-types';
+import Link from 'react-router-dom/Link';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from '../../../components/announcement_icon_button';
+import Motion from 'react-motion/lib/Motion';
+import spring from 'react-motion/lib/spring';
+
+const Collapsable = ({ fullHeight, minHeight, isVisible, children }) => (
+
+ {({ height }) =>
+
+ {children}
+
+ }
+
+);
+
+Collapsable.propTypes = {
+ fullHeight: PropTypes.number.isRequired,
+ minHeight: PropTypes.number.isRequired,
+ isVisible: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired,
+};
+
+const messages = defineMessages({
+ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
+ welcome: { id: 'welcome.message', defaultMessage: '{domain}へようこそ!' },
+ markdown: { id: 'markdown.list', defaultMessage: 'markdown一覧' },
+});
+
+const hashtags = Immutable.fromJS([
+ '神崎ドン自己紹介',
+]);
+
+class Announcements extends React.PureComponent {
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ homeSize: PropTypes.number,
+ isLoading: PropTypes.bool,
+ };
+
+ state = {
+ showId: null,
+ isLoaded: false,
+ };
+
+ onClick = (announcementId, currentState) => {
+ this.setState({ showId: currentState.showId === announcementId ? null : announcementId });
+ }
+ nl2br (text) {
+ return text.split(/(\n)/g).map((line, i) => {
+ if (line.match(/(\n)/g)) {
+ return React.createElement('br', { key: i });
+ }
+ return line;
+ });
+ }
+
+ render () {
+ const { intl } = this.props;
+
+ return (
+
+ );
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (!this.state.isLoaded) {
+ if (!nextProps.isLoading && (nextProps.homeSize === 0 || this.props.homeSize !== nextProps.homeSize)) {
+ this.setState({ isLoaded: true });
+ }
+ }
+ }
+
+}
+
+export default injectIntl(Announcements);
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index d7635da40..0997c4d7e 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -4,6 +4,7 @@ import Button from '../../../components/button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import QuoteIndicatorContainer from '../containers/quote_indicator_container';
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import AutosuggestInput from '../../../components/autosuggest_input';
import PollButtonContainer from '../containers/poll_button_container';
@@ -20,6 +21,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz';
import { countableText } from '../util/counter';
import Icon from 'mastodon/components/icon';
+import { UserCounter } from './user_counter';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
@@ -28,6 +30,8 @@ const messages = defineMessages({
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
+ utilBtns_goji: { id: 'compose_form.utilBtns_goji', defaultMessage: 'Typo!!!' },
+ utilBtns_harukin: { id: 'compose_form.utilBtns_harukin', defaultMessage: 'Burn Harukin' },
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
});
@@ -62,6 +66,9 @@ class ComposeForm extends ImmutablePureComponent {
onPickEmoji: PropTypes.func.isRequired,
showSearch: PropTypes.bool,
anyMedia: PropTypes.bool,
+ singleColumn: PropTypes.bool,
+ onGojiSubmit: PropTypes.func.isRequired,
+ onHarukinSubmit: PropTypes.func.isRequired,
isInReply: PropTypes.bool,
singleColumn: PropTypes.bool,
};
@@ -89,7 +96,7 @@ class ComposeForm extends ImmutablePureComponent {
const fulltext = this.getFulltextForCharacterCounting();
const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
- return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia));
+ return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 2048 || (isOnlyWhitespace && !anyMedia));
}
handleSubmit = () => {
@@ -201,6 +208,10 @@ class ComposeForm extends ImmutablePureComponent {
this.props.onPickEmoji(position, data, needsSpace);
}
+
+ handleOnGojiSubmit = () => this.props.onGojiSubmit(this.autosuggestTextarea.textarea);
+ handleOnHarukinSubmit = () => this.props.onHarukinSubmit(this.autosuggestTextarea.textarea);
+
render () {
const { intl, onPaste, showSearch } = this.props;
const disabled = this.props.isSubmitting;
@@ -219,6 +230,7 @@ class ComposeForm extends ImmutablePureComponent {
+
-
+
-
+
+
+
+
+
+
+
+
+
+
);
diff --git a/app/javascript/mastodon/features/compose/components/quote_indicator.js b/app/javascript/mastodon/features/compose/components/quote_indicator.js
new file mode 100644
index 000000000..1d4c6a341
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/quote_indicator.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from '../../../components/avatar';
+import IconButton from '../../../components/icon_button';
+import DisplayName from '../../../components/display_name';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import AttachmentList from 'mastodon/components/attachment_list';
+
+const messages = defineMessages({
+ cancel: { id: 'quote_indicator.cancel', defaultMessage: 'Cancel' },
+});
+
+export default @injectIntl
+class QuoteIndicator extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map,
+ onCancel: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleClick = () => {
+ this.props.onCancel();
+ }
+
+ handleAccountClick = (e) => {
+ if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+ }
+ }
+
+ render () {
+ const { status, intl } = this.props;
+
+ if (!status) {
+ return null;
+ }
+
+ const content = { __html: status.get('contentHtml') };
+
+ return (
+
+
+
+
+
+ {status.get('media_attachments').size > 0 && (
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/user_counter.js b/app/javascript/mastodon/features/compose/components/user_counter.js
new file mode 100644
index 000000000..e49d2f1a8
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/user_counter.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class UserCounter extends React.PureComponent {
+ render () {
+ return (
+ 10人
+ );
+ }
+}
diff --git a/app/javascript/mastodon/features/compose/containers/announcements_container.js b/app/javascript/mastodon/features/compose/containers/announcements_container.js
new file mode 100644
index 000000000..e1dbb29b9
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/announcements_container.js
@@ -0,0 +1,11 @@
+import { connect } from 'react-redux';
+import Announcements from '../components/announcements';
+
+const mapStateToProps = state => {
+ return {
+ homeSize: state.getIn(['timelines', 'home', 'items']).size,
+ isLoading: state.getIn(['timelines', 'home', 'isLoading']),
+ };
+};
+
+export default connect(mapStateToProps)(Announcements);
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 1be7633cc..bd2b3998e 100644
--- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -11,6 +11,11 @@ import {
uploadCompose,
} from '../../../actions/compose';
+import {
+ submitGoji,
+ submitHarukin
+} from '../../../actions/UtilBtns';
+
const mapStateToProps = state => ({
text: state.getIn(['compose', 'text']),
suggestions: state.getIn(['compose', 'suggestions']),
@@ -62,6 +67,14 @@ const mapDispatchToProps = (dispatch) => ({
onPickEmoji (position, data, needsSpace) {
dispatch(insertEmojiCompose(position, data, needsSpace));
},
+
+ onGojiSubmit (textarea) {
+ dispatch(submitGoji(textarea));
+ },
+
+ onHarukinSubmit (textarea) {
+ dispatch(submitHarukin(textarea));
+ },
});
diff --git a/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js b/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js
new file mode 100644
index 000000000..8a3ad4959
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { cancelQuoteCompose } from '../../../actions/compose';
+import { makeGetStatus } from '../../../selectors';
+import QuoteIndicator from '../components/quote_indicator';
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = state => ({
+ status: getStatus(state, { id: state.getIn(['compose', 'quote_from']) }),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+
+ onCancel () {
+ dispatch(cancelQuoteCompose());
+ },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(QuoteIndicator);
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index 663dd324f..e47772bd4 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { Fragment } from 'react';
import ComposeFormContainer from './containers/compose_form_container';
import NavigationContainer from './containers/navigation_container';
import PropTypes from 'prop-types';
@@ -14,16 +14,28 @@ import SearchResultsContainer from './containers/search_results_container';
import { changeComposing } from '../../actions/compose';
import { openModal } from 'mastodon/actions/modal';
import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
-import { mascot } from '../../initial_state';
+import { mascot, show_tab_bar_label } from '../../initial_state';
import Icon from 'mastodon/components/icon';
import { logOut } from 'mastodon/utils/log_out';
+import AnnouncementsContainer from './containers/announcements_container';
+import NotificationsCounterIcon from '../ui/components/notifications_counter_icon';
+import classNames from 'classnames';
const messages = defineMessages({
+ short_start: { id: 'navigation_bar.short.getting_started', defaultMessage: 'Started' },
+ short_home_timeline: { id: 'navigation_bar.short.home', defaultMessage: 'Home' },
+ short_notifications: { id: 'navigation_bar.short.notifications', defaultMessage: 'Notif.' },
+ short_public: { id: 'navigation_bar.short.public_timeline', defaultMessage: 'FTL' },
+ short_community: { id: 'navigation_bar.short.community_timeline', defaultMessage: 'LTL' },
+ short_lists: { id: 'navigation_bar.short.lists', defaultMessage: 'Lists' },
+ short_preferences: { id: 'navigation_bar.short.preferences', defaultMessage: 'Pref.' },
+ short_logout: { id: 'navigation_bar.short.logout', defaultMessage: 'Logout' },
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+ lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' },
@@ -89,30 +101,54 @@ class Compose extends React.PureComponent {
this.props.dispatch(changeComposing(false));
}
+ tab (id) {
+ const { columns, intl: { formatMessage } } = this.props;
+
+ if (!columns.some(column => column.get('id') === id)) {
+ const tabParams = {
+ 'START': { to: '/getting-started', title: formatMessage(messages.start), label: formatMessage(messages.short_start), icon_id: 'bars' },
+ 'HOME': { to: '/timelines/home', title: formatMessage(messages.home_timeline), label: formatMessage(messages.short_home_timeline), icon_id: 'home' },
+ 'NOTIFICATIONS': { to: '/notifications', title: formatMessage(messages.notifications), label: formatMessage(messages.short_notifications), icon_id: 'bell' },
+ 'COMMUNITY': { to: '/timelines/public/local', title: formatMessage(messages.community), label: formatMessage(messages.short_community), icon_id: 'users' },
+ 'PUBLIC': { to: '/timelines/public', title: formatMessage(messages.public), label: formatMessage(messages.short_public), icon_id: 'globe' },
+ 'LIST': { to: '/lists', title: formatMessage(messages.lists), label: formatMessage(messages.short_lists), icon_id: 'list-ul' },
+ 'PREFERENCES': { href: '/settings/preferences', title: formatMessage(messages.preferences), label: formatMessage(messages.short_preferences), icon_id: 'cog' },
+ 'SIGN_OUT': { href: '/auth/sign_out', title: formatMessage(messages.logout), label: formatMessage(messages.short_logout), icon_id: 'sign-out', method: 'delete' },
+ };
+
+ const { href, to, title, label, icon_id, method } = tabParams[id];
+
+ const icon = (id === 'NOTIFICATIONS') ? : ;
+
+ if (href) {
+ return (
+ {icon}{label}
+ );
+ } else {
+ return (
+ {icon}{label}
+ );
+ }
+ }
+ return null;
+ }
+
render () {
const { multiColumn, showSearch, isSearchPage, intl } = this.props;
let header = '';
if (multiColumn) {
- const { columns } = this.props;
+ const defaultTabIds = ['START', 'HOME', 'NOTIFICATIONS', 'COMMUNITY', 'PUBLIC', 'LIST', 'PREFERENCES', 'SIGN_OUT'];
+// const defaultTabIds = ['START', 'HOME', 'NOTIFICATIONS', 'PUBLIC', 'LIST', 'PREFERENCES', 'SIGN_OUT'];
+
+ let tabs = defaultTabIds;
+
header = (
);
}
@@ -128,6 +164,7 @@ class Compose extends React.PureComponent {
+

diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index a9000a3d9..57bc6da37 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -18,6 +18,7 @@ import TrendsContainer from './containers/trends_container';
const messages = defineMessages({
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
+ admin_notifications: { id: 'tabs_bar.admin_notifications', defaultMessage: 'Admin Notifications' },
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.js b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
index 0cb42b25a..b01f0d97d 100644
--- a/app/javascript/mastodon/features/picture_in_picture/components/footer.js
+++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
@@ -7,7 +7,7 @@ import IconButton from 'mastodon/components/icon_button';
import classNames from 'classnames';
import { me, boostModal } from 'mastodon/initial_state';
import { defineMessages, injectIntl } from 'react-intl';
-import { replyCompose } from 'mastodon/actions/compose';
+import { replyCompose, quoteCompose } from 'mastodon/actions/compose';
import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions';
import { makeGetStatus } from 'mastodon/selectors';
import { initBoostModal } from 'mastodon/actions/boosts';
@@ -20,9 +20,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' },
+ cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
+ quote: { id: 'status.quote', defaultMessage: 'Quote' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
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' },
});
@@ -123,6 +127,31 @@ class Footer extends ImmutablePureComponent {
router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
}
+ _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();
+ }
+ }
+
render () {
const { status, intl, withOpenButton } = this.props;
@@ -156,6 +185,7 @@ class Footer extends ImmutablePureComponent {
+
{withOpenButton &&
}
);
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index 3f3ec0e71..e05e3af6e 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -18,7 +18,9 @@ const messages = defineMessages({
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+ cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ quote: { id: 'status.quote', defaultMessage: 'Quote' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
more: { id: 'status.more', defaultMessage: 'More' },
@@ -57,6 +59,7 @@ class ActionBar extends React.PureComponent {
relationship: ImmutablePropTypes.map,
onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired,
+ onQuote: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
@@ -84,6 +87,10 @@ class ActionBar extends React.PureComponent {
this.props.onReblog(this.props.status, e);
}
+ handleQuoteClick = () => {
+ this.props.onQuote(this.props.status, this.context.router.history);
+ }
+
handleFavouriteClick = () => {
this.props.onFavourite(this.props.status);
}
@@ -285,6 +292,7 @@ class ActionBar extends React.PureComponent {
+
{shareButton}
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index 90f9ae7ae..f4d95b4cb 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -60,6 +60,10 @@ const addAutoPlay = html => {
export default class Card extends React.PureComponent {
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
static propTypes = {
card: ImmutablePropTypes.map,
maxDescription: PropTypes.number,
@@ -68,6 +72,7 @@ export default class Card extends React.PureComponent {
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
sensitive: PropTypes.bool,
+ quote: PropTypes.bool,
};
static defaultProps = {
@@ -184,7 +189,7 @@ export default class Card extends React.PureComponent {
}
render () {
- const { card, maxDescription, compact } = this.props;
+ const { card, maxDescription, compact, quote } = this.props;
const { width, embedded, revealed } = this.state;
if (card === null) {
@@ -197,7 +202,11 @@ export default class Card extends React.PureComponent {
const className = classnames('status-card', { horizontal, compact, interactive });
const title = interactive ? {card.get('title')} : {card.get('title')};
const ratio = card.get('width') / card.get('height');
- const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
+ let height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
+
+ if (quote && height) {
+ height /= 2;
+ }
const description = (
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index c99e01f73..1b9d1ee5b 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -1,4 +1,5 @@
import React from 'react';
+import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar';
@@ -6,7 +7,7 @@ import DisplayName from '../../../components/display_name';
import StatusContent from '../../../components/status_content';
import MediaGallery from '../../../components/media_gallery';
import { Link } from 'react-router-dom';
-import { injectIntl, defineMessages, FormattedDate } from 'react-intl';
+import { injectIntl, defineMessages, FormattedDate, FormattedMessage } from 'react-intl';
import Card from './card';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from '../../video';
@@ -25,7 +26,31 @@ const messages = defineMessages({
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
-export default @injectIntl
+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 {
+ quote_muted: status.get('quote_id', null) ? true : false,
+ };
+ }
+ const id = status.getIn(['quote', 'account', 'id'], null);
+
+ return {
+ quote_muted: 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', 'quote_muted']),
+ };
+};
+
+export default @connect(mapStateToProps)
+@injectIntl
class DetailedStatus extends ImmutablePureComponent {
static contextTypes = {
@@ -34,8 +59,11 @@ class DetailedStatus extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
+ quote_muted: PropTypes.bool,
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
+ onOpenMediaQuote: PropTypes.func.isRequired,
+ onOpenVideoQuote: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired,
measureHeight: PropTypes.bool,
onHeightChange: PropTypes.func,
@@ -47,6 +75,9 @@ class DetailedStatus extends ImmutablePureComponent {
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func,
+ onQuoteToggleHidden: PropTypes.func.isRequired,
+ showQuoteMedia: PropTypes.bool,
+ onToggleQuoteMediaVisibility: PropTypes.func,
};
state = {
@@ -55,6 +86,7 @@ class DetailedStatus extends ImmutablePureComponent {
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
+ const id = e.currentTarget.getAttribute('data-id');
e.preventDefault();
this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
}
@@ -66,6 +98,10 @@ class DetailedStatus extends ImmutablePureComponent {
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options);
}
+ handleOpenVideoQuote = (options) => {
+ this.props.onOpenVideoQuote(this.props.status.getIn(['quote', 'media_attachments', 0]), options);
+ }
+
handleExpandedToggle = () => {
this.props.onToggleHidden(this.props.status);
}
@@ -103,8 +139,22 @@ class DetailedStatus extends ImmutablePureComponent {
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
}
+ handleExpandedQuoteToggle = () => {
+ this.props.onQuoteToggleHidden(this.props.status);
+ }
+
+ handleQuoteClick = () => {
+ if (!this.context.router) {
+ return;
+ }
+
+ const { status } = this.props;
+ this.context.router.history.push(`/statuses/${status.getIn(['quote', 'id'])}`);
+ }
+
render () {
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
+ const quote_muted = this.props.quote_muted
const outerStyle = { boxSizing: 'border-box' };
const { intl, compact, pictureInPicture } = this.props;
@@ -123,6 +173,95 @@ class DetailedStatus extends ImmutablePureComponent {
outerStyle.height = `${this.state.height}px`;
}
+ let quote = null;
+ if (status.get('quote', null) !== null) {
+ let quote_status = status.get('quote');
+
+ let quote_media = null;
+ if (quote_status.get('media_attachments').size > 0) {
+
+ if (quote_status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+ const attachment = quote_status.getIn(['media_attachments', 0]);
+
+ quote_media = (
+
+ );
+ } else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ const attachment = quote_status.getIn(['media_attachments', 0]);
+
+ quote_media = (
+
+ );
+ } else {
+ quote_media = (
+
+ );
+ }
+ }
+
+ if (quote_muted) {
+ quote = (
+
+ );
+ } else {
+ quote = (
+
+
+
+
+
+
+
+ {quote_media}
+
+ );
+ }
+ } else if (quote_muted) {
+ quote = (
+
+ );
+ }
+
if (pictureInPicture.get('inUse')) {
media =
;
} else if (status.get('media_attachments').size > 0) {
@@ -258,6 +397,7 @@ class DetailedStatus extends ImmutablePureComponent {
+ {quote}
{media}
diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
index bfed16620..8238f654d 100644
--- a/app/javascript/mastodon/features/status/containers/detailed_status_container.js
+++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
@@ -20,6 +20,8 @@ import {
deleteStatus,
hideStatus,
revealStatus,
+ hideQuote,
+ revealQuote,
} from '../../../actions/statuses';
import { initMuteModal } from '../../../actions/mutes';
import { initBlockModal } from '../../../actions/blocks';
@@ -136,6 +138,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(openModal('VIDEO', { media, options }));
},
+ onOpenMediaQuote (media, index) {
+ dispatch(openModal('MEDIA', { media, index }));
+ },
+
+ onOpenVideoQuote (media, options) {
+ dispatch(openModal('VIDEO', { media, options }));
+ },
+
onBlock (status) {
const account = status.get('account');
dispatch(initBlockModal(account));
@@ -165,6 +175,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
+ onQuoteToggleHidden (status) {
+ if (status.get('quote_hidden')) {
+ dispatch(revealQuote(status.get('id')));
+ } else {
+ dispatch(hideQuote(status.get('id')));
+ }
+ },
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 4d7f24834..cc8403af5 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -22,6 +22,7 @@ import {
} from '../../actions/interactions';
import {
replyCompose,
+ quoteCompose,
mentionCompose,
directCompose,
} from '../../actions/compose';
@@ -32,6 +33,8 @@ import {
editStatus,
hideStatus,
revealStatus,
+ hideQuote,
+ revealQuote,
} from '../../actions/statuses';
import {
unblockAccount,
@@ -69,6 +72,8 @@ const messages = defineMessages({
detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
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?' },
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
});
@@ -181,6 +186,7 @@ class Status extends ImmutablePureComponent {
state = {
fullscreen: false,
showMedia: defaultMediaVisibility(this.props.status),
+ showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
loadedStatusId: undefined,
};
@@ -199,7 +205,8 @@ class Status extends ImmutablePureComponent {
}
if (nextProps.status && nextProps.status.get('id') !== this.state.loadedStatusId) {
- this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get('id') });
+ this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get('id'),
+ showQuoteMedia: defaultMediaVisibility(nextProps.status.get('quote', null)) });
}
}
@@ -207,6 +214,10 @@ class Status extends ImmutablePureComponent {
this.setState({ showMedia: !this.state.showMedia });
}
+ handleToggleQuoteMediaVisibility = () => {
+ this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
+ }
+
handleFavouriteClick = (status) => {
if (status.get('favourited')) {
this.props.dispatch(unfavourite(status));
@@ -260,6 +271,19 @@ class Status extends ImmutablePureComponent {
}
}
+ handleQuoteClick = (status) => {
+ let { askReplyConfirmation, dispatch, intl } = this.props;
+ if (askReplyConfirmation) {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.quoteMessage),
+ confirm: intl.formatMessage(messages.quoteConfirm),
+ onConfirm: () => dispatch(quoteCompose(status, this.context.router.history)),
+ }));
+ } else {
+ dispatch(quoteCompose(status, this.context.router.history));
+ }
+ }
+
handleDeleteClick = (status, history, withRedraft = false) => {
const { dispatch, intl } = this.props;
@@ -294,6 +318,14 @@ class Status extends ImmutablePureComponent {
this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.get('id'), media, options }));
}
+ handleOpenMediaQuote = (media, index) => {
+ this.props.dispatch(openModal('MEDIA', { statusId: this.props.status.getIn(['quote', 'id']), media, index }));
+ }
+
+ handleOpenVideoQuote = (media, options) => {
+ this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.getIn(['quote', 'id']), media, options }));
+ }
+
handleHotkeyOpenMedia = e => {
const { status } = this.props;
@@ -328,6 +360,14 @@ class Status extends ImmutablePureComponent {
}
}
+ handleQuoteToggleHidden = (status) => {
+ if (status.get('quote_hidden')) {
+ this.props.dispatch(revealQuote(status.get('id')));
+ } else {
+ this.props.dispatch(hideQuote(status.get('id')));
+ }
+ }
+
handleToggleAll = () => {
const { status, ancestorsIds, descendantsIds } = this.props;
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
@@ -557,11 +597,16 @@ class Status extends ImmutablePureComponent {
status={status}
onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
+ onOpenVideoQuote={this.handleOpenVideoQuote}
+ onOpenMediaQuote={this.handleOpenMediaQuote}
onToggleHidden={this.handleToggleHidden}
domain={domain}
showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}
pictureInPicture={pictureInPicture}
+ onQuoteToggleHidden={this.handleQuoteToggleHidden}
+ showQuoteMedia={this.state.showQuoteMedia}
+ onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
/>
;
-
+ const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : ;
const content = columnIndex !== -1 ? (
-
+
{links.map(this.renderView)}
) : (
- {children}
+ {children}
);
return (
diff --git a/app/javascript/mastodon/features/ui/components/link_footer.js b/app/javascript/mastodon/features/ui/components/link_footer.js
index edf1104c4..78654a390 100644
--- a/app/javascript/mastodon/features/ui/components/link_footer.js
+++ b/app/javascript/mastodon/features/ui/components/link_footer.js
@@ -55,6 +55,7 @@ class LinkFooter extends React.PureComponent {
{profileDirectory && · }
·
·
+ ·
·
·
diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js
index 55668cab6..56afb6b3a 100644
--- a/app/javascript/mastodon/features/ui/components/tabs_bar.js
+++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js
@@ -6,14 +6,16 @@ import { debounce } from 'lodash';
import { isUserTouching } from '../../../is_mobile';
import Icon from 'mastodon/components/icon';
import NotificationsCounterIcon from './notifications_counter_icon';
+import { place_tab_bar_at_bottom, show_tab_bar_label } from 'mastodon/initial_state';
+import classNames from 'classnames';
export const links = [
- ,
- ,
- ,
- ,
- ,
- ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
];
export function getIndex (path) {
@@ -31,7 +33,7 @@ class TabsBar extends React.PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
- }
+ };
setRef = ref => {
this.node = ref;
@@ -74,8 +76,8 @@ class TabsBar extends React.PureComponent {
return (
-