diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index c4cd4887876a0d..00000000000000 --- 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/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml new file mode 100644 index 00000000000000..b8f37801ccf7f9 --- /dev/null +++ b/.github/workflows/docker-build-dev.yml @@ -0,0 +1,22 @@ +name: Build and Push Dev Image to Docker Hub + +on: + push: + branches: + - dev + +jobs: + docker-build: + runs-on: ubuntu-latest + + steps: + - name: 🔍 Checkout code + uses: actions/checkout@v2 + + - name: ⚓ Build and push Docker images + uses: docker/build-push-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + repository: mashirozx/mastodon + tags: alpha \ No newline at end of file diff --git a/.github/workflows/docker-bulid-alpha.yml b/.github/workflows/docker-bulid-alpha.yml new file mode 100644 index 00000000000000..c38d6a86410d48 --- /dev/null +++ b/.github/workflows/docker-bulid-alpha.yml @@ -0,0 +1,23 @@ +name: Build and Push Alpha Image to Docker Hub + +on: + push: + branches: + - dev + +jobs: + docker-build: + runs-on: ubuntu-latest + + steps: + - name: 🔍 Checkout code + uses: actions/checkout@v2 + + - name: ⚓ Build and push Docker images + uses: docker/build-push-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + repository: mashirozx/mastodon + tag_with_ref: true + tag_with_sha: true diff --git a/.gitignore b/.gitignore index 4545270b30b38e..0e73f6a81c054f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ .env .env.production .env.development +.env.development.sample /node_modules/ /build/ diff --git a/Gemfile b/Gemfile index 33e467732a3f69..82bc112c323277 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,4 @@ -# frozen_string_literal: true +# frozen_string_literal: truey source 'https://rubygems.org' ruby '>= 2.5.0', '< 3.0.0' @@ -63,6 +63,9 @@ gem 'http_accept_language', '~> 2.1' gem 'httplog', '~> 1.4.3' gem 'idn-ruby', require: 'idn' gem 'kaminari', '~> 1.2' +gem 'kramdown', '~> 2.3' +gem 'kramdown-parser-gfm', '~> 1.1' +gem 'rouge', '~> 3.21' gem 'link_header', '~> 0.0' gem 'mime-types', '~> 3.3.1', require: 'mime/types/columnar' gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532' @@ -160,5 +163,6 @@ end gem 'concurrent-ruby', require: false gem 'connection_pool', require: false +gem "sidekiq-statistic", "~> 1.4" gem 'xorcist', '~> 1.1' gem 'pluck_each', '~> 0.1.3' diff --git a/Gemfile.lock b/Gemfile.lock index 9ed0ed9ec05326..dd8424ae692333 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -313,6 +313,10 @@ GEM activerecord kaminari-core (= 1.2.1) kaminari-core (1.2.1) + kramdown (2.3.0) + rexml + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) launchy (2.5.0) addressable (~> 2.7) letter_opener (1.7.0) @@ -367,7 +371,7 @@ GEM concurrent-ruby (~> 1.0, >= 1.0.2) sidekiq (>= 3.5) statsd-ruby (~> 1.4, >= 1.4.0) - oj (3.10.15) + oj (3.10.14) omniauth (1.9.1) hashie (>= 3.4.6) rack (>= 1.6.2, < 3) @@ -375,7 +379,7 @@ GEM addressable (~> 2.3) nokogiri (~> 1.5) omniauth (~> 1.2) - omniauth-saml (1.10.3) + omniauth-saml (1.10.2) omniauth (~> 1.3, >= 1.3.2) ruby-saml (~> 1.9) openssl (2.2.0) @@ -505,6 +509,7 @@ GEM railties (>= 5.0) rexml (3.2.4) rotp (2.1.2) + rouge (3.21.0) rpam2 (4.0.2) rqrcode (1.1.2) chunky_png (~> 1.0) @@ -532,7 +537,7 @@ GEM rspec-support (3.9.3) rspec_junit_formatter (0.4.1) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (0.93.1) + rubocop (0.93.0) parallel (~> 1.10) parser (>= 2.7.1.5) rainbow (>= 2.2.2, < 4.0) @@ -541,7 +546,7 @@ GEM rubocop-ast (>= 0.6.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 2.0) - rubocop-ast (0.8.0) + rubocop-ast (0.7.1) parser (>= 2.7.1.5) rubocop-rails (2.8.1) activesupport (>= 4.2.0) @@ -573,6 +578,9 @@ GEM sidekiq (>= 3) thwait tilt (>= 1.4.0) + sidekiq-statistic (1.4.0) + sidekiq (>= 5.0) + tilt (~> 2.0) sidekiq-unique-jobs (6.0.25) concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 4.0, < 7.0) @@ -649,7 +657,7 @@ GEM safety_net_attestation (~> 0.4.0) securecompare (~> 1.0) tpm-key_attestation (~> 0.9.0) - webmock (3.9.3) + webmock (3.9.1) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -726,6 +734,8 @@ DEPENDENCIES json-ld json-ld-preloaded (~> 3.1) kaminari (~> 1.2) + kramdown (~> 2.3) + kramdown-parser-gfm (~> 1.1) letter_opener (~> 1.7) letter_opener_web (~> 1.4) link_header (~> 0.0) @@ -771,6 +781,7 @@ DEPENDENCIES redis (~> 4.2) redis-namespace (~> 1.8) redis-rails (~> 5.0) + rouge (~> 3.21) rqrcode (~> 1.1) rspec-rails (~> 4.0) rspec-sidekiq (~> 3.1) @@ -782,6 +793,7 @@ DEPENDENCIES sidekiq (~> 6.1) sidekiq-bulk (~> 0.2.0) sidekiq-scheduler (~> 3.0) + sidekiq-statistic (~> 1.4) sidekiq-unique-jobs (~> 6.0) simple-navigation (~> 4.1) simple_form (~> 5.0) @@ -802,3 +814,9 @@ DEPENDENCIES webpacker (~> 5.2) webpush xorcist (~> 1.1) + +RUBY VERSION + ruby 2.6.6p146 + +BUNDLED WITH + 1.17.2 diff --git a/app/chewy/accounts_index.rb b/app/chewy/accounts_index.rb index b814e009e5f779..f86928a302fcd4 100644 --- a/app/chewy/accounts_index.rb +++ b/app/chewy/accounts_index.rb @@ -4,7 +4,8 @@ class AccountsIndex < Chewy::Index settings index: { refresh_interval: '5m' }, analysis: { analyzer: { content: { - tokenizer: 'whitespace', + #tokenizer: 'whitespace', + tokenizer: 'ik_max_word', filter: %w(lowercase asciifolding cjk_width), }, diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb index 47cb856ea944a4..7315867fd0a6b0 100644 --- a/app/chewy/statuses_index.rb +++ b/app/chewy/statuses_index.rb @@ -16,9 +16,18 @@ class StatusesIndex < Chewy::Index language: 'possessive_english', }, }, + char_filter: { + tsconvert: { + type: 'stconvert', + keep_both: false, + delimiter: '#', + convert_type: 't2s', + }, + }, analyzer: { content: { - tokenizer: 'uax_url_email', + #tokenizer: 'uax_url_email', + tokenizer: 'ik_max_word', filter: %w( english_possessive_stemmer lowercase @@ -27,6 +36,7 @@ class StatusesIndex < Chewy::Index english_stop english_stemmer ), + char_filter: %w(tsconvert), }, }, } diff --git a/app/chewy/tags_index.rb b/app/chewy/tags_index.rb index 300fc128f63f0f..16a10d411626ac 100644 --- a/app/chewy/tags_index.rb +++ b/app/chewy/tags_index.rb @@ -2,10 +2,21 @@ class TagsIndex < Chewy::Index settings index: { refresh_interval: '15m' }, analysis: { + char_filter: { + tsconvert: { + type: 'stconvert', + keep_both: false, + delimiter: '#', + convert_type: 't2s', + }, + }, + analyzer: { content: { - tokenizer: 'keyword', + #tokenizer: 'keyword', + tokenizer: 'ik_max_word', filter: %w(lowercase asciifolding cjk_width), + char_filter: %w(tsconvert), }, edge_ngram: { diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index ccb5ef8e86a576..5dc20199fc445f 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -42,7 +42,7 @@ def show expires_in 1.minute, public: true limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE - @statuses = filtered_statuses.without_reblogs.limit(limit) + @statuses = filtered_statuses.without_reblogs.without_local_only.limit(limit) @statuses = cache_collection(@statuses, Status) render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag]) end @@ -73,7 +73,11 @@ def filtered_statuses end def default_statuses - @account.statuses.where(visibility: [:public, :unlisted]) + if current_user.nil? + @account.statuses.without_local_only.where(visibility: [:public, :unlisted]) + else + @account.statuses.where(visibility: [:public, :unlisted]) + end end def only_media_scope diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 64b5cb747cd6c0..209c8958dedf3f 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -33,6 +33,8 @@ def user_settings_params 'setting_default_privacy' => source_params.fetch(:privacy, @account.user.setting_default_privacy), 'setting_default_sensitive' => source_params.fetch(:sensitive, @account.user.setting_default_sensitive), 'setting_default_language' => source_params.fetch(:language, @account.user.setting_default_language), + 'setting_default_federation' => source_params.fetch(:federation, @account.user.setting_default_federation), + 'setting_default_content_type' => source_params.fetch(:content_type, @account.user.setting_default_content_type), } end end diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb index 08b3474cc85865..20d780d1fc458c 100644 --- a/app/controllers/api/v1/custom_emojis_controller.rb +++ b/app/controllers/api/v1/custom_emojis_controller.rb @@ -1,10 +1,29 @@ # frozen_string_literal: true class Api::V1::CustomEmojisController < Api::BaseController - skip_before_action :set_cache_headers - + before_action :set_tags + def index - expires_in 3.minutes, public: true - render_with_cache(each_serializer: REST::CustomEmojiSerializer) { CustomEmoji.listed.includes(:category) } + @tags = set_tags + # must cache in Nginx side, or should use original render_with_cache method + render json: @tags, each_serializer: REST::CustomEmojiSerializer + end + + private + + def custom_emojis_params + params.slice(:range).permit(:range) + end + + def set_tags + @range = custom_emojis_params[:range] + case @range + when 'all' + CustomEmoji.fullist.includes(:category) + when 'unlisted' + CustomEmoji.unlisted.includes(:category) + else + CustomEmoji.listed.includes(:category) + end end end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 106fc8224e2876..cd155853659f17 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -46,7 +46,10 @@ def create application: doorkeeper_token.application, poll: status_params[:poll], idempotency: request.headers['Idempotency-Key'], - with_rate_limit: true) + with_rate_limit: true, + content_type: status_params[:content_type], #'text/markdown' + local_only: status_params[:local_only], + quote_id: status_params[:quote_id].presence) render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer end @@ -85,6 +88,9 @@ def status_params :spoiler_text, :visibility, :scheduled_at, + :content_type, + :local_only, + :quote_id, media_ids: [], poll: [ :multiple, diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 32b5d79487b741..128e4fa4441abb 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -36,6 +36,8 @@ def user_settings_params :setting_default_privacy, :setting_default_sensitive, :setting_default_language, + :setting_default_federation, + :setting_default_content_type, :setting_unfollow_modal, :setting_boost_modal, :setting_delete_modal, diff --git a/app/controllers/translate_controller.rb b/app/controllers/translate_controller.rb new file mode 100644 index 00000000000000..3be7624e92188e --- /dev/null +++ b/app/controllers/translate_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class TranslateController < ApplicationController + before_action :authenticate_user! + def create + return unless user_signed_in? + translation_endpoint = ENV['TRANSLATION_SERVER_HOST'] || 'http://localhost:30031' + + resp = Faraday.post(translation_endpoint, text: params[:data][:text], to: params[:data][:to]) + render json: ActiveSupport::JSON.decode(resp.body) + end + + # def gets + # return unless user_signed_in? + # translation_endpoint = ENV['TRANSLATION_SERVER_HOST'] || 'http://localhost:30031' + + # resp = Faraday.post(translation_endpoint, text: 'hello', to: 'ja') + # render json: ActiveSupport::JSON.decode(resp.body) + # end +end diff --git a/app/javascript/fonts/witchesAwesome/witchesAwesome.eot b/app/javascript/fonts/witchesAwesome/witchesAwesome.eot new file mode 100644 index 00000000000000..b56aa18f236290 Binary files /dev/null and b/app/javascript/fonts/witchesAwesome/witchesAwesome.eot differ diff --git a/app/javascript/fonts/witchesAwesome/witchesAwesome.svg b/app/javascript/fonts/witchesAwesome/witchesAwesome.svg new file mode 100644 index 00000000000000..59395cbfe85687 --- /dev/null +++ b/app/javascript/fonts/witchesAwesome/witchesAwesome.svg @@ -0,0 +1,17 @@ + + + +Generated by IcoMoon + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/javascript/fonts/witchesAwesome/witchesAwesome.ttf b/app/javascript/fonts/witchesAwesome/witchesAwesome.ttf new file mode 100644 index 00000000000000..f8dff87753ac58 Binary files /dev/null and b/app/javascript/fonts/witchesAwesome/witchesAwesome.ttf differ diff --git a/app/javascript/fonts/witchesAwesome/witchesAwesome.woff b/app/javascript/fonts/witchesAwesome/witchesAwesome.woff new file mode 100644 index 00000000000000..d1f69451d7bbfc Binary files /dev/null and b/app/javascript/fonts/witchesAwesome/witchesAwesome.woff differ diff --git a/app/javascript/images/elephant-fren_sub.png b/app/javascript/images/elephant-fren_sub.png new file mode 100644 index 00000000000000..cb745ef788d72b Binary files /dev/null and b/app/javascript/images/elephant-fren_sub.png differ diff --git a/app/javascript/images/elephant_ui_disappointed_sub.png b/app/javascript/images/elephant_ui_disappointed_sub.png new file mode 100644 index 00000000000000..e6e7068d8869f3 Binary files /dev/null and b/app/javascript/images/elephant_ui_disappointed_sub.png differ diff --git a/app/javascript/images/elephant_ui_plane_sub.png b/app/javascript/images/elephant_ui_plane_sub.png new file mode 100644 index 00000000000000..b9b0b53b12f398 Binary files /dev/null and b/app/javascript/images/elephant_ui_plane_sub.png differ diff --git a/app/javascript/images/elephant_ui_working_sub.png b/app/javascript/images/elephant_ui_working_sub.png new file mode 100644 index 00000000000000..c350567efa6480 Binary files /dev/null and b/app/javascript/images/elephant_ui_working_sub.png differ diff --git a/app/javascript/images/google_logo.svg b/app/javascript/images/google_logo.svg new file mode 100644 index 00000000000000..3790851de18406 --- /dev/null +++ b/app/javascript/images/google_logo.svg @@ -0,0 +1,2 @@ + + diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 030922520264f0..ff1a1cdbae556b 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'; @@ -47,6 +49,8 @@ export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; +export const COMPOSE_FEDERATION_CHANGE = 'COMPOSE_FEDERATION_CHANGE'; +export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE'; export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; @@ -100,6 +104,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 +168,9 @@ 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), + local_only: !getState().getIn(['compose', 'federation']), + content_type: getState().getIn(['compose', 'content_type']), + quote_id: getState().getIn(['compose', 'quote_from'], null), }, { headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), @@ -594,6 +618,20 @@ export function changeComposeVisibility(value) { }; }; +export function changeComposeFederation(value) { + return { + type: COMPOSE_FEDERATION_CHANGE, + value, + }; +}; + +export function changeComposeContentType(value) { + return { + type: COMPOSE_CONTENT_TYPE_CHANGE, + value, + }; +}; + export function insertEmojiCompose(position, emoji, needsSpace) { return { type: COMPOSE_EMOJI_INSERT, diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index dca44917a5b2f9..47eb97e90990ba 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -60,6 +60,8 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.contentHtml = normalOldStatus.get('contentHtml'); normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); normalStatus.hidden = normalOldStatus.get('hidden'); + normalStatus.quote = normalOldStatus.get('quote'); + normalStatus.quote_hidden = normalOldStatus.get('quote_hidden'); } else { const spoilerText = normalStatus.spoiler_text || ''; const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); @@ -69,6 +71,32 @@ 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); + } + }); + // TODO: how to use normalOldStatus? + // let _contentHtml = docElem.textContent; + // normalStatus.quote.contentHtml = '

