diff --git a/.eslintrc.yml b/.eslintrc.yml index 7c6da9d57a0..b1b38351cdb 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -29,6 +29,11 @@ settings: import/ignore: - node_modules - \\.(css|scss|json)$ + import/resolver: + node: + moduleDirectory: + - node_modules + - app/javascript rules: brace-style: warn diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000000..35b0cd787d2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "app/javascript/themes/mastodon-go"] + path = app/javascript/themes/mastodon-go + url = https://github.com/marrus-sh/mastodon-go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 299306299a9..42dfc57dc2d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,36 @@ +# Contributing to Mastodon Glitch Edition # + +Thank you for your interest in contributing to the `glitch-soc` project! +Here are some guidelines, and ways you can help. + +> (This document is a bit of a work-in-progress, so please bear with us. +> If you don't see what you're looking for here, please don't hesitate to reach out!) + +## Planning ## + +Right now a lot of the planning for this project takes place in our development Discord, or through GitHub Issues and Projects. +We're working on ways to improve the planning structure and better solicit feedback, and if you feel like you can help in this respect, feel free to give us a holler. + +## Documentation ## + +The documentation for this repository is available at [`glitch-soc/docs`](https://github.com/glitch-soc/docs) (online at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/)). +Right now, we've mostly focused on the features that make this fork different from upstream in some manner. +Adding screenshots, improving descriptions, and so forth are all ways to help contribute to the project even if you don't know any code. + +## Frontend Development ## + +Check out [the documentation here](https://glitch-soc.github.io/docs/contributing/frontend/) for more information. + +## Backend Development ## + +See the guidelines below. + + - - - + +You should also try to follow the guidelines set out in the original `CONTRIBUTING.md` from `tootsuite/mastodon`, reproduced below. + +
+ CONTRIBUTING ============ @@ -49,3 +82,5 @@ It is expected that you have a working development environment set up (see back- * If you are introducing new strings, they must be using localization methods If the JavaScript or CSS assets won't compile due to a syntax error, it's a good sign that the pull request isn't ready for submission yet. + +
diff --git a/README.md b/README.md index 5cf91d52ca9..998d5700533 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,10 @@ -![Mastodon](https://i.imgur.com/NhZc40l.png) -======== +# Mastodon Glitch Edition # -[![Build Status](https://img.shields.io/travis/tootsuite/mastodon.svg)][travis] -[![Code Climate](https://img.shields.io/codeclimate/maintainability/tootsuite/mastodon.svg)][code_climate] +> Now with automated deploys! -[travis]: https://travis-ci.org/tootsuite/mastodon -[code_climate]: https://codeclimate.com/github/tootsuite/mastodon +[![Build Status](https://travis-ci.org/glitch-soc/mastodon.svg?branch=master)](https://travis-ci.org/glitch-soc/mastodon) -Mastodon is a **free, open-source social network server** based on **open web protocols** like ActivityPub and OStatus. The social focus of the project is a viable decentralized alternative to commercial social media silos that returns the control of the content distribution channels to the people. The technical focus of the project is a good user interface, a clean REST API for 3rd party apps and robust anti-abuse tools. +So here's the deal: we all work on this code, and then it runs on dev.glitch.social and anyone who uses that does so absolutely at their own risk. can you dig it? -Click on the screenshot below to watch a demo of the UI: - -[![Screenshot](https://i.imgur.com/pG3Nnz3.jpg)][youtube_demo] - -[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU - -**Ruby on Rails** is used for the back-end, while **React.js** and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided. - -If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd` - -[patreon]: https://www.patreon.com/user?u=619786 - ---- - -## Resources - -- [Frequently Asked Questions](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md) -- [Use this tool to find Twitter friends on Mastodon](https://bridge.joinmastodon.org) -- [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md) -- [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md) -- [List of apps](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md) -- [List of sponsors](https://joinmastodon.org/sponsors) - -## Features - -**No vendor lock-in: Fully interoperable with any conforming platform** - -It doesn't have to be Mastodon, whatever implements ActivityPub or OStatus is part of the social network! - -**Real-time timeline updates** - -See the updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well! - -**Federated thread resolving** - -If someone you follow replies to a user unknown to the server, the server fetches the full thread so you can view it without leaving the UI - -**Media attachments like images and short videos** - -Upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos are looped - like vines! - -**OAuth2 and a straightforward REST API** - -Mastodon acts as an OAuth2 provider so 3rd party apps can use the API - -**Fast response times** - -Mastodon tries to be as fast and responsive as possible, so all long-running tasks are delegated to background processing - -**Deployable via Docker** - -You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy - ---- - -## Development - -Please follow the [development guide](https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Development-guide.md) from the documentation repository. - -## Deployment - -There are guides in the documentation repository for [deploying on various platforms](https://github.com/tootsuite/documentation#running-mastodon). - -## Contributing - -You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. [Here are the guidelines for code contributions](CONTRIBUTING.md) - -**IRC channel**: #mastodon on irc.freenode.net - ---- - -## Extra credits - -The elephant friend illustrations are created by [Dopatwo](https://mastodon.social/@dopatwo) +- You can view documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/). +- And contributing guidelines are available [here](CONTRIBUTING.md) and [here](https://glitch-soc.github.io/docs/contributing/). diff --git a/Vagrantfile b/Vagrantfile index 0c21bed68c8..351ab5cfaa0 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -83,7 +83,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| config.vm.provider :virtualbox do |vb| vb.name = "mastodon" - vb.customize ["modifyvm", :id, "--memory", "2048"] + vb.customize ["modifyvm", :id, "--memory", "4096"] # Disable VirtualBox DNS proxy to skip long-delay IPv6 resolutions. # https://github.com/mitchellh/vagrant/issues/1172 diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 4676f60de7c..85eb2d60e29 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -13,9 +13,11 @@ class Api::V1::AccountsController < Api::BaseController end def follow - FollowService.new.call(current_user.account, @account.acct) + reblogs_arg = { reblogs: params[:reblogs] } + + FollowService.new.call(current_user.account, @account.acct, reblogs_arg) - options = @account.locked? ? {} : { following_map: { @account.id => true }, requested_map: { @account.id => false } } + options = @account.locked? ? {} : { following_map: { @account.id => reblogs_arg }, requested_map: { @account.id => false } } render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) end diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index 0c43cb94302..92ad251efc8 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -8,10 +8,15 @@ class Api::V1::MutesController < Api::BaseController respond_to :json def index - @accounts = load_accounts + @data = @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer end + def details + @data = @mutes = load_mutes + render json: @mutes, each_serializer: REST::MuteSerializer + end + private def load_accounts @@ -22,6 +27,10 @@ class Api::V1::MutesController < Api::BaseController Account.includes(:muted_by).references(:muted_by) end + def load_mutes + paginated_mutes.includes(:account, :target_account).to_a + end + def paginated_mutes Mute.where(account: current_account).paginate_by_max_id( limit_param(DEFAULT_ACCOUNTS_LIMIT), @@ -36,26 +45,34 @@ class Api::V1::MutesController < Api::BaseController def next_path if records_continue? - api_v1_mutes_url pagination_params(max_id: pagination_max_id) + url_for pagination_params(max_id: pagination_max_id) end end def prev_path - unless @accounts.empty? - api_v1_mutes_url pagination_params(since_id: pagination_since_id) + unless@data.empty? + url_for pagination_params(since_id: pagination_since_id) end end def pagination_max_id - @accounts.last.muted_by_ids.last + if params[:action] == "details" + @mutes.last.id + else + @accounts.last.muted_by_ids.last + end end def pagination_since_id - @accounts.first.muted_by_ids.first + if params[:action] == "details" + @mutes.first.id + else + @accounts.first.muted_by_ids.first + end end def records_continue? - @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + @data.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end def pagination_params(core_params) diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 8910b77e931..a949752fb43 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -24,11 +24,20 @@ class Api::V1::NotificationsController < Api::BaseController render_empty end + def destroy + dismiss + end + def dismiss current_account.notifications.find_by!(id: params[:id]).destroy! render_empty end + def destroy_multiple + current_account.notifications.where(id: params[:ids]).destroy_all + render_empty + end + private def load_notifications diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb index 997eed6e2c5..d1b4e040220 100644 --- a/app/controllers/api/v1/search_controller.rb +++ b/app/controllers/api/v1/search_controller.rb @@ -3,7 +3,7 @@ class Api::V1::SearchController < Api::BaseController include Authorization - RESULTS_LIMIT = 5 + RESULTS_LIMIT = 10 before_action -> { doorkeeper_authorize! :read } before_action :require_user! diff --git a/app/controllers/api/v1/timelines/direct_controller.rb b/app/controllers/api/v1/timelines/direct_controller.rb new file mode 100644 index 00000000000..d455227eb56 --- /dev/null +++ b/app/controllers/api/v1/timelines/direct_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::DirectController < Api::BaseController + before_action -> { doorkeeper_authorize! :read }, only: [:show] + before_action :require_user!, only: [:show] + after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + + respond_to :json + + def show + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + end + + private + + def load_statuses + cached_direct_statuses + end + + def cached_direct_statuses + cache_collection direct_statuses, Status + end + + def direct_statuses + direct_timeline_statuses.paginate_by_max_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def direct_timeline_statuses + Status.as_direct_timeline(current_account) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_params(core_params) + params.permit(:local, :limit).merge(core_params) + end + + def next_path + api_v1_timelines_direct_url pagination_params(max_id: pagination_max_id) + end + + def prev_path + api_v1_timelines_direct_url pagination_params(since_id: pagination_since_id) + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id + end +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 21dde20ce40..ad7f09f3486 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -6,6 +6,7 @@ class HomeController < ApplicationController def index @body_classes = 'app-body' + @frontend = (params[:frontend] and Rails.configuration.x.available_frontends.include? params[:frontend] + '.js') ? params[:frontend] : 'mastodon' end private diff --git a/app/controllers/settings/keyword_mutes_controller.rb b/app/controllers/settings/keyword_mutes_controller.rb new file mode 100644 index 00000000000..f79e1b320b7 --- /dev/null +++ b/app/controllers/settings/keyword_mutes_controller.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class Settings::KeywordMutesController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + before_action :load_keyword_mute, only: [:edit, :update, :destroy] + + def index + @keyword_mutes = paginated_keyword_mutes_for_account + end + + def new + @keyword_mute = keyword_mutes_for_account.build + end + + def create + @keyword_mute = keyword_mutes_for_account.create(keyword_mute_params) + + if @keyword_mute.persisted? + redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg') + else + render :new + end + end + + def update + if @keyword_mute.update(keyword_mute_params) + redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg') + else + render :edit + end + end + + def destroy + @keyword_mute.destroy! + + redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg') + end + + def destroy_all + keyword_mutes_for_account.delete_all + + redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg') + end + + private + + def keyword_mutes_for_account + Glitch::KeywordMute.where(account: current_account) + end + + def load_keyword_mute + @keyword_mute = keyword_mutes_for_account.find(params[:id]) + end + + def keyword_mute_params + params.require(:keyword_mute).permit(:keyword, :whole_word) + end + + def paginated_keyword_mutes_for_account + keyword_mutes_for_account.order(:keyword).page params[:page] + end +end diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index cc579dbc889..5f61e2182cf 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -48,7 +48,7 @@ class StreamEntriesController < ApplicationController @type = @stream_entry.activity_type.downcase raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil? - authorize @stream_entry.activity, :show? if @stream_entry.hidden? + authorize @stream_entry.activity, :show? if @stream_entry.hidden? || @stream_entry.local_only? rescue Mastodon::NotPermittedError # Reraise in order to get a 404 raise ActiveRecord::RecordNotFound diff --git a/app/helpers/settings/keyword_mutes_helper.rb b/app/helpers/settings/keyword_mutes_helper.rb new file mode 100644 index 00000000000..7b98cd59e0c --- /dev/null +++ b/app/helpers/settings/keyword_mutes_helper.rb @@ -0,0 +1,2 @@ +module Settings::KeywordMutesHelper +end diff --git a/app/javascript/glitch/actions/local_settings.js b/app/javascript/glitch/actions/local_settings.js new file mode 100644 index 00000000000..93c5a9a171f --- /dev/null +++ b/app/javascript/glitch/actions/local_settings.js @@ -0,0 +1,93 @@ +/* + +`actions/local_settings` +======================== + +> For more information on the contents of this file, please contact: +> +> - kibigo! [@kibi@glitch.social] + +This file provides our Redux actions related to local settings. It +consists of the following: + + - __`changesLocalSetting(key, value)` :__ + Changes the local setting with the given `key` to the given + `value`. `key` **MUST** be an array of strings, as required by + `Immutable.Map.prototype.getIn()`. + + - __`saveLocalSettings()` :__ + Saves the local settings to `localStorage` as a JSON object. We + shouldn't ever need to call this ourselves. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Constants: +---------- + +We provide the following constants: + + - __`LOCAL_SETTING_CHANGE` :__ + This string constant is used to dispatch a setting change to our + reducer in `reducers/local_settings`, where the setting is + actually changed. + +*/ + +export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +`changeLocalSetting(key, value)`: +--------------------------------- + +Changes the local setting with the given `key` to the given `value`. +`key` **MUST** be an array of strings, as required by +`Immutable.Map.prototype.getIn()`. + +To accomplish this, we just dispatch a `LOCAL_SETTING_CHANGE` to our +reducer in `reducers/local_settings`. + +*/ + +export function changeLocalSetting(key, value) { + return dispatch => { + dispatch({ + type: LOCAL_SETTING_CHANGE, + key, + value, + }); + + dispatch(saveLocalSettings()); + }; +}; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +`saveLocalSettings()`: +---------------------- + +Saves the local settings to `localStorage` as a JSON object. +`changeLocalSetting()` calls this whenever it changes a setting. We +shouldn't ever need to call this ourselves. + +> __TODO :__ +> Right now `saveLocalSettings()` doesn't keep track of which user +> is currently signed in, but it might be better to give each user +> their *own* local settings. + +*/ + +export function saveLocalSettings() { + return (_, getState) => { + const localSettings = getState().get('local_settings').toJS(); + localStorage.setItem('mastodon-settings', JSON.stringify(localSettings)); + }; +}; diff --git a/app/javascript/glitch/components/account/header.js b/app/javascript/glitch/components/account/header.js new file mode 100644 index 00000000000..7bc1a2189bb --- /dev/null +++ b/app/javascript/glitch/components/account/header.js @@ -0,0 +1,227 @@ +/* + +`` +================= + +> For more information on the contents of this file, please contact: +> +> - kibigo! [@kibi@glitch.social] + +Original file by @gargron@mastodon.social et al as part of +tootsuite/mastodon. We've expanded it in order to handle user bio +frontmatter. + +The `` component provides the header for account +timelines. It is a fairly simple component which mostly just consists +of a `render()` method. + +__Props:__ + + - __`account` (`ImmutablePropTypes.map`) :__ + The account to render a header for. + + - __`me` (`PropTypes.number.isRequired`) :__ + The id of the currently-signed-in account. + + - __`onFollow` (`PropTypes.func.isRequired`) :__ + The function to call when the user clicks the "follow" button. + + - __`intl` (`PropTypes.object.isRequired`) :__ + Our internationalization object, inserted by `@injectIntl`. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Mastodon imports // +import emojify from '../../../mastodon/features/emoji/emoji'; +import IconButton from '../../../mastodon/components/icon_button'; +import Avatar from '../../../mastodon/components/avatar'; +import { me } from '../../../mastodon/initial_state'; + +// Our imports // +import { processBio } from '../../util/bio_metadata'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Inital setup: +------------- + +The `messages` constant is used to define any messages that we need +from inside props. In our case, these are the `unfollow`, `follow`, and +`requested` messages used in the `title` of our buttons. + +*/ + +const messages = defineMessages({ + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, +}); + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Implementation: +--------------- + +*/ + +@injectIntl +export default class AccountHeader extends ImmutablePureComponent { + + static propTypes = { + account : ImmutablePropTypes.map, + onFollow : PropTypes.func.isRequired, + intl : PropTypes.object.isRequired, + }; + +/* + +### `render()` + +The `render()` function is used to render our component. + +*/ + + render () { + const { account, intl } = this.props; + +/* + +If no `account` is provided, then we can't render a header. Otherwise, +we get the `displayName` for the account, if available. If it's blank, +then we set the `displayName` to just be the `username` of the account. + +*/ + + if (!account) { + return null; + } + + let displayName = account.get('display_name_html'); + let info = ''; + let actionBtn = ''; + let following = false; + +/* + +Next, we handle the account relationships. If the account follows the +user, then we add an `info` message. If the user has requested a +follow, then we disable the `actionBtn` and display an hourglass. +Otherwise, if the account isn't blocked, we set the `actionBtn` to the +appropriate icon. + +*/ + + if (me !== account.get('id')) { + if (account.getIn(['relationship', 'followed_by'])) { + info = ( + + + + ); + } + if (account.getIn(['relationship', 'requested'])) { + actionBtn = ( +
+ +
+ ); + } else if (!account.getIn(['relationship', 'blocking'])) { + following = account.getIn(['relationship', 'following']); + actionBtn = ( +
+ +
+ ); + } + } + +/* + we extract the `text` and +`metadata` from our account's `note` using `processBio()`. + +*/ + + const { text, metadata } = processBio(account.get('note')); + +/* + +Here, we render our component using all the things we've defined above. + +*/ + + return ( +
+
+
+ + + + + + + + @{account.get('acct')} + {account.get('locked') ? : null} + +
+ + {info} + {actionBtn} +
+
+ + {metadata.length && ( + + + {(() => { + let data = []; + for (let i = 0; i < metadata.length; i++) { + data.push( + + + + + ); + } + return data; + })()} + +
+ ) || null} +
+ ); + } + +} diff --git a/app/javascript/glitch/components/column/notif_cleaning_widget/container.js b/app/javascript/glitch/components/column/notif_cleaning_widget/container.js new file mode 100644 index 00000000000..d3507d75246 --- /dev/null +++ b/app/javascript/glitch/components/column/notif_cleaning_widget/container.js @@ -0,0 +1,80 @@ +/* + +`` +========================= + +This container connects ``s to the Redux store. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import { connect } from 'react-redux'; + +// Our imports // +import NotificationPurgeButtons from './notification_purge_buttons'; +import { + deleteMarkedNotifications, + enterNotificationClearingMode, + markAllNotifications, +} from '../../../../mastodon/actions/notifications'; +import { defineMessages, injectIntl } from 'react-intl'; +import { openModal } from '../../../../mastodon/actions/modal'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Dispatch mapping: +----------------- + +The `mapDispatchToProps()` function maps dispatches to our store to the +various props of our component. We only need to provide a dispatch for +deleting notifications. + +*/ + +const messages = defineMessages({ + clearMessage: { id: 'notifications.marked_clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all selected notifications?' }, + clearConfirm: { id: 'notifications.marked_clear', defaultMessage: 'Clear selected notifications' }, +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onEnterCleaningMode(yes) { + dispatch(enterNotificationClearingMode(yes)); + }, + + onDeleteMarked() { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.clearMessage), + confirm: intl.formatMessage(messages.clearConfirm), + onConfirm: () => dispatch(deleteMarkedNotifications()), + })); + }, + + onMarkAll() { + dispatch(markAllNotifications(true)); + }, + + onMarkNone() { + dispatch(markAllNotifications(false)); + }, + + onInvert() { + dispatch(markAllNotifications(null)); + }, +}); + +const mapStateToProps = state => ({ + markNewForDelete: state.getIn(['notifications', 'markNewForDelete']), +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationPurgeButtons)); diff --git a/app/javascript/glitch/components/column/notif_cleaning_widget/notification_purge_buttons.js b/app/javascript/glitch/components/column/notif_cleaning_widget/notification_purge_buttons.js new file mode 100644 index 00000000000..62c887fb751 --- /dev/null +++ b/app/javascript/glitch/components/column/notif_cleaning_widget/notification_purge_buttons.js @@ -0,0 +1,62 @@ +/** + * Buttons widget for controlling the notification clearing mode. + * In idle state, the cleaning mode button is shown. When the mode is active, + * a Confirm and Abort buttons are shown in its place. + */ + + +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Mastodon imports // + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const messages = defineMessages({ + btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' }, + btnNone : { id: 'notification_purge.btn_none', defaultMessage: 'Select\nnone' }, + btnInvert : { id: 'notification_purge.btn_invert', defaultMessage: 'Invert\nselection' }, + btnApply : { id: 'notification_purge.btn_apply', defaultMessage: 'Clear\nselected' }, +}); + +@injectIntl +export default class NotificationPurgeButtons extends ImmutablePureComponent { + + static propTypes = { + onDeleteMarked : PropTypes.func.isRequired, + onMarkAll : PropTypes.func.isRequired, + onMarkNone : PropTypes.func.isRequired, + onInvert : PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + markNewForDelete: PropTypes.bool, + }; + + render () { + const { intl, markNewForDelete } = this.props; + + //className='active' + return ( +
+ + + + + + + +
+ ); + } + +} diff --git a/app/javascript/glitch/components/compose/advanced_options/container.js b/app/javascript/glitch/components/compose/advanced_options/container.js new file mode 100644 index 00000000000..160f2273765 --- /dev/null +++ b/app/javascript/glitch/components/compose/advanced_options/container.js @@ -0,0 +1,66 @@ +/* + +`` +=================================== + +This container connects `` to the Redux store. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import { connect } from 'react-redux'; + +// Mastodon imports // +import { toggleComposeAdvancedOption } from '../../../../mastodon/actions/compose'; + +// Our imports // +import ComposeAdvancedOptions from '.'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +State mapping: +-------------- + +The `mapStateToProps()` function maps various state properties to the +props of our component. The only property we care about is +`compose.advanced_options`. + +*/ + +const mapStateToProps = state => ({ + values: state.getIn(['compose', 'advanced_options']), +}); + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Dispatch mapping: +----------------- + +The `mapDispatchToProps()` function maps dispatches to our store to the +various props of our component. We just need to provide a dispatch for +when an advanced option toggle changes. + +*/ + +const mapDispatchToProps = dispatch => ({ + + onChange (option) { + dispatch(toggleComposeAdvancedOption(option)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions); diff --git a/app/javascript/glitch/components/compose/advanced_options/index.js b/app/javascript/glitch/components/compose/advanced_options/index.js new file mode 100644 index 00000000000..8251baf4dd1 --- /dev/null +++ b/app/javascript/glitch/components/compose/advanced_options/index.js @@ -0,0 +1,163 @@ +/* + +`` +========================== + +> For more information on the contents of this file, please contact: +> +> - surinna [@srn@dev.glitch.social] + +This adds an advanced options dropdown to the toot compose box, for +toggles that don't necessarily fit elsewhere. + +__Props:__ + + - __`values` (`ImmutablePropTypes.contains(…).isRequired`) :__ + An Immutable map with the following values: + + - __`do_not_federate` (`PropTypes.bool.isRequired`) :__ + Specifies whether or not to federate the status. + + - __`onChange` (`PropTypes.func.isRequired`) :__ + The function to call when a toggle is changed. We pass this from + our container to the toggle. + + - __`intl` (`PropTypes.object.isRequired`) :__ + Our internationalization object, inserted by `@injectIntl`. + +__State:__ + + - __`open` :__ + This tells whether the dropdown is currently open or closed. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { injectIntl, defineMessages } from 'react-intl'; + +// Our imports // +import ComposeAdvancedOptionsToggle from './toggle'; +import ComposeDropdown from '../dropdown/index'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Inital setup: +------------- + +The `messages` constant is used to define any messages that we need +from inside props. These are the various titles and labels on our +toggles. + +`iconStyle` styles the icon used for the dropdown button. + +*/ + +const messages = defineMessages({ + local_only_short : + { id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' }, + local_only_long : + { id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' }, + advanced_options_icon_title : + { id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' }, +}); + +/* + +Implementation: +--------------- + +*/ + +@injectIntl +export default class ComposeAdvancedOptions extends React.PureComponent { + + static propTypes = { + values : ImmutablePropTypes.contains({ + do_not_federate : PropTypes.bool.isRequired, + }).isRequired, + onChange : PropTypes.func.isRequired, + intl : PropTypes.object.isRequired, + }; + + +/* + +### `render()` + +`render()` actually puts our component on the screen. + +*/ + + render () { + const { intl, values } = this.props; + +/* + +The `options` array provides all of the available advanced options +alongside their icon, text, and name. + +*/ + const options = [ + { icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' }, + ]; + +/* + +`anyEnabled` tells us if any of our advanced options have been enabled. + +*/ + + const anyEnabled = values.some((enabled) => enabled); + +/* + +`optionElems` takes our `options` and creates +``s out of them. We use the `name` of the +toggle as its `key` so that React can keep track of it. + +*/ + + const optionElems = options.map((option) => { + return ( + + ); + }); + +/* + +Finally, we can render our component. + +*/ + return ( + + {optionElems} + + ); + } + +} diff --git a/app/javascript/glitch/components/compose/advanced_options/toggle.js b/app/javascript/glitch/components/compose/advanced_options/toggle.js new file mode 100644 index 00000000000..d6907472a4f --- /dev/null +++ b/app/javascript/glitch/components/compose/advanced_options/toggle.js @@ -0,0 +1,103 @@ +/* + +`` +================================ + +> For more information on the contents of this file, please contact: +> +> - surinna [@srn@dev.glitch.social] + +This creates the toggle used by ``. + +__Props:__ + + - __`onChange` (`PropTypes.func`) :__ + This provides the function to call when the toggle is + (de-?)activated. + + - __`active` (`PropTypes.bool`) :__ + This prop controls whether the toggle is currently active or not. + + - __`name` (`PropTypes.string`) :__ + This identifies the toggle, and is sent to `onChange()` when it is + called. + + - __`shortText` (`PropTypes.string`) :__ + This is a short string used as the title of the toggle. + + - __`longText` (`PropTypes.string`) :__ + This is a longer string used as a subtitle for the toggle. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import Toggle from 'react-toggle'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Implementation: +--------------- + +*/ + +export default class ComposeAdvancedOptionsToggle extends React.PureComponent { + + static propTypes = { + onChange: PropTypes.func.isRequired, + active: PropTypes.bool.isRequired, + name: PropTypes.string.isRequired, + shortText: PropTypes.string.isRequired, + longText: PropTypes.string.isRequired, + } + +/* + +### `onToggle()` + +The `onToggle()` function simply calls the `onChange()` prop with the +toggle's `name`. + +*/ + + onToggle = () => { + this.props.onChange(this.props.name); + } + +/* + +### `render()` + +The `render()` function is used to render our component. We just render +a `` and place next to it our text. + +*/ + + render() { + const { active, shortText, longText } = this.props; + return ( +
+
+ +
+
+ {shortText} + {longText} +
+
+ ); + } + +} diff --git a/app/javascript/glitch/components/compose/attach_options/index.js b/app/javascript/glitch/components/compose/attach_options/index.js new file mode 100644 index 00000000000..4340972f01e --- /dev/null +++ b/app/javascript/glitch/components/compose/attach_options/index.js @@ -0,0 +1,133 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { injectIntl, defineMessages } from 'react-intl'; + +// Our imports // +import ComposeDropdown from '../dropdown/index'; +import { uploadCompose } from '../../../../mastodon/actions/compose'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { openModal } from '../../../../mastodon/actions/modal'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const messages = defineMessages({ + upload : + { id: 'compose.attach.upload', defaultMessage: 'Upload a file' }, + doodle : + { id: 'compose.attach.doodle', defaultMessage: 'Draw something' }, + attach : + { id: 'compose.attach', defaultMessage: 'Attach...' }, +}); + +const mapStateToProps = state => ({ + // This horrible expression is copied from vanilla upload_button_container + disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), + resetFileKey: state.getIn(['compose', 'resetFileKey']), + acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']), +}); + +const mapDispatchToProps = dispatch => ({ + onSelectFile (files) { + dispatch(uploadCompose(files)); + }, + onOpenDoodle () { + dispatch(openModal('DOODLE', { noEsc: true })); + }, +}); + +@injectIntl +@connect(mapStateToProps, mapDispatchToProps) +export default class ComposeAttachOptions extends ImmutablePureComponent { + + static propTypes = { + intl : PropTypes.object.isRequired, + resetFileKey: PropTypes.number, + acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired, + disabled: PropTypes.bool, + onSelectFile: PropTypes.func.isRequired, + onOpenDoodle: PropTypes.func.isRequired, + }; + + handleItemClick = bt => { + if (bt === 'upload') { + this.fileElement.click(); + } + + if (bt === 'doodle') { + this.props.onOpenDoodle(); + } + + this.dropdown.setState({ open: false }); + }; + + handleFileChange = (e) => { + if (e.target.files.length > 0) { + this.props.onSelectFile(e.target.files); + } + } + + setFileRef = (c) => { + this.fileElement = c; + } + + setDropdownRef = (c) => { + this.dropdown = c; + } + + render () { + const { intl, resetFileKey, disabled, acceptContentTypes } = this.props; + + const options = [ + { icon: 'cloud-upload', text: messages.upload, name: 'upload' }, + { icon: 'paint-brush', text: messages.doodle, name: 'doodle' }, + ]; + + const optionElems = options.map((item) => { + const hdl = () => this.handleItemClick(item.name); + return ( +
+
+ +
+ +
+ {intl.formatMessage(item.text)} +
+
+ ); + }); + + return ( +
+ + {optionElems} + + +
+ ); + } + +} diff --git a/app/javascript/glitch/components/compose/dropdown/index.js b/app/javascript/glitch/components/compose/dropdown/index.js new file mode 100644 index 00000000000..5f6467155a7 --- /dev/null +++ b/app/javascript/glitch/components/compose/dropdown/index.js @@ -0,0 +1,77 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; + +// Mastodon imports // +import IconButton from '../../../../mastodon/components/icon_button'; + +const iconStyle = { + height : null, + lineHeight : '27px', +}; + +export default class ComposeDropdown extends React.PureComponent { + + static propTypes = { + title: PropTypes.string.isRequired, + icon: PropTypes.string, + highlight: PropTypes.bool, + disabled: PropTypes.bool, + children: PropTypes.arrayOf(PropTypes.node).isRequired, + }; + + state = { + open: false, + }; + + onGlobalClick = (e) => { + if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) { + this.setState({ open: false }); + } + }; + + componentDidMount () { + window.addEventListener('click', this.onGlobalClick); + window.addEventListener('touchstart', this.onGlobalClick); + } + componentWillUnmount () { + window.removeEventListener('click', this.onGlobalClick); + window.removeEventListener('touchstart', this.onGlobalClick); + } + + onToggleDropdown = () => { + if (this.props.disabled) return; + this.setState({ open: !this.state.open }); + }; + + setRef = (c) => { + this.node = c; + }; + + render () { + const { open } = this.state; + let { highlight, title, icon, disabled } = this.props; + + if (!icon) icon = 'ellipsis-h'; + + return ( +
+
+ +
+
+ {this.props.children} +
+
+ ); + } + +} diff --git a/app/javascript/glitch/components/local_settings/container.js b/app/javascript/glitch/components/local_settings/container.js new file mode 100644 index 00000000000..4569db99f49 --- /dev/null +++ b/app/javascript/glitch/components/local_settings/container.js @@ -0,0 +1,24 @@ +// Package imports // +import { connect } from 'react-redux'; + +// Mastodon imports // +import { closeModal } from '../../../mastodon/actions/modal'; + +// Our imports // +import { changeLocalSetting } from '../../../glitch/actions/local_settings'; +import LocalSettings from '.'; + +const mapStateToProps = state => ({ + settings: state.get('local_settings'), +}); + +const mapDispatchToProps = dispatch => ({ + onChange (setting, value) { + dispatch(changeLocalSetting(setting, value)); + }, + onClose () { + dispatch(closeModal()); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(LocalSettings); diff --git a/app/javascript/glitch/components/local_settings/index.js b/app/javascript/glitch/components/local_settings/index.js new file mode 100644 index 00000000000..ef711229a2d --- /dev/null +++ b/app/javascript/glitch/components/local_settings/index.js @@ -0,0 +1,50 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Our imports +import LocalSettingsPage from './page'; +import LocalSettingsNavigation from './navigation'; + +// Stylesheet imports +import './style.scss'; + +export default class LocalSettings extends React.PureComponent { + + static propTypes = { + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + settings: ImmutablePropTypes.map.isRequired, + }; + + state = { + currentIndex: 0, + }; + + navigateTo = (index) => + this.setState({ currentIndex: +index }); + + render () { + + const { navigateTo } = this; + const { onChange, onClose, settings } = this.props; + const { currentIndex } = this.state; + + return ( +
+ + +
+ ); + } + +} diff --git a/app/javascript/glitch/components/local_settings/navigation/index.js b/app/javascript/glitch/components/local_settings/navigation/index.js new file mode 100644 index 00000000000..fa35e83c753 --- /dev/null +++ b/app/javascript/glitch/components/local_settings/navigation/index.js @@ -0,0 +1,74 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; + +// Our imports +import LocalSettingsNavigationItem from './item'; + +// Stylesheet imports +import './style.scss'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const messages = defineMessages({ + general: { id: 'settings.general', defaultMessage: 'General' }, + collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' }, + media: { id: 'settings.media', defaultMessage: 'Media' }, + preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' }, + close: { id: 'settings.close', defaultMessage: 'Close' }, +}); + +@injectIntl +export default class LocalSettingsNavigation extends React.PureComponent { + + static propTypes = { + index : PropTypes.number, + intl : PropTypes.object.isRequired, + onClose : PropTypes.func.isRequired, + onNavigate : PropTypes.func.isRequired, + }; + + render () { + + const { index, intl, onClose, onNavigate } = this.props; + + return ( + + ); + } + +} diff --git a/app/javascript/glitch/components/local_settings/navigation/item/index.js b/app/javascript/glitch/components/local_settings/navigation/item/index.js new file mode 100644 index 00000000000..a352d5fb2a7 --- /dev/null +++ b/app/javascript/glitch/components/local_settings/navigation/item/index.js @@ -0,0 +1,69 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +// Stylesheet imports +import './style.scss'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +export default class LocalSettingsPage extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + className: PropTypes.string, + href: PropTypes.string, + icon: PropTypes.string, + index: PropTypes.number.isRequired, + onNavigate: PropTypes.func, + title: PropTypes.string, + }; + + handleClick = (e) => { + const { index, onNavigate } = this.props; + if (onNavigate) { + onNavigate(index); + e.preventDefault(); + } + } + + render () { + const { handleClick } = this; + const { + active, + className, + href, + icon, + onNavigate, + title, + } = this.props; + + const finalClassName = classNames('glitch', 'local-settings__navigation__item', { + active, + }, className); + + const iconElem = icon ? : null; + + if (href) return ( + + {iconElem} {title} + + ); + else if (onNavigate) return ( + + {iconElem} {title} + + ); + else return null; + } + +} diff --git a/app/javascript/glitch/components/local_settings/navigation/item/style.scss b/app/javascript/glitch/components/local_settings/navigation/item/style.scss new file mode 100644 index 00000000000..7f7371993af --- /dev/null +++ b/app/javascript/glitch/components/local_settings/navigation/item/style.scss @@ -0,0 +1,27 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings__navigation__item { + display: block; + padding: 15px 20px; + color: inherit; + background: $primary-text-color; + border-bottom: 1px $ui-primary-color solid; + cursor: pointer; + text-decoration: none; + outline: none; + transition: background .3s; + + &:hover { + background: $ui-secondary-color; + } + + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + } + + &.close, &.close:hover { + background: $error-value-color; + color: $primary-text-color; + } +} diff --git a/app/javascript/glitch/components/local_settings/navigation/style.scss b/app/javascript/glitch/components/local_settings/navigation/style.scss new file mode 100644 index 00000000000..0336f943bea --- /dev/null +++ b/app/javascript/glitch/components/local_settings/navigation/style.scss @@ -0,0 +1,10 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings__navigation { + background: $primary-text-color; + color: $ui-base-color; + width: 200px; + font-size: 15px; + line-height: 20px; + overflow-y: auto; +} diff --git a/app/javascript/glitch/components/local_settings/page/index.js b/app/javascript/glitch/components/local_settings/page/index.js new file mode 100644 index 00000000000..498230f7bda --- /dev/null +++ b/app/javascript/glitch/components/local_settings/page/index.js @@ -0,0 +1,212 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; + +// Our imports +import LocalSettingsPageItem from './item'; + +// Stylesheet imports +import './style.scss'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const messages = defineMessages({ + layout_auto: { id: 'layout.auto', defaultMessage: 'Auto' }, + layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' }, + layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' }, + side_arm_none: { id: 'settings.side_arm.none', defaultMessage: 'None' }, +}); + +@injectIntl +export default class LocalSettingsPage extends React.PureComponent { + + static propTypes = { + index : PropTypes.number, + intl : PropTypes.object.isRequired, + onChange : PropTypes.func.isRequired, + settings : ImmutablePropTypes.map.isRequired, + }; + + pages = [ + ({ intl, onChange, settings }) => ( +
+

