diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index c4cd48878..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/Gemfile b/Gemfile
index a7187d691..a973753f8 100644
--- a/Gemfile
+++ b/Gemfile
@@ -99,6 +99,8 @@ gem 'json-ld'
gem 'json-ld-preloaded', '~> 3.1'
gem 'rdf-normalize', '~> 0.4'
+gem 'redcarpet', "~> 3.4.0"
+
group :development, :test do
gem 'fabrication', '~> 2.22'
gem 'fuubar', '~> 2.5'
diff --git a/Gemfile.lock b/Gemfile.lock
index 201893d47..8042f546c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -491,6 +491,14 @@ GEM
rdf-normalize (0.4.0)
rdf (~> 3.1)
redis (4.2.5)
+ 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.1)
redis (>= 3.0.4)
regexp_parser (2.1.1)
@@ -768,6 +776,7 @@ DEPENDENCIES
rails-i18n (~> 6.0)
rails-settings-cached (~> 0.6)
rdf-normalize (~> 0.4)
+ redcarpet (~> 3.4.0)
redis (~> 4.2)
redis-namespace (~> 1.8)
resolv (~> 0.1.0)
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 92ccb8061..f301666db 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 106fc8224..d8f4db42f 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -30,8 +30,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
@@ -46,7 +47,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
end
@@ -85,6 +87,7 @@ class Api::V1::StatusesController < Api::BaseController
:spoiler_text,
:visibility,
: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 32b5d7948..6598e9ea1 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -55,6 +55,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/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 891403969..fb4d26cf2 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -20,6 +20,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';
@@ -100,6 +102,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,
@@ -147,6 +166,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 5002292b9..1b9d781a7 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -62,6 +62,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.
@@ -78,6 +80,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/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 3464ac995..22f1e0cbb 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -46,7 +46,7 @@ defineMessages({
});
const fetchRelatedRelationships = (dispatch, notifications) => {
- const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
+ const accountIds = notifications.map(item => item.account.id);
if (accountIds.length > 0) {
dispatch(fetchRelationships(accountIds));
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 3fc7c0702..0c520a796 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 function fetchStatusRequest(id, skipLoading) {
return {
type: STATUS_FETCH_REQUEST,
@@ -272,3 +275,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 477f56e13..1126b0121 100644
--- a/app/javascript/mastodon/components/poll.js
+++ b/app/javascript/mastodon/components/poll.js
@@ -39,6 +39,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 };
@@ -59,7 +62,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 ccc9067d1..5cb49d312 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']);
@@ -59,7 +83,8 @@ const messages = defineMessages({
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
-export default @injectIntl
+export default @connect(mapStateToProps)
+@injectIntl
class Status extends ImmutablePureComponent {
static contextTypes = {
@@ -70,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,
@@ -85,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,
@@ -101,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
@@ -112,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,
};
@@ -123,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 {
@@ -134,6 +165,10 @@ class Status extends ImmutablePureComponent {
this.setState({ showMedia: !this.state.showMedia });
}
+ handleToggleQuoteMediaVisibility = () => {
+ this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
+ }
+
handleClick = () => {
if (this.props.onClick) {
this.props.onClick();
@@ -164,6 +199,15 @@ class Status extends ImmutablePureComponent {
}
}
+ 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 (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
const id = e.currentTarget.getAttribute('data-id');
@@ -180,6 +224,10 @@ class Status extends ImmutablePureComponent {
this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
}
+ handleExpandedQuoteToggle = () => {
+ this.props.onQuoteToggleHidden(this._properStatus());
+ };
+
renderLoadingMediaGallery () {
return ;
}
@@ -197,10 +245,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();
@@ -275,6 +332,16 @@ 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;
}
@@ -283,7 +350,7 @@ class Status extends ImmutablePureComponent {
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;
@@ -354,10 +421,10 @@ class Status extends ImmutablePureComponent {
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 (
@@ -483,6 +676,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 9981f2449..68ea9d6a2 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -23,7 +23,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' },
@@ -61,6 +63,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,
@@ -121,6 +124,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');
}
@@ -129,6 +140,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);
}
@@ -326,7 +341,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 bf21a9fd6..71fbbd7c5 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 9abdec138..ce055cd96 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,
} from '../actions/statuses';
import {
unmuteAccount,
@@ -50,6 +53,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' },
});
@@ -99,6 +104,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));
@@ -215,6 +236,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 ba2d20cc7..81eb74c32 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,10 @@ 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' }
});
export default @injectIntl
@@ -60,7 +66,9 @@ class ComposeForm extends ImmutablePureComponent {
onPickEmoji: PropTypes.func.isRequired,
showSearch: PropTypes.bool,
anyMedia: PropTypes.bool,
- singleColumn: PropTypes.bool,
+ singleColumn: PropTypes.bool,
+ onGojiSubmit: PropTypes.func.isRequired,
+ onHarukinSubmit: PropTypes.func.isRequired
};
static defaultProps = {
@@ -86,7 +94,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 = () => {
@@ -193,6 +201,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;
@@ -209,6 +221,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 37a0e8845..d78b20fc7 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']),
@@ -60,6 +65,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 e2de8b0e6..e7e0fb423 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' },
@@ -88,30 +100,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 = (
);
}
@@ -127,6 +163,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 1b9994612..67cb7db1a 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' },
@@ -107,9 +108,10 @@ class GettingStarted extends ImmutablePureComponent {
if (profile_directory) {
navItems.push(
,
+
,
);
- height += 48;
+ height += 48*2;
}
navItems.push(
@@ -120,9 +122,10 @@ class GettingStarted extends ImmutablePureComponent {
} else if (profile_directory) {
navItems.push(
,
+
,
);
- height += 48;
+ height += 48*2;
}
if (multiColumn && !columns.find(item => item.get('id') === 'HOME')) {
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 1ecb18bf8..c89b86ffe 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' },
});
@@ -119,6 +123,31 @@ class Footer extends ImmutablePureComponent {
router.history.push(`/statuses/${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;
@@ -152,6 +181,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 ffa2510c0..61833f37b 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -17,7 +17,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' },
@@ -56,6 +58,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,
@@ -82,6 +85,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);
}
@@ -277,6 +284,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 043a749ed..14bd6a3bf 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';
@@ -24,7 +25,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 = {
@@ -33,8 +58,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,
@@ -46,6 +74,9 @@ class DetailedStatus extends ImmutablePureComponent {
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func,
+ onQuoteToggleHidden: PropTypes.func.isRequired,
+ showQuoteMedia: PropTypes.bool,
+ onToggleQuoteMediaVisibility: PropTypes.func,
};
state = {
@@ -54,6 +85,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(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}
@@ -65,6 +97,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);
}
@@ -102,8 +138,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;
@@ -121,6 +171,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) {
@@ -247,6 +386,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 69cd245fb..fc597c05a 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';
@@ -31,6 +32,8 @@ import {
deleteStatus,
hideStatus,
revealStatus,
+ hideQuote,
+ revealQuote,
} from '../../actions/statuses';
import {
unblockAccount,
@@ -68,6 +71,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' },
});
@@ -180,6 +185,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,
};
@@ -198,7 +204,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)) });
}
}
@@ -206,6 +213,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));
@@ -259,6 +270,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;
@@ -289,6 +313,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;
@@ -323,6 +355,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());
@@ -552,11 +592,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 3c4cff9f9..432fd5f14 100644
--- a/app/javascript/mastodon/features/ui/components/link_footer.js
+++ b/app/javascript/mastodon/features/ui/components/link_footer.js
@@ -53,6 +53,7 @@ class LinkFooter extends React.PureComponent {
·
·
·
+ ·
·
·
diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js
index 1911da8ba..4252339ea 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 (
-