'+emojify(_contentHtml.substr(0, 150), quote_emojiMap) + (_contentHtml.substr(150) ? '...' : '')+'

'; + let _contentHtml = docElem.innerHTML; + normalStatus.quote.contentHtml = '

'+emojify(_contentHtml, quote_emojiMap)+'

'; + 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 3fc7c07023d627..0c520a79675cb6 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/common.js b/app/javascript/mastodon/common.js index 6818aa5d540ddb..eb95e95632f693 100644 --- a/app/javascript/mastodon/common.js +++ b/app/javascript/mastodon/common.js @@ -1,7 +1,8 @@ import Rails from '@rails/ujs'; export function start() { - require('font-awesome/css/font-awesome.css'); + require('@moezx/fontawesome-pro/css/all.css'); + require('@moezx/fontawesome-pro/css/v4-shims.css'); require.context('../images/', true); try { diff --git a/app/javascript/mastodon/components/animated_number.js b/app/javascript/mastodon/components/animated_number.js index fbe948c5b02371..ce6d1bc017f361 100644 --- a/app/javascript/mastodon/components/animated_number.js +++ b/app/javascript/mastodon/components/animated_number.js @@ -8,10 +8,10 @@ import { reduceMotion } from 'mastodon/initial_state'; const obfuscatedCount = count => { if (count < 0) { return 0; - } else if (count <= 1) { + } else if (count <= 10) { return count; } else { - return '1+'; + return '9+'; } }; diff --git a/app/javascript/mastodon/components/icon.js b/app/javascript/mastodon/components/icon.js index d8a17722fed2a7..82d3a998eb544f 100644 --- a/app/javascript/mastodon/components/icon.js +++ b/app/javascript/mastodon/components/icon.js @@ -13,8 +13,22 @@ export default class Icon extends React.PureComponent { render () { const { id, className, fixedWidth, ...other } = this.props; + const key = ['fab', 'fas', 'far', 'fal', 'fad']; + + let fa = 'fa', + tag = id; + if (id.includes(':')) { + key.forEach(key => { + if (id.includes(`:${key}`)) { + fa = key; + tag = id.replace(`:${key}`, ''); + return false; + } + }) + } + return ( - + ); } diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 0a8f4258504e3b..368a1b70c35e92 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/missing_indicator.js b/app/javascript/mastodon/components/missing_indicator.js index 7b0101bab852aa..51274fa5c58325 100644 --- a/app/javascript/mastodon/components/missing_indicator.js +++ b/app/javascript/mastodon/components/missing_indicator.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; -import illustration from 'mastodon/../images/elephant_ui_disappointed.svg'; +import illustration from 'mastodon/../images/elephant_ui_disappointed_sub.png'; import classNames from 'classnames'; const MissingIndicator = ({ fullPage }) => ( diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js index 41c99710fc79da..3e59328a37d63e 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/regeneration_indicator.js b/app/javascript/mastodon/components/regeneration_indicator.js index faf88c6b5031ac..8bf5a043c98008 100644 --- a/app/javascript/mastodon/components/regeneration_indicator.js +++ b/app/javascript/mastodon/components/regeneration_indicator.js @@ -1,6 +1,6 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; -import illustration from 'mastodon/../images/elephant_ui_working.svg'; +import illustration from 'mastodon/../images/elephant_ui_working_sub.png'; const MissingIndicator = () => (
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index be4f0bcca3f9ef..e2937da089ef7c 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -85,6 +85,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, @@ -98,6 +99,7 @@ class Status extends ImmutablePureComponent { scrollKey: PropTypes.string, deployPictureInPicture: PropTypes.func, usingPiP: PropTypes.bool, + contextType: PropTypes.string, }; // Avoid checking props that are functions (and whose equality will always @@ -113,6 +115,7 @@ class Status extends ImmutablePureComponent { state = { showMedia: defaultMediaVisibility(this.props.status), + showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null), statusId: undefined, }; @@ -120,6 +123,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 { @@ -131,6 +135,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(); @@ -161,6 +169,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'); @@ -177,6 +194,10 @@ class Status extends ImmutablePureComponent { this.props.onToggleCollapsed(this._properStatus(), isCollapsed); } + handleExpandedQuoteToggle = () => { + this.props.onQuoteToggleHidden(this._properStatus()); + }; + renderLoadingMediaGallery () { return
; } @@ -273,11 +294,17 @@ class Status extends ImmutablePureComponent { this.node = c; } + _properQuoteStatus () { + const { status } = this.props; + + return status.get('quote'); + } + render () { let media = null; - let statusAvatar, prepend, rebloggedByText; + let statusAvatar, prepend, rebloggedByText, unlistedQuoteText; - const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, usingPiP } = this.props; + const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, usingPiP, contextType } = this.props; let { status, account, ...other } = this.props; @@ -348,10 +375,10 @@ class Status extends ImmutablePureComponent { status = status.get('reblog'); } - if (usingPiP) { - media = ; - } else if (status.get('media_attachments').size > 0) { - if (this.props.muted) { + if (status.get('media_attachments').size > 0) { + if (usingPiP) { + media = ; + } else if (this.props.muted) { media = ( 0) { + if (usingPiP) { + 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_status.get('visibility') === 'unlisted' && contextType !== 'home') { + unlistedQuoteText = intl.formatMessage({ id: 'status.unlisted_quote', defaultMessage: 'Unlisted quote' }); + quote = ( +
+
+ +
+
+ ); + } else { + quote = ( +
+ + + {quote_media} +
+ ); + } + } + return (
@@ -476,6 +611,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 66b5a17ac22c70..2c91285aee9acb 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -23,7 +23,10 @@ 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' }, + local_only: { id: 'status.local_only', defaultMessage: 'This post is only visible by other users of your instance' }, + 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 +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, @@ -129,6 +133,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); } @@ -226,7 +234,9 @@ class StatusActionBar extends ImmutablePureComponent { const mutingConversation = status.get('muted'); const anonymousAccess = !me; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const localOnly = status.get('local_only'); const account = status.get('account'); + const federated = !status.get('local_only'); let menu = []; @@ -322,7 +332,7 @@ class StatusActionBar extends ImmutablePureComponent { - + {shareButton}
@@ -337,6 +347,9 @@ class StatusActionBar extends ImmutablePureComponent { title={intl.formatMessage(messages.more)} />
+ { !federated && + + }
); } diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 3200f2d82f6d7f..cc22b458d9f28f 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -8,6 +8,10 @@ import classnames from 'classnames'; import PollContainer from 'mastodon/containers/poll_container'; import Icon from 'mastodon/components/icon'; import { autoPlayGif } from 'mastodon/initial_state'; +import { getLocale } from 'mastodon/locales'; +import { parse as htmlParser } from 'node-html-parser'; +import googleLogo from 'images/google_logo.svg'; +import api from '../api'; const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) @@ -25,10 +29,14 @@ export default class StatusContent extends React.PureComponent { onClick: PropTypes.func, collapsable: PropTypes.bool, onCollapsedToggle: PropTypes.func, + quote: PropTypes.bool, }; state = { hidden: true, + hideTranslation: true, + translation: null, + translationStatus: null, }; _updateStatusLinks () { @@ -169,12 +177,53 @@ export default class StatusContent extends React.PureComponent { } } + handleTranslationClick = (e) => { + e.preventDefault(); + const translationServiceEndpoint = '/translate/'; + + if (this.state.hideTranslation === true && this.state.translation === null) { + const { status } = this.props; + const content = status.get('content').length === 0 ? '' : htmlParser(status.get('content')).structuredText; + + let locale = getLocale().localeData[0].locale; + if (locale === 'zh') { + const domLang = document.documentElement.lang; + locale = domLang === 'zh-CN' ? 'zh-cn' : 'zh-tw'; + } + + this.setState({ translationStatus: 'fetching' }); + api().post( + translationServiceEndpoint, + { + data: { + text: content === '' ? 'Nothing to translate' : content, + to: locale, + }, + }) + .then(res => { + this.setState({ + translation: res.data.text, + translationStatus: 'succeed', + hideTranslation: false, + }); + }) + .catch(() => { + this.setState({ + translationStatus: 'failed', + hideTranslation: true, + }); + }); + } else { + this.setState({ hideTranslation: !this.state.hideTranslation }); + } + } + setRef = (c) => { this.node = c; } render () { - const { status } = this.props; + const { status, quote } = this.props; if (status.get('content').length === 0) { return null; @@ -184,8 +233,18 @@ export default class StatusContent extends React.PureComponent { 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 content = { __html: status.get('contentHtml') }; - const spoilerContent = { __html: status.get('spoilerHtml') }; + const addHashtagMarkup = (html) => { + let template = document.createElement('template'); + template.innerHTML = `${html}`; + template.content.firstChild.querySelectorAll('.hashtag').forEach((e)=>{ + e.innerHTML = e.innerHTML.replace('#', '#'); + }) + return template.content.firstChild.innerHTML + } + + const content = { __html: addHashtagMarkup(status.get('contentHtml')) }; + const spoilerContent = { __html: addHashtagMarkup(status.get('spoilerHtml')) }; + const directionStyle = { direction: 'ltr' }; const classNames = classnames('status__content', { 'status__content--with-action': this.props.onClick && this.context.router, @@ -203,12 +262,57 @@ export default class StatusContent extends React.PureComponent { ); + const toggleTranslation = !this.state.hideTranslation ? : ; + const readMoreButton = ( ); + const translationContainer = ( + getLocale().localeData[0].locale !== status.get('language') ? + + + + {/* error message */} +
+
+

+
+
+
+
+
+
+
+
+ {/*

Fetching translation, please wait

*/} +
+
+

+ , + }} + /> +

+

{this.state.translation}

+
+
+
: null + ); + if (status.get('spoiler_text').length > 0) { let mentionsPlaceholder = ''; @@ -236,7 +340,9 @@ export default class StatusContent extends React.PureComponent {
- {!hidden && !!status.get('poll') && } + {!hidden ? translationContainer : null} + + {!quote && !hidden && !!status.get('poll') && } {renderViewThread && showThreadButton}
@@ -246,7 +352,9 @@ export default class StatusContent extends React.PureComponent {
- {!!status.get('poll') && } + {translationContainer} + + {!quote && !!status.get('poll') && } {renderViewThread && showThreadButton}
, @@ -262,7 +370,9 @@ export default class StatusContent extends React.PureComponent {
- {!!status.get('poll') && } + {translationContainer} + + {!quote && !!status.get('poll') && } {renderViewThread && showThreadButton}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 7bfd66d3eaa59d..50beafcfcfd493 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 } 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, @@ -49,6 +52,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' }, }); @@ -97,6 +102,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)); @@ -213,6 +234,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/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 47e189251c9f0d..e5b5afb459b1bc 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'; @@ -11,6 +12,8 @@ import UploadButtonContainer from '../containers/upload_button_container'; import { defineMessages, injectIntl } from 'react-intl'; import SpoilerButtonContainer from '../containers/spoiler_button_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; +import FederationDropdownContainer from '../containers/federation_dropdown_container'; +import ContentTypeDropdownContainer from '../containers/content_type_dropdown_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import PollFormContainer from '../containers/poll_form_container'; import UploadFormContainer from '../containers/upload_form_container'; @@ -21,6 +24,8 @@ import { length } from 'stringz'; import { countableText } from '../util/counter'; import Icon from 'mastodon/components/icon'; +const MAX_CHARS = 2048; + 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'; const messages = defineMessages({ @@ -43,6 +48,8 @@ class ComposeForm extends ImmutablePureComponent { suggestions: ImmutablePropTypes.list, spoiler: PropTypes.bool, privacy: PropTypes.string, + federation: PropTypes.bool, + contentType: PropTypes.string, spoilerText: PropTypes.string, focusDate: PropTypes.instanceOf(Date), caretPosition: PropTypes.number, @@ -88,7 +95,7 @@ class ComposeForm extends ImmutablePureComponent { const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props; const fulltext = [this.props.spoilerText, countableText(this.props.text)].join(''); - if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { + if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > MAX_CHARS || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { return; } @@ -181,7 +188,7 @@ class ComposeForm extends ImmutablePureComponent { const { intl, onPaste, showSearch, anyMedia } = this.props; const disabled = this.props.isSubmitting; const text = [this.props.spoilerText, countableText(this.props.text)].join(''); - const disabledButton = disabled || this.props.isUploading || this.props.isChangingUpload || length(text) > 500 || (text.length !== 0 && text.trim().length === 0 && !anyMedia); + const disabledButton = disabled || this.props.isUploading || this.props.isChangingUpload || length(text) > MAX_CHARS || (text.length !== 0 && text.trim().length === 0 && !anyMedia); let publishText = ''; if (this.props.privacy === 'private' || this.props.privacy === 'direct') { @@ -195,6 +202,7 @@ class ComposeForm extends ImmutablePureComponent { +
+ +
-
+
diff --git a/app/javascript/mastodon/features/compose/components/content_type_dropdown.js b/app/javascript/mastodon/features/compose/components/content_type_dropdown.js new file mode 100644 index 00000000000000..f0a2d40d00a4b1 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/content_type_dropdown.js @@ -0,0 +1,272 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import IconButton from '../../../components/icon_button'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from '../../ui/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { supportsPassiveEvents } from 'detect-passive-events'; +import classNames from 'classnames'; +import Icon from 'mastodon/components/icon'; + +const messages = defineMessages({ + plain: { id: 'content_type.plain.short', defaultMessage: 'Plain Text' }, + markdown: { id: 'content_type.markdown.short', defaultMessage: 'Markdown' }, + change_content_type: { id: 'content_type.change', defaultMessage: 'Adjust status content type' }, +}); + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +class ContentTypeDropdownMenu extends React.PureComponent { + + static propTypes = { + style: PropTypes.object, + items: PropTypes.array.isRequired, + value: PropTypes.string.isRequired, + placement: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + }; + + state = { + mounted: false, + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + handleKeyDown = e => { + const { items } = this.props; + const value = e.currentTarget.getAttribute('data-index'); + const index = items.findIndex(item => { + return (item.value === value); + }); + let element = null; + + switch(e.key) { + case 'Escape': + this.props.onClose(); + break; + case 'Enter': + this.handleClick(e); + break; + case 'ArrowDown': + element = this.node.childNodes[index + 1] || this.node.firstChild; + break; + case 'ArrowUp': + element = this.node.childNodes[index - 1] || this.node.lastChild; + break; + case 'Tab': + if (e.shiftKey) { + element = this.node.childNodes[index - 1] || this.node.lastChild; + } else { + element = this.node.childNodes[index + 1] || this.node.firstChild; + } + break; + case 'Home': + element = this.node.firstChild; + break; + case 'End': + element = this.node.lastChild; + break; + } + + if (element) { + element.focus(); + this.props.onChange(element.getAttribute('data-index')); + e.preventDefault(); + e.stopPropagation(); + } + } + + handleClick = e => { + const value = e.currentTarget.getAttribute('data-index'); + + e.preventDefault(); + + this.props.onClose(); + this.props.onChange(value); + } + + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + if (this.focusedItem) this.focusedItem.focus({ preventScroll: true }); + this.setState({ mounted: true }); + } + + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + } + + setFocusRef = c => { + this.focusedItem = c; + } + + render () { + const { mounted } = this.state; + const { style, items, placement, value } = this.props; + + return ( + + {({ opacity, scaleX, scaleY }) => ( + // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays +
+ {items.map(item => ( +
+
+ +
+ +
+ {item.text} + {item.meta} +
+
+ ))} +
+ )} +
+ ); + } + +} + +export default @injectIntl +class ContentTypeDropdown extends React.PureComponent { + + static propTypes = { + isUserTouching: PropTypes.func, + isModalOpen: PropTypes.bool.isRequired, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + open: false, + placement: 'bottom', + }; + + handleToggle = ({ target }) => { + if (this.props.isUserTouching()) { + if (this.state.open) { + this.props.onModalClose(); + } else { + this.props.onModalOpen({ + actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })), + onClick: this.handleModalActionClick, + }); + } + } else { + const { top } = target.getBoundingClientRect(); + if (this.state.open && this.activeElement) { + this.activeElement.focus({ preventScroll: true }); + } + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + this.setState({ open: !this.state.open }); + } + } + + handleModalActionClick = (e) => { + e.preventDefault(); + + const { value } = this.options[e.currentTarget.getAttribute('data-index')]; + + this.props.onModalClose(); + this.props.onChange(value); + } + + handleKeyDown = e => { + switch(e.key) { + case 'Escape': + this.handleClose(); + break; + } + } + + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + } + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(); + break; + } + } + + handleClose = () => { + if (this.state.open && this.activeElement) { + this.activeElement.focus({ preventScroll: true }); + } + this.setState({ open: false }); + } + + handleChange = value => { + this.props.onChange(value); + } + + componentWillMount () { + const { intl: { formatMessage } } = this.props; + + this.options = [ + { icon: 'feather:fas', value: 'text/plain', text: formatMessage(messages.plain), meta: null }, + { icon: 'markdown:fab', value: 'text/markdown', text: formatMessage(messages.markdown), meta: null }, + ]; + } + + render () { + const { value, intl } = this.props; + const { open, placement } = this.state; + + const valueOption = this.options.find(item => item.value === value); + + return ( +
+
+ +
+ + + + +
+ ); + } + +} diff --git a/app/javascript/mastodon/features/compose/components/cw_mark_button.js b/app/javascript/mastodon/features/compose/components/cw_mark_button.js new file mode 100644 index 00000000000000..49d80ff6057f2b --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/cw_mark_button.js @@ -0,0 +1,55 @@ +/** + * This file substitude `text_icon_button.js` + * @ mashiro + */ +import React from 'react'; +import PropTypes from 'prop-types'; +import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + marked: { id: 'compose_form.spoiler.marked', defaultMessage: 'Text is hidden behind warning' }, + unmarked: { id: 'compose_form.spoiler.unmarked', defaultMessage: 'Text is not hidden' }, +}); + +const iconStyle = { + height: null, + lineHeight: '27px', +}; + +export default +@injectIntl +class CwMarkIconButton extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + disabled: PropTypes.bool, + }; + + handleClick = (e) => { + e.preventDefault(); + this.props.onClick(); + } + + render () { + const { intl, disabled, active } = this.props; + + return ( +
+ +
+ ); + } + +} diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index dc4f480609e9cc..62ca4d77fe7cfb 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -254,7 +254,7 @@ class EmojiPickerMenu extends React.PureComponent { sheetSize={32} custom={buildCustomEmojis(custom_emojis)} color='' - emoji='' + emoji='grinning' set='twitter' title={title} i18n={this.getI18n()} @@ -262,7 +262,7 @@ class EmojiPickerMenu extends React.PureComponent { include={categoriesSort} recent={frequentlyUsedEmojis} skin={skinTone} - showPreview={false} + showPreview backgroundImageFn={backgroundImageFn} autoFocus emojiTooltip diff --git a/app/javascript/mastodon/features/compose/components/federation_dropdown.js b/app/javascript/mastodon/features/compose/components/federation_dropdown.js new file mode 100644 index 00000000000000..adcf60c7c77efa --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/federation_dropdown.js @@ -0,0 +1,250 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import IconButton from '../../../components/icon_button'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from '../../ui/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { supportsPassiveEvents } from 'detect-passive-events'; +import classNames from 'classnames'; + +const messages = defineMessages({ + federate_short: { id: 'federation.federated.short', defaultMessage: 'Federated' }, + federate_long: { id: 'federation.federated.long', defaultMessage: 'Allow toot to reach other instances' }, + local_only_short: { id: 'federation.local_only.short', defaultMessage: 'Local-only' }, + local_only_long: { id: 'federation.local_only.long', defaultMessage: 'Restrict this toot only to my instance' }, + change_federation: { id: 'federation.change', defaultMessage: 'Adjust status federation' }, +}); + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +class FederationDropdownMenu extends React.PureComponent { + + static propTypes = { + style: PropTypes.object, + items: PropTypes.array.isRequired, + value: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + }; + + state = { + mounted: false, + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + handleKeyDown = e => { + const { items } = this.props; + const value = Boolean(e.currentTarget.getAttribute('data-index')); + const index = items.findIndex(item => { + return (item.value === value); + }); + let element; + + switch(e.key) { + case 'Escape': + this.props.onClose(); + break; + case 'Enter': + this.handleClick(e); + break; + case 'ArrowDown': + element = this.node.childNodes[index + 1]; + if (element) { + element.focus(); + this.props.onChange(Boolean(element.getAttribute('data-index'))); + } + break; + case 'ArrowUp': + element = this.node.childNodes[index - 1]; + if (element) { + element.focus(); + this.props.onChange(Boolean(element.getAttribute('data-index'))); + } + break; + case 'Home': + element = this.node.firstChild; + if (element) { + element.focus(); + this.props.onChange(Boolean(element.getAttribute('data-index'))); + } + break; + case 'End': + element = this.node.lastChild; + if (element) { + element.focus(); + this.props.onChange(Boolean(element.getAttribute('data-index'))); + } + break; + } + } + + handleClick = e => { + const value = Boolean(e.currentTarget.getAttribute('data-index')); + + e.preventDefault(); + + this.props.onClose(); + this.props.onChange(value); + } + + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + if (this.focusedItem) this.focusedItem.focus(); + this.setState({ mounted: true }); + } + + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + } + + setFocusRef = c => { + this.focusedItem = c; + } + + render () { + const { mounted } = this.state; + const { style, items, value } = this.props; + + return ( + + {({ opacity, scaleX, scaleY }) => ( + // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays +
+ {items.map(item => ( +
+
+ +
+ +
+ {item.text} + {item.meta} +
+
+ ))} +
+ )} +
+ ); + } + +} + +@injectIntl +export default class FederationDropdown extends React.PureComponent { + + static propTypes = { + isUserTouching: PropTypes.func, + isModalOpen: PropTypes.bool.isRequired, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, + value: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + open: false, + placement: null, + }; + + handleToggle = ({ target }) => { + if (this.props.isUserTouching()) { + if (this.state.open) { + this.props.onModalClose(); + } else { + this.props.onModalOpen({ + actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })), + onClick: this.handleModalActionClick, + }); + } + } else { + const { top } = target.getBoundingClientRect(); + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + this.setState({ open: !this.state.open }); + } + } + + handleModalActionClick = (e) => { + e.preventDefault(); + + const { value } = this.options[e.currentTarget.getAttribute('data-index')]; + + this.props.onModalClose(); + this.props.onChange(value); + } + + handleKeyDown = e => { + switch(e.key) { + case 'Escape': + this.handleClose(); + break; + } + } + + handleClose = () => { + this.setState({ open: false }); + } + + handleChange = value => { + this.props.onChange(value); + } + + componentWillMount () { + const { intl: { formatMessage } } = this.props; + + this.options = [ + { icon: 'link', value: true, text: formatMessage(messages.federate_short), meta: formatMessage(messages.federate_long) }, + { icon: 'chain-broken', value: false, text: formatMessage(messages.local_only_short), meta: formatMessage(messages.local_only_long) }, + ]; + } + + render () { + const { value, intl } = this.props; + const { open, placement } = this.state; + + const valueOption = this.options.find(item => item.value === value); + + return ( +
+
+ +
+ + + + +
+ ); + } + +} diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js index db49f90eb4cb95..0c3d808310387e 100644 --- a/app/javascript/mastodon/features/compose/components/poll_form.js +++ b/app/javascript/mastodon/features/compose/components/poll_form.js @@ -157,7 +157,7 @@ class PollForm extends ImmutablePureComponent {
- + {/* eslint-disable-next-line jsx-a11y/no-onchange */}