+ + + + + + + + + +
+

+ + + +
+
+ ), + ({ onChange, settings }) => ( +
+

+ + + +
+

+ + + + + + + + + + + + + + + + + + +
+
+

+ + + + + + +
+
+ ), + ({ onChange, settings }) => ( +
+

+ + + + + + +
+ ), + ]; + + render () { + const { pages } = this; + const { index, intl, onChange, settings } = this.props; + const CurrentPage = pages[index] || pages[0]; + + return ; + } + +} diff --git a/app/javascript/glitch/components/local_settings/page/item/index.js b/app/javascript/glitch/components/local_settings/page/item/index.js new file mode 100644 index 00000000000..37e28c08452 --- /dev/null +++ b/app/javascript/glitch/components/local_settings/page/item/index.js @@ -0,0 +1,90 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Stylesheet imports +import './style.scss'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +export default class LocalSettingsPageItem extends React.PureComponent { + + static propTypes = { + children: PropTypes.element.isRequired, + dependsOn: PropTypes.array, + dependsOnNot: PropTypes.array, + id: PropTypes.string.isRequired, + item: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + })), + settings: ImmutablePropTypes.map.isRequired, + }; + + handleChange = e => { + const { target } = e; + const { item, onChange, options } = this.props; + if (options && options.length > 0) onChange(item, target.value); + else onChange(item, target.checked); + } + + render () { + const { handleChange } = this; + const { settings, item, id, options, children, dependsOn, dependsOnNot } = this.props; + let enabled = true; + + if (dependsOn) { + for (let i = 0; i < dependsOn.length; i++) { + enabled = enabled && settings.getIn(dependsOn[i]); + } + } + if (dependsOnNot) { + for (let i = 0; i < dependsOnNot.length; i++) { + enabled = enabled && !settings.getIn(dependsOnNot[i]); + } + } + + if (options && options.length > 0) { + const currentValue = settings.getIn(item); + const optionElems = options && options.length > 0 && options.map((opt) => ( + + )); + return ( + + ); + } else return ( + + ); + } + +} diff --git a/app/javascript/glitch/components/local_settings/page/item/style.scss b/app/javascript/glitch/components/local_settings/page/item/style.scss new file mode 100644 index 00000000000..b2d8f7185c9 --- /dev/null +++ b/app/javascript/glitch/components/local_settings/page/item/style.scss @@ -0,0 +1,7 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings__page__item { + select { + margin-bottom: 5px; + } +} diff --git a/app/javascript/glitch/components/local_settings/page/style.scss b/app/javascript/glitch/components/local_settings/page/style.scss new file mode 100644 index 00000000000..e9eedcad03e --- /dev/null +++ b/app/javascript/glitch/components/local_settings/page/style.scss @@ -0,0 +1,9 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings__page { + display: block; + flex: auto; + padding: 15px 20px 15px 20px; + width: 360px; + overflow-y: auto; +} diff --git a/app/javascript/glitch/components/local_settings/style.scss b/app/javascript/glitch/components/local_settings/style.scss new file mode 100644 index 00000000000..765294607da --- /dev/null +++ b/app/javascript/glitch/components/local_settings/style.scss @@ -0,0 +1,34 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings { + position: relative; + display: flex; + flex-direction: row; + background: $ui-secondary-color; + color: $ui-base-color; + border-radius: 8px; + height: 80vh; + width: 80vw; + max-width: 740px; + max-height: 450px; + overflow: hidden; + + label { + display: block; + } + + h1 { + font-size: 18px; + font-weight: 500; + line-height: 24px; + margin-bottom: 20px; + } + + h2 { + font-size: 15px; + font-weight: 500; + line-height: 20px; + margin-top: 20px; + margin-bottom: 10px; + } +} diff --git a/app/javascript/glitch/components/notification/container.js b/app/javascript/glitch/components/notification/container.js new file mode 100644 index 00000000000..dc4c2168a97 --- /dev/null +++ b/app/javascript/glitch/components/notification/container.js @@ -0,0 +1,48 @@ +/* + +`` +========================= + +This container connects ``s to the Redux store. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import { connect } from 'react-redux'; + +// Our imports // +import Notification from '.'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const mapStateToProps = (state, props) => { + // replace account id with object + let leNotif = props.notification.set('account', state.getIn(['accounts', props.notification.get('account')])); + + // populate markedForDelete from state - is mysteriously lost somewhere + for (let n of state.getIn(['notifications', 'items'])) { + if (n.get('id') === props.notification.get('id')) { + leNotif = leNotif.set('markedForDelete', n.get('markedForDelete')); + break; + } + } + + return ({ + notification: leNotif, + settings: state.get('local_settings'), + notifCleaning: state.getIn(['notifications', 'cleaningMode']), + }); +}; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +export default connect(mapStateToProps)(Notification); diff --git a/app/javascript/glitch/components/notification/follow.js b/app/javascript/glitch/components/notification/follow.js new file mode 100644 index 00000000000..e2c21bf3505 --- /dev/null +++ b/app/javascript/glitch/components/notification/follow.js @@ -0,0 +1,72 @@ +// `` +// ====================== + +// * * * * * * * // + +// Imports +// ------- + +// Package imports. +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Mastodon imports. +import Permalink from '../../../mastodon/components/permalink'; +import AccountContainer from '../../../mastodon/containers/account_container'; + +// Our imports. +import NotificationOverlayContainer from '../notification/overlay/container'; + +// * * * * * * * // + +// Implementation +// -------------- + +export default class NotificationFollow extends ImmutablePureComponent { + + static propTypes = { + id : PropTypes.string.isRequired, + account : ImmutablePropTypes.map.isRequired, + notification : ImmutablePropTypes.map.isRequired, + }; + + render () { + const { account, notification } = this.props; + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + + ); + + // Renders. + return ( +
+
+
+ +
+ + +
+ + + +
+ ); + } + +} diff --git a/app/javascript/glitch/components/notification/index.js b/app/javascript/glitch/components/notification/index.js new file mode 100644 index 00000000000..b2e55aad510 --- /dev/null +++ b/app/javascript/glitch/components/notification/index.js @@ -0,0 +1,82 @@ +// Package imports // +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Mastodon imports // + +// Our imports // +import StatusContainer from '../status/container'; +import NotificationFollow from './follow'; + +export default class Notification extends ImmutablePureComponent { + + static propTypes = { + notification: ImmutablePropTypes.map.isRequired, + settings: ImmutablePropTypes.map.isRequired, + }; + + renderFollow (notification) { + return ( + + ); + } + + renderMention (notification) { + return ( + + ); + } + + renderFavourite (notification) { + return ( + + ); + } + + renderReblog (notification) { + return ( + + ); + } + + render () { + const { notification } = this.props; + + switch(notification.get('type')) { + case 'follow': + return this.renderFollow(notification); + case 'mention': + return this.renderMention(notification); + case 'favourite': + return this.renderFavourite(notification); + case 'reblog': + return this.renderReblog(notification); + } + + return null; + } + +} diff --git a/app/javascript/glitch/components/notification/overlay/container.js b/app/javascript/glitch/components/notification/overlay/container.js new file mode 100644 index 00000000000..089f615f088 --- /dev/null +++ b/app/javascript/glitch/components/notification/overlay/container.js @@ -0,0 +1,49 @@ +/* + +`` +========================= + +This container connects ``s to the Redux store. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import { connect } from 'react-redux'; + +// Our imports // +import NotificationOverlay from './notification_overlay'; +import { markNotificationForDelete } from '../../../../mastodon/actions/notifications'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Dispatch mapping: +----------------- + +The `mapDispatchToProps()` function maps dispatches to our store to the +various props of our component. We only need to provide a dispatch for +deleting notifications. + +*/ + +const mapDispatchToProps = dispatch => ({ + onMarkForDelete(id, yes) { + dispatch(markNotificationForDelete(id, yes)); + }, +}); + +const mapStateToProps = state => ({ + show: state.getIn(['notifications', 'cleaningMode']), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay); diff --git a/app/javascript/glitch/components/notification/overlay/notification_overlay.js b/app/javascript/glitch/components/notification/overlay/notification_overlay.js new file mode 100644 index 00000000000..aaca95cacbe --- /dev/null +++ b/app/javascript/glitch/components/notification/overlay/notification_overlay.js @@ -0,0 +1,61 @@ +/** + * Notification overlay + */ + + +// Package imports // +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl } from 'react-intl'; + +// Mastodon imports // + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const messages = defineMessages({ + markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' }, +}); + +@injectIntl +export default class NotificationOverlay extends ImmutablePureComponent { + + static propTypes = { + notification : ImmutablePropTypes.map.isRequired, + onMarkForDelete : PropTypes.func.isRequired, + show : PropTypes.bool.isRequired, + intl : PropTypes.object.isRequired, + }; + + onToggleMark = () => { + const mark = !this.props.notification.get('markedForDelete'); + const id = this.props.notification.get('id'); + this.props.onMarkForDelete(id, mark); + } + + render () { + const { notification, show, intl } = this.props; + + const active = notification.get('markedForDelete'); + const label = intl.formatMessage(messages.markForDeletion); + + return show ? ( +
+
+ +
+
+ ) : null; + } + +} diff --git a/app/javascript/glitch/components/status/action_bar.js b/app/javascript/glitch/components/status/action_bar.js new file mode 100644 index 00000000000..34588b00833 --- /dev/null +++ b/app/javascript/glitch/components/status/action_bar.js @@ -0,0 +1,187 @@ +// Package imports // +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Mastodon imports // +import RelativeTimestamp from '../../../mastodon/components/relative_timestamp'; +import IconButton from '../../../mastodon/components/icon_button'; +import DropdownMenuContainer from '../../../mastodon/containers/dropdown_menu_container'; +import { me } from '../../../mastodon/initial_state'; + +const messages = defineMessages({ + delete: { id: 'status.delete', defaultMessage: 'Delete' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + share: { id: 'status.share', defaultMessage: 'Share' }, + replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + open: { id: 'status.open', defaultMessage: 'Expand this status' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' }, + muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, + unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, +}); + +@injectIntl +export default class StatusActionBar extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReply: PropTypes.func, + onFavourite: PropTypes.func, + onReblog: PropTypes.func, + onDelete: PropTypes.func, + onMention: PropTypes.func, + onMute: PropTypes.func, + onBlock: PropTypes.func, + onReport: PropTypes.func, + onEmbed: PropTypes.func, + onMuteConversation: PropTypes.func, + onPin: PropTypes.func, + withDismiss: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + // Avoid checking props that are functions (and whose equality will always + // evaluate to false. See react-immutable-pure-component for usage. + updateOnProps = [ + 'status', + 'withDismiss', + ] + + handleReplyClick = () => { + this.props.onReply(this.props.status, this.context.router.history); + } + + handleShareClick = () => { + navigator.share({ + text: this.props.status.get('search_index'), + url: this.props.status.get('url'), + }); + } + + handleFavouriteClick = () => { + this.props.onFavourite(this.props.status); + } + + handleReblogClick = (e) => { + this.props.onReblog(this.props.status, e); + } + + handleDeleteClick = () => { + this.props.onDelete(this.props.status); + } + + handlePinClick = () => { + this.props.onPin(this.props.status); + } + + handleMentionClick = () => { + this.props.onMention(this.props.status.get('account'), this.context.router.history); + } + + handleMuteClick = () => { + this.props.onMute(this.props.status.get('account')); + } + + handleBlockClick = () => { + this.props.onBlock(this.props.status.get('account')); + } + + handleOpen = () => { + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); + } + + handleEmbed = () => { + this.props.onEmbed(this.props.status); + } + + handleReport = () => { + this.props.onReport(this.props.status); + } + + handleConversationMuteClick = () => { + this.props.onMuteConversation(this.props.status); + } + + render () { + const { status, intl, withDismiss } = this.props; + + const mutingConversation = status.get('muted'); + const anonymousAccess = !me; + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + + let menu = []; + let reblogIcon = 'retweet'; + let replyIcon; + let replyTitle; + + menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); + + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + } + + menu.push(null); + + if (status.getIn(['account', 'id']) === me || withDismiss) { + menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + menu.push(null); + } + + if (status.getIn(['account', 'id']) === me) { + if (publicStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); + } else { + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); + menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); + menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); + } + + if (status.get('in_reply_to_id', null) === null) { + replyIcon = 'reply'; + replyTitle = intl.formatMessage(messages.reply); + } else { + replyIcon = 'reply-all'; + replyTitle = intl.formatMessage(messages.replyAll); + } + + const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && ( + + ); + + return ( +
+ + + + {shareButton} + +
+ +
+ + +
+ ); + } + +} diff --git a/app/javascript/glitch/components/status/container.js b/app/javascript/glitch/components/status/container.js new file mode 100644 index 00000000000..0054abd1499 --- /dev/null +++ b/app/javascript/glitch/components/status/container.js @@ -0,0 +1,263 @@ +/* + +`` +=================== + +Original file by @gargron@mastodon.social et al as part of +tootsuite/mastodon. Documentation by @kibi@glitch.social. The code +detecting reblogs has been moved here from . + +*/ + + /* * * * */ + +/* + +Imports: +-------- + +*/ + +// Package imports // +import React from 'react'; +import { connect } from 'react-redux'; +import { + defineMessages, + injectIntl, + FormattedMessage, +} from 'react-intl'; + +// Mastodon imports // +import { makeGetStatus } from '../../../mastodon/selectors'; +import { + replyCompose, + mentionCompose, +} from '../../../mastodon/actions/compose'; +import { + reblog, + favourite, + unreblog, + unfavourite, + pin, + unpin, +} from '../../../mastodon/actions/interactions'; +import { blockAccount } from '../../../mastodon/actions/accounts'; +import { initMuteModal } from '../../../mastodon/actions/mutes'; +import { + muteStatus, + unmuteStatus, + deleteStatus, +} from '../../../mastodon/actions/statuses'; +import { initReport } from '../../../mastodon/actions/reports'; +import { openModal } from '../../../mastodon/actions/modal'; + +// Our imports // +import Status from '.'; + + /* * * * */ + +/* + +Inital setup: +------------- + +The `messages` constant is used to define any messages that we will +need in our component. In our case, these are the various confirmation +messages used with statuses. + +*/ + +const messages = defineMessages({ + deleteConfirm : { + id : 'confirmations.delete.confirm', + defaultMessage : 'Delete', + }, + deleteMessage : { + id : 'confirmations.delete.message', + defaultMessage : 'Are you sure you want to delete this status?', + }, + blockConfirm : { + id : 'confirmations.block.confirm', + defaultMessage : 'Block', + }, +}); + + /* * * * */ + +/* + +State mapping: +-------------- + +The `mapStateToProps()` function maps various state properties to the +props of our component. We wrap this in a `makeMapStateToProps()` +function to give us closure and preserve `getStatus()` across function +calls. + +*/ + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, ownProps) => { + + let status = getStatus(state, ownProps.id); + + if(status === null) { + console.error(`ERROR! NULL STATUS! ${ownProps.id}`); + // work-around: find first good status + for (let k of state.get('statuses').keys()) { + status = getStatus(state, k); + if (status !== null) break; + } + } + + let reblogStatus = status.get('reblog', null); + let account = undefined; + let prepend = undefined; + +/* + +Here we process reblogs. If our status is a reblog, then we create a +`prependMessage` to pass along to our `` along with the +reblogger's `account`, and set `coreStatus` (the one we will actually +render) to the status which has been reblogged. + +*/ + + if (reblogStatus !== null && typeof reblogStatus === 'object') { + account = status.get('account'); + status = reblogStatus; + prepend = 'reblogged_by'; + } + +/* + +Here are the props we pass to ``. + +*/ + + return { + status : status, + account : account || ownProps.account, + settings : state.get('local_settings'), + prepend : prepend || ownProps.prepend, + reblogModal : state.getIn(['meta', 'boost_modal']), + deleteModal : state.getIn(['meta', 'delete_modal']), + }; + }; + + return mapStateToProps; +}; + + /* * * * */ + +/* + +Dispatch mapping: +----------------- + +The `mapDispatchToProps()` function maps dispatches to our store to the +various props of our component. We need to provide dispatches for all +of the things you can do with a status: reply, reblog, favourite, et +cetera. + +For a few of these dispatches, we open up confirmation modals; the rest +just immediately execute their corresponding actions. + +*/ + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onReply (status, router) { + dispatch(replyCompose(status, router)); + }, + + onModalReblog (status) { + dispatch(reblog(status)); + }, + + onReblog (status, e) { + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else { + if (e.shiftKey || !this.reblogModal) { + this.onModalReblog(status); + } else { + dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); + } + } + }, + + onFavourite (status) { + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }, + + onPin (status) { + if (status.get('pinned')) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } + }, + + onEmbed (status) { + dispatch(openModal('EMBED', { url: status.get('url') })); + }, + + onDelete (status) { + if (!this.deleteModal) { + dispatch(deleteStatus(status.get('id'))); + } else { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.get('id'))), + })); + } + }, + + onMention (account, router) { + dispatch(mentionCompose(account, router)); + }, + + onOpenMedia (media, index) { + dispatch(openModal('MEDIA', { media, index })); + }, + + onOpenVideo (media, time) { + dispatch(openModal('VIDEO', { media, time })); + }, + + onBlock (account) { + dispatch(openModal('CONFIRM', { + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.blockConfirm), + onConfirm: () => dispatch(blockAccount(account.get('id'))), + })); + }, + + onReport (status) { + dispatch(initReport(status.get('account'), status)); + }, + + onMute (account) { + dispatch(initMuteModal(account)); + }, + + onMuteConversation (status) { + if (status.get('muted')) { + dispatch(unmuteStatus(status.get('id'))); + } else { + dispatch(muteStatus(status.get('id'))); + } + }, +}); + +export default injectIntl( + connect(makeMapStateToProps, mapDispatchToProps)(Status) +); diff --git a/app/javascript/glitch/components/status/content.js b/app/javascript/glitch/components/status/content.js new file mode 100644 index 00000000000..06015619b14 --- /dev/null +++ b/app/javascript/glitch/components/status/content.js @@ -0,0 +1,241 @@ +// Package imports // +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import classnames from 'classnames'; + +// Mastodon imports // +import { isRtl } from '../../../mastodon/rtl'; +import Permalink from '../../../mastodon/components/permalink'; + +export default class StatusContent extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + expanded: PropTypes.oneOf([true, false, null]), + setExpansion: PropTypes.func, + onHeightUpdate: PropTypes.func, + media: PropTypes.element, + mediaIcon: PropTypes.string, + parseClick: PropTypes.func, + disabled: PropTypes.bool, + }; + + state = { + hidden: true, + }; + + componentDidMount () { + const node = this.node; + const links = node.querySelectorAll('a'); + + for (let i = 0; i < links.length; ++i) { + let link = links[i]; + let mention = this.props.status.get('mentions').find(item => link.href === item.get('url')); + + if (mention) { + link.addEventListener('click', this.onMentionClick.bind(this, mention), false); + link.setAttribute('title', mention.get('acct')); + } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { + link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); + } else { + link.addEventListener('click', this.onLinkClick.bind(this), false); + link.setAttribute('title', link.href); + } + + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener'); + } + } + + componentDidUpdate () { + if (this.props.onHeightUpdate) { + this.props.onHeightUpdate(); + } + } + + onLinkClick = (e) => { + if (this.props.expanded === false) { + if (this.props.parseClick) this.props.parseClick(e); + } + } + + onMentionClick = (mention, e) => { + if (this.props.parseClick) { + this.props.parseClick(e, `/accounts/${mention.get('id')}`); + } + } + + onHashtagClick = (hashtag, e) => { + hashtag = hashtag.replace(/^#/, '').toLowerCase(); + + if (this.props.parseClick) { + this.props.parseClick(e, `/timelines/tag/${hashtag}`); + } + } + + handleMouseDown = (e) => { + this.startXY = [e.clientX, e.clientY]; + } + + handleMouseUp = (e) => { + const { parseClick } = this.props; + + if (!this.startXY) { + return; + } + + const [ startX, startY ] = this.startXY; + const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; + + if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) { + return; + } + + if (deltaX + deltaY < 5 && e.button === 0 && parseClick) { + parseClick(e); + } + + this.startXY = null; + } + + handleSpoilerClick = (e) => { + e.preventDefault(); + + if (this.props.setExpansion) { + this.props.setExpansion(this.props.expanded ? null : true); + } else { + this.setState({ hidden: !this.state.hidden }); + } + } + + setRef = (c) => { + this.node = c; + } + + render () { + const { + status, + media, + mediaIcon, + parseClick, + disabled, + } = this.props; + + const hidden = ( + this.props.setExpansion ? + !this.props.expanded : + this.state.hidden + ); + + const content = { __html: status.get('contentHtml') }; + const spoilerContent = { __html: status.get('spoilerHtml') }; + const directionStyle = { direction: 'ltr' }; + const classNames = classnames('status__content', { + 'status__content--with-action': parseClick && !disabled, + }); + + if (isRtl(status.get('search_index'))) { + directionStyle.direction = 'rtl'; + } + + if (status.get('spoiler_text').length > 0) { + let mentionsPlaceholder = ''; + + const mentionLinks = status.get('mentions').map(item => ( + + @{item.get('username')} + + )).reduce((aggregate, item) => [...aggregate, item, ' '], []); + + const toggleText = hidden ? [ + , + mediaIcon ? ( +