From f91a18606f9b47e8c803d37a7a7d9c003ea2727d Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 10 Apr 2018 22:13:15 +0200 Subject: [PATCH 01/18] Add backend support for bookmarks Bookmarks behave like favourites, except they aren't shared with other users and do not have an associated counter. --- .../api/v1/bookmarks_controller.rb | 71 +++++++++++++++++++ .../api/v1/statuses/bookmarks_controller.rb | 39 ++++++++++ app/models/bookmark.rb | 26 +++++++ app/models/concerns/account_associations.rb | 1 + app/models/concerns/account_interactions.rb | 4 ++ app/models/status.rb | 5 ++ .../status_relationships_presenter.rb | 2 + app/serializers/rest/status_serializer.rb | 9 +++ config/routes.rb | 4 ++ db/migrate/20180831171112_create_bookmarks.rb | 14 ++++ db/schema.rb | 12 ++++ 11 files changed, 187 insertions(+) create mode 100644 app/controllers/api/v1/bookmarks_controller.rb create mode 100644 app/controllers/api/v1/statuses/bookmarks_controller.rb create mode 100644 app/models/bookmark.rb create mode 100644 db/migrate/20180831171112_create_bookmarks.rb diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb new file mode 100644 index 000000000..49038807d --- /dev/null +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +class Api::V1::BookmarksController < Api::BaseController + before_action -> { doorkeeper_authorize! :read } + before_action :require_user! + after_action :insert_pagination_headers + + respond_to :json + + def index + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + end + + private + + def load_statuses + cached_bookmarks + end + + def cached_bookmarks + cache_collection( + Status.reorder(nil).joins(:bookmarks).merge(results), + Status + ) + end + + def results + @_results ||= account_bookmarks.paginate_by_max_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def account_bookmarks + current_account.bookmarks + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + if records_continue? + api_v1_bookmarks_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless results.empty? + api_v1_bookmarks_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + results.last.id + end + + def pagination_since_id + results.first.id + end + + def records_continue? + results.size == limit_param(DEFAULT_STATUSES_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/statuses/bookmarks_controller.rb b/app/controllers/api/v1/statuses/bookmarks_controller.rb new file mode 100644 index 000000000..d7def5f1f --- /dev/null +++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::BookmarksController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write } + before_action :require_user! + + respond_to :json + + def create + @status = bookmarked_status + render json: @status, serializer: REST::StatusSerializer + end + + def destroy + @status = requested_status + @bookmarks_map = { @status.id => false } + + bookmark = Bookmark.find_by!(account: current_user.account, status: @status) + bookmark.destroy! + + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, bookmarks_map: @bookmarks_map) + end + + private + + def bookmarked_status + authorize_with current_user.account, requested_status, :show? + + bookmark = Bookmark.find_or_create_by!(account: current_user.account, status: requested_status) + + bookmark.status.reload + end + + def requested_status + Status.find(params[:status_id]) + end +end diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb new file mode 100644 index 000000000..01dc48ee7 --- /dev/null +++ b/app/models/bookmark.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: bookmarks +# +# id :integer not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null +# status_id :integer not null +# + +class Bookmark < ApplicationRecord + include Paginable + + update_index('statuses#status', :status) if Chewy.enabled? + + belongs_to :account, inverse_of: :bookmarks + belongs_to :status, inverse_of: :bookmarks + + validates :status_id, uniqueness: { scope: :account_id } + + before_validation do + self.status = status.reblog if status&.reblog? + end +end diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index 7dafeee34..4e730451a 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -11,6 +11,7 @@ module AccountAssociations has_many :stream_entries, inverse_of: :account, dependent: :destroy has_many :statuses, inverse_of: :account, dependent: :destroy has_many :favourites, inverse_of: :account, dependent: :destroy + has_many :bookmarks, inverse_of: :account, dependent: :destroy has_many :mentions, inverse_of: :account, dependent: :destroy has_many :notifications, inverse_of: :account, dependent: :destroy has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index ad2909d91..f27d39483 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -186,6 +186,10 @@ module AccountInteractions status.proper.favourites.where(account: self).exists? end + def bookmarked?(status) + status.proper.bookmarks.where(account: self).exists? + end + def reblogged?(status) status.proper.reblogs.where(account: self).exists? end diff --git a/app/models/status.rb b/app/models/status.rb index 035423b40..bd52c3387 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -49,6 +49,7 @@ class Status < ApplicationRecord belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true has_many :favourites, inverse_of: :status, dependent: :destroy + has_many :bookmarks, inverse_of: :status, dependent: :destroy has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread has_many :mentions, dependent: :destroy, inverse_of: :status @@ -324,6 +325,10 @@ class Status < ApplicationRecord Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true } end + def bookmarks_map(status_ids, account_id) + Bookmark.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h + end + def reblogs_map(status_ids, account_id) select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).reorder(nil).each_with_object({}) { |s, h| h[s.reblog_of_id] = true } end diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index b04e10e2f..64e688d87 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -7,6 +7,7 @@ class StatusRelationshipsPresenter if current_account_id.nil? @reblogs_map = {} @favourites_map = {} + @bookmarks_map = {} @mutes_map = {} @pins_map = {} else @@ -17,6 +18,7 @@ class StatusRelationshipsPresenter @reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {}) @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {}) + @bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {}) @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {}) @pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {}) end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index bfc2d78b4..8a61c1056 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -9,6 +9,7 @@ class REST::StatusSerializer < ActiveModel::Serializer attribute :favourited, if: :current_user? attribute :reblogged, if: :current_user? attribute :muted, if: :current_user? + attribute :bookmarked, if: :current_user? attribute :pinned, if: :pinnable? belongs_to :reblog, serializer: REST::StatusSerializer @@ -85,6 +86,14 @@ class REST::StatusSerializer < ActiveModel::Serializer end end + def bookmarked + if instance_options && instance_options[:bookmarks] + instance_options[:bookmarks].bookmarks_map[object.id] || false + else + current_user.account.bookmarked?(object) + end + end + def pinned if instance_options && instance_options[:relationships] instance_options[:relationships].pins_map[object.id] || false diff --git a/config/routes.rb b/config/routes.rb index af49845cc..d077f1643 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -255,6 +255,9 @@ Rails.application.routes.draw do resource :favourite, only: :create post :unfavourite, to: 'favourites#destroy' + resource :bookmark, only: :create + post :unbookmark, to: 'bookmarks#destroy' + resource :mute, only: :create post :unmute, to: 'mutes#destroy' @@ -294,6 +297,7 @@ Rails.application.routes.draw do resources :blocks, only: [:index] resources :mutes, only: [:index] resources :favourites, only: [:index] + resources :bookmarks, only: [:index] resources :reports, only: [:create] resources :filters, only: [:index, :create, :show, :update, :destroy] resources :endorsements, only: [:index] diff --git a/db/migrate/20180831171112_create_bookmarks.rb b/db/migrate/20180831171112_create_bookmarks.rb new file mode 100644 index 000000000..208da452b --- /dev/null +++ b/db/migrate/20180831171112_create_bookmarks.rb @@ -0,0 +1,14 @@ +class CreateBookmarks < ActiveRecord::Migration[5.1] + def change + create_table :bookmarks do |t| + t.references :account, null: false + t.references :status, null: false + + t.timestamps + end + + add_foreign_key :bookmarks, :accounts, column: :account_id, on_delete: :cascade + add_foreign_key :bookmarks, :statuses, column: :status_id, on_delete: :cascade + add_index :bookmarks, [:account_id, :status_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 3487adf08..2dcdf1f0e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -182,6 +182,16 @@ ActiveRecord::Schema.define(version: 2019_01_17_114553) do t.index ["target_account_id"], name: "index_blocks_on_target_account_id" end + create_table "bookmarks", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "status_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "status_id"], name: "index_bookmarks_on_account_id_and_status_id", unique: true + t.index ["account_id"], name: "index_bookmarks_on_account_id" + t.index ["status_id"], name: "index_bookmarks_on_status_id" + end + create_table "conversation_mutes", force: :cascade do |t| t.bigint "conversation_id", null: false t.bigint "account_id", null: false @@ -702,6 +712,8 @@ ActiveRecord::Schema.define(version: 2019_01_17_114553) do add_foreign_key "backups", "users", on_delete: :nullify add_foreign_key "blocks", "accounts", column: "target_account_id", name: "fk_9571bfabc1", on_delete: :cascade add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade + add_foreign_key "bookmarks", "accounts", on_delete: :cascade + add_foreign_key "bookmarks", "statuses", on_delete: :cascade add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade add_foreign_key "custom_filters", "accounts", on_delete: :cascade From 4add8797574ca6034886ebe59c1c19346b019f5f Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Wed, 11 Apr 2018 21:05:08 +0200 Subject: [PATCH 02/18] Add spec for bookmark endpoints --- .../api/v1/bookmarks_controller_spec.rb | 78 +++++++++++++++++++ .../v1/statuses/bookmarks_controller_spec.rb | 57 ++++++++++++++ spec/fabricators/bookmark_fabricator.rb | 4 + 3 files changed, 139 insertions(+) create mode 100644 spec/controllers/api/v1/bookmarks_controller_spec.rb create mode 100644 spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb create mode 100644 spec/fabricators/bookmark_fabricator.rb diff --git a/spec/controllers/api/v1/bookmarks_controller_spec.rb b/spec/controllers/api/v1/bookmarks_controller_spec.rb new file mode 100644 index 000000000..26130e71b --- /dev/null +++ b/spec/controllers/api/v1/bookmarks_controller_spec.rb @@ -0,0 +1,78 @@ +require 'rails_helper' + +RSpec.describe Api::V1::BookmarksController, type: :controller do + render_views + + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') } + + describe 'GET #index' do + context 'without token' do + it 'returns http unauthorized' do + get :index + expect(response).to have_http_status :unauthorized + end + end + + context 'with token' do + context 'without read scope' do + before do + allow(controller).to receive(:doorkeeper_token) do + Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: '') + end + end + + it 'returns http forbidden' do + get :index + expect(response).to have_http_status :forbidden + end + end + + context 'without valid resource owner' do + before do + token = Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') + user.destroy! + + allow(controller).to receive(:doorkeeper_token) { token } + end + + it 'returns http unprocessable entity' do + get :index + expect(response).to have_http_status :unprocessable_entity + end + end + + context 'with read scope and valid resource owner' do + before do + allow(controller).to receive(:doorkeeper_token) do + Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') + end + end + + it 'shows bookmarks owned by the user' do + bookmarked_by_user = Fabricate(:bookmark, account: user.account) + bookmarked_by_others = Fabricate(:bookmark) + + get :index + + expect(assigns(:statuses)).to match_array [bookmarked_by_user.status] + end + + it 'adds pagination headers if necessary' do + bookmark = Fabricate(:bookmark, account: user.account) + + get :index, params: { limit: 1 } + + expect(response.headers['Link'].find_link(['rel', 'next']).href).to eq "http://test.host/api/v1/bookmarks?limit=1&max_id=#{bookmark.id}" + expect(response.headers['Link'].find_link(['rel', 'prev']).href).to eq "http://test.host/api/v1/bookmarks?limit=1&since_id=#{bookmark.id}" + end + + it 'does not add pagination headers if not necessary' do + get :index + + expect(response.headers['Link']).to eq nil + end + end + end + end +end diff --git a/spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb b/spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb new file mode 100644 index 000000000..d881e22ed --- /dev/null +++ b/spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Api::V1::Statuses::BookmarksController do + render_views + + let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write', application: app) } + + context 'with an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'POST #create' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + post :create, params: { status_id: status.id } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'updates the bookmarked attribute' do + expect(user.account.bookmarked?(status)).to be true + end + + it 'return json with updated attributes' do + hash_body = body_as_json + + expect(hash_body[:id]).to eq status.id.to_s + expect(hash_body[:bookmarked]).to be true + end + end + + describe 'POST #destroy' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + Bookmark.find_or_create_by!(account: user.account, status: status) + post :destroy, params: { status_id: status.id } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'updates the bookmarked attribute' do + expect(user.account.bookmarked?(status)).to be false + end + end + end +end diff --git a/spec/fabricators/bookmark_fabricator.rb b/spec/fabricators/bookmark_fabricator.rb new file mode 100644 index 000000000..12cbc5bfa --- /dev/null +++ b/spec/fabricators/bookmark_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:bookmark) do + account + status +end From 04c114866f962f2eea03f0e7f94f731a6eefed93 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Wed, 11 Apr 2018 20:24:54 +0200 Subject: [PATCH 03/18] Add front-end support for bookmarks --- app/javascript/mastodon/actions/bookmarks.js | 90 +++++++++++++++++ .../mastodon/actions/interactions.js | 78 +++++++++++++++ .../mastodon/components/status_action_bar.js | 7 ++ .../mastodon/containers/status_container.js | 10 ++ .../features/bookmarked_statuses/index.js | 98 +++++++++++++++++++ .../features/getting_started/index.js | 2 + .../features/status/components/action_bar.js | 7 ++ .../mastodon/features/status/index.js | 11 +++ .../features/ui/components/columns_area.js | 3 +- app/javascript/mastodon/features/ui/index.js | 2 + .../features/ui/util/async-components.js | 4 + .../mastodon/reducers/status_lists.js | 29 ++++++ app/javascript/mastodon/reducers/statuses.js | 6 ++ 13 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 app/javascript/mastodon/actions/bookmarks.js create mode 100644 app/javascript/mastodon/features/bookmarked_statuses/index.js diff --git a/app/javascript/mastodon/actions/bookmarks.js b/app/javascript/mastodon/actions/bookmarks.js new file mode 100644 index 000000000..544ed2ff2 --- /dev/null +++ b/app/javascript/mastodon/actions/bookmarks.js @@ -0,0 +1,90 @@ +import api, { getLinks } from '../api'; +import { importFetchedStatuses } from './importer'; + +export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST'; +export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS'; +export const BOOKMARKED_STATUSES_FETCH_FAIL = 'BOOKMARKED_STATUSES_FETCH_FAIL'; + +export const BOOKMARKED_STATUSES_EXPAND_REQUEST = 'BOOKMARKED_STATUSES_EXPAND_REQUEST'; +export const BOOKMARKED_STATUSES_EXPAND_SUCCESS = 'BOOKMARKED_STATUSES_EXPAND_SUCCESS'; +export const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL'; + +export function fetchBookmarkedStatuses() { + return (dispatch, getState) => { + if (getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) { + return; + } + + dispatch(fetchBookmarkedStatusesRequest()); + + api(getState).get('/api/v1/bookmarks').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchBookmarkedStatusesFail(error)); + }); + }; +}; + +export function fetchBookmarkedStatusesRequest() { + return { + type: BOOKMARKED_STATUSES_FETCH_REQUEST, + }; +}; + +export function fetchBookmarkedStatusesSuccess(statuses, next) { + return { + type: BOOKMARKED_STATUSES_FETCH_SUCCESS, + statuses, + next, + }; +}; + +export function fetchBookmarkedStatusesFail(error) { + return { + type: BOOKMARKED_STATUSES_FETCH_FAIL, + error, + }; +}; + +export function expandBookmarkedStatuses() { + return (dispatch, getState) => { + const url = getState().getIn(['status_lists', 'bookmarks', 'next'], null); + + if (url === null || getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) { + return; + } + + dispatch(expandBookmarkedStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandBookmarkedStatusesFail(error)); + }); + }; +}; + +export function expandBookmarkedStatusesRequest() { + return { + type: BOOKMARKED_STATUSES_EXPAND_REQUEST, + }; +}; + +export function expandBookmarkedStatusesSuccess(statuses, next) { + return { + type: BOOKMARKED_STATUSES_EXPAND_SUCCESS, + statuses, + next, + }; +}; + +export function expandBookmarkedStatusesFail(error) { + return { + type: BOOKMARKED_STATUSES_EXPAND_FAIL, + error, + }; +}; diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 2dc4c574c..3682370fc 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -33,6 +33,14 @@ export const UNPIN_REQUEST = 'UNPIN_REQUEST'; export const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; export const UNPIN_FAIL = 'UNPIN_FAIL'; +export const BOOKMARK_REQUEST = 'BOOKMARK_REQUEST'; +export const BOOKMARK_SUCCESS = 'BOOKMARKED_SUCCESS'; +export const BOOKMARK_FAIL = 'BOOKMARKED_FAIL'; + +export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; +export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; +export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; + export function reblog(status) { return function (dispatch, getState) { dispatch(reblogRequest(status)); @@ -187,6 +195,76 @@ export function unfavouriteFail(status, error) { }; }; +export function bookmark(status) { + return function (dispatch, getState) { + dispatch(bookmarkRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) { + dispatch(bookmarkSuccess(status, response.data)); + }).catch(function (error) { + dispatch(bookmarkFail(status, error)); + }); + }; +}; + +export function unbookmark(status) { + return (dispatch, getState) => { + dispatch(unbookmarkRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { + dispatch(unbookmarkSuccess(status, response.data)); + }).catch(error => { + dispatch(unbookmarkFail(status, error)); + }); + }; +}; + +export function bookmarkRequest(status) { + return { + type: BOOKMARK_REQUEST, + status: status, + }; +}; + +export function bookmarkSuccess(status, response) { + return { + type: BOOKMARK_SUCCESS, + status: status, + response: response, + }; +}; + +export function bookmarkFail(status, error) { + return { + type: BOOKMARK_FAIL, + status: status, + error: error, + }; +}; + +export function unbookmarkRequest(status) { + return { + type: UNBOOKMARK_REQUEST, + status: status, + }; +}; + +export function unbookmarkSuccess(status, response) { + return { + type: UNBOOKMARK_SUCCESS, + status: status, + response: response, + }; +}; + +export function unbookmarkFail(status, error) { + return { + type: UNBOOKMARK_FAIL, + status: status, + error: error, + }; +}; + export function fetchReblogs(id) { return (dispatch, getState) => { dispatch(fetchReblogsRequest(id)); diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 0995a1490..46af83066 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -23,6 +23,7 @@ const messages = defineMessages({ cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, open: { id: 'status.open', defaultMessage: 'Expand this status' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, @@ -65,6 +66,7 @@ class StatusActionBar extends ImmutablePureComponent { onEmbed: PropTypes.func, onMuteConversation: PropTypes.func, onPin: PropTypes.func, + onBookmark: PropTypes.func, withDismiss: PropTypes.bool, intl: PropTypes.object.isRequired, }; @@ -93,6 +95,10 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onFavourite(this.props.status); } + handleBookmarkClick = () => { + this.props.onBookmark(this.props.status); + } + handleReblogClick = (e) => { this.props.onReblog(this.props.status, e); } @@ -215,6 +221,7 @@ class StatusActionBar extends ImmutablePureComponent { {shareButton} +
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index b3555c76e..352d957e6 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -10,8 +10,10 @@ import { import { reblog, favourite, + bookmark, unreblog, unfavourite, + unbookmark, pin, unpin, } from '../actions/interactions'; @@ -91,6 +93,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onBookmark (status) { + if (status.get('bookmarked')) { + dispatch(unbookmark(status)); + } else { + dispatch(bookmark(status)); + } + }, + onPin (status) { if (status.get('pinned')) { dispatch(unpin(status)); diff --git a/app/javascript/mastodon/features/bookmarked_statuses/index.js b/app/javascript/mastodon/features/bookmarked_statuses/index.js new file mode 100644 index 000000000..195eba2a9 --- /dev/null +++ b/app/javascript/mastodon/features/bookmarked_statuses/index.js @@ -0,0 +1,98 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions/bookmarks'; +import Column from '../ui/components/column'; +import ColumnHeader from '../../components/column_header'; +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; +import StatusList from '../../components/status_list'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { debounce } from 'lodash'; + +const messages = defineMessages({ + heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'bookmarks', 'items']), + isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true), + hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Bookmarks extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchBookmarkedStatuses()); + } + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('BOOKMARKS', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandBookmarkedStatuses()); + }, 300, { leading: true }) + + render () { + const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; + const pinned = !!columnId; + + return ( + + + + + + ); + } + +} diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index 709a3aa96..9163467fd 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -20,6 +20,7 @@ const messages = defineMessages({ settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' }, community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' }, + bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, @@ -115,6 +116,7 @@ class GettingStarted extends ImmutablePureComponent { navItems.push( , + , , ); diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index d3b725283..df3e9a054 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -17,6 +17,7 @@ const messages = defineMessages({ cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, @@ -42,6 +43,7 @@ class ActionBar extends React.PureComponent { onReply: PropTypes.func.isRequired, onReblog: PropTypes.func.isRequired, onFavourite: PropTypes.func.isRequired, + onBookmark: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, @@ -66,6 +68,10 @@ class ActionBar extends React.PureComponent { this.props.onFavourite(this.props.status); } + handleBookmarkClick = (e) => { + this.props.onBookmark(this.props.status, e); + } + handleDeleteClick = () => { this.props.onDelete(this.props.status, this.context.router.history); } @@ -177,6 +183,7 @@ class ActionBar extends React.PureComponent {
{shareButton} +
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index d48b682eb..a5699f192 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -12,6 +12,8 @@ import Column from '../ui/components/column'; import { favourite, unfavourite, + bookmark, + unbookmark, reblog, unreblog, pin, @@ -191,6 +193,14 @@ class Status extends ImmutablePureComponent { } } + handleBookmarkClick = (status) => { + if (status.get('bookmarked')) { + this.props.dispatch(unbookmark(status)); + } else { + this.props.dispatch(bookmark(status)); + } + } + handleDeleteClick = (status, history, withRedraft = false) => { const { dispatch, intl } = this.props; @@ -448,6 +458,7 @@ class Status extends ImmutablePureComponent { onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} + onBookmark={this.handleBookmarkClick} onDelete={this.handleDeleteClick} onDirect={this.handleDirectClick} onMention={this.handleMentionClick} diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index b7e350cbc..fe2d5ea02 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -12,7 +12,7 @@ import BundleContainer from '../containers/bundle_container'; import ColumnLoading from './column_loading'; import DrawerLoading from './drawer_loading'; import BundleColumnError from './bundle_column_error'; -import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from '../../ui/util/async-components'; +import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, BookmarkedStatuses, ListTimeline } from '../../ui/util/async-components'; import detectPassiveEvents from 'detect-passive-events'; import { scrollRight } from '../../../scroll'; @@ -26,6 +26,7 @@ const componentMap = { 'HASHTAG': HashtagTimeline, 'DIRECT': DirectTimeline, 'FAVOURITES': FavouritedStatuses, + 'BOOKMARKS': BookmarkedStatuses, 'LIST': ListTimeline, }; diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index f01c2bf24..ebb0b1a96 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -39,6 +39,7 @@ import { FollowRequests, GenericNotFound, FavouritedStatuses, + BookmarkedStatuses, ListTimeline, Blocks, DomainBlocks, @@ -157,6 +158,7 @@ class SwitchingColumnsArea extends React.PureComponent { + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 235fd2a07..c9b922e6b 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -90,6 +90,10 @@ export function FavouritedStatuses () { return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses'); } +export function BookmarkedStatuses () { + return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses'); +} + export function Blocks () { return import(/* webpackChunkName: "features/blocks" */'../../blocks'); } diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js index 6c5f33557..9f8f28dee 100644 --- a/app/javascript/mastodon/reducers/status_lists.js +++ b/app/javascript/mastodon/reducers/status_lists.js @@ -6,6 +6,14 @@ import { FAVOURITED_STATUSES_EXPAND_SUCCESS, FAVOURITED_STATUSES_EXPAND_FAIL, } from '../actions/favourites'; +import { + BOOKMARKED_STATUSES_FETCH_REQUEST, + BOOKMARKED_STATUSES_FETCH_SUCCESS, + BOOKMARKED_STATUSES_FETCH_FAIL, + BOOKMARKED_STATUSES_EXPAND_REQUEST, + BOOKMARKED_STATUSES_EXPAND_SUCCESS, + BOOKMARKED_STATUSES_EXPAND_FAIL, +} from '../actions/bookmarks'; import { PINNED_STATUSES_FETCH_SUCCESS, } from '../actions/pin_statuses'; @@ -13,6 +21,8 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { FAVOURITE_SUCCESS, UNFAVOURITE_SUCCESS, + BOOKMARK_SUCCESS, + UNBOOKMARK_SUCCESS, PIN_SUCCESS, UNPIN_SUCCESS, } from '../actions/interactions'; @@ -23,6 +33,11 @@ const initialState = ImmutableMap({ loaded: false, items: ImmutableList(), }), + bookmarks: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableList(), + }), pins: ImmutableMap({ next: null, loaded: false, @@ -71,10 +86,24 @@ export default function statusLists(state = initialState, action) { return normalizeList(state, 'favourites', action.statuses, action.next); case FAVOURITED_STATUSES_EXPAND_SUCCESS: return appendToList(state, 'favourites', action.statuses, action.next); + case BOOKMARKED_STATUSES_FETCH_REQUEST: + case BOOKMARKED_STATUSES_EXPAND_REQUEST: + return state.setIn(['bookmarks', 'isLoading'], true); + case BOOKMARKED_STATUSES_FETCH_FAIL: + case BOOKMARKED_STATUSES_EXPAND_FAIL: + return state.setIn(['bookmarks', 'isLoading'], false); + case BOOKMARKED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'bookmarks', action.statuses, action.next); + case BOOKMARKED_STATUSES_EXPAND_SUCCESS: + return appendToList(state, 'bookmarks', action.statuses, action.next); case FAVOURITE_SUCCESS: return prependOneToList(state, 'favourites', action.status); case UNFAVOURITE_SUCCESS: return removeOneFromList(state, 'favourites', action.status); + case BOOKMARK_SUCCESS: + return prependOneToList(state, 'bookmarks', action.status); + case UNBOOKMARK_SUCCESS: + return removeOneFromList(state, 'bookmarks', action.status); case PINNED_STATUSES_FETCH_SUCCESS: return normalizeList(state, 'pins', action.statuses, action.next); case PIN_SUCCESS: diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index 885cc221c..735902e72 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -3,6 +3,8 @@ import { REBLOG_FAIL, FAVOURITE_REQUEST, FAVOURITE_FAIL, + BOOKMARK_REQUEST, + BOOKMARK_FAIL, } from '../actions/interactions'; import { STATUS_MUTE_SUCCESS, @@ -39,6 +41,10 @@ export default function statuses(state = initialState, action) { return state.setIn([action.status.get('id'), 'favourited'], true); case FAVOURITE_FAIL: return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false); + case BOOKMARK_REQUEST: + return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], true); + case BOOKMARK_FAIL: + return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false); case REBLOG_REQUEST: return state.setIn([action.status.get('id'), 'reblogged'], true); case REBLOG_FAIL: From 33a4c201466416546cd2b71ac2a99b46a510896e Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Fri, 10 Aug 2018 15:22:04 +0200 Subject: [PATCH 04/18] Introduce OAuth scopes for bookmarks --- app/controllers/api/v1/bookmarks_controller.rb | 2 +- app/controllers/api/v1/statuses/bookmarks_controller.rb | 2 +- config/initializers/doorkeeper.rb | 2 ++ config/locales/doorkeeper.en.yml | 2 ++ spec/controllers/api/v1/bookmarks_controller_spec.rb | 2 +- spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb | 2 +- 6 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb index 49038807d..1cab3c372 100644 --- a/app/controllers/api/v1/bookmarks_controller.rb +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::V1::BookmarksController < Api::BaseController - before_action -> { doorkeeper_authorize! :read } + before_action -> { doorkeeper_authorize! :read, :'read:bookmarks' } before_action :require_user! after_action :insert_pagination_headers diff --git a/app/controllers/api/v1/statuses/bookmarks_controller.rb b/app/controllers/api/v1/statuses/bookmarks_controller.rb index d7def5f1f..bb9729cf5 100644 --- a/app/controllers/api/v1/statuses/bookmarks_controller.rb +++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb @@ -3,7 +3,7 @@ class Api::V1::Statuses::BookmarksController < Api::BaseController include Authorization - before_action -> { doorkeeper_authorize! :write } + before_action -> { doorkeeper_authorize! :write, :'write:bookmarks' } before_action :require_user! respond_to :json diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 367eead6a..2a963b32b 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -58,6 +58,7 @@ Doorkeeper.configure do optional_scopes :write, :'write:accounts', :'write:blocks', + :'write:bookmarks', :'write:conversations', :'write:favourites', :'write:filters', @@ -71,6 +72,7 @@ Doorkeeper.configure do :read, :'read:accounts', :'read:blocks', + :'read:bookmarks', :'read:favourites', :'read:filters', :'read:follows', diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml index f1fe03716..211b210d7 100644 --- a/config/locales/doorkeeper.en.yml +++ b/config/locales/doorkeeper.en.yml @@ -119,6 +119,7 @@ en: read: read all your account's data read:accounts: see accounts information read:blocks: see your blocks + read:bookmarks: see your bookmarks read:favourites: see your favourites read:filters: see your filters read:follows: see your follows @@ -131,6 +132,7 @@ en: write: modify all your account's data write:accounts: modify your profile write:blocks: block accounts and domains + write:bookmarks: bookmark statuses write:favourites: favourite statuses write:filters: create filters write:follows: follow people diff --git a/spec/controllers/api/v1/bookmarks_controller_spec.rb b/spec/controllers/api/v1/bookmarks_controller_spec.rb index 26130e71b..79601b6e6 100644 --- a/spec/controllers/api/v1/bookmarks_controller_spec.rb +++ b/spec/controllers/api/v1/bookmarks_controller_spec.rb @@ -4,7 +4,7 @@ RSpec.describe Api::V1::BookmarksController, type: :controller do render_views let(:user) { Fabricate(:user) } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:bookmarks') } describe 'GET #index' do context 'without token' do diff --git a/spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb b/spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb index d881e22ed..b79853718 100644 --- a/spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb +++ b/spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb @@ -7,7 +7,7 @@ describe Api::V1::Statuses::BookmarksController do let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write', application: app) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:bookmarks', application: app) } context 'with an oauth token' do before do From c4a69903b6990c873a6d127c720ed371cf30485d Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Wed, 29 Aug 2018 13:06:37 +0200 Subject: [PATCH 05/18] Add bookmarks to archive takeout --- app/services/backup_service.rb | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb index 4cfa22ab8..5fcc98057 100644 --- a/app/services/backup_service.rb +++ b/app/services/backup_service.rb @@ -45,6 +45,7 @@ class BackupService < BaseService dump_media_attachments!(tar) dump_outbox!(tar) dump_likes!(tar) + dump_bookmarks!(tar) dump_actor!(tar) end end @@ -85,6 +86,7 @@ class BackupService < BaseService actor[:image][:url] = 'header' + File.extname(actor[:image][:url]) if actor[:image] actor[:outbox] = 'outbox.json' actor[:likes] = 'likes.json' + actor[:bookmarks] = 'bookmarks.json' download_to_tar(tar, account.avatar, 'avatar' + File.extname(account.avatar.path)) if account.avatar.exists? download_to_tar(tar, account.header, 'header' + File.extname(account.header.path)) if account.header.exists? @@ -115,6 +117,25 @@ class BackupService < BaseService end end + def dump_bookmarks!(tar) + collection = serialize(ActivityPub::CollectionPresenter.new(id: 'bookmarks.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer) + + Status.reorder(nil).joins(:bookmarks).includes(:account).merge(account.bookmarks).find_in_batches do |statuses| + statuses.each do |status| + collection[:totalItems] += 1 + collection[:orderedItems] << ActivityPub::TagManager.instance.uri_for(status) + end + + GC.start + end + + json = Oj.dump(collection) + + tar.add_file_simple('bookmarks.json', 0o444, json.bytesize) do |io| + io.write(json) + end + end + def collection_presenter ActivityPub::CollectionPresenter.new( id: 'outbox.json', From 667b598e5ce789fdf93f883b7c77dff274db1de3 Mon Sep 17 00:00:00 2001 From: YoheiZuho Date: Mon, 12 Aug 2019 02:36:51 +0000 Subject: [PATCH 06/18] =?UTF-8?q?=E3=83=96=E3=83=83=E3=82=AF=E3=83=BC?= =?UTF-8?q?=E3=83=9E=E3=83=BC=E3=82=AF=E3=81=AE=E5=86=8D=E5=AE=9F=E8=A3=85?= =?UTF-8?q?(=E3=82=B7=E3=83=B3=E3=82=B0=E3=83=AB=E3=82=AB=E3=83=A9?= =?UTF-8?q?=E3=83=A0=E3=83=BB=E7=BF=BB=E8=A8=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/javascript/mastodon/features/getting_started/index.js | 2 +- .../mastodon/features/ui/components/navigation_panel.js | 1 + app/javascript/mastodon/locales/ja.json | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index a1e3f86ad..9a2e0fa9c 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -134,7 +134,7 @@ class GettingStarted extends ImmutablePureComponent { ); - height += 48*3; + height += 48*4; if (myAccount.get('locked')) { navItems.push(); diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js index 64a40a9da..907dbc665 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.js +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js @@ -16,6 +16,7 @@ const NavigationPanel = () => ( + diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 7312ce5ac..362cbf3ab 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -230,6 +230,7 @@ "mute_modal.hide_notifications": "このユーザーからの通知を隠しますか?", "navigation_bar.apps": "アプリ", "navigation_bar.blocks": "ブロックしたユーザー", + "navigation_bar.bookmarks": "ブックマーク", "navigation_bar.community_timeline": "ローカルタイムライン", "navigation_bar.compose": "トゥートの新規作成", "navigation_bar.direct": "ダイレクトメッセージ", From 87875c093e3f6deb3ad27d71bac6ed792b4883f5 Mon Sep 17 00:00:00 2001 From: YoheiZuho Date: Mon, 12 Aug 2019 04:42:03 +0000 Subject: [PATCH 07/18] =?UTF-8?q?=E3=83=9C=E3=82=BF=E3=83=B3=E3=81=AE?= =?UTF-8?q?=E5=BE=A9=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/javascript/mastodon/actions/UtilBtns.js | 71 +++++++++++++++++++ .../compose/components/compose_form.js | 32 +++++++-- .../compose/components/user_counter.js | 10 +++ .../containers/compose_form_container.js | 17 +++++ app/javascript/mastodon/locales/ja.json | 19 +++-- .../styles/mastodon/components.scss | 67 ++++++++--------- 6 files changed, 173 insertions(+), 43 deletions(-) create mode 100644 app/javascript/mastodon/actions/UtilBtns.js create mode 100644 app/javascript/mastodon/features/compose/components/user_counter.js 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/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 47e189251..14d8ee795 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -20,6 +20,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 +29,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 +65,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 = { @@ -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) > 2048 || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { return; } @@ -177,11 +184,15 @@ 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, 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) > 2048 || (text.length !== 0 && text.trim().length === 0 && !anyMedia); let publishText = ''; if (this.props.privacy === 'private' || this.props.privacy === 'direct') { @@ -243,11 +254,22 @@ class ComposeForm extends ImmutablePureComponent {
-
+
-
+
+ + +
+
+ +
+ +
); 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/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index 37a0e8845..c4b27ad79 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,18 @@ const mapDispatchToProps = (dispatch) => ({ onPickEmoji (position, data, needsSpace) { dispatch(insertEmojiCompose(position, data, needsSpace)); }, + + onRisaSubmit (textarea) { + dispatch(submitRisa(textarea)); + }, + + onGojiSubmit (textarea) { + dispatch(submitGoji(textarea)); + }, + + onHarukinSubmit (textarea) { + dispatch(submitHarukin(textarea)); + }, }); diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 362cbf3ab..95bd39465 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -54,7 +54,7 @@ "column.lists": "リスト", "column.mutes": "ミュートしたユーザー", "column.notifications": "通知", - "column.pins": "固定されたトゥート", + "column.pins": "固定された投稿", "column.public": "連合タイムライン", "column_back_button.label": "戻る", "column_header.hide_settings": "設定を隠す", @@ -70,7 +70,7 @@ "compose_form.hashtag_warning": "このトゥートは公開設定ではないのでハッシュタグの一覧に表示されません。公開トゥートだけがハッシュタグで検索できます。", "compose_form.lock_disclaimer": "あなたのアカウントは{locked}になっていません。誰でもあなたをフォローすることができ、フォロワー限定の投稿を見ることができます。", "compose_form.lock_disclaimer.lock": "承認制", - "compose_form.placeholder": "今なにしてる?", + "compose_form.placeholder": "ちょうどL.A.に到着しました。", "compose_form.poll.add_option": "追加", "compose_form.poll.duration": "アンケート期間", "compose_form.poll.option_placeholder": "項目 {number}", @@ -83,6 +83,8 @@ "compose_form.spoiler.marked": "閲覧注意が設定されています", "compose_form.spoiler.unmarked": "閲覧注意が設定されていません", "compose_form.spoiler_placeholder": "ここに警告を書いてください", + "compose_form.utilBtns_goji": "誤字盛!", + "compose_form.utilBtns_harukin": "はるきん焼却", "confirmation_modal.cancel": "キャンセル", "confirmations.block.block_and_report": "ブロックし通報", "confirmations.block.confirm": "ブロック", @@ -143,7 +145,7 @@ "getting_started.invite": "招待", "getting_started.open_source_notice": "Mastodonはオープンソースソフトウェアです。誰でもGitHub ( {github} ) から開発に参加したり、問題を報告したりできます。", "getting_started.security": "セキュリティ", - "getting_started.terms": "プライバシーポリシー", + "getting_started.terms": "利用規約とプライバシーポリシー", "hashtag.column_header.tag_mode.all": "と {additional}", "hashtag.column_header.tag_mode.any": "か {additional}", "hashtag.column_header.tag_mode.none": "({additional} を除く)", @@ -153,6 +155,7 @@ "hashtag.column_settings.tag_mode.any": "いずれかを含む", "hashtag.column_settings.tag_mode.none": "これらを除く", "hashtag.column_settings.tag_toggle": "このカラムに追加のタグを含める", + "home.column_settings.advanced": "高度な設定", "home.column_settings.basic": "基本設定", "home.column_settings.show_reblogs": "ブースト表示", "home.column_settings.show_replies": "返信表示", @@ -230,7 +233,6 @@ "mute_modal.hide_notifications": "このユーザーからの通知を隠しますか?", "navigation_bar.apps": "アプリ", "navigation_bar.blocks": "ブロックしたユーザー", - "navigation_bar.bookmarks": "ブックマーク", "navigation_bar.community_timeline": "ローカルタイムライン", "navigation_bar.compose": "トゥートの新規作成", "navigation_bar.direct": "ダイレクトメッセージ", @@ -241,7 +243,8 @@ "navigation_bar.filters": "フィルター設定", "navigation_bar.follow_requests": "フォローリクエスト", "navigation_bar.follows_and_followers": "フォロー・フォロワー", - "navigation_bar.info": "このサーバーについて", + "navigation_bar.generate_qrcode": "プロフィールのQRコードを生成", + "navigation_bar.info": "Yづドンについて", "navigation_bar.keyboard_shortcuts": "ホットキー", "navigation_bar.lists": "リスト", "navigation_bar.logout": "ログアウト", @@ -252,7 +255,10 @@ "navigation_bar.profile_directory": "ディレクトリ", "navigation_bar.public_timeline": "連合タイムライン", "navigation_bar.security": "セキュリティ", - "notification.favourite": "{name}さんがあなたのトゥートをお気に入りに登録しました", + "navigation_bar.announcements": "運営からのお知らせ", + "navigation_bar.trends": "トレンド", + "navigation_bar.bookmarks": "ブックマーク", + "notification.favourite": "{name}さんがあなたのトゥートに╰( ^o^)╮-=ニ=一=三★しました", "notification.follow": "{name}さんにフォローされました", "notification.mention": "{name}さんがあなたに返信しました", "notification.poll": "アンケートが終了しました", @@ -293,6 +299,7 @@ "privacy.public.short": "公開", "privacy.unlisted.long": "公開TLで表示しない", "privacy.unlisted.short": "未収載", + "qr_modal.description": "QRコードを読み取って簡単にプロフィールにアクセスしましょう。", "regeneration_indicator.label": "読み込み中…", "regeneration_indicator.sublabel": "ホームタイムラインは準備中です!", "relative_time.days": "{number}日前", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 8de72d72e..a7cbca4b5 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -657,10 +657,29 @@ justify-content: flex-end; min-width: 0; flex: 0 0 auto; - + padding-top: 10px; + .compose-form__publish-button-wrapper { overflow: hidden; - padding-top: 10px; + + button { + display: inline-block; + width: auto; + + margin-right: 0.5em; + } + + button:last-child { + margin-right: auto; + } + } + } + + .compose-form__utilBtns { + padding-top: 10px; + + * { + margin-bottom: 1em; } } } @@ -2212,6 +2231,7 @@ a.account__display-name { } .getting-started__wrapper, + .getting-started__trends, .search { margin-bottom: 10px; } @@ -2318,24 +2338,13 @@ a.account__display-name { margin-bottom: 10px; height: calc(100% - 20px); overflow-y: auto; - display: flex; - flex-direction: column; - - & > a { - flex: 0 0 auto; - } hr { - flex: 0 0 auto; border: 0; background: transparent; border-top: 1px solid lighten($ui-base-color, 4%); margin: 10px 0; } - - .flex-spacer { - background: transparent; - } } .drawer__pager { @@ -2727,10 +2736,8 @@ a.account__display-name { } &__trends { + background: $ui-base-color; flex: 0 1 auto; - opacity: 1; - animation: fade 150ms linear; - margin-top: 10px; @media screen and (max-height: 810px) { .trends__item:nth-child(3) { @@ -2747,15 +2754,11 @@ a.account__display-name { @media screen and (max-height: 670px) { display: none; } + } - .trends__item { - border-bottom: 0; - padding: 10px; - - &__current { - color: $darker-text-color; - } - } + &__scrollable { + max-height: 100%; + overflow-y: auto; } } @@ -5520,6 +5523,12 @@ noscript { } } +.embed-modal__qrcode { + display: block; + margin-left: auto; + margin-right: auto; +} + .account__moved-note { padding: 14px 10px; padding-bottom: 16px; @@ -5984,8 +5993,7 @@ noscript { font-size: 24px; line-height: 36px; font-weight: 500; - text-align: right; - padding-right: 15px; + text-align: center; color: $secondary-text-color; } @@ -5993,12 +6001,7 @@ noscript { flex: 0 0 auto; width: 50px; - path:first-child { - fill: rgba($highlight-text-color, 0.25) !important; - fill-opacity: 1 !important; - } - - path:last-child { + path { stroke: lighten($highlight-text-color, 6%) !important; } } From 4a2269092ac81ef8ed2684d0ea8e8443d4747704 Mon Sep 17 00:00:00 2001 From: YoheiZuho Date: Mon, 12 Aug 2019 11:38:13 +0000 Subject: [PATCH 08/18] =?UTF-8?q?Admin=E3=81=8B=E3=82=89=E3=81=AE=E3=81=8A?= =?UTF-8?q?=E7=9F=A5=E3=82=89=E3=81=9B=E6=AC=84=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mastodon/features/getting_started/index.js | 17 ++++++++++------- .../features/ui/components/navigation_panel.js | 1 + app/javascript/mastodon/locales/ja.json | 1 + 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index 9a2e0fa9c..3c6dc7194 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' }, @@ -100,7 +101,7 @@ class GettingStarted extends ImmutablePureComponent { if (multiColumn) { navItems.push( , - , + , , ); @@ -108,23 +109,25 @@ class GettingStarted extends ImmutablePureComponent { if (profile_directory) { navItems.push( - - ); + , + , + ); - height += 48; + height += 48*2; } navItems.push( - + , ); height += 34; } else if (profile_directory) { navItems.push( - + , + , ); - height += 48; + height += 48*2; } navItems.push( diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js index 907dbc665..05e823c92 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.js +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js @@ -16,6 +16,7 @@ const NavigationPanel = () => ( + diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 95bd39465..c8f88c39d 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -367,6 +367,7 @@ "status.unpin": "プロフィールへの固定を解除", "suggestions.dismiss": "隠す", "suggestions.header": "興味あるかもしれません…", + "tabs_bar.admin_notifications": "Adminからのお知らせ", "tabs_bar.federated_timeline": "連合", "tabs_bar.home": "ホーム", "tabs_bar.local_timeline": "ローカル", From 09b4c05ce19d5ec824b4b382b9b8eb58baba2da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9C=A7=E5=B3=B6=E3=81=B2=E3=81=AA=E3=81=9F?= Date: Mon, 20 Aug 2018 23:41:39 +0900 Subject: [PATCH 09/18] SecurityUpdate --- Gemfile | 2 + Gemfile.lock | 2 + app/lib/formatter.rb | 17 +- app/lib/formatter_markdown.rb | 340 ++++++++++++++++++++++++++++++++++ 4 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 app/lib/formatter_markdown.rb diff --git a/Gemfile b/Gemfile index 263be0ac3..cbf00459c 100644 --- a/Gemfile +++ b/Gemfile @@ -96,6 +96,8 @@ gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: '345b7a57333 gem 'json-ld-preloaded', '~> 3.0' gem 'rdf-normalize', '~> 0.3' +gem 'redcarpet', "~> 3.4.0" + group :development, :test do gem 'fabrication', '~> 2.20' gem 'fuubar', '~> 2.4' diff --git a/Gemfile.lock b/Gemfile.lock index f185f3fa5..eb5e5445a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -496,6 +496,7 @@ GEM link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.3.3) rdf (>= 2.2, < 4.0) + redcarpet (3.4.0) redis (4.1.2) redis-actionpack (5.0.2) actionpack (>= 4.0, < 6) @@ -755,6 +756,7 @@ DEPENDENCIES rails-i18n (~> 5.1) rails-settings-cached (~> 0.6) rdf-normalize (~> 0.3) + redcarpet (~> 3.4.0) redis (~> 4.1) redis-namespace (~> 1.5) redis-rails (~> 5.0) diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index b5f42305f..c1ad9c701 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'singleton' +require_relative './formatter_markdown' require_relative './sanitize_config' class Formatter @@ -35,12 +36,21 @@ class Formatter linkable_accounts << status.account html = raw_content + + mdFormatter = Formatter_Markdown.new(html) + html = mdFormatter.formatted + html = "RT @#{prepend_reblog} #{html}" if prepend_reblog html = encode_and_link_urls(html, linkable_accounts) html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify] html = simple_format(html, {}, sanitize: false) html = html.delete("\n") + mdLinkDecoder = MDLinkDecoder.new(html) + html = mdLinkDecoder.decode + + html.gsub!(/(&)/){"&"} + html.html_safe # rubocop:disable Rails/OutputSafety end @@ -111,13 +121,18 @@ class Formatter def encode_and_link_urls(html, accounts = nil, options = {}) entities = utf8_friendly_extractor(html, extract_url_without_protocol: false) + mdExtractor = MDExtractor.new(html) + entities.concat(mdExtractor.extractEntities) + if accounts.is_a?(Hash) options = accounts accounts = nil end rewrite(html.dup, entities) do |entity| - if entity[:url] + if entity[:markdown] + html[entity[:indices][0]...entity[:indices][1]] + elsif entity[:url] link_to_url(entity, options) elsif entity[:hashtag] link_to_hashtag(entity) diff --git a/app/lib/formatter_markdown.rb b/app/lib/formatter_markdown.rb new file mode 100644 index 000000000..30a08d558 --- /dev/null +++ b/app/lib/formatter_markdown.rb @@ -0,0 +1,340 @@ +require 'uri' +require 'redcarpet' +require 'redcarpet/render_strip' + +class Formatter_Markdown + def initialize(html) + @html = html.dup + end + + def formatted + mdRenderer = CustomMDRenderer.new( + strikethrough: true, + hard_wrap: true, + autolink: false, + superscript:false, + fenced_link: true, + fenced_image: true, + no_intra_emphasis: true, + no_links: true, + no_styles: true, + no_images: true, + filter_html: true, + escape_html: true, + safe_links_only: true, + with_toc_data: true, + xhtml: false, + prettify: true, + link_attributes: true + ) + + md = Redcarpet::Markdown.new( + mdRenderer, + strikethrough: true, + hard_wrap: true, + superscript:false, + autolink: false, + space_after_headers: true, + no_intra_emphasis: true, + no_links: true, + no_styles: true, + no_images: true, + filter_html: true, + escape_html: true, + safe_links_only: true, + with_toc_data: true, + xhtml: false, + prettify: true, + link_attributes: true + ) + s = @html + s.gsub!(/\n[\n]+/) {"\n \n"}# 改行周りの問題を修正 + s.gsub!(/`[ ]+`/) {"` `"}# code内が半角スペースのみだとHTMLが壊れるのでそれの回避 + + renderedMD = md.render(s) + + result = renderedMD + result.gsub!(/(<\w+)([^>]*>)/) { "#{$1} data-md='true' #{$2}" }# ToDo data-md="true" を認識して他鯖の人にmarkdownの使用を伝える機能の実装 + result.gsub!(/(https?:\/\/[^<>"\[\]  ]+)/){"#{$1} "}#URLの後ろにスペースをねじ込む奴 mastodonのURL認識がゆるいのをmarkdownで対処 + + result + + end + + class CustomMDRenderer < Redcarpet::Render::HTML + + #基本的な実装の流れ + #URLの削除(mastodonの機能上URLとして認識されると十中八九HTMLが壊れるので) + #markdownコンテンツ内でのmarkdownコンテンツの禁止(意図しないHTMLタグの生成によってHTMLの不正出力を防ぐ目的) + #最後にHTMLに出力される際にHTML的にヤバイ子たちのエスケープ + + def paragraph(text) + %(#{text.strip}) + end + + def linebreak() + %(
) + end + + def block_quote(quote) + urlRemoved = "#{remove_url(quote)}" + escapedContents = "#{blockquote_markdown_escape(urlRemoved)}" + %(
#{escapedContents.strip}
) + end + + def header(text, header_level) + urlRemoved = "#{remove_url(text)}" + mdContentsRemoved = "#{markdown_escape(urlRemoved)}" + %(#{encode(mdContentsRemoved)}\n) + end + + def codespan(code) + urlRemoved = "#{remove_url(code)}" + escapedCode = "#{escape_bbcode(urlRemoved)}" + %(#{encode(escapedCode)}) + end + + def list(contents, list_type) + if list_type == :unordered + %(
    #{contents.strip}
) + elsif list_type == :ordered + %(
    #{contents.strip}
) + else + %(<#{list_type}>#{contents.strip}) + end + end + + def list_item(text, list_type) + urlRemoved = "#{remove_url(text)}" + mdContentsRemoved = "#{markdown_escape(urlRemoved)}" + %(
  • #{encode(mdContentsRemoved)}
  • ) + end + + def emphasis(text) + urlRemoved = "#{remove_url(text)}" + mdContentsRemoved = "#{markdown_escape(urlRemoved)}" + %(#{encode(mdContentsRemoved)}) + end + + def double_emphasis(text) + urlRemoved = "#{remove_url(text)}" + mdContentsRemoved = "#{markdown_escape(urlRemoved)}" + %(#{encode(mdContentsRemoved)}) + end + + def triple_emphasis(text) + urlRemoved = "#{remove_url(text)}" + mdContentsRemoved = "#{markdown_escape(urlRemoved)}" + %(#{encode(mdContentsRemoved)}) + end + + def strikethrough(text) + urlRemoved = "#{remove_url(text)}" + mdContentsRemoved = "#{markdown_escape(urlRemoved)}" + %(#{encode(mdContentsRemoved)}) + end + + def superscript(text) + urlRemoved = "#{remove_url(text)}" + mdContentsRemoved = "#{markdown_escape(urlRemoved)}" + %(#{encode(mdContentsRemoved)}) + end + + def underline(text) + urlRemoved = "#{remove_url(text)}" + mdContentsRemoved = "#{markdown_escape(urlRemoved)}" + %(#{encode(mdContentsRemoved)}) + end + + def highlight(text) + urlRemoved = "#{remove_url(text)}" + mdContentsRemoved = "#{markdown_escape(urlRemoved)}" + %(#{encode(mdContentsRemoved)}) + end + + #オートリンクはmastodonとの相性が悪いので基本的には使わない + + def autolink(link, link_type) + %(リンク) + end + + #https以外の物がURLとして記入された時にTextをHTML的に考えて安全に表示するように変更 + + def image(link, title, alt_text) + + if alt_text =~ /[<>"\[\]  ]+/ + alt_text = "設定なし" + end + + imgcheck = "#{link}" + if imgcheck !~ /\Ahttps:\/\/[^<>"\[\]  ]+\z/ + %(#{encode(alt_text)}) + else + %() + end + end + + def link(link, title, content) + + if content =~ /([<>"\[\]  ]+|https?:\/\/|#|@)/ + content = "リンク" + elsif content !~ /.+/ + content = "リンク" + end + + linkcheck = "#{link}" + if linkcheck !~ /\Ahttps:\/\/[^<>"\[\]  ]+\z/ + %(#{encode(content)}) + else + %(#{encode(content)}) + end + end + + #ここから下はいろいろエスケープするための奴 + + #HTML的に考えてよろしくない子たちをエスケープする奴 + def encode(html) + HTMLEntities.new.encode(html) + end + + #markdownコンテンツないでURLが生成されるのを防ぐためのエスケープする奴 + def remove_url(string) + url = string.gsub(/https?:\/\//){ "URL:" } + reply = url.gsub(/@/){ "@" } + hashTag = reply.gsub(/#/){ "#" } + end + + #前々から要望があったcode内でBBCodeを無効化するための奴 + def escape_bbcode(string) + string.gsub(/\[/){ "[" } + end + + #markdownの中でmarkdownを展開させないためのエスケープする奴 + + #blockquote以外は下のが使える + def markdown_escape(string) + string.gsub(/<[^>]+>/) { "" } + end + + #blockquoteコンテンツ内でblockquoteタグだけを許可するためのエスケープ + def blockquote_markdown_escape(string) + string.gsub(/<([\/]?a[^>]*|[\/]?img[^>]*|[\/]?code[^>]*|[\/]?h[1-6][^>]*|[\/]?sup[^>]*|[\/]?sub[^>]*|[\/]?small[^>]*|[\/]?ul[^>]*|[\/]?ol[^>]*|[\/]?li[^>]*|[\/]?hr[^>]*|[\/]?s[^>]*|[\/]?u[^>]*|[\/]?mark[^>]*)>/) { "" } + end + + #テストで書きなぐった奴 + def html_escape(string) + string.gsub(/['&\"<>\/]/, { + '&' => '&', + '<' => '<', + '>' => '>', + '"' => '"', + "'" => ''', + "/" => '/', + }) + end + + end + +end + +#URLとかいう人類には早すぎたやばい子たちを大人しくするために必要な機構 + +class MDLinkDecoder + def initialize(html) + @html = html.dup + end + + def decode + imageDecoded = @html.gsub(/]*)>/) { "" } + + imageDecoded.gsub(/]*)>/) { "" } + end +end + +#エスケープを回避するHTMLタグの設定とかその他 + +class MDExtractor + def initialize(html) + @html = html.dup + end + + def extractEntities + [ + extractByHTMLTagName("h1"), + extractByHTMLTagName("h2"), + extractByHTMLTagName("h3"), + extractByHTMLTagName("h4"), + extractByHTMLTagName("h5"), + extractByHTMLTagName("h6"), + extractByHTMLTagName("em"), + extractByHTMLTagName("sup"), + extractByHTMLTagName("sub"), + extractByHTMLTagName("small"), + extractByHTMLTagName("u"), + extractByHTMLTagName("strong"), + extractByHTMLTagName("ul", false, false, "li"), + extractByHTMLTagName("ol", false, false, "li"), + extractByHTMLTagName("code"), + extractByHTMLTagName("blockquote", false), + extractByHTMLTagName("hr", false, true), + extractByHTMLTagName("br", false, true), + extractByHTMLTagName("a"), + extractByHTMLTagName("img", false, true), + extractByHTMLTagName("s") + ].flatten.compact + end + + def extractByHTMLTagName(tagName, isNoNest = true, isSingle = false, itemTagName = nil) + entities = [] + + @html.to_s.scan(htmlTagPatternByCond(tagName, isNoNest, isSingle, itemTagName)) do + match = $~ + + beginPos = match.char_begin(0) + endPos = match.char_end(0) + #puts "MDExtractor extracted with:\n" + @html + "\nbeginPos: " + beginPos.to_s + ", endPos: " + endPos.to_s + ", length: " + @html.length.to_s + + entity = { + :markdown => true, + :indices => [beginPos, endPos] + } + + entities.push(entity) + end + + entities + end + + def htmlTagPatternByCond(tagName, isNoNest, isSingle, itemTagName) + if isSingle + htmlTagPatternSingle(tagName) + elsif isNoNest + htmlTagPatternNoNest(tagName) + elsif itemTagName && itemTagName.length > 0 + htmlTagPatternOuterMostWithItem(tagName, itemTagName) + else + htmlTagPatternOuterMost(tagName) + end + end + + def htmlTagPattern(tagName) + Regexp.compile("<#{tagName} data-md=[^>]*>(?:[^<]|<#{tagName} data-md=[^>]*>|<\\/#{tagName}>)*(?:<\\/#{tagName}>)*") + end + + def htmlTagPatternNoNest(tagName) + Regexp.compile("<#{tagName} data-md=[^>]*>(?:.|\n)*?<\\/#{tagName}>") + end + + def htmlTagPatternSingle(tagName) + Regexp.compile("<#{tagName} data-md=[^>]*>") + end + + # https://stackoverflow.com/questions/546433/regular-expression-to-match-outer-brackets + def htmlTagPatternOuterMost(tagName) + Regexp.compile("<#{tagName} data-md=[^>]*>(?:[^<>]|(\\g<0>))*<\/#{tagName}>") + end + + def htmlTagPatternOuterMostWithItem(tagName, itemTagName) + Regexp.compile("<#{tagName} data-md=[^>]*>(?:[^<>]|<#{itemTagName} data-md=[^>]*>|<\\/#{itemTagName}>|(\\g<0>))*<\/#{tagName}>") + end +end \ No newline at end of file From 0542d92c40d5e651b47f5552ab93ec8eb04292a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9C=A7=E5=B3=B6=E3=81=B2=E3=81=AA=E3=81=9F?= Date: Mon, 20 Aug 2018 23:42:23 +0900 Subject: [PATCH 10/18] add Markdown front-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdownのフロントエンドの追加です --- app/javascript/styles/application.scss | 2 + app/javascript/styles/contrast.scss | 2 + app/javascript/styles/markdown.scss | 196 ++++++++++++++++++++++ app/javascript/styles/mastodon-light.scss | 2 + 4 files changed, 202 insertions(+) create mode 100644 app/javascript/styles/markdown.scss diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss index 8ebc45b62..cf32a70b0 100644 --- a/app/javascript/styles/application.scss +++ b/app/javascript/styles/application.scss @@ -26,3 +26,5 @@ @import 'mastodon/dashboard'; @import 'mastodon/rtl'; @import 'mastodon/accessibility'; + +@import 'markdown'; diff --git a/app/javascript/styles/contrast.scss b/app/javascript/styles/contrast.scss index 5b43aecbe..31805363c 100644 --- a/app/javascript/styles/contrast.scss +++ b/app/javascript/styles/contrast.scss @@ -1,3 +1,5 @@ @import 'contrast/variables'; @import 'application'; @import 'contrast/diff'; + +@import 'markdown'; diff --git a/app/javascript/styles/markdown.scss b/app/javascript/styles/markdown.scss new file mode 100644 index 000000000..d1c1adb9c --- /dev/null +++ b/app/javascript/styles/markdown.scss @@ -0,0 +1,196 @@ +.status__content { + + font-family: inherit; + max-height: 210px; + overflow-x: hidden; + overflow-y: auto; + + h1{ + font-size: 195%; + border-bottom: 2px solid #ff3333; + } + + h2{ + font-size: 180%; + border-bottom: 2px solid #ff3333; + } + + h3{ + font-size: 165%; + border-bottom: 2px solid #ff3333; + } + + h4{ + font-size: 150%; + border-bottom: 2px solid #ff3333; + } + + h5{ + font-size: 135%; + border-bottom: 2px solid #ff3333; + } + + h6{ + font-size: 120%; + border-bottom: 2px solid #ff3333; + } + + em{ + font-style: italic; + } + + strong{ + font-weight: bold; + } + + code{ + display: block; + border-left: 2px solid; + border-color: #079903; + color: $white; + padding-left: 10px; + margin-top: 5px; + margin-bottom: 5px; + margin-left: 5px; + background-color: #000000; + } + + pre{ + display: inline-block; + font-family: 'Noto Sans Japanese', sans-serif; + font-weight: 400; + } + + p ~ blockquote { + margin-top: -8px; + } + + blockquote{ + padding-left: 8px; + margin-top: 0.5em; + margin-bottom: 0.5em; + color: $white; + background-color: $ui-base-color; + display: block; + border-left: 4px solid $classic-highlight-color; + } + + ul{ + list-style: inside; + br{ + display: none; + } + } + + ul>ul{ + br{ + display: none; + } + } + + ul>li>ul{ + padding-left: 1em; + list-style: inside circle; + } + + ul>li{ + padding-left: 1em; + list-style: inside circle; + } + + ul>br>li{ + padding-left: 1em; + list-style: inside circle; + } + + ul>ul>li{ + padding-left: 2em; + list-style: inside circle; + } + + ul>br>ul>br>li{ + padding-left: 2em; + list-style: inside circle; + } + + ol{ + list-style: inside decimal; + } + + ol>li>ol{ + padding-left: 1em; + } + + p>a>img{ + width: 100%; + height: 100%; + object-fit: contain; + } + + a>img{ + width: 100%; + height: 100%; + object-fit: contain; + } + + p>a{ + color: #1FBFF9; + } + + a{ + color: #1FBFF9; + } + + sup{ + font-size: 75.5%; + vertical-align: top; + position: relative; + top: -0.5em; + } + + sub{ + font-size: 75.5%; + vertical-align: bottom; + position: relative; + top: 0.5em; + } + + small{ + font-size: 50.0%; + vertical-align: bottom; + position: relative; + } + + table { + margin-top: 0.5em; + margin-bottom: 0.5em; + color: $classic-highlight-color; + display: block; + width: 100%; + overflow: auto; + border-spacing: 0; + border-collapse: collapse; + } + + table tr{ + background-color: #000000; + } + + table th, table td{ + padding: 6px 13px; + border: 1px solid $classic-highlight-color; + } + + table th{ + font-weight: 600; + } + + table thead tr{ + background-color: $black; + } + + td, th{ + padding: 0; + } + + } \ No newline at end of file diff --git a/app/javascript/styles/mastodon-light.scss b/app/javascript/styles/mastodon-light.scss index 756a12d86..c99938bfa 100644 --- a/app/javascript/styles/mastodon-light.scss +++ b/app/javascript/styles/mastodon-light.scss @@ -1,3 +1,5 @@ @import 'mastodon-light/variables'; @import 'application'; @import 'mastodon-light/diff'; + +@import 'markdown'; From 4d4f8b5f5217dc47b144767905f5b55c1865df30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9C=A7=E5=B3=B6=E3=81=B2=E3=81=AA=E3=81=9F?= Date: Wed, 28 Nov 2018 20:02:48 +0900 Subject: [PATCH 11/18] UPDATE Markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit アスタルテで動いてる最新のMarkdownに追従する更新です。 一応テストしてから実装してください。 今回のアップデートで + 見出しのレイアウト変更 + リストのレイアウトの変更 + 罫線のレイアウトの変更 + より詳細なclass分け + codeのハイライト + 外部サーバー向け画像添付メッセージの表示 + 1件のセキュリティー的な問題の修正 などが変更になっています --- app/javascript/styles/markdown.scss | 462 ++++++++++++++++------------ app/lib/formatter_markdown.rb | 43 ++- 2 files changed, 301 insertions(+), 204 deletions(-) diff --git a/app/javascript/styles/markdown.scss b/app/javascript/styles/markdown.scss index d1c1adb9c..be08de0cd 100644 --- a/app/javascript/styles/markdown.scss +++ b/app/javascript/styles/markdown.scss @@ -1,196 +1,266 @@ -.status__content { - - font-family: inherit; - max-height: 210px; - overflow-x: hidden; - overflow-y: auto; - - h1{ - font-size: 195%; - border-bottom: 2px solid #ff3333; - } - - h2{ - font-size: 180%; - border-bottom: 2px solid #ff3333; - } - - h3{ - font-size: 165%; - border-bottom: 2px solid #ff3333; - } - - h4{ - font-size: 150%; - border-bottom: 2px solid #ff3333; - } - - h5{ - font-size: 135%; - border-bottom: 2px solid #ff3333; - } - - h6{ - font-size: 120%; - border-bottom: 2px solid #ff3333; - } - - em{ - font-style: italic; - } - - strong{ - font-weight: bold; - } - - code{ - display: block; - border-left: 2px solid; - border-color: #079903; - color: $white; - padding-left: 10px; - margin-top: 5px; - margin-bottom: 5px; - margin-left: 5px; - background-color: #000000; - } - - pre{ - display: inline-block; - font-family: 'Noto Sans Japanese', sans-serif; - font-weight: 400; - } - - p ~ blockquote { - margin-top: -8px; - } - - blockquote{ - padding-left: 8px; - margin-top: 0.5em; - margin-bottom: 0.5em; - color: $white; - background-color: $ui-base-color; - display: block; - border-left: 4px solid $classic-highlight-color; - } - - ul{ - list-style: inside; - br{ - display: none; - } - } - - ul>ul{ - br{ - display: none; - } - } - - ul>li>ul{ - padding-left: 1em; - list-style: inside circle; - } - - ul>li{ - padding-left: 1em; - list-style: inside circle; - } - - ul>br>li{ - padding-left: 1em; - list-style: inside circle; - } - - ul>ul>li{ - padding-left: 2em; - list-style: inside circle; - } - - ul>br>ul>br>li{ - padding-left: 2em; - list-style: inside circle; - } - - ol{ - list-style: inside decimal; - } - - ol>li>ol{ - padding-left: 1em; - } - - p>a>img{ - width: 100%; - height: 100%; - object-fit: contain; - } - - a>img{ - width: 100%; - height: 100%; - object-fit: contain; - } - - p>a{ - color: #1FBFF9; - } - - a{ - color: #1FBFF9; - } - - sup{ - font-size: 75.5%; - vertical-align: top; - position: relative; - top: -0.5em; - } - - sub{ - font-size: 75.5%; - vertical-align: bottom; - position: relative; - top: 0.5em; - } - - small{ - font-size: 50.0%; - vertical-align: bottom; - position: relative; - } - - table { - margin-top: 0.5em; - margin-bottom: 0.5em; - color: $classic-highlight-color; - display: block; - width: 100%; - overflow: auto; - border-spacing: 0; - border-collapse: collapse; - } - - table tr{ - background-color: #000000; - } - - table th, table td{ - padding: 6px 13px; - border: 1px solid $classic-highlight-color; - } - - table th{ - font-weight: 600; - } - - table thead tr{ - background-color: $black; - } - - td, th{ - padding: 0; - } - - } \ No newline at end of file +.status__content { + + font-family: inherit; + + h1{ + color: #ec840d; + font-weight: bold; + font-size: 1.6em; + padding: 0.5em; + display: inline-block; + line-height: 1.3; + background: #dbebf8; + vertical-align: middle; + border-radius: 25px 25px 25px 25px; + text-align: center; + border-bottom: solid 3px #ff0000; + } + + h2{ + color: #ec840d; + font-weight: bold; + font-size: 1.5em; + padding: 0.5em; + display: inline-block; + line-height: 1.3; + background: #dbebf8; + vertical-align: middle; + border-radius: 25px 25px 25px 25px; + text-align: center; + border-bottom: solid 3px #fffb00; + } + + h3{ + color: #ec840d; + font-weight: bold; + font-size: 1.4em; + padding: 0.5em; + display: inline-block; + line-height: 1.3; + background: #dbebf8; + vertical-align: middle; + border-radius: 25px 25px 25px 25px; + text-align: center; + border-bottom: solid 3px #2bff00; + } + + h4{ + color: #ec840d; + font-weight: bold; + font-size: 1.3em; + padding: 0.5em; + display: inline-block; + line-height: 1.3; + background: #dbebf8; + vertical-align: middle; + border-radius: 25px 25px 25px 25px; + text-align: center; + border-bottom: solid 3px #00ffea; + } + + h5{ + color: #ec840d; + font-weight: bold; + font-size: 1.2em; + padding: 0.5em; + display: inline-block; + line-height: 1.3; + background: #dbebf8; + vertical-align: middle; + border-radius: 25px 25px 25px 25px; + text-align: center; + border-bottom: solid 3px #0004ff; + } + + h6{ + color: #ec840d; + font-weight: bold; + font-size: 1.1em; + padding: 0.5em; + display: inline-block; + line-height: 1.3; + background: #dbebf8; + vertical-align: middle; + border-radius: 25px 25px 25px 25px; + text-align: center; + border-bottom: solid 3px #7700ff; + } + + em{ + font-style: italic; + } + + strong{ + font-weight: bold; + } + + code{ + display: block; + border-left: 2px solid; + border-color: #079903; + color: $white; + padding-left: 10px; + margin-top: 5px; + margin-bottom: 5px; + margin-left: 5px; + background-color: #000000; + + .positive{ + color: #5bda57; + } + + .negative{ + color: #ff4949; + } + + .rust-fanc{ + color: #ba7eff; + } + + .ruby-func{ + color: #24a8e6; + } + + .rust-macro{ + color: #d2ff6a; + } + + .contents{ + color: #ff9925; + } + } + + pre{ + display: inline-block; + font-family: 'Noto Sans Japanese', sans-serif; + font-weight: 400; + } + + p ~ blockquote { + margin-top: -8px; + } + + blockquote{ + padding-left: 8px; + margin-top: 0.5em; + margin-bottom: 0.5em; + color: $primary-text-color; + background-color: $ui-base-color; + display: block; + border-left: 4px solid $classic-highlight-color; + } + + ul.md-contents { + border: double 4px #21b384; + padding: 0.5em 1em 0.5em 2.3em; + position: relative; + } + ul li.md-contents { + line-height: 1.5; + padding: 0.2em 0; + list-style-type: none!important; + } + ul li.md-contents:before { + font-family: FontAwesome; + content: "\f0a4"; + position: absolute; + left : 1em; + color: #21b384; + } + + ol.md-contents { + border: double 4px #ff954f; + padding: 0.5em 1em 0.5em 1em; + position: relative; + list-style: inside decimal; + } + + ol li.md-contents { + line-height: 1.5; + padding: 0.2em 0; + } + + hr { + border-width: 2px 0px 0px 0px; + border-style: dashed; + border-color: #ff7676; + height: 1px; + } + + p>a>img{ + width: 100%; + height: 100%; + object-fit: contain; + } + + a>img{ + width: 100%; + height: 100%; + object-fit: contain; + } + + p>a{ + color: #1FBFF9; + } + + a{ + color: #1FBFF9; + } + + sup{ + font-size: 75.5%; + vertical-align: top; + position: relative; + top: -0.5em; + } + + sub{ + font-size: 75.5%; + vertical-align: bottom; + position: relative; + top: 0.5em; + } + + small{ + font-size: 50.0%; + vertical-align: bottom; + position: relative; + } + + table { + margin-top: 0.5em; + margin-bottom: 0.5em; + color: $classic-highlight-color; + display: block; + width: 100%; + overflow: auto; + border-spacing: 0; + border-collapse: collapse; + } + + table tr{ + background-color: #000000; + } + + table th, table td{ + padding: 6px 13px; + border: 1px solid $classic-highlight-color; + } + + table th{ + font-weight: 600; + } + + table thead tr{ + background-color: $black; + } + + td, th{ + padding: 0; + } + + span.img_FTL { + display: none; + } + +} \ No newline at end of file diff --git a/app/lib/formatter_markdown.rb b/app/lib/formatter_markdown.rb index 30a08d558..0ee01b972 100644 --- a/app/lib/formatter_markdown.rb +++ b/app/lib/formatter_markdown.rb @@ -88,26 +88,31 @@ class Formatter_Markdown %(#{encode(mdContentsRemoved)}\n) end + def block_code(code, language) + %(
    #{code.strip}) + end + def codespan(code) urlRemoved = "#{remove_url(code)}" escapedCode = "#{escape_bbcode(urlRemoved)}" - %(#{encode(escapedCode)}) + encoded = "#{encode(escapedCode)}" + %(#{code_contents(encoded)}) end def list(contents, list_type) if list_type == :unordered - %(
      #{contents.strip}
    ) + %(
      #{contents.strip}
    ) elsif list_type == :ordered - %(
      #{contents.strip}
    ) + %(
      #{contents.strip}
    ) else - %(<#{list_type}>#{contents.strip}) + %(<#{list_type} class='md-contents'>#{contents.strip}) end end def list_item(text, list_type) urlRemoved = "#{remove_url(text)}" mdContentsRemoved = "#{markdown_escape(urlRemoved)}" - %(
  • #{encode(mdContentsRemoved)}
  • ) + %(
  • #{encode(mdContentsRemoved)}
  • ) end def emphasis(text) @@ -170,7 +175,7 @@ class Formatter_Markdown if imgcheck !~ /\Ahttps:\/\/[^<>"\[\]  ]+\z/ %(#{encode(alt_text)}) else - %() + %(画像が添付されています。) end end @@ -206,7 +211,7 @@ class Formatter_Markdown #前々から要望があったcode内でBBCodeを無効化するための奴 def escape_bbcode(string) - string.gsub(/\[/){ "[" } + string.gsub(/\[/){ "[" } end #markdownの中でmarkdownを展開させないためのエスケープする奴 @@ -221,6 +226,27 @@ class Formatter_Markdown string.gsub(/<([\/]?a[^>]*|[\/]?img[^>]*|[\/]?code[^>]*|[\/]?h[1-6][^>]*|[\/]?sup[^>]*|[\/]?sub[^>]*|[\/]?small[^>]*|[\/]?ul[^>]*|[\/]?ol[^>]*|[\/]?li[^>]*|[\/]?hr[^>]*|[\/]?s[^>]*|[\/]?u[^>]*|[\/]?mark[^>]*)>/) { "" } end + #code内の一部を色分けするための変更 + def code_contents(string) + simple = string.gsub(/(true|error|false|failed|def|puts|end|fn|let|mut|use|String|println!)/ , + "true" => "#{:true}", + "error" => "#{:error}", + "false" => "#{:false}", + "failed" => "#{:failed}", + "def" => "#{:def}", + "puts" => "#{:puts}", + "end" => "#{:end}", + "fn" => "#{:fn}", + "let" => "#{:let}", + "mut" => "#{:mut}", + "use" => "#{:use}", + "String" => "#{:String}", + "println!" => "#{:println!}", + ) + simple.gsub(/("[a-zA-Z0-9_ ,]+")/){ "#{$1}" } +# "" => "#{:}", + end + #テストで書きなぐった奴 def html_escape(string) string.gsub(/['&\"<>\/]/, { @@ -280,7 +306,8 @@ class MDExtractor extractByHTMLTagName("br", false, true), extractByHTMLTagName("a"), extractByHTMLTagName("img", false, true), - extractByHTMLTagName("s") + extractByHTMLTagName("s"), + extractByHTMLTagName("span") ].flatten.compact end From 8c9d3e4c2d5b02359f18de76c10685044995d741 Mon Sep 17 00:00:00 2001 From: YoheiZuho Date: Mon, 12 Aug 2019 17:07:21 +0000 Subject: [PATCH 12/18] =?UTF-8?q?Markdown=E3=81=AE=E5=86=8D=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/announcement_icon_button.js | 90 +++++++++++++ .../compose/components/announcements.js | 127 ++++++++++++++++++ .../containers/announcements_container.js | 11 ++ .../mastodon/features/compose/index.js | 2 + app/javascript/styles/application.scss | 1 + app/javascript/styles/astarte.scss | 59 ++++++++ app/lib/formatter_markdown.rb | 2 +- 7 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 app/javascript/mastodon/components/announcement_icon_button.js create mode 100644 app/javascript/mastodon/features/compose/components/announcements.js create mode 100644 app/javascript/mastodon/features/compose/containers/announcements_container.js create mode 100644 app/javascript/styles/astarte.scss 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 }) => +