diff options
227 files changed, 8549 insertions, 567 deletions
diff --git a/.eslintrc.yml b/.eslintrc.yml index 7c6da9d57..b1b38351c 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 000000000..35b0cd787 --- /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 299306299..42dfc57dc 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. + +<blockquote> + 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. + +</blockquote> diff --git a/README.md b/README.md index 5cf91d52c..998d57005 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 0c21bed68..351ab5cfa 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 4676f60de..85eb2d60e 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 0c43cb943..92ad251ef 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 8910b77e9..a949752fb 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 997eed6e2..d1b4e0402 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 000000000..d455227eb --- /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/application_controller.rb b/app/controllers/application_controller.rb index a213302cb..f5dbe837e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -13,6 +13,7 @@ class ApplicationController < ActionController::Base helper_method :current_account helper_method :current_session helper_method :current_theme + helper_method :theme_data helper_method :single_user_mode? rescue_from ActionController::RoutingError, with: :not_found @@ -88,6 +89,10 @@ class ApplicationController < ActionController::Base current_user.setting_theme end + def theme_data + Themes.instance.get(current_theme) + end + def cache_collection(raw, klass) return raw unless klass.respond_to?(:with_includes) diff --git a/app/controllers/settings/keyword_mutes_controller.rb b/app/controllers/settings/keyword_mutes_controller.rb new file mode 100644 index 000000000..f79e1b320 --- /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 cc579dbc8..5f61e2182 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 000000000..7b98cd59e --- /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 000000000..93c5a9a17 --- /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 000000000..7bc1a2189 --- /dev/null +++ b/app/javascript/glitch/components/account/header.js @@ -0,0 +1,227 @@ +/* + +`<AccountHeader>` +================= + +> 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 `<AccountHeader>` 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 = ( + <span className='account--follows-info'> + <FormattedMessage id='account.follows_you' defaultMessage='Follows you' /> + </span> + ); + } + if (account.getIn(['relationship', 'requested'])) { + actionBtn = ( + <div className='account--action-button'> + <IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} /> + </div> + ); + } else if (!account.getIn(['relationship', 'blocking'])) { + following = account.getIn(['relationship', 'following']); + actionBtn = ( + <div className='account--action-button'> + <IconButton + size={26} + icon={following ? 'user-times' : 'user-plus'} + active={following ? true : false} + title={intl.formatMessage(following ? messages.unfollow : messages.follow)} + onClick={this.props.onFollow} + /> + </div> + ); + } + } + +/* + 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 ( + <div className='account__header__wrapper'> + <div + className='account__header' + style={{ backgroundImage: `url(${account.get('header')})` }} + > + <div> + <a href={account.get('url')} target='_blank' rel='noopener'> + <span className='account__header__avatar'> + <Avatar account={account} size={90} /> + </span> + <span + className='account__header__display-name' + dangerouslySetInnerHTML={{ __html: displayName }} + /> + </a> + <span className='account__header__username'> + @{account.get('acct')} + {account.get('locked') ? <i className='fa fa-lock' /> : null} + </span> + <div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} /> + + {info} + {actionBtn} + </div> + </div> + + {metadata.length && ( + <table className='account__metadata'> + <tbody> + {(() => { + let data = []; + for (let i = 0; i < metadata.length; i++) { + data.push( + <tr key={i}> + <th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th> + <td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td> + </tr> + ); + } + return data; + })()} + </tbody> + </table> + ) || null} + </div> + ); + } + +} 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 000000000..d3507d752 --- /dev/null +++ b/app/javascript/glitch/components/column/notif_cleaning_widget/container.js @@ -0,0 +1,80 @@ +/* + +`<NotificationPurgeButtonsContainer>` +========================= + +This container connects `<NotificationPurgeButtons>`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 000000000..62c887fb7 --- /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 ( + <div className='column-header__notif-cleaning-buttons'> + <button onClick={this.props.onMarkAll} className={markNewForDelete ? 'active' : ''}> + <b>∀</b><br />{intl.formatMessage(messages.btnAll)} + </button> + + <button onClick={this.props.onMarkNone} className={!markNewForDelete ? 'active' : ''}> + <b>∅</b><br />{intl.formatMessage(messages.btnNone)} + </button> + + <button onClick={this.props.onInvert}> + <b>¬</b><br />{intl.formatMessage(messages.btnInvert)} + </button> + + <button onClick={this.props.onDeleteMarked}> + <i className='fa fa-trash' /><br />{intl.formatMessage(messages.btnApply)} + </button> + </div> + ); + } + +} 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 000000000..160f22737 --- /dev/null +++ b/app/javascript/glitch/components/compose/advanced_options/container.js @@ -0,0 +1,66 @@ +/* + +`<ComposeAdvancedOptionsContainer>` +=================================== + +This container connects `<ComposeAdvancedOptions>` 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 000000000..8251baf4d --- /dev/null +++ b/app/javascript/glitch/components/compose/advanced_options/index.js @@ -0,0 +1,163 @@ +/* + +`<ComposeAdvancedOptions>` +========================== + +> 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 +`<ComposeAdvancedOptionsToggle>`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 ( + <ComposeAdvancedOptionsToggle + onChange={this.props.onChange} + active={values.get(option.name)} + key={option.name} + name={option.name} + shortText={intl.formatMessage(option.shortText)} + longText={intl.formatMessage(option.longText)} + /> + ); + }); + +/* + +Finally, we can render our component. + +*/ + return ( + <ComposeDropdown + title={intl.formatMessage(messages.advanced_options_icon_title)} + icon='home' + highlight={anyEnabled} + > + {optionElems} + </ComposeDropdown> + ); + } + +} 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 000000000..d6907472a --- /dev/null +++ b/app/javascript/glitch/components/compose/advanced_options/toggle.js @@ -0,0 +1,103 @@ +/* + +`<ComposeAdvancedOptionsToggle>` +================================ + +> For more information on the contents of this file, please contact: +> +> - surinna [@srn@dev.glitch.social] + +This creates the toggle used by `<ComposeAdvancedOptions>`. + +__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 `<Toggle>` and place next to it our text. + +*/ + + render() { + const { active, shortText, longText } = this.props; + return ( + <div role='button' tabIndex='0' className='advanced-options-dropdown__option' onClick={this.onToggle}> + <div className='advanced-options-dropdown__option__toggle'> + <Toggle checked={active} onChange={this.onToggle} /> + </div> + <div className='advanced-options-dropdown__option__content'> + <strong>{shortText}</strong> + {longText} + </div> + </div> + ); + } + +} 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 000000000..4340972f0 --- /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 ( + <div + role='button' + tabIndex='0' + key={item.name} + onClick={hdl} + className='privacy-dropdown__option' + > + <div className='privacy-dropdown__option__icon'> + <i className={`fa fa-fw fa-${item.icon}`} /> + </div> + + <div className='privacy-dropdown__option__content'> + <strong>{intl.formatMessage(item.text)}</strong> + </div> + </div> + ); + }); + + return ( + <div> + <ComposeDropdown + title={intl.formatMessage(messages.attach)} + icon='paperclip' + disabled={disabled} + ref={this.setDropdownRef} + > + {optionElems} + </ComposeDropdown> + <input + key={resetFileKey} + ref={this.setFileRef} + type='file' + multiple={false} + accept={acceptContentTypes.toArray().join(',')} + onChange={this.handleFileChange} + disabled={disabled} + style={{ display: 'none' }} + /> + </div> + ); + } + +} 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 000000000..5f6467155 --- /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 ( + <div ref={this.setRef} className={`advanced-options-dropdown ${open ? 'open' : ''} ${highlight ? 'active' : ''} `}> + <div className='advanced-options-dropdown__value'> + <IconButton + className={'inverted'} + title={title} + icon={icon} active={open || highlight} + size={18} + style={iconStyle} + disabled={disabled} + onClick={this.onToggleDropdown} + /> + </div> + <div className='advanced-options-dropdown__dropdown'> + {this.props.children} + </div> + </div> + ); + } + +} 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 000000000..4569db99f --- /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 000000000..ef711229a --- /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 ( + <div className='glitch modal-root__modal local-settings'> + <LocalSettingsNavigation + index={currentIndex} + onClose={onClose} + onNavigate={navigateTo} + /> + <LocalSettingsPage + index={currentIndex} + onChange={onChange} + settings={settings} + /> + </div> + ); + } + +} 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 000000000..fa35e83c7 --- /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 ( + <nav className='glitch local-settings__navigation'> + <LocalSettingsNavigationItem + active={index === 0} + index={0} + onNavigate={onNavigate} + title={intl.formatMessage(messages.general)} + /> + <LocalSettingsNavigationItem + active={index === 1} + index={1} + onNavigate={onNavigate} + title={intl.formatMessage(messages.collapsed)} + /> + <LocalSettingsNavigationItem + active={index === 2} + index={2} + onNavigate={onNavigate} + title={intl.formatMessage(messages.media)} + /> + <LocalSettingsNavigationItem + active={index === 3} + href='/settings/preferences' + index={3} + icon='cog' + title={intl.formatMessage(messages.preferences)} + /> + <LocalSettingsNavigationItem + active={index === 4} + className='close' + index={4} + onNavigate={onClose} + title={intl.formatMessage(messages.close)} + /> + </nav> + ); + } + +} 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 000000000..a352d5fb2 --- /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 ? <i className={`fa fa-fw fa-${icon}`} /> : null; + + if (href) return ( + <a + href={href} + className={finalClassName} + > + {iconElem} {title} + </a> + ); + else if (onNavigate) return ( + <a + onClick={handleClick} + role='button' + tabIndex='0' + className={finalClassName} + > + {iconElem} {title} + </a> + ); + 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 000000000..7f7371993 --- /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 000000000..0336f943b --- /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 000000000..498230f7b --- /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 }) => ( + <div className='glitch local-settings__page general'> + <h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['layout']} + id='mastodon-settings--layout' + options={[ + { value: 'auto', message: intl.formatMessage(messages.layout_auto) }, + { value: 'multiple', message: intl.formatMessage(messages.layout_desktop) }, + { value: 'single', message: intl.formatMessage(messages.layout_mobile) }, + ]} + onChange={onChange} + > + <FormattedMessage id='settings.layout' defaultMessage='Layout:' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['stretch']} + id='mastodon-settings--stretch' + onChange={onChange} + > + <FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['navbar_under']} + id='mastodon-settings--navbar_under' + onChange={onChange} + > + <FormattedMessage id='settings.navbar_under' defaultMessage='Navbar at the bottom (Mobile only)' /> + </LocalSettingsPageItem> + <section> + <h2><FormattedMessage id='settings.compose_box_opts' defaultMessage='Compose box options' /></h2> + <LocalSettingsPageItem + settings={settings} + item={['side_arm']} + id='mastodon-settings--side_arm' + options={[ + { value: 'none', message: intl.formatMessage(messages.side_arm_none) }, + { value: 'direct', message: intl.formatMessage({ id: 'privacy.direct.short' }) }, + { value: 'private', message: intl.formatMessage({ id: 'privacy.private.short' }) }, + { value: 'unlisted', message: intl.formatMessage({ id: 'privacy.unlisted.short' }) }, + { value: 'public', message: intl.formatMessage({ id: 'privacy.public.short' }) }, + ]} + onChange={onChange} + > + <FormattedMessage id='settings.side_arm' defaultMessage='Secondary toot button:' /> + </LocalSettingsPageItem> + </section> + </div> + ), + ({ onChange, settings }) => ( + <div className='glitch local-settings__page collapsed'> + <h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'enabled']} + id='mastodon-settings--collapsed-enabled' + onChange={onChange} + > + <FormattedMessage id='settings.enable_collapsed' defaultMessage='Enable collapsed toots' /> + </LocalSettingsPageItem> + <section> + <h2><FormattedMessage id='settings.auto_collapse' defaultMessage='Automatic collapsing' /></h2> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'all']} + id='mastodon-settings--collapsed-auto-all' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.auto_collapse_all' defaultMessage='Everything' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'notifications']} + id='mastodon-settings--collapsed-auto-notifications' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_notifications' defaultMessage='Notifications' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'lengthy']} + id='mastodon-settings--collapsed-auto-lengthy' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'reblogs']} + id='mastodon-settings--collapsed-auto-reblogs' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_reblogs' defaultMessage='Boosts' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'replies']} + id='mastodon-settings--collapsed-auto-replies' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_replies' defaultMessage='Replies' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'media']} + id='mastodon-settings--collapsed-auto-media' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' /> + </LocalSettingsPageItem> + </section> + <section> + <h2><FormattedMessage id='settings.image_backgrounds' defaultMessage='Image backgrounds' /></h2> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'backgrounds', 'user_backgrounds']} + id='mastodon-settings--collapsed-user-backgrouns' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.image_backgrounds_users' defaultMessage='Give collapsed toots an image background' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'backgrounds', 'preview_images']} + id='mastodon-settings--collapsed-preview-images' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.image_backgrounds_media' defaultMessage='Preview collapsed toot media' /> + </LocalSettingsPageItem> + </section> + </div> + ), + ({ onChange, settings }) => ( + <div className='glitch local-settings__page media'> + <h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['media', 'letterbox']} + id='mastodon-settings--media-letterbox' + onChange={onChange} + > + <FormattedMessage id='settings.media_letterbox' defaultMessage='Letterbox media' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['media', 'fullwidth']} + id='mastodon-settings--media-fullwidth' + onChange={onChange} + > + <FormattedMessage id='settings.media_fullwidth' defaultMessage='Full-width media previews' /> + </LocalSettingsPageItem> + </div> + ), + ]; + + render () { + const { pages } = this; + const { index, intl, onChange, settings } = this.props; + const CurrentPage = pages[index] || pages[0]; + + return <CurrentPage intl={intl} onChange={onChange} settings={settings} />; + } + +} 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 000000000..37e28c084 --- /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) => ( + <option + key={opt.value} + value={opt.value} + > + {opt.message} + </option> + )); + return ( + <label className='glitch local-settings__page__item' htmlFor={id}> + <p>{children}</p> + <p> + <select + id={id} + disabled={!enabled} + onBlur={handleChange} + onChange={handleChange} + value={currentValue} + > + {optionElems} + </select> + </p> + </label> + ); + } else return ( + <label className='glitch local-settings__page__item' htmlFor={id}> + <input + id={id} + type='checkbox' + checked={settings.getIn(item)} + onChange={handleChange} + disabled={!enabled} + /> + {children} + </label> + ); + } + +} 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 000000000..b2d8f7185 --- /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 000000000..e9eedcad0 --- /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 000000000..765294607 --- /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 000000000..dc4c2168a --- /dev/null +++ b/app/javascript/glitch/components/notification/container.js @@ -0,0 +1,48 @@ +/* + +`<NotificationContainer>` +========================= + +This container connects `<Notification>`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 000000000..e2c21bf35 --- /dev/null +++ b/app/javascript/glitch/components/notification/follow.js @@ -0,0 +1,72 @@ +// `<NotificationFollow>` +// ====================== + +// * * * * * * * // + +// 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 = ( + <Permalink + className='notification__display-name' + href={account.get('url')} + title={account.get('acct')} + to={`/accounts/${account.get('id')}`} + dangerouslySetInnerHTML={{ __html: displayName }} + /> + ); + + // Renders. + return ( + <div className='notification notification-follow'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-user-plus' /> + </div> + + <FormattedMessage + id='notification.follow' + defaultMessage='{name} followed you' + values={{ name: link }} + /> + </div> + + <AccountContainer id={account.get('id')} withNote={false} /> + <NotificationOverlayContainer notification={notification} /> + </div> + ); + } + +} diff --git a/app/javascript/glitch/components/notification/index.js b/app/javascript/glitch/components/notification/index.js new file mode 100644 index 000000000..b2e55aad5 --- /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 ( + <NotificationFollow + id={notification.get('id')} + account={notification.get('account')} + notification={notification} + /> + ); + } + + renderMention (notification) { + return ( + <StatusContainer + id={notification.get('status')} + notification={notification} + withDismiss + /> + ); + } + + renderFavourite (notification) { + return ( + <StatusContainer + id={notification.get('status')} + account={notification.get('account')} + prepend='favourite' + muted + notification={notification} + withDismiss + /> + ); + } + + renderReblog (notification) { + return ( + <StatusContainer + id={notification.get('status')} + account={notification.get('account')} + prepend='reblog' + muted + notification={notification} + withDismiss + /> + ); + } + + 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 000000000..089f615f0 --- /dev/null +++ b/app/javascript/glitch/components/notification/overlay/container.js @@ -0,0 +1,49 @@ +/* + +`<NotificationOverlayContainer>` +========================= + +This container connects `<NotificationOverlay>`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 000000000..aaca95cac --- /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 ? ( + <div + aria-label={label} + role='checkbox' + aria-checked={active} + tabIndex={0} + className={`notification__dismiss-overlay ${active ? 'active' : ''}`} + onClick={this.onToggleMark} + > + <div className='wrappy'> + <div className='ckbox' aria-hidden='true' title={label}> + {active ? (<i className='fa fa-check' />) : ''} + </div> + </div> + </div> + ) : 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 000000000..34588b008 --- /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' && ( + <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} /> + ); + + return ( + <div className='status__action-bar'> + <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /> + <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> + <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> + {shareButton} + + <div className='status__action-bar-dropdown'> + <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' /> + </div> + + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> + </div> + ); + } + +} diff --git a/app/javascript/glitch/components/status/container.js b/app/javascript/glitch/components/status/container.js new file mode 100644 index 000000000..0054abd14 --- /dev/null +++ b/app/javascript/glitch/components/status/container.js @@ -0,0 +1,263 @@ +/* + +`<StatusContainer>` +=================== + +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 <Status>. + +*/ + + /* * * * */ + +/* + +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 `<Status>` 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 `<Status>`. + +*/ + + 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: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + 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 000000000..06015619b --- /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 => ( + <Permalink + to={`/accounts/${item.get('id')}`} + href={item.get('url')} + key={item.get('id')} + className='mention' + > + @<span>{item.get('username')}</span> + </Permalink> + )).reduce((aggregate, item) => [...aggregate, item, ' '], []); + + const toggleText = hidden ? [ + <FormattedMessage + id='status.show_more' + defaultMessage='Show more' + key='0' + />, + mediaIcon ? ( + <i + className={ + `fa fa-fw fa-${mediaIcon} status__content__spoiler-icon` + } + aria-hidden='true' + key='1' + /> + ) : null, + ] : [ + <FormattedMessage + id='status.show_less' + defaultMessage='Show less' + key='0' + />, + ]; + + if (hidden) { + mentionsPlaceholder = <div>{mentionLinks}</div>; + } + + return ( + <div className={classNames}> + <p + style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + > + <span dangerouslySetInnerHTML={spoilerContent} /> + {' '} + <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}> + {toggleText} + </button> + </p> + + {mentionsPlaceholder} + + <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}> + <div + ref={this.setRef} + style={directionStyle} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + dangerouslySetInnerHTML={content} + /> + {media} + </div> + + </div> + ); + } else if (parseClick) { + return ( + <div + className={classNames} + style={directionStyle} + > + <div + ref={this.setRef} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + dangerouslySetInnerHTML={content} + /> + {media} + </div> + ); + } else { + return ( + <div + className='status__content' + style={directionStyle} + > + <div ref={this.setRef} dangerouslySetInnerHTML={content} /> + {media} + </div> + ); + } + } + +} diff --git a/app/javascript/glitch/components/status/gallery/index.js b/app/javascript/glitch/components/status/gallery/index.js new file mode 100644 index 000000000..ae03dc08d --- /dev/null +++ b/app/javascript/glitch/components/status/gallery/index.js @@ -0,0 +1,79 @@ +// Package imports // +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +// Mastodon imports // +import IconButton from '../../../../mastodon/components/icon_button'; + +// Our imports // +import StatusGalleryItem from './item'; + +const messages = defineMessages({ + toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, +}); + +@injectIntl +export default class StatusGallery extends React.PureComponent { + + static propTypes = { + sensitive: PropTypes.bool, + media: ImmutablePropTypes.list.isRequired, + letterbox: PropTypes.bool, + fullwidth: PropTypes.bool, + height: PropTypes.number.isRequired, + onOpenMedia: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + autoPlayGif: PropTypes.bool.isRequired, + }; + + state = { + visible: !this.props.sensitive, + }; + + handleOpen = () => { + this.setState({ visible: !this.state.visible }); + } + + handleClick = (index) => { + this.props.onOpenMedia(this.props.media, index); + } + + render () { + const { media, intl, sensitive, letterbox, fullwidth } = this.props; + + let children; + + if (!this.state.visible) { + let warning; + + if (sensitive) { + warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; + } else { + warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />; + } + + children = ( + <div role='button' tabIndex='0' className='media-spoiler' onClick={this.handleOpen}> + <span className='media-spoiler__warning'>{warning}</span> + <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } else { + const size = media.take(4).size; + children = media.take(4).map((attachment, i) => <StatusGalleryItem key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} letterbox={letterbox} />); + } + + return ( + <div className={`media-gallery ${fullwidth ? 'full-width' : ''}`} style={{ height: `${this.props.height}px` }}> + <div className={`spoiler-button ${this.state.visible ? 'spoiler-button--visible' : ''}`}> + <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> + </div> + + {children} + </div> + ); + } + +} diff --git a/app/javascript/glitch/components/status/gallery/item.js b/app/javascript/glitch/components/status/gallery/item.js new file mode 100644 index 000000000..7fcc14377 --- /dev/null +++ b/app/javascript/glitch/components/status/gallery/item.js @@ -0,0 +1,158 @@ +// Package imports // +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; + +// Mastodon imports // +import { isIOS } from '../../../../mastodon/is_mobile'; + +export default class StatusGalleryItem extends React.PureComponent { + + static propTypes = { + attachment: ImmutablePropTypes.map.isRequired, + index: PropTypes.number.isRequired, + size: PropTypes.number.isRequired, + letterbox: PropTypes.bool, + onClick: PropTypes.func.isRequired, + autoPlayGif: PropTypes.bool.isRequired, + }; + + handleMouseEnter = (e) => { + if (this.hoverToPlay()) { + e.target.play(); + } + } + + handleMouseLeave = (e) => { + if (this.hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; + } + } + + hoverToPlay () { + const { attachment, autoPlayGif } = this.props; + return !autoPlayGif && attachment.get('type') === 'gifv'; + } + + handleClick = (e) => { + const { index, onClick } = this.props; + + if (e.button === 0) { + e.preventDefault(); + onClick(index); + } + + e.stopPropagation(); + } + + render () { + const { attachment, index, size, letterbox } = this.props; + + let width = 50; + let height = 100; + let top = 'auto'; + let left = 'auto'; + let bottom = 'auto'; + let right = 'auto'; + + if (size === 1) { + width = 100; + } + + if (size === 4 || (size === 3 && index > 0)) { + height = 50; + } + + if (size === 2) { + if (index === 0) { + right = '2px'; + } else { + left = '2px'; + } + } else if (size === 3) { + if (index === 0) { + right = '2px'; + } else if (index > 0) { + left = '2px'; + } + + if (index === 1) { + bottom = '2px'; + } else if (index > 1) { + top = '2px'; + } + } else if (size === 4) { + if (index === 0 || index === 2) { + right = '2px'; + } + + if (index === 1 || index === 3) { + left = '2px'; + } + + if (index < 2) { + bottom = '2px'; + } else { + top = '2px'; + } + } + + let thumbnail = ''; + + if (attachment.get('type') === 'image') { + const previewUrl = attachment.get('preview_url'); + const previewWidth = attachment.getIn(['meta', 'small', 'width']); + + const originalUrl = attachment.get('url'); + const originalWidth = attachment.getIn(['meta', 'original', 'width']); + + const srcSet = `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`; + const sizes = `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`; + + thumbnail = ( + <a + className='media-gallery__item-thumbnail' + href={attachment.get('remote_url') || originalUrl} + onClick={this.handleClick} + target='_blank' + > + <img + className={letterbox ? 'letterbox' : ''} + src={previewUrl} srcSet={srcSet} + sizes={sizes} + alt={attachment.get('description')} + title={attachment.get('description')} + /> + </a> + ); + } else if (attachment.get('type') === 'gifv') { + const autoPlay = !isIOS() && this.props.autoPlayGif; + + thumbnail = ( + <div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}> + <video + className={`media-gallery__item-gifv-thumbnail${letterbox ? ' letterbox' : ''}`} + role='application' + src={attachment.get('url')} + onClick={this.handleClick} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + autoPlay={autoPlay} + loop + muted + /> + + <span className='media-gallery__gifv__label'>GIF</span> + </div> + ); + } + + return ( + <div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> + {thumbnail} + </div> + ); + } + +} diff --git a/app/javascript/glitch/components/status/header.js b/app/javascript/glitch/components/status/header.js new file mode 100644 index 000000000..f741950b1 --- /dev/null +++ b/app/javascript/glitch/components/status/header.js @@ -0,0 +1,146 @@ +/* + +`<StatusHeader>` +================ + +Originally a part of `<Status>`, but extracted into a separate +component for better documentation and maintainance by +@kibi@glitch.social as a part of glitch-soc/mastodon. + +*/ + +// * * * * * * * // + +// Imports +// ------- + +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl } from 'react-intl'; + +// Mastodon imports. +import Avatar from '../../../mastodon/components/avatar'; +import AvatarOverlay from '../../../mastodon/components/avatar_overlay'; +import DisplayName from '../../../mastodon/components/display_name'; +import IconButton from '../../../mastodon/components/icon_button'; +import VisibilityIcon from './visibility_icon'; + +// * * * * * * * // + +// Initial setup +// ------------- + +// Messages for use with internationalization stuff. +const messages = defineMessages({ + collapse: { id: 'status.collapse', defaultMessage: 'Collapse' }, + uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' }, + public: { id: 'privacy.public.short', defaultMessage: 'Public' }, + unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, +}); + +// * * * * * * * // + +// The component +// ------------- + +@injectIntl +export default class StatusHeader extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + friend: ImmutablePropTypes.map, + mediaIcon: PropTypes.string, + collapsible: PropTypes.bool, + collapsed: PropTypes.bool, + parseClick: PropTypes.func.isRequired, + setExpansion: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + // Handles clicks on collapsed button + handleCollapsedClick = (e) => { + const { collapsed, setExpansion } = this.props; + if (e.button === 0) { + setExpansion(collapsed ? null : false); + e.preventDefault(); + } + } + + // Handles clicks on account name/image + handleAccountClick = (e) => { + const { status, parseClick } = this.props; + parseClick(e, `/accounts/${+status.getIn(['account', 'id'])}`); + } + + // Rendering. + render () { + const { + status, + friend, + mediaIcon, + collapsible, + collapsed, + intl, + } = this.props; + + const account = status.get('account'); + + return ( + <header className='status__info'> + <a + href={account.get('url')} + target='_blank' + className='status__avatar' + onClick={this.handleAccountClick} + > + { + friend ? ( + <AvatarOverlay account={account} friend={friend} /> + ) : ( + <Avatar account={account} size={48} /> + ) + } + </a> + <a + href={account.get('url')} + target='_blank' + className='status__display-name' + onClick={this.handleAccountClick} + > + <DisplayName account={account} /> + </a> + <div className='status__info__icons'> + {mediaIcon ? ( + <i + className={`fa fa-fw fa-${mediaIcon}`} + aria-hidden='true' + /> + ) : null} + {( + <VisibilityIcon visibility={status.get('visibility')} /> + )} + {collapsible ? ( + <IconButton + className='status__collapse-button' + animate flip + active={collapsed} + title={ + collapsed ? + intl.formatMessage(messages.uncollapse) : + intl.formatMessage(messages.collapse) + } + icon='angle-double-up' + onClick={this.handleCollapsedClick} + /> + ) : null} + </div> + + </header> + ); + } + +} diff --git a/app/javascript/glitch/components/status/index.js b/app/javascript/glitch/components/status/index.js new file mode 100644 index 000000000..33a9730e5 --- /dev/null +++ b/app/javascript/glitch/components/status/index.js @@ -0,0 +1,760 @@ +/* + +`<Status>` +========== + +Original file by @gargron@mastodon.social et al as part of +tootsuite/mastodon. *Heavily* rewritten (and documented!) by +@kibi@glitch.social as a part of glitch-soc/mastodon. The following +features have been added: + + - Better separating the "guts" of statuses from their wrapper(s) + - Collapsing statuses + - Moving images inside of CWs + +A number of aspects of this original file have been split off into +their own components for better maintainance; for these, see: + + - <StatusHeader> + - <StatusPrepend> + +…And, of course, the other <Status>-related components as well. + +*/ + + /* * * * */ + +/* + +Imports: +-------- + +*/ + +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Mastodon imports // +import scheduleIdleTask from '../../../mastodon/features/ui/util/schedule_idle_task'; +import { autoPlayGif } from '../../../mastodon/initial_state'; + +// Our imports // +import StatusPrepend from './prepend'; +import StatusHeader from './header'; +import StatusContent from './content'; +import StatusActionBar from './action_bar'; +import StatusGallery from './gallery'; +import StatusPlayer from './player'; +import NotificationOverlayContainer from '../notification/overlay/container'; + + /* * * * */ + +/* + +The `<Status>` component: +------------------------- + +The `<Status>` component is a container for statuses. It consists of a +few parts: + + - The `<StatusPrepend>`, which contains tangential information about + the status, such as who reblogged it. + - The `<StatusHeader>`, which contains the avatar and username of the + status author, as well as a media icon and the "collapse" toggle. + - The `<StatusContent>`, which contains the content of the status. + - The `<StatusActionBar>`, which provides actions to be performed + on statuses, like reblogging or sending a reply. + +### Context + + - __`router` (`PropTypes.object`) :__ + We need to get our router from the surrounding React context. + +### Props + + - __`id` (`PropTypes.number`) :__ + The id of the status. + + - __`status` (`ImmutablePropTypes.map`) :__ + The status object, straight from the store. + + - __`account` (`ImmutablePropTypes.map`) :__ + Don't be confused by this one! This is **not** the account which + posted the status, but the associated account with any further + action (eg, a reblog or a favourite). + + - __`settings` (`ImmutablePropTypes.map`) :__ + These are our local settings, fetched from our store. We need this + to determine how best to collapse our statuses, among other things. + + - __`onFavourite`, `onReblog`, `onModalReblog`, `onDelete`, + `onMention`, `onMute`, `onMuteConversation`, onBlock`, `onReport`, + `onOpenMedia`, `onOpenVideo` (`PropTypes.func`) :__ + These are all functions passed through from the + `<StatusContainer>`. We don't deal with them directly here. + + - __`reblogModal`, `deleteModal` (`PropTypes.bool`) :__ + These tell whether or not the user has modals activated for + reblogging and deleting statuses. They are used by the `onReblog` + and `onDelete` functions, but we don't deal with them here. + + - __`muted` (`PropTypes.bool`) :__ + This has nothing to do with a user or conversation mute! "Muted" is + what Mastodon internally calls the subdued look of statuses in the + notifications column. This should be `true` for notifications, and + `false` otherwise. + + - __`collapse` (`PropTypes.bool`) :__ + This prop signals a directive from a higher power to (un)collapse + a status. Most of the time it should be `undefined`, in which case + we do nothing. + + - __`prepend` (`PropTypes.string`) :__ + The type of prepend: `'reblogged_by'`, `'reblog'`, or + `'favourite'`. + + - __`withDismiss` (`PropTypes.bool`) :__ + Whether or not the status can be dismissed. Used for notifications. + + - __`intersectionObserverWrapper` (`PropTypes.object`) :__ + This holds our intersection observer. In Mastodon parlance, + an "intersection" is just when the status is viewable onscreen. + +### State + + - __`isExpanded` :__ + Should be either `true`, `false`, or `null`. The meanings of + these values are as follows: + + - __`true` :__ The status contains a CW and the CW is expanded. + - __`false` :__ The status is collapsed. + - __`null` :__ The status is not collapsed or expanded. + + - __`isIntersecting` :__ + This boolean tells us whether or not the status is currently + onscreen. + + - __`isHidden` :__ + This boolean tells us if the status has been unrendered to save + CPUs. + +*/ + +export default class Status extends ImmutablePureComponent { + + static contextTypes = { + router : PropTypes.object, + }; + + static propTypes = { + id : PropTypes.string, + status : ImmutablePropTypes.map, + account : ImmutablePropTypes.map, + settings : ImmutablePropTypes.map, + notification : ImmutablePropTypes.map, + onFavourite : PropTypes.func, + onReblog : PropTypes.func, + onModalReblog : PropTypes.func, + onDelete : PropTypes.func, + onPin : PropTypes.func, + onMention : PropTypes.func, + onMute : PropTypes.func, + onMuteConversation : PropTypes.func, + onBlock : PropTypes.func, + onEmbed : PropTypes.func, + onHeightChange : PropTypes.func, + onReport : PropTypes.func, + onOpenMedia : PropTypes.func, + onOpenVideo : PropTypes.func, + reblogModal : PropTypes.bool, + deleteModal : PropTypes.bool, + muted : PropTypes.bool, + collapse : PropTypes.bool, + prepend : PropTypes.string, + withDismiss : PropTypes.bool, + intersectionObserverWrapper : PropTypes.object, + }; + + state = { + isExpanded : null, + isIntersecting : true, + isHidden : false, + markedForDelete : false, + } + +/* + +### Implementation + +#### `updateOnProps` and `updateOnStates`. + +`updateOnProps` and `updateOnStates` tell the component when to update. +We specify them explicitly because some of our props are dynamically= +generated functions, which would otherwise always trigger an update. +Of course, this means that if we add an important prop, we will need +to remember to specify it here. + +*/ + + updateOnProps = [ + 'status', + 'account', + 'settings', + 'prepend', + 'boostModal', + 'muted', + 'collapse', + 'notification', + ] + + updateOnStates = [ + 'isExpanded', + 'markedForDelete', + ] + +/* + +#### `componentWillReceiveProps()`. + +If our settings have changed to disable collapsed statuses, then we +need to make sure that we uncollapse every one. We do that by watching +for changes to `settings.collapsed.enabled` in +`componentWillReceiveProps()`. + +We also need to watch for changes on the `collapse` prop---if this +changes to anything other than `undefined`, then we need to collapse or +uncollapse our status accordingly. + +*/ + + componentWillReceiveProps (nextProps) { + if (!nextProps.settings.getIn(['collapsed', 'enabled'])) { + if (this.state.isExpanded === false) { + this.setExpansion(null); + } + } else if ( + nextProps.collapse !== this.props.collapse && + nextProps.collapse !== undefined + ) this.setExpansion(nextProps.collapse ? false : null); + } + +/* + +#### `componentDidMount()`. + +When mounting, we just check to see if our status should be collapsed, +and collapse it if so. We don't need to worry about whether collapsing +is enabled here, because `setExpansion()` already takes that into +account. + +The cases where a status should be collapsed are: + + - The `collapse` prop has been set to `true` + - The user has decided in local settings to collapse all statuses. + - The user has decided to collapse all notifications ('muted' + statuses). + - The user has decided to collapse long statuses and the status is + over 400px (without media, or 650px with). + - The status is a reply and the user has decided to collapse all + replies. + - The status contains media and the user has decided to collapse all + statuses with media. + +We also start up our intersection observer to monitor our statuses. +`componentMounted` lets us know that everything has been set up +properly and our intersection observer is good to go. + +*/ + + componentDidMount () { + const { node, handleIntersection } = this; + const { + status, + settings, + collapse, + muted, + id, + intersectionObserverWrapper, + prepend, + } = this.props; + const autoCollapseSettings = settings.getIn(['collapsed', 'auto']); + + if ( + collapse || + autoCollapseSettings.get('all') || ( + autoCollapseSettings.get('notifications') && muted + ) || ( + autoCollapseSettings.get('lengthy') && + node.clientHeight > ( + status.get('media_attachments').size && !muted ? 650 : 400 + ) + ) || ( + autoCollapseSettings.get('reblogs') && + prepend === 'reblogged_by' + ) || ( + autoCollapseSettings.get('replies') && + status.get('in_reply_to_id', null) !== null + ) || ( + autoCollapseSettings.get('media') && + !(status.get('spoiler_text').length) && + status.get('media_attachments').size + ) + ) this.setExpansion(false); + + if (!intersectionObserverWrapper) return; + else intersectionObserverWrapper.observe( + id, + node, + handleIntersection + ); + + this.componentMounted = true; + } + +/* + +#### `shouldComponentUpdate()`. + +If the status is about to be both offscreen (not intersecting) and +hidden, then we only need to update it if it's not that way currently. +If the status is moving from offscreen to onscreen, then we *have* to +re-render, so that we can unhide the element if necessary. + +If neither of these cases are true, we can leave it up to our +`updateOnProps` and `updateOnStates` arrays. + +*/ + + shouldComponentUpdate (nextProps, nextState) { + switch (true) { + case !nextState.isIntersecting && nextState.isHidden: + return this.state.isIntersecting || !this.state.isHidden; + case nextState.isIntersecting && !this.state.isIntersecting: + return true; + default: + return super.shouldComponentUpdate(nextProps, nextState); + } + } + +/* + +#### `componentDidUpdate()`. + +If our component is being rendered for any reason and an update has +triggered, this will save its height. + +This is, frankly, a bit overkill, as the only instance when we +actually *need* to update the height right now should be when the +value of `isExpanded` has changed. But it makes for more readable +code and prevents bugs in the future where the height isn't set +properly after some change. + +*/ + + componentDidUpdate () { + if ( + this.state.isIntersecting || !this.state.isHidden + ) this.saveHeight(); + } + +/* + +#### `componentWillUnmount()`. + +If our component is about to unmount, then we'd better unset +`this.componentMounted`. + +*/ + + componentWillUnmount () { + this.componentMounted = false; + } + +/* + +#### `handleIntersection()`. + +`handleIntersection()` either hides the status (if it is offscreen) or +unhides it (if it is onscreen). It's called by +`intersectionObserverWrapper.observe()`. + +If our status isn't intersecting, we schedule an idle task (using the +aptly-named `scheduleIdleTask()`) to hide the status at the next +available opportunity. + +tootsuite/mastodon left us with the following enlightening comment +regarding this function: + +> Edge 15 doesn't support isIntersecting, but we can infer it + +It then implements a polyfill (intersectionRect.height > 0) which isn't +actually sufficient. The short answer is, this behaviour isn't really +supported on Edge but we can get kinda close. + +*/ + + handleIntersection = (entry) => { + const isIntersecting = ( + typeof entry.isIntersecting === 'boolean' ? + entry.isIntersecting : + entry.intersectionRect.height > 0 + ); + this.setState( + (prevState) => { + if (prevState.isIntersecting && !isIntersecting) { + scheduleIdleTask(this.hideIfNotIntersecting); + } + return { + isIntersecting : isIntersecting, + isHidden : false, + }; + } + ); + } + +/* + +#### `hideIfNotIntersecting()`. + +This function will hide the status if we're still not intersecting. +Hiding the status means that it will just render an empty div instead +of actual content, which saves RAMS and CPUs or some such. + +*/ + + hideIfNotIntersecting = () => { + if (!this.componentMounted) return; + this.setState( + (prevState) => ({ isHidden: !prevState.isIntersecting }) + ); + } + +/* + +#### `saveHeight()`. + +`saveHeight()` saves the height of our status so that when whe hide it +we preserve its dimensions. We only want to store our height, though, +if our status has content (otherwise, it would imply that it is +already hidden). + +*/ + + saveHeight = () => { + if (this.node && this.node.children.length) { + this.height = this.node.getBoundingClientRect().height; + } + } + +/* + +#### `setExpansion()`. + +`setExpansion()` sets the value of `isExpanded` in our state. It takes +one argument, `value`, which gives the desired value for `isExpanded`. +The default for this argument is `null`. + +`setExpansion()` automatically checks for us whether toot collapsing +is enabled, so we don't have to. + +We use a `switch` statement to simplify our code. + +*/ + + setExpansion = (value) => { + switch (true) { + case value === undefined || value === null: + this.setState({ isExpanded: null }); + break; + case !value && this.props.settings.getIn(['collapsed', 'enabled']): + this.setState({ isExpanded: false }); + break; + case !!value: + this.setState({ isExpanded: true }); + break; + } + } + +/* + +#### `handleRef()`. + +`handleRef()` just saves a reference to our status node to `this.node`. +It also saves our height, in case the height of our node has changed. + +*/ + + handleRef = (node) => { + this.node = node; + this.saveHeight(); + } + +/* + +#### `parseClick()`. + +`parseClick()` takes a click event and responds appropriately. +If our status is collapsed, then clicking on it should uncollapse it. +If `Shift` is held, then clicking on it should collapse it. +Otherwise, we open the url handed to us in `destination`, if +applicable. + +*/ + + parseClick = (e, destination) => { + const { router } = this.context; + const { status } = this.props; + const { isExpanded } = this.state; + if (!router) return; + if (destination === undefined) { + destination = `/statuses/${ + status.getIn(['reblog', 'id'], status.get('id')) + }`; + } + if (e.button === 0) { + if (isExpanded === false) this.setExpansion(null); + else if (e.shiftKey) { + this.setExpansion(false); + document.getSelection().removeAllRanges(); + } else router.history.push(destination); + e.preventDefault(); + } + } + +/* + +#### `render()`. + +`render()` actually puts our element on the screen. The particulars of +this operation are further explained in the code below. + +*/ + + render () { + const { + parseClick, + setExpansion, + saveHeight, + handleRef, + } = this; + const { router } = this.context; + const { + status, + account, + settings, + collapsed, + muted, + prepend, + intersectionObserverWrapper, + onOpenVideo, + onOpenMedia, + notification, + ...other + } = this.props; + const { isExpanded, isIntersecting, isHidden } = this.state; + let background = null; + let attachments = null; + let media = null; + let mediaIcon = null; + +/* + +If we don't have a status, then we don't render anything. + +*/ + + if (status === null) { + return null; + } + +/* + +If our status is offscreen and hidden, then we render an empty <div> in +its place. We fill it with "content" but note that opacity is set to 0. + +*/ + + if (!isIntersecting && isHidden) { + return ( + <div + ref={this.handleRef} + data-id={status.get('id')} + style={{ + height : `${this.height}px`, + opacity : 0, + overflow : 'hidden', + }} + > + { + status.getIn(['account', 'display_name']) || + status.getIn(['account', 'username']) + } + {status.get('content')} + </div> + ); + } + +/* + +If user backgrounds for collapsed statuses are enabled, then we +initialize our background accordingly. This will only be rendered if +the status is collapsed. + +*/ + + if ( + settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds']) + ) background = status.getIn(['account', 'header']); + +/* + +This handles our media attachments. Note that we don't show media on +muted (notification) statuses. If the media type is unknown, then we +simply ignore it. + +After we have generated our appropriate media element and stored it in +`media`, we snatch the thumbnail to use as our `background` if media +backgrounds for collapsed statuses are enabled. + +*/ + + attachments = status.get('media_attachments'); + if (attachments.size && !muted) { + if (attachments.some((item) => item.get('type') === 'unknown')) { + + } else if ( + attachments.getIn([0, 'type']) === 'video' + ) { + media = ( // Media type is 'video' + <StatusPlayer + media={attachments.get(0)} + sensitive={status.get('sensitive')} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + height={250} + onOpenVideo={onOpenVideo} + /> + ); + mediaIcon = 'video-camera'; + } else { // Media type is 'image' or 'gifv' + media = ( + <StatusGallery + media={attachments} + sensitive={status.get('sensitive')} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + height={250} + onOpenMedia={onOpenMedia} + autoPlayGif={autoPlayGif} + /> + ); + mediaIcon = 'picture-o'; + } + + if ( + !status.get('sensitive') && + !(status.get('spoiler_text').length > 0) && + settings.getIn(['collapsed', 'backgrounds', 'preview_images']) + ) background = attachments.getIn([0, 'preview_url']); + } + +/* + +Here we prepare extra data-* attributes for CSS selectors. +Users can use those for theming, hiding avatars etc via UserStyle + +*/ + + const selectorAttribs = { + 'data-status-by': `@${status.getIn(['account', 'acct'])}`, + }; + + if (prepend && account) { + const notifKind = { + favourite: 'favourited', + reblog: 'boosted', + reblogged_by: 'boosted', + }[prepend]; + + selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`; + } + +/* + +Finally, we can render our status. We just put the pieces together +from above. We only render the action bar if the status isn't +collapsed. + +*/ + + return ( + <article + className={ + `status${ + muted ? ' muted' : '' + } status-${status.get('visibility')}${ + isExpanded === false ? ' collapsed' : '' + }${ + isExpanded === false && background ? ' has-background' : '' + }${ + this.state.markedForDelete ? ' marked-for-delete' : '' + }` + } + style={{ + backgroundImage: ( + isExpanded === false && background ? + `url(${background})` : + 'none' + ), + }} + ref={handleRef} + {...selectorAttribs} + > + {prepend && account ? ( + <StatusPrepend + type={prepend} + account={account} + parseClick={parseClick} + notificationId={this.props.notificationId} + /> + ) : null} + <StatusHeader + status={status} + friend={account} + mediaIcon={mediaIcon} + collapsible={settings.getIn(['collapsed', 'enabled'])} + collapsed={isExpanded === false} + parseClick={parseClick} + setExpansion={setExpansion} + /> + <StatusContent + status={status} + media={media} + mediaIcon={mediaIcon} + expanded={isExpanded} + setExpansion={setExpansion} + onHeightUpdate={saveHeight} + parseClick={parseClick} + disabled={!router} + /> + {isExpanded !== false ? ( + <StatusActionBar + {...other} + status={status} + account={status.get('account')} + /> + ) : null} + {notification ? ( + <NotificationOverlayContainer + notification={notification} + /> + ) : null} + </article> + ); + + } + +} diff --git a/app/javascript/glitch/components/status/player.js b/app/javascript/glitch/components/status/player.js new file mode 100644 index 000000000..cc65cd34e --- /dev/null +++ b/app/javascript/glitch/components/status/player.js @@ -0,0 +1,203 @@ +// Package imports // +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +// Mastodon imports // +import IconButton from '../../../mastodon/components/icon_button'; +import { isIOS } from '../../../mastodon/is_mobile'; + +const messages = defineMessages({ + toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }, + toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' }, + expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' }, +}); + +@injectIntl +export default class StatusPlayer extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + letterbox: PropTypes.bool, + fullwidth: PropTypes.bool, + height: PropTypes.number, + sensitive: PropTypes.bool, + intl: PropTypes.object.isRequired, + autoplay: PropTypes.bool, + onOpenVideo: PropTypes.func.isRequired, + }; + + static defaultProps = { + height: 110, + }; + + state = { + visible: !this.props.sensitive, + preview: true, + muted: true, + hasAudio: true, + videoError: false, + }; + + handleClick = () => { + this.setState({ muted: !this.state.muted }); + } + + handleVideoClick = (e) => { + e.stopPropagation(); + + const node = this.video; + + if (node.paused) { + node.play(); + } else { + node.pause(); + } + } + + handleOpen = () => { + this.setState({ preview: !this.state.preview }); + } + + handleVisibility = () => { + this.setState({ + visible: !this.state.visible, + preview: true, + }); + } + + handleExpand = () => { + this.video.pause(); + this.props.onOpenVideo(this.props.media, this.video.currentTime); + } + + setRef = (c) => { + this.video = c; + } + + handleLoadedData = () => { + if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) { + this.setState({ hasAudio: false }); + } + } + + handleVideoError = () => { + this.setState({ videoError: true }); + } + + componentDidMount () { + if (!this.video) { + return; + } + + this.video.addEventListener('loadeddata', this.handleLoadedData); + this.video.addEventListener('error', this.handleVideoError); + } + + componentDidUpdate () { + if (!this.video) { + return; + } + + this.video.addEventListener('loadeddata', this.handleLoadedData); + this.video.addEventListener('error', this.handleVideoError); + } + + componentWillUnmount () { + if (!this.video) { + return; + } + + this.video.removeEventListener('loadeddata', this.handleLoadedData); + this.video.removeEventListener('error', this.handleVideoError); + } + + render () { + const { media, intl, letterbox, fullwidth, height, sensitive, autoplay } = this.props; + + let spoilerButton = ( + <div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}> + <IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} /> + </div> + ); + + let expandButton = !this.context.router ? '' : ( + <div className='status__video-player-expand'> + <IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} /> + </div> + ); + + let muteButton = ''; + + if (this.state.hasAudio) { + muteButton = ( + <div className='status__video-player-mute'> + <IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /> + </div> + ); + } + + if (!this.state.visible) { + if (sensitive) { + return ( + <div role='button' tabIndex='0' style={{ height: `${height}px` }} className={`media-spoiler ${fullwidth ? 'full-width' : ''}`} onClick={this.handleVisibility}> + {spoilerButton} + <span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } else { + return ( + <div role='button' tabIndex='0' style={{ height: `${height}px` }} className={`media-spoiler ${fullwidth ? 'full-width' : ''}`} onClick={this.handleVisibility}> + {spoilerButton} + <span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> + <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } + } + + if (this.state.preview && !autoplay) { + return ( + <div role='button' tabIndex='0' className={`media-spoiler-video ${fullwidth ? 'full-width' : ''}`} style={{ height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}> + {spoilerButton} + <div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div> + </div> + ); + } + + if (this.state.videoError) { + return ( + <div style={{ height: `${height}px` }} className='video-error-cover' > + <span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span> + </div> + ); + } + + return ( + <div className={`status__video-player ${fullwidth ? 'full-width' : ''}`} style={{ height: `${height}px` }}> + {spoilerButton} + {muteButton} + {expandButton} + + <video + className={`status__video-player-video${letterbox ? ' letterbox' : ''}`} + role='button' + tabIndex='0' + ref={this.setRef} + src={media.get('url')} + autoPlay={!isIOS()} + loop + muted={this.state.muted} + onClick={this.handleVideoClick} + /> + </div> + ); + } + +} diff --git a/app/javascript/glitch/components/status/prepend.js b/app/javascript/glitch/components/status/prepend.js new file mode 100644 index 000000000..8c0aed0f4 --- /dev/null +++ b/app/javascript/glitch/components/status/prepend.js @@ -0,0 +1,159 @@ +/* + +`<StatusPrepend>` +================= + +Originally a part of `<Status>`, but extracted into a separate +component for better documentation and maintainance by +@kibi@glitch.social as a part of glitch-soc/mastodon. + +*/ + + /* * * * */ + +/* + +Imports: +-------- + +*/ + +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; + + /* * * * */ + +/* + +The `<StatusPrepend>` component: +-------------------------------- + +The `<StatusPrepend>` component holds a status's prepend, ie the text +that says “X reblogged this,” etc. It is represented by an `<aside>` +element. + +### Props + + - __`type` (`PropTypes.string`) :__ + The type of prepend. One of `'reblogged_by'`, `'reblog'`, + `'favourite'`. + + - __`account` (`ImmutablePropTypes.map`) :__ + The account associated with the prepend. + + - __`parseClick` (`PropTypes.func.isRequired`) :__ + Our click parsing function. + +*/ + +export default class StatusPrepend extends React.PureComponent { + + static propTypes = { + type: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + parseClick: PropTypes.func.isRequired, + notificationId: PropTypes.number, + }; + +/* + +### Implementation + +#### `handleClick()`. + +This is just a small wrapper for `parseClick()` that gets fired when +an account link is clicked. + +*/ + + handleClick = (e) => { + const { account, parseClick } = this.props; + parseClick(e, `/accounts/${+account.get('id')}`); + } + +/* + +#### `<Message>`. + +`<Message>` is a quick functional React component which renders the +actual prepend message based on our provided `type`. First we create a +`link` for the account's name, and then use `<FormattedMessage>` to +generate the message. + +*/ + + Message = () => { + const { type, account } = this.props; + let link = ( + <a + onClick={this.handleClick} + href={account.get('url')} + className='status__display-name' + > + <b + dangerouslySetInnerHTML={{ + __html : account.get('display_name_html') || account.get('username'), + }} + /> + </a> + ); + switch (type) { + case 'reblogged_by': + return ( + <FormattedMessage + id='status.reblogged_by' + defaultMessage='{name} boosted' + values={{ name : link }} + /> + ); + case 'favourite': + return ( + <FormattedMessage + id='notification.favourite' + defaultMessage='{name} favourited your status' + values={{ name : link }} + /> + ); + case 'reblog': + return ( + <FormattedMessage + id='notification.reblog' + defaultMessage='{name} boosted your status' + values={{ name : link }} + /> + ); + } + return null; + } + +/* + +#### `render()`. + +Our `render()` is incredibly simple; we just render the icon and then +the `<Message>` inside of an <aside>. + +*/ + + render () { + const { Message } = this; + const { type } = this.props; + + return !type ? null : ( + <aside className={type === 'reblogged_by' ? 'status__prepend' : 'notification__message'}> + <div className={type === 'reblogged_by' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}> + <i + className={`fa fa-fw fa-${ + type === 'favourite' ? 'star star-icon' : 'retweet' + } status__prepend-icon`} + /> + </div> + <Message /> + </aside> + ); + } + +} diff --git a/app/javascript/glitch/components/status/visibility_icon.js b/app/javascript/glitch/components/status/visibility_icon.js new file mode 100644 index 000000000..017b69cbb --- /dev/null +++ b/app/javascript/glitch/components/status/visibility_icon.js @@ -0,0 +1,48 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + public: { id: 'privacy.public.short', defaultMessage: 'Public' }, + unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, +}); + +@injectIntl +export default class VisibilityIcon extends ImmutablePureComponent { + + static propTypes = { + visibility: PropTypes.string, + intl: PropTypes.object.isRequired, + withLabel: PropTypes.bool, + }; + + render() { + const { withLabel, visibility, intl } = this.props; + + const visibilityClass = { + public: 'globe', + unlisted: 'unlock-alt', + private: 'lock', + direct: 'envelope', + }[visibility]; + + const label = intl.formatMessage(messages[visibility]); + + const icon = (<i + className={`status__visibility-icon fa fa-fw fa-${visibilityClass}`} + title={label} + aria-hidden='true' + />); + + if (withLabel) { + return (<span style={{ whiteSpace: 'nowrap' }}>{icon} {label}</span>); + } else { + return icon; + } + } + +} diff --git a/app/javascript/glitch/locales/en.json b/app/javascript/glitch/locales/en.json new file mode 100644 index 000000000..69aa29108 --- /dev/null +++ b/app/javascript/glitch/locales/en.json @@ -0,0 +1,44 @@ +{ + "getting_started.open_source_notice": "Glitchsoc is free open source software forked from {Mastodon}. You can contribute or report issues on GitHub at {github}.", + "layout.auto": "Auto", + "layout.current_is": "Your current layout is:", + "layout.desktop": "Desktop", + "layout.mobile": "Mobile", + "navigation_bar.app_settings": "App settings", + "getting_started.onboarding": "Show me around", + "onboarding.page_one.federation": "{domain} is an 'instance' of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.welcome": "Welcome to {domain}!", + "onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}, and is compatible with any Mastodon instance or app. Glitchsoc is entirely free and open-source. You can report bugs, request features, or contribute to the code on {github}.", + "settings.auto_collapse": "Automatic collapsing", + "settings.auto_collapse_all": "Everything", + "settings.auto_collapse_lengthy": "Lengthy toots", + "settings.auto_collapse_media": "Toots with media", + "settings.auto_collapse_notifications": "Notifications", + "settings.auto_collapse_reblogs": "Boosts", + "settings.auto_collapse_replies": "Replies", + "settings.close": "Close", + "settings.collapsed_statuses": "Collapsed toots", + "settings.enable_collapsed": "Enable collapsed toots", + "settings.general": "General", + "settings.image_backgrounds": "Image backgrounds", + "settings.image_backgrounds_media": "Preview collapsed toot media", + "settings.image_backgrounds_users": "Give collapsed toots an image background", + "settings.media": "Media", + "settings.media_letterbox": "Letterbox media", + "settings.media_fullwidth": "Full-width media previews", + "settings.preferences": "User preferences", + "settings.wide_view": "Wide view (Desktop mode only)", + "settings.navbar_under": "Navbar at the bottom (Mobile only)", + "status.collapse": "Collapse", + "status.uncollapse": "Uncollapse", + + "notification.markForDeletion": "Mark for deletion", + "notifications.clear": "Clear all my notifications", + "notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?", + "notifications.marked_clear": "Clear selected notifications", + + "notification_purge.btn_all": "Select\nall", + "notification_purge.btn_none": "Select\nnone", + "notification_purge.btn_invert": "Invert\nselection", + "notification_purge.btn_apply": "Clear\nselected" +} diff --git a/app/javascript/glitch/reducers/local_settings.js b/app/javascript/glitch/reducers/local_settings.js new file mode 100644 index 000000000..03654fbe2 --- /dev/null +++ b/app/javascript/glitch/reducers/local_settings.js @@ -0,0 +1,126 @@ +/* + +`reducers/local_settings` +======================== + +> For more information on the contents of this file, please contact: +> +> - kibigo! [@kibi@glitch.social] + +This file provides our Redux reducers related to local settings. The +associated actions are: + + - __`STORE_HYDRATE` :__ + Used to hydrate the store with its initial values. + + - __`LOCAL_SETTING_CHANGE` :__ + Used to change the value of a local setting in the store. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import { Map as ImmutableMap } from 'immutable'; + +// Mastodon imports // +import { STORE_HYDRATE } from '../../mastodon/actions/store'; + +// Our imports // +import { LOCAL_SETTING_CHANGE } from '../actions/local_settings'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +initialState: +------------- + +You can see the default values for all of our local settings here. +These are only used if no previously-saved values exist. + +*/ + +const initialState = ImmutableMap({ + layout : 'auto', + stretch : true, + navbar_under : false, + side_arm : 'none', + collapsed : ImmutableMap({ + enabled : true, + auto : ImmutableMap({ + all : false, + notifications : true, + lengthy : true, + reblogs : false, + replies : false, + media : false, + }), + backgrounds : ImmutableMap({ + user_backgrounds : false, + preview_images : false, + }), + }), + media : ImmutableMap({ + letterbox : true, + fullwidth : true, + }), +}); + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Helper functions: +----------------- + +### `hydrate(state, localSettings)` + +`hydrate()` is used to hydrate the `local_settings` part of our store +with its initial values. The `state` will probably just be the +`initialState`, and the `localSettings` should be whatever we pulled +from `localStorage`. + +*/ + +const hydrate = (state, localSettings) => state.mergeDeep(localSettings); + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +`localSettings(state = initialState, action)`: +---------------------------------------------- + +This function holds our actual reducer. + +If our action is `STORE_HYDRATE`, then we call `hydrate()` with the +`local_settings` property of the provided `action.state`. + +If our action is `LOCAL_SETTING_CHANGE`, then we set `action.key` in +our state to the provided `action.value`. Note that `action.key` MUST +be an array, since we use `setIn()`. + +> __Note :__ +> We call this function `localSettings`, but its associated object +> in the store is `local_settings`. + +*/ + +export default function localSettings(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return hydrate(state, action.state.get('local_settings')); + case LOCAL_SETTING_CHANGE: + return state.setIn(action.key, action.value); + default: + return state; + } +}; diff --git a/app/javascript/glitch/util/bio_metadata.js b/app/javascript/glitch/util/bio_metadata.js new file mode 100644 index 000000000..599ec20e2 --- /dev/null +++ b/app/javascript/glitch/util/bio_metadata.js @@ -0,0 +1,331 @@ +/* + +`util/bio_metadata` +=================== + +> For more information on the contents of this file, please contact: +> +> - kibigo! [@kibi@glitch.social] + +This file provides two functions for dealing with bio metadata. The +functions are: + + - __`processBio(content)` :__ + Processes `content` to extract any frontmatter. The returned + object has two properties: `text`, which contains the text of + `content` sans-frontmatter, and `metadata`, which is an array + of key-value pairs (in two-element array format). If no + frontmatter was provided in `content`, then `metadata` will be + an empty array. + + - __`createBio(note, data)` :__ + Reverses the process in `processBio()`; takes a `note` and an + array of two-element arrays (which should give keys and values) + and outputs a string containing a well-formed bio with + frontmatter. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/*********************************************************************\ + + To my lovely code maintainers, + + The syntax recognized by the Mastodon frontend for its bio metadata + feature is a subset of that provided by the YAML 1.2 specification. + In particular, Mastodon recognizes metadata which is provided as an + implicit YAML map, where each key-value pair takes up only a single + line (no multi-line values are permitted). To simplify the level of + processing required, Mastodon metadata frontmatter has been limited + to only allow those characters in the `c-printable` set, as defined + by the YAML 1.2 specification, instead of permitting those from the + `nb-json` characters inside double-quoted strings like YAML proper. + ¶ It is important to note that Mastodon only borrows the *syntax* + of YAML, not its semantics. This is to say, Mastodon won't make any + attempt to interpret the data it receives. `true` will not become a + boolean; `56` will not be interpreted as a number. Rather, each key + and every value will be read as a string, and as a string they will + remain. The order of the pairs is unchanged, and any duplicate keys + are preserved. However, YAML escape sequences will be replaced with + the proper interpretations according to the YAML 1.2 specification. + ¶ The implementation provided below interprets `<br>` as `\n` and + allows for an open <p> tag at the beginning of the bio. It replaces + the escaped character entities `'` and `"` with single or + double quotes, respectively, prior to processing. However, no other + escaped characters are replaced, not even those which might have an + impact on the syntax otherwise. These minor allowances are provided + because the Mastodon backend will insert these things automatically + into a bio before sending it through the API, so it is important we + account for them. Aside from this, the YAML frontmatter must be the + very first thing in the bio, leading with three consecutive hyphen- + minues (`---`), and ending with the same or, alternatively, instead + with three periods (`...`). No limits have been set with respect to + the number of characters permitted in the frontmatter, although one + should note that only limited space is provided for them in the UI. + ¶ The regular expression used to check the existence of, and then + process, the YAML frontmatter has been split into a number of small + components in the code below, in the vain hope that it will be much + easier to read and to maintain. I leave it to the future readers of + this code to determine the extent of my successes in this endeavor. + + UPDATE 19 Oct 2017: We no longer allow character escapes inside our + double-quoted strings for ease of processing. We now internally use + the name "ƔAML" in our code to clarify that this is Not Quite YAML. + + Sending love + warmth eternal, + - kibigo [@kibi@glitch.social] + +\*********************************************************************/ + +/* "u" FLAG COMPATABILITY */ + +let compat_mode = false; +try { + new RegExp('.', 'u'); +} catch (e) { + compat_mode = true; +} + +/* CONVENIENCE FUNCTIONS */ + +const unirex = str => compat_mode ? new RegExp(str) : new RegExp(str, 'u'); +const rexstr = exp => '(?:' + exp.source + ')'; + +/* CHARACTER CLASSES */ + +const DOCUMENT_START = /^/; +const DOCUMENT_END = /$/; +const ALLOWED_CHAR = unirex( // `c-printable` in the YAML 1.2 spec. + compat_mode ? '[\t\n\r\x20-\x7e\x85\xa0-\ufffd]' : '[\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\u{10000}-\u{10FFFF}]' + ); +const WHITE_SPACE = /[ \t]/; +const LINE_BREAK = /\r?\n|\r|<br\s*\/?>/; +const INDICATOR = /[-?:,[\]{}&#*!|>'"%@`]/; +const FLOW_CHAR = /[,[\]{}]/; + +/* NEGATED CHARACTER CLASSES */ + +const NOT_WHITE_SPACE = unirex('(?!' + rexstr(WHITE_SPACE) + ')[^]'); +const NOT_LINE_BREAK = unirex('(?!' + rexstr(LINE_BREAK) + ')[^]'); +const NOT_INDICATOR = unirex('(?!' + rexstr(INDICATOR) + ')[^]'); +const NOT_FLOW_CHAR = unirex('(?!' + rexstr(FLOW_CHAR) + ')[^]'); +const NOT_ALLOWED_CHAR = unirex( + '(?!' + rexstr(ALLOWED_CHAR) + ')[^]' +); + +/* BASIC CONSTRUCTS */ + +const ANY_WHITE_SPACE = unirex(rexstr(WHITE_SPACE) + '*'); +const ANY_ALLOWED_CHARS = unirex(rexstr(ALLOWED_CHAR) + '*'); +const NEW_LINE = unirex( + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) +); +const SOME_NEW_LINES = unirex( + '(?:' + rexstr(NEW_LINE) + ')+' +); +const POSSIBLE_STARTS = unirex( + rexstr(DOCUMENT_START) + rexstr(/<p[^<>]*>/) + '?' +); +const POSSIBLE_ENDS = unirex( + rexstr(SOME_NEW_LINES) + '|' + + rexstr(DOCUMENT_END) + '|' + + rexstr(/<\/p>/) +); +const QUOTE_CHAR = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')[^"]' +); +const ANY_QUOTE_CHAR = unirex( + rexstr(QUOTE_CHAR) + '*' +); + +const ESCAPED_APOS = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/) +); +const ANY_ESCAPED_APOS = unirex( + rexstr(ESCAPED_APOS) + '*' +); +const FIRST_KEY_CHAR = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + rexstr(NOT_INDICATOR) + '|' + + rexstr(/[?:-]/) + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + '(?=' + rexstr(NOT_FLOW_CHAR) + ')' +); +const FIRST_VALUE_CHAR = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + rexstr(NOT_INDICATOR) + '|' + + rexstr(/[?:-]/) + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + // Flow indicators are allowed in values. +); +const LATER_KEY_CHAR = unirex( + rexstr(WHITE_SPACE) + '|' + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + '(?=' + rexstr(NOT_FLOW_CHAR) + ')' + + rexstr(/[^:#]#?/) + '|' + + rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +); +const LATER_VALUE_CHAR = unirex( + rexstr(WHITE_SPACE) + '|' + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + // Flow indicators are allowed in values. + rexstr(/[^:#]#?/) + '|' + + rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +); + +/* YAML CONSTRUCTS */ + +const ƔAML_START = unirex( + rexstr(ANY_WHITE_SPACE) + '---' +); +const ƔAML_END = unirex( + rexstr(ANY_WHITE_SPACE) + '(?:---|\.\.\.)' +); +const ƔAML_LOOKAHEAD = unirex( + '(?=' + + rexstr(ƔAML_START) + + rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) + + rexstr(ƔAML_END) + rexstr(POSSIBLE_ENDS) + + ')' +); +const ƔAML_DOUBLE_QUOTE = unirex( + '"' + rexstr(ANY_QUOTE_CHAR) + '"' +); +const ƔAML_SINGLE_QUOTE = unirex( + '\'' + rexstr(ANY_ESCAPED_APOS) + '\'' +); +const ƔAML_SIMPLE_KEY = unirex( + rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*' +); +const ƔAML_SIMPLE_VALUE = unirex( + rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*' +); +const ƔAML_KEY = unirex( + rexstr(ƔAML_DOUBLE_QUOTE) + '|' + + rexstr(ƔAML_SINGLE_QUOTE) + '|' + + rexstr(ƔAML_SIMPLE_KEY) +); +const ƔAML_VALUE = unirex( + rexstr(ƔAML_DOUBLE_QUOTE) + '|' + + rexstr(ƔAML_SINGLE_QUOTE) + '|' + + rexstr(ƔAML_SIMPLE_VALUE) +); +const ƔAML_SEPARATOR = unirex( + rexstr(ANY_WHITE_SPACE) + + ':' + rexstr(WHITE_SPACE) + + rexstr(ANY_WHITE_SPACE) +); +const ƔAML_LINE = unirex( + '(' + rexstr(ƔAML_KEY) + ')' + + rexstr(ƔAML_SEPARATOR) + + '(' + rexstr(ƔAML_VALUE) + ')' +); + +/* FRONTMATTER REGEX */ + +const ƔAML_FRONTMATTER = unirex( + rexstr(POSSIBLE_STARTS) + + rexstr(ƔAML_LOOKAHEAD) + + rexstr(ƔAML_START) + rexstr(SOME_NEW_LINES) + + '(?:' + + rexstr(ANY_WHITE_SPACE) + rexstr(ƔAML_LINE) + rexstr(SOME_NEW_LINES) + + '){0,5}' + + rexstr(ƔAML_END) + rexstr(POSSIBLE_ENDS) +); + +/* SEARCHES */ + +const FIND_ƔAML_LINE = unirex( + rexstr(NEW_LINE) + rexstr(ANY_WHITE_SPACE) + rexstr(ƔAML_LINE) +); + +/* STRING PROCESSING */ + +function processString (str) { + switch (str.charAt(0)) { + case '"': + return str.substring(1, str.length - 1); + case '\'': + return str + .substring(1, str.length - 1) + .replace(/''/g, '\''); + default: + return str; + } +} + +/* BIO PROCESSING */ + +export function processBio(content) { + content = content.replace(/"/g, '"').replace(/'/g, '\''); + let result = { + text: content, + metadata: [], + }; + let ɣaml = content.match(ƔAML_FRONTMATTER); + if (!ɣaml) { + return result; + } else { + ɣaml = ɣaml[0]; + } + const start = content.search(ƔAML_START); + const end = start + ɣaml.length - ɣaml.search(ƔAML_START); + result.text = content.substr(end); + let metadata = null; + let query = new RegExp(rexstr(FIND_ƔAML_LINE), 'g'); // Some browsers don't allow flags unless both args are strings + while ((metadata = query.exec(ɣaml))) { + result.metadata.push([ + processString(metadata[1]), + processString(metadata[2]), + ]); + } + return result; +} + +/* BIO CREATION */ + +export function createBio(note, data) { + if (!note) note = ''; + let frontmatter = ''; + if ((data && data.length) || note.match(/^\s*---\s+/)) { + if (!data) frontmatter = '---\n...\n'; + else { + frontmatter += '---\n'; + for (let i = 0; i < data.length; i++) { + let key = '' + data[i][0]; + let val = '' + data[i][1]; + + // Key processing + if (key === (key.match(ƔAML_SIMPLE_KEY) || [])[0]) /* do nothing */; + else if (key === (key.match(ANY_QUOTE_CHAR) || [])[0]) key = '"' + key + '"'; + else { + key = key + .replace(/'/g, '\'\'') + .replace(new RegExp(rexstr(NOT_ALLOWED_CHAR), compat_mode ? 'g' : 'gu'), '�'); + key = '\'' + key + '\''; + } + + // Value processing + if (val === (val.match(ƔAML_SIMPLE_VALUE) || [])[0]) /* do nothing */; + else if (val === (val.match(ANY_QUOTE_CHAR) || [])[0]) val = '"' + val + '"'; + else { + key = key + .replace(/'/g, '\'\'') + .replace(new RegExp(rexstr(NOT_ALLOWED_CHAR), compat_mode ? 'g' : 'gu'), '�'); + key = '\'' + key + '\''; + } + + frontmatter += key + ': ' + val + '\n'; + } + frontmatter += '...\n'; + } + } + return frontmatter + note; +} diff --git a/app/javascript/images/mastodon-getting-started.png b/app/javascript/images/mastodon-getting-started.png index e05dd493f..8fe0df76a 100644 --- a/app/javascript/images/mastodon-getting-started.png +++ b/app/javascript/images/mastodon-getting-started.png Binary files differdiff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index fbaebf786..f63325658 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -105,12 +105,13 @@ export function fetchAccountFail(id, error) { }; }; -export function followAccount(id) { +export function followAccount(id, reblogs = true) { return (dispatch, getState) => { + const alreadyFollowing = getState().getIn(['relationships', id, 'following']); dispatch(followAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => { - dispatch(followAccountSuccess(response.data)); + api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => { + dispatch(followAccountSuccess(response.data, alreadyFollowing)); }).catch(error => { dispatch(followAccountFail(error)); }); @@ -136,10 +137,11 @@ export function followAccountRequest(id) { }; }; -export function followAccountSuccess(relationship) { +export function followAccountSuccess(relationship, alreadyFollowing) { return { type: ACCOUNT_FOLLOW_SUCCESS, relationship, + alreadyFollowing, }; }; diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 8a35049b3..3ee9e1e7b 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -8,6 +8,7 @@ import { refreshHomeTimeline, refreshCommunityTimeline, refreshPublicTimeline, + refreshDirectTimeline, } from './timelines'; export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; @@ -31,6 +32,7 @@ export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; +export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE'; export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; @@ -44,6 +46,8 @@ export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST' export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; +export const COMPOSE_DOODLE_SET = 'COMPOSE_DOODLE_SET'; + export function changeCompose(text) { return { type: COMPOSE_CHANGE, @@ -91,14 +95,16 @@ export function mentionCompose(account, router) { export function submitCompose() { return function (dispatch, getState) { - const status = getState().getIn(['compose', 'text'], ''); + let status = getState().getIn(['compose', 'text'], ''); if (!status || !status.length) { return; } dispatch(submitComposeRequest()); - + if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) { + status = status + ' 👁️'; + } api(getState).post('/api/v1/statuses', { status, in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), @@ -128,6 +134,8 @@ export function submitCompose() { if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { insertOrRefresh('community', refreshCommunityTimeline); insertOrRefresh('public', refreshPublicTimeline); + } else if (response.data.visibility === 'direct') { + insertOrRefresh('direct', refreshDirectTimeline); } }).catch(function (error) { dispatch(submitComposeFail(error)); @@ -155,6 +163,13 @@ export function submitComposeFail(error) { }; }; +export function doodleSet(options) { + return { + type: COMPOSE_DOODLE_SET, + options: options, + }; +}; + export function uploadCompose(files) { return function (dispatch, getState) { if (getState().getIn(['compose', 'media_attachments']).size > 3) { @@ -334,6 +349,13 @@ export function unmountCompose() { }; }; +export function toggleComposeAdvancedOption(option) { + return { + type: COMPOSE_ADVANCED_OPTIONS_CHANGE, + option: option, + }; +} + export function changeComposeSensitivity() { return { type: COMPOSE_SENSITIVITY_CHANGE, diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index b24ac8b73..4a4462e1d 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -6,6 +6,17 @@ import { defineMessages } from 'react-intl'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; +// tracking the notif cleaning request +export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST'; +export const NOTIFICATIONS_DELETE_MARKED_SUCCESS = 'NOTIFICATIONS_DELETE_MARKED_SUCCESS'; +export const NOTIFICATIONS_DELETE_MARKED_FAIL = 'NOTIFICATIONS_DELETE_MARKED_FAIL'; +export const NOTIFICATIONS_MARK_ALL_FOR_DELETE = 'NOTIFICATIONS_MARK_ALL_FOR_DELETE'; +export const NOTIFICATIONS_ENTER_CLEARING_MODE = 'NOTIFICATIONS_ENTER_CLEARING_MODE'; // arg: yes +// Unmark notifications (when the cleaning mode is left) +export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR_DELETE'; +// Mark one for delete +export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE'; + export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST'; export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS'; export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL'; @@ -188,3 +199,67 @@ export function scrollTopNotifications(top) { top, }; }; + +export function deleteMarkedNotifications() { + return (dispatch, getState) => { + dispatch(deleteMarkedNotificationsRequest()); + + let ids = []; + getState().getIn(['notifications', 'items']).forEach((n) => { + if (n.get('markedForDelete')) { + ids.push(n.get('id')); + } + }); + + if (ids.length === 0) { + return; + } + + api(getState).delete(`/api/v1/notifications/destroy_multiple?ids[]=${ids.join('&ids[]=')}`).then(() => { + dispatch(deleteMarkedNotificationsSuccess()); + }).catch(error => { + console.error(error); + dispatch(deleteMarkedNotificationsFail(error)); + }); + }; +}; + +export function enterNotificationClearingMode(yes) { + return { + type: NOTIFICATIONS_ENTER_CLEARING_MODE, + yes: yes, + }; +}; + +export function markAllNotifications(yes) { + return { + type: NOTIFICATIONS_MARK_ALL_FOR_DELETE, + yes: yes, // true, false or null. null = invert + }; +}; + +export function deleteMarkedNotificationsRequest() { + return { + type: NOTIFICATIONS_DELETE_MARKED_REQUEST, + }; +}; + +export function deleteMarkedNotificationsFail() { + return { + type: NOTIFICATIONS_DELETE_MARKED_FAIL, + }; +}; + +export function markNotificationForDelete(id, yes) { + return { + type: NOTIFICATION_MARK_FOR_DELETE, + id: id, + yes: yes, + }; +}; + +export function deleteMarkedNotificationsSuccess() { + return { + type: NOTIFICATIONS_DELETE_MARKED_SUCCESS, + }; +}; diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index dcce048ca..e60ddacd9 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -51,3 +51,4 @@ export const connectCommunityStream = () => connectTimelineStream('community', ' export const connectMediaStream = () => connectTimelineStream('community', 'public:local'); export const connectPublicStream = () => connectTimelineStream('public', 'public'); export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`); +export const connectDirectStream = () => connectTimelineStream('direct', 'direct'); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 09abe2702..935bbb6f0 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -115,6 +115,7 @@ export function refreshTimeline(timelineId, path, params = {}) { export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home'); export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public'); export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true }); +export const refreshDirectTimeline = () => refreshTimeline('direct', '/api/v1/timelines/direct'); export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); @@ -155,6 +156,7 @@ export function expandTimeline(timelineId, path, params = {}) { export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home'); export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public'); export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true }); +export const expandDirectTimeline = () => expandTimeline('direct', '/api/v1/timelines/direct'); export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap index 76ab3374a..4005c860f 100644 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap @@ -3,6 +3,7 @@ exports[`<Avatar /> Autoplay renders a animated avatar 1`] = ` <div className="account__avatar" + data-avatar-of="@alice" onMouseEnter={[Function]} onMouseLeave={[Function]} style={ @@ -19,6 +20,7 @@ exports[`<Avatar /> Autoplay renders a animated avatar 1`] = ` exports[`<Avatar /> Still renders a still avatar 1`] = ` <div className="account__avatar" + data-avatar-of="@alice" onMouseEnter={[Function]} onMouseLeave={[Function]} style={ diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap index d59fee42f..d9e5e5252 100644 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap @@ -6,6 +6,7 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = ` > <div className="account__avatar-overlay-base" + data-avatar-of="@alice" style={ Object { "backgroundImage": "url(/static/alice.jpg)", @@ -14,6 +15,7 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = ` /> <div className="account__avatar-overlay-overlay" + data-avatar-of="@eve@blackhat.lair" style={ Object { "backgroundImage": "url(/static/eve.jpg)", diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap index c3f018d90..707cbf673 100644 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap @@ -112,3 +112,19 @@ exports[`<Button /> renders the props.text instead of children 1`] = ` foo </button> `; + +exports[`<Button /> renders title if props.title is given 1`] = ` +<button + className="button" + disabled={undefined} + onClick={[Function]} + style={ + Object { + "height": "36px", + "lineHeight": "36px", + "padding": "0 16px", + } + } + title="foo" +/> +`; diff --git a/app/javascript/mastodon/components/__tests__/button-test.js b/app/javascript/mastodon/components/__tests__/button-test.js index 160cd3cbc..924ba39dc 100644 --- a/app/javascript/mastodon/components/__tests__/button-test.js +++ b/app/javascript/mastodon/components/__tests__/button-test.js @@ -72,4 +72,11 @@ describe('<Button />', () => { expect(tree).toMatchSnapshot(); }); + + it('renders title if props.title is given', () => { + const component = renderer.create(<Button title='foo' />); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); }); diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 724b10980..2c3a00064 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -15,8 +15,8 @@ const messages = defineMessages({ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, - mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' }, - unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' }, + mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'You are not currently muting notifications from @{name}. Click to mute notifications' }, + unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'You are currently muting notifications from @{name}. Click to unmute notifications' }, }); @injectIntl @@ -93,7 +93,7 @@ export default class Account extends ImmutablePureComponent { </div> ); } else { - buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; + buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following ? true : false} />; } } diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index 14a8d4c38..a065ac988 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -11,8 +11,8 @@ import classNames from 'classnames'; const textAtCursorMatchesToken = (str, caretPosition) => { let word; - let left = str.slice(0, caretPosition).search(/\S+$/); - let right = str.slice(caretPosition).search(/\s/); + let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/); + let right = str.slice(caretPosition).search(/[\s\u200B]/); if (right < 0) { word = str.slice(left); diff --git a/app/javascript/mastodon/components/avatar.js b/app/javascript/mastodon/components/avatar.js index f7c484ee3..dd155f059 100644 --- a/app/javascript/mastodon/components/avatar.js +++ b/app/javascript/mastodon/components/avatar.js @@ -64,6 +64,7 @@ export default class Avatar extends React.PureComponent { onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={style} + data-avatar-of={`@${account.get('acct')}`} /> ); } diff --git a/app/javascript/mastodon/components/avatar_overlay.js b/app/javascript/mastodon/components/avatar_overlay.js index f5d67b34e..2ecf9fa44 100644 --- a/app/javascript/mastodon/components/avatar_overlay.js +++ b/app/javascript/mastodon/components/avatar_overlay.js @@ -21,8 +21,8 @@ export default class AvatarOverlay extends React.PureComponent { return ( <div className='account__avatar-overlay'> - <div className='account__avatar-overlay-base' style={baseStyle} /> - <div className='account__avatar-overlay-overlay' style={overlayStyle} /> + <div className='account__avatar-overlay-base' style={baseStyle} data-avatar-of={`@${account.get('acct')}`} /> + <div className='account__avatar-overlay-overlay' style={overlayStyle} data-avatar-of={`@${friend.get('acct')}`} /> </div> ); } diff --git a/app/javascript/mastodon/components/button.js b/app/javascript/mastodon/components/button.js index 51e2e6a7a..16868010c 100644 --- a/app/javascript/mastodon/components/button.js +++ b/app/javascript/mastodon/components/button.js @@ -14,6 +14,7 @@ export default class Button extends React.PureComponent { className: PropTypes.string, style: PropTypes.object, children: PropTypes.node, + title: PropTypes.string, }; static defaultProps = { @@ -35,26 +36,26 @@ export default class Button extends React.PureComponent { } render () { - const style = { - padding: `0 ${this.props.size / 2.25}px`, - height: `${this.props.size}px`, - lineHeight: `${this.props.size}px`, - ...this.props.style, + let attrs = { + className: classNames('button', this.props.className, { + 'button-secondary': this.props.secondary, + 'button--block': this.props.block, + }), + disabled: this.props.disabled, + onClick: this.handleClick, + ref: this.setRef, + style: { + padding: `0 ${this.props.size / 2.25}px`, + height: `${this.props.size}px`, + lineHeight: `${this.props.size}px`, + ...this.props.style, + }, }; - const className = classNames('button', this.props.className, { - 'button-secondary': this.props.secondary, - 'button--block': this.props.block, - }); + if (this.props.title) attrs.title = this.props.title; return ( - <button - className={className} - disabled={this.props.disabled} - onClick={this.handleClick} - ref={this.setRef} - style={style} - > + <button {...attrs}> {this.props.text || this.props.children} </button> ); diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js index e81236d26..2e1467595 100644 --- a/app/javascript/mastodon/components/column.js +++ b/app/javascript/mastodon/components/column.js @@ -7,6 +7,8 @@ export default class Column extends React.PureComponent { static propTypes = { children: PropTypes.node, + extraClasses: PropTypes.string, + name: PropTypes.string, }; scrollTop () { @@ -40,10 +42,10 @@ export default class Column extends React.PureComponent { } render () { - const { children } = this.props; + const { children, extraClasses, name } = this.props; return ( - <div role='region' className='column' ref={this.setRef}> + <div role='region' data-column={name} className={`column ${extraClasses || ''}`} ref={this.setRef}> {children} </div> ); diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js index 8a60c4192..50c3bf11f 100644 --- a/app/javascript/mastodon/components/column_back_button.js +++ b/app/javascript/mastodon/components/column_back_button.js @@ -9,7 +9,8 @@ export default class ColumnBackButton extends React.PureComponent { }; handleClick = () => { - if (window.history && window.history.length === 1) { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) { this.context.router.history.push('/'); } else { this.context.router.history.goBack(); diff --git a/app/javascript/mastodon/components/column_back_button_slim.js b/app/javascript/mastodon/components/column_back_button_slim.js index 3b4f46d99..2cdf1b25b 100644 --- a/app/javascript/mastodon/components/column_back_button_slim.js +++ b/app/javascript/mastodon/components/column_back_button_slim.js @@ -9,8 +9,12 @@ export default class ColumnBackButtonSlim extends React.PureComponent { }; handleClick = () => { - if (window.history && window.history.length === 1) this.context.router.history.push('/'); - else this.context.router.history.goBack(); + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) { + this.context.router.history.push('/'); + } else { + this.context.router.history.goBack(); + } } render () { diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index 80a8fbdb3..71530ffdd 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -1,13 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Glitch imports +import NotificationPurgeButtonsContainer from '../../glitch/components/column/notif_cleaning_widget/container'; const messages = defineMessages({ show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' }, moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' }, + enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' }, }); @injectIntl @@ -22,14 +27,19 @@ export default class ColumnHeader extends React.PureComponent { title: PropTypes.node.isRequired, icon: PropTypes.string.isRequired, active: PropTypes.bool, + localSettings : ImmutablePropTypes.map, multiColumn: PropTypes.bool, focusable: PropTypes.bool, showBackButton: PropTypes.bool, + notifCleaning: PropTypes.bool, // true only for the notification column + notifCleaningActive: PropTypes.bool, + onEnterCleaningMode: PropTypes.func, children: PropTypes.node, pinned: PropTypes.bool, onPin: PropTypes.func, onMove: PropTypes.func, onClick: PropTypes.func, + intl: PropTypes.object.isRequired, }; static defaultProps = { @@ -39,6 +49,7 @@ export default class ColumnHeader extends React.PureComponent { state = { collapsed: true, animating: false, + animatingNCD: false, }; handleToggleClick = (e) => { @@ -59,17 +70,32 @@ export default class ColumnHeader extends React.PureComponent { } handleBackClick = () => { - if (window.history && window.history.length === 1) this.context.router.history.push('/'); - else this.context.router.history.goBack(); + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) { + this.context.router.history.push('/'); + } else { + this.context.router.history.goBack(); + } } handleTransitionEnd = () => { this.setState({ animating: false }); } + handleTransitionEndNCD = () => { + this.setState({ animatingNCD: false }); + } + + onEnterCleaningMode = () => { + this.setState({ animatingNCD: true }); + this.props.onEnterCleaningMode(!this.props.notifCleaningActive); + } + render () { - const { title, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage } } = this.props; - const { collapsed, animating } = this.state; + const { intl, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage }, notifCleaning, notifCleaningActive } = this.props; + const { collapsed, animating, animatingNCD } = this.state; + + let title = this.props.title; const wrapperClassName = classNames('column-header__wrapper', { 'active': active, @@ -88,8 +114,20 @@ export default class ColumnHeader extends React.PureComponent { 'active': !collapsed, }); + const notifCleaningButtonClassName = classNames('column-header__button', { + 'active': notifCleaningActive, + }); + + const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', { + 'collapsed': !notifCleaningActive, + 'animating': animatingNCD, + }); + let extraContent, pinButton, moveButtons, backButton, collapseButton; + //*glitch + const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning); + if (children) { extraContent = ( <div key='extra-content' className='column-header__collapsible__extra'> @@ -140,13 +178,30 @@ export default class ColumnHeader extends React.PureComponent { <span className='column-header__title'> {title} </span> - <div className='column-header__buttons'> {backButton} + { notifCleaning ? ( + <button + aria-label={msgEnterNotifCleaning} + title={msgEnterNotifCleaning} + onClick={this.onEnterCleaningMode} + className={notifCleaningButtonClassName} + > + <i className='fa fa-eraser' /> + </button> + ) : null} {collapseButton} </div> </h1> + { notifCleaning ? ( + <div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}> + <div className='column-header__collapsible-inner nopad-drawer'> + {(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null } + </div> + </div> + ) : null} + <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}> <div className='column-header__collapsible-inner'> {(!collapsed || animating) && collapsedContent} diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index 06f53841d..d0c1b049f 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -20,8 +20,10 @@ export default class IconButton extends React.PureComponent { disabled: PropTypes.bool, inverted: PropTypes.bool, animate: PropTypes.bool, + flip: PropTypes.bool, overlay: PropTypes.bool, tabIndex: PropTypes.string, + label: PropTypes.string, }; static defaultProps = { @@ -42,14 +44,18 @@ export default class IconButton extends React.PureComponent { } render () { - const style = { + let 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 : {}), }; + if (!this.props.label) { + style.width = `${this.props.size * 1.28571429}px`; + } else { + style.textAlign = 'left'; + } const { active, @@ -59,6 +65,7 @@ export default class IconButton extends React.PureComponent { expanded, icon, inverted, + flip, overlay, pressed, tabIndex, @@ -72,6 +79,21 @@ export default class IconButton extends React.PureComponent { overlayed: overlay, }); + const flipDeg = flip ? -180 : -360; + const rotateDeg = active ? flipDeg : 0; + + const motionDefaultStyle = { + rotate: rotateDeg, + }; + + const springOpts = { + stiffness: this.props.flip ? 60 : 120, + damping: 7, + }; + const motionStyle = { + rotate: animate ? spring(rotateDeg, springOpts) : 0, + }; + if (!animate) { // Perf optimization: avoid unnecessary <Motion> components unless // we actually need to animate. @@ -92,7 +114,7 @@ export default class IconButton extends React.PureComponent { } return ( - <Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}> + <Motion defaultStyle={motionDefaultStyle} style={motionStyle}> {({ rotate }) => <button aria-label={title} @@ -105,6 +127,7 @@ export default class IconButton extends React.PureComponent { tabIndex={tabIndex} > <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> + {this.props.label} </button> } </Motion> diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 20febdb16..5ed46dc93 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/status/gallery + import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index d23ff87fa..5a01c0cdd 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/status + import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 7021c198e..35daf70b9 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/status/action_bar + import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 3b8155632..0f7f15dfc 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/status/content + import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 58a7b228a..214955591 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -1,7 +1,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import StatusContainer from '../containers/status_container'; +import StatusContainer from '../../glitch/components/status/container'; import ImmutablePureComponent from 'react-immutable-pure-component'; import ScrollableList from './scrollable_list'; diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index b22540204..b9c461f31 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/status/container + import React from 'react'; import { connect } from 'react-redux'; import Status from '../components/status'; diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js index e375131d4..389296c42 100644 --- a/app/javascript/mastodon/features/account/components/action_bar.js +++ b/app/javascript/mastodon/features/account/components/action_bar.js @@ -20,6 +20,8 @@ const messages = defineMessages({ media: { id: 'account.media', defaultMessage: 'Media' }, blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, + hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' }, + showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' }, }); @injectIntl @@ -30,6 +32,7 @@ export default class ActionBar extends React.PureComponent { onFollow: PropTypes.func, onBlock: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, + onReblogToggle: PropTypes.func.isRequired, onReport: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, onBlockDomain: PropTypes.func.isRequired, @@ -60,6 +63,15 @@ export default class ActionBar extends React.PureComponent { if (account.get('id') === me) { menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); } else { + const following = account.getIn(['relationship', 'following']); + if (following) { + if (following.get('reblogs')) { + menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); + } else { + menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); + } + } + if (account.getIn(['relationship', 'muting'])) { menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute }); } else { diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index f0d2d481f..b3a73a590 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/account/header + import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index 8cf7b92ca..9a087e922 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -1,7 +1,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import InnerHeader from '../../account/components/header'; +import InnerHeader from '../../../../glitch/components/account/header'; import ActionBar from '../../account/components/action_bar'; import MissingIndicator from '../../../components/missing_indicator'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -13,6 +13,7 @@ export default class Header extends ImmutablePureComponent { onFollow: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, + onReblogToggle: PropTypes.func.isRequired, onReport: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, onBlockDomain: PropTypes.func.isRequired, @@ -39,6 +40,10 @@ export default class Header extends ImmutablePureComponent { this.props.onReport(this.props.account); } + handleReblogToggle = () => { + this.props.onReblogToggle(this.props.account); + } + handleMute = () => { this.props.onMute(this.props.account); } @@ -77,6 +82,7 @@ export default class Header extends ImmutablePureComponent { account={account} onBlock={this.handleBlock} onMention={this.handleMention} + onReblogToggle={this.handleReblogToggle} onReport={this.handleReport} onMute={this.handleMute} onBlockDomain={this.handleBlockDomain} diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index 8e50ec405..b41eb19d4 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -67,6 +67,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(mentionCompose(account, router)); }, + onReblogToggle (account) { + if (account.getIn(['relationship', 'following', 'reblogs'])) { + dispatch(followAccount(account.get('id'), false)); + } else { + dispatch(followAccount(account.get('id'), true)); + } + }, + onReport (account) { dispatch(initReport(account)); }, diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index f8c85c296..3ad370e32 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -59,7 +59,7 @@ export default class AccountTimeline extends ImmutablePureComponent { } return ( - <Column> + <Column name='account'> <ColumnBackButton /> <StatusList diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js index 14a512ae8..9199529dd 100644 --- a/app/javascript/mastodon/features/blocks/index.js +++ b/app/javascript/mastodon/features/blocks/index.js @@ -54,7 +54,7 @@ export default class Blocks extends ImmutablePureComponent { } return ( - <Column icon='ban' heading={intl.formatMessage(messages.heading)}> + <Column name='blocks' icon='ban' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> <ScrollContainer scrollKey='blocks'> <div className='scrollable' onScroll={this.handleScroll}> diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index 596a89412..62b1c8ee9 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -79,7 +79,7 @@ export default class CommunityTimeline extends React.PureComponent { const pinned = !!columnId; return ( - <Column ref={this.setRef}> + <Column ref={this.setRef} name='local'> <ColumnHeader icon='users' active={hasUnread} diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 7890755f3..13a41f23f 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -5,11 +5,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import ReplyIndicatorContainer from '../containers/reply_indicator_container'; import AutosuggestTextarea from '../../../components/autosuggest_textarea'; -import UploadButtonContainer from '../containers/upload_button_container'; import { defineMessages, injectIntl } from 'react-intl'; import Collapsable from '../../../components/collapsable'; import SpoilerButtonContainer from '../containers/spoiler_button_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; +import ComposeAdvancedOptionsContainer from '../../../../glitch/components/compose/advanced_options/container'; import SensitiveButtonContainer from '../containers/sensitive_button_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import UploadFormContainer from '../containers/upload_form_container'; @@ -18,6 +18,7 @@ import { isMobile } from '../../../is_mobile'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { length } from 'stringz'; import { countableText } from '../util/counter'; +import ComposeAttachOptions from '../../../../glitch/components/compose/attach_options/index'; const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, @@ -36,6 +37,9 @@ export default class ComposeForm extends ImmutablePureComponent { suggestions: ImmutablePropTypes.list, spoiler: PropTypes.bool, privacy: PropTypes.string, + advanced_options: ImmutablePropTypes.contains({ + do_not_federate: PropTypes.bool, + }), spoiler_text: PropTypes.string, focusDate: PropTypes.instanceOf(Date), preselectDate: PropTypes.instanceOf(Date), @@ -45,11 +49,13 @@ export default class ComposeForm extends ImmutablePureComponent { onSubmit: PropTypes.func.isRequired, onClearSuggestions: PropTypes.func.isRequired, onFetchSuggestions: PropTypes.func.isRequired, + onPrivacyChange: PropTypes.func.isRequired, onSuggestionSelected: PropTypes.func.isRequired, onChangeSpoilerText: PropTypes.func.isRequired, onPaste: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired, showSearch: PropTypes.bool, + settings : ImmutablePropTypes.map.isRequired, }; static defaultProps = { @@ -66,6 +72,11 @@ export default class ComposeForm extends ImmutablePureComponent { } } + handleSubmit2 = () => { + this.props.onPrivacyChange(this.props.settings.get('side_arm')); + this.handleSubmit(); + } + handleSubmit = () => { if (this.props.text !== this.autosuggestTextarea.textarea.value) { // Something changed the text inside the textarea (e.g. browser extensions like Grammarly) @@ -144,16 +155,58 @@ export default class ComposeForm extends ImmutablePureComponent { render () { const { intl, onPaste, showSearch } = this.props; const disabled = this.props.is_submitting; - const text = [this.props.spoiler_text, countableText(this.props.text)].join(''); + const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : ''; + const text = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join(''); + + const secondaryVisibility = this.props.settings.get('side_arm'); + let showSideArm = secondaryVisibility !== 'none'; let publishText = ''; + let publishText2 = ''; + let title = ''; + let title2 = ''; + + const privacyIcons = { + none: '', + public: 'globe', + unlisted: 'unlock-alt', + private: 'lock', + direct: 'envelope', + }; + + title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`; + + if (showSideArm) { + // Enhanced behavior with dual toot buttons + publishText = ( + <span> + { + <i + className={`fa fa-${privacyIcons[this.props.privacy]}`} + style={{ paddingRight: '5px' }} + /> + }{intl.formatMessage(messages.publish)} + </span> + ); - if (this.props.privacy === 'private' || this.props.privacy === 'direct') { - publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>; + title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`; + publishText2 = ( + <i + className={`fa fa-${privacyIcons[secondaryVisibility]}`} + aria-label={title2} + /> + ); } else { - publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); + // Original vanilla behavior - no icon if public or unlisted + if (this.props.privacy === 'private' || this.props.privacy === 'direct') { + publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>; + } else { + publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); + } } + const submitDisabled = disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0); + return ( <div className='compose-form'> <Collapsable isVisible={this.props.spoiler} fullHeight={50}> @@ -192,17 +245,35 @@ export default class ComposeForm extends ImmutablePureComponent { <UploadFormContainer /> </div> - <div className='compose-form__buttons-wrapper'> - <div className='compose-form__buttons'> - <UploadButtonContainer /> - <PrivacyDropdownContainer /> - <SensitiveButtonContainer /> - <SpoilerButtonContainer /> - </div> + <div className='compose-form__buttons'> + <ComposeAttachOptions /> + <SensitiveButtonContainer /> + <div className='compose-form__buttons-separator' /> + <PrivacyDropdownContainer /> + <SpoilerButtonContainer /> + <ComposeAdvancedOptionsContainer /> + </div> - <div className='compose-form__publish'> - <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div> - <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0)} block /></div> + <div className='compose-form__publish'> + <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div> + <div className='compose-form__publish-button-wrapper'> + { + showSideArm ? + <Button + className='compose-form__publish__side-arm' + text={publishText2} + title={title2} + onClick={this.handleSubmit2} + disabled={submitDisabled} + /> : '' + } + <Button + className='compose-form__publish__primary' + text={publishText} + title={title} + onClick={this.handleSubmit} + disabled={submitDisabled} + /> </div> </div> </div> diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js index 8350d20a5..a3e68643f 100644 --- a/app/javascript/mastodon/features/compose/components/search_results.js +++ b/app/javascript/mastodon/features/compose/components/search_results.js @@ -2,7 +2,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage } from 'react-intl'; import AccountContainer from '../../../containers/account_container'; -import StatusContainer from '../../../containers/status_container'; +import StatusContainer from '../../../../glitch/components/status/container'; import { Link } from 'react-router-dom'; import ImmutablePureComponent from 'react-immutable-pure-component'; 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 5f5509dbe..dfe8241c6 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import ComposeForm from '../components/compose_form'; -import { uploadCompose } from '../../../actions/compose'; +import { changeComposeVisibility, uploadCompose } from '../../../actions/compose'; import { changeCompose, submitCompose, @@ -15,6 +15,7 @@ const mapStateToProps = state => ({ text: state.getIn(['compose', 'text']), suggestion_token: state.getIn(['compose', 'suggestion_token']), suggestions: state.getIn(['compose', 'suggestions']), + advanced_options: state.getIn(['compose', 'advanced_options']), spoiler: state.getIn(['compose', 'spoiler']), spoiler_text: state.getIn(['compose', 'spoiler_text']), privacy: state.getIn(['compose', 'privacy']), @@ -23,6 +24,8 @@ const mapStateToProps = state => ({ is_submitting: state.getIn(['compose', 'is_submitting']), is_uploading: state.getIn(['compose', 'is_uploading']), showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), + settings: state.get('local_settings'), + filesAttached: state.getIn(['compose', 'media_attachments']).size > 0, }); const mapDispatchToProps = (dispatch) => ({ @@ -31,6 +34,10 @@ const mapDispatchToProps = (dispatch) => ({ dispatch(changeCompose(text)); }, + onPrivacyChange (value) { + dispatch(changeComposeVisibility(value)); + }, + onSubmit () { dispatch(submitCompose()); }, diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index 0c66585c9..a487f2c89 100644 --- a/app/javascript/mastodon/features/compose/index.js +++ b/app/javascript/mastodon/features/compose/index.js @@ -5,6 +5,8 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { mountCompose, unmountCompose } from '../../actions/compose'; +import { openModal } from '../../actions/modal'; +import { changeLocalSetting } from '../../../glitch/actions/local_settings'; import { Link } from 'react-router-dom'; import { injectIntl, defineMessages } from 'react-intl'; import SearchContainer from './containers/search_container'; @@ -19,7 +21,7 @@ const messages = defineMessages({ notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, - preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, }); @@ -48,6 +50,16 @@ export default class Compose extends React.PureComponent { this.props.dispatch(unmountCompose()); } + onLayoutClick = (e) => { + const layout = e.currentTarget.getAttribute('data-mastodon-layout'); + this.props.dispatch(changeLocalSetting(['layout'], layout)); + e.preventDefault(); + } + + openSettings = () => { + this.props.dispatch(openModal('SETTINGS', {})); + } + onFocus = () => { this.props.dispatch(changeComposing(true)); } @@ -78,12 +90,14 @@ export default class Compose extends React.PureComponent { {!columns.some(column => column.get('id') === 'PUBLIC') && ( <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><i role='img' className='fa fa-fw fa-globe' /></Link> )} - <a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><i role='img' className='fa fa-fw fa-cog' /></a> + <a onClick={this.openSettings} role='button' tabIndex='0' className='drawer__tab' title={intl.formatMessage(messages.settings)} aria-label={intl.formatMessage(messages.settings)}><i role='img' className='fa fa-fw fa-cogs' /></a> <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><i role='img' className='fa fa-fw fa-sign-out' /></a> </nav> ); } + + return ( <div className='drawer'> {header} @@ -91,7 +105,7 @@ export default class Compose extends React.PureComponent { <SearchContainer /> <div className='drawer__pager'> - <div className='drawer__inner' onFocus={this.onFocus}> + <div className='drawer__inner scrollable optionally-scrollable' onFocus={this.onFocus}> <NavigationContainer onClose={this.onBlur} /> <ComposeFormContainer /> </div> @@ -104,6 +118,7 @@ export default class Compose extends React.PureComponent { } </Motion> </div> + </div> ); } diff --git a/app/javascript/mastodon/features/direct_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/direct_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..1833f69e5 --- /dev/null +++ b/app/javascript/mastodon/features/direct_timeline/containers/column_settings_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../../community_timeline/components/column_settings'; +import { changeSetting } from '../../../actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'direct']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['direct', ...key], checked)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js new file mode 100644 index 000000000..05e092ee0 --- /dev/null +++ b/app/javascript/mastodon/features/direct_timeline/index.js @@ -0,0 +1,107 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from '../ui/containers/status_list_container'; +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; +import { + refreshDirectTimeline, + expandDirectTimeline, +} from '../../actions/timelines'; +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { connectDirectStream } from '../../actions/streaming'; + +const messages = defineMessages({ + title: { id: 'column.direct', defaultMessage: 'Direct messages' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0, +}); + +@connect(mapStateToProps) +@injectIntl +export default class DirectTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('DIRECT', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(refreshDirectTimeline()); + this.disconnect = dispatch(connectDirectStream()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = () => { + this.props.dispatch(expandDirectTimeline()); + } + + render () { + const { intl, hasUnread, columnId, multiColumn } = this.props; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef}> + <ColumnHeader + icon='envelope' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <ColumnSettingsContainer /> + </ColumnHeader> + + <StatusListContainer + trackScroll={!pinned} + scrollKey={`direct_timeline-${columnId}`} + timelineId='direct' + loadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />} + /> + </Column> + ); + } + +} diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js index 1e1f5873c..8135527c9 100644 --- a/app/javascript/mastodon/features/favourited_statuses/index.js +++ b/app/javascript/mastodon/features/favourited_statuses/index.js @@ -68,7 +68,7 @@ export default class Favourites extends ImmutablePureComponent { const pinned = !!columnId; return ( - <Column ref={this.setRef}> + <Column ref={this.setRef} name='favourites'> <ColumnHeader icon='star' title={intl.formatMessage(messages.heading)} diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js index eae821f92..1fa52d511 100644 --- a/app/javascript/mastodon/features/follow_requests/index.js +++ b/app/javascript/mastodon/features/follow_requests/index.js @@ -47,14 +47,14 @@ export default class FollowRequests extends ImmutablePureComponent { if (!accountIds) { return ( - <Column> + <Column name='follow-requests'> <LoadingIndicator /> </Column> ); } return ( - <Column icon='users' heading={intl.formatMessage(messages.heading)}> + <Column name='follow-requests' icon='users' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> <ScrollContainer scrollKey='follow_requests'> diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index 4b4ae6947..2f7d9281e 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -4,6 +4,7 @@ import ColumnLink from '../ui/components/column_link'; import ColumnSubheading from '../ui/components/column_subheading'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { connect } from 'react-redux'; +import { openModal } from '../../actions/modal'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -17,13 +18,16 @@ const messages = defineMessages({ navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' }, 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' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' }, + show_me_around: { id: 'getting_started.onboarding', defaultMessage: 'Show me around' }, pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, }); @@ -41,8 +45,18 @@ export default class GettingStarted extends ImmutablePureComponent { myAccount: ImmutablePropTypes.map.isRequired, columns: ImmutablePropTypes.list, multiColumn: PropTypes.bool, + dispatch: PropTypes.func.isRequired, }; + openSettings = () => { + this.props.dispatch(openModal('SETTINGS', {})); + } + + openOnboardingModal = (e) => { + e.preventDefault(); + this.props.dispatch(openModal('ONBOARDING')); + } + render () { const { intl, myAccount, columns, multiColumn } = this.props; @@ -66,43 +80,62 @@ export default class GettingStarted extends ImmutablePureComponent { } } + if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) { + navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />); + } + navItems = navItems.concat([ - <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, - <ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />, + <ColumnLink key='5' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, + <ColumnLink key='6' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />, ]); if (myAccount.get('locked')) { - navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />); + navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />); } navItems = navItems.concat([ - <ColumnLink key='7' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />, - <ColumnLink key='8' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />, + <ColumnLink key='8' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />, + <ColumnLink key='9' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />, ]); return ( - <Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile> - <div className='getting-started__wrapper'> - <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} /> - {navItems} - <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} /> - <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> - <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> - <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> - </div> + <Column name='getting-started' icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile> + <div className='scrollable optionally-scrollable'> + <div className='getting-started__wrapper'> + <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} /> + {navItems} + <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} /> + <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> + <ColumnLink icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} /> + <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> + <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={this.openSettings} /> + <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> + </div> - <div className='getting-started__footer scrollable optionally-scrollable'> - <div className='static-content getting-started'> - <p> - <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /></a> - </p> - <p> - <FormattedMessage - id='getting_started.open_source_notice' - defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.' - values={{ github: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>tootsuite/mastodon</a> }} - /> - </p> + <div className='getting-started__footer'> + <div className='static-content getting-started'> + <p> + <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'> + <FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /> + </a> • + <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'> + <FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /> + </a> • + <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'> + <FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /> + </a> + </p> + <p> + <FormattedMessage + id='getting_started.open_source_notice' + defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.' + values={{ + github: <a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a>, + Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a>, + }} + /> + </p> + </div> </div> </div> </Column> diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index 5fe21ce90..2077b7cdf 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -91,7 +91,7 @@ export default class HashtagTimeline extends React.PureComponent { const pinned = !!columnId; return ( - <Column ref={this.setRef}> + <Column ref={this.setRef} name='hashtag'> <ColumnHeader icon='hashtag' active={hasUnread} diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js index a4bc60fac..b35347ba6 100644 --- a/app/javascript/mastodon/features/home_timeline/index.js +++ b/app/javascript/mastodon/features/home_timeline/index.js @@ -62,7 +62,7 @@ export default class HomeTimeline extends React.PureComponent { const pinned = !!columnId; return ( - <Column ref={this.setRef}> + <Column ref={this.setRef} name='home'> <ColumnHeader icon='home' active={hasUnread} diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js index bb351ece2..ae6ec343f 100644 --- a/app/javascript/mastodon/features/mutes/index.js +++ b/app/javascript/mastodon/features/mutes/index.js @@ -54,7 +54,7 @@ export default class Mutes extends ImmutablePureComponent { } return ( - <Column icon='volume-off' heading={intl.formatMessage(messages.heading)}> + <Column name='mutes' icon='volume-off' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> <ScrollContainer scrollKey='mutes'> <div className='scrollable mutes' onScroll={this.handleScroll}> diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 9d170cad5..903526822 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/notification + import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js index 921aa460f..fd16c4331 100644 --- a/app/javascript/mastodon/features/notifications/containers/notification_container.js +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/notification/container + import { connect } from 'react-redux'; import { makeGetNotification } from '../../../selectors'; import Notification from '../components/notification'; diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 35b430bfb..9c6802482 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -4,9 +4,13 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Column from '../../components/column'; import ColumnHeader from '../../components/column_header'; -import { expandNotifications, scrollTopNotifications } from '../../actions/notifications'; +import { + enterNotificationClearingMode, + expandNotifications, + scrollTopNotifications, +} from '../../actions/notifications'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; -import NotificationContainer from './containers/notification_container'; +import NotificationContainer from '../../../glitch/components/notification/container'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; import { createSelector } from 'reselect'; @@ -25,12 +29,22 @@ const getNotifications = createSelector([ const mapStateToProps = state => ({ notifications: getNotifications(state), + localSettings: state.get('local_settings'), isLoading: state.getIn(['notifications', 'isLoading'], true), isUnread: state.getIn(['notifications', 'unread']) > 0, hasMore: !!state.getIn(['notifications', 'next']), + notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), }); -@connect(mapStateToProps) +/* glitch */ +const mapDispatchToProps = dispatch => ({ + onEnterCleaningMode(yes) { + dispatch(enterNotificationClearingMode(yes)); + }, + dispatch, +}); + +@connect(mapStateToProps, mapDispatchToProps) @injectIntl export default class Notifications extends React.PureComponent { @@ -44,6 +58,9 @@ export default class Notifications extends React.PureComponent { isUnread: PropTypes.bool, multiColumn: PropTypes.bool, hasMore: PropTypes.bool, + localSettings: ImmutablePropTypes.map, + notifCleaningActive: PropTypes.bool, + onEnterCleaningMode: PropTypes.func, }; static defaultProps = { @@ -146,7 +163,11 @@ export default class Notifications extends React.PureComponent { ); return ( - <Column ref={this.setColumnRef}> + <Column + ref={this.setColumnRef} + name='notifications' + extraClasses={this.props.notifCleaningActive ? 'notif-cleaning' : null} + > <ColumnHeader icon='bell' active={isUnread} @@ -156,6 +177,10 @@ export default class Notifications extends React.PureComponent { onClick={this.handleHeaderClick} pinned={pinned} multiColumn={multiColumn} + localSettings={this.props.localSettings} + notifCleaning + notifCleaningActive={this.props.notifCleaningActive} // this is used to toggle the header text + onEnterCleaningMode={this.props.onEnterCleaningMode} > <ColumnSettingsContainer /> </ColumnHeader> diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js index 193489c63..1821bc448 100644 --- a/app/javascript/mastodon/features/public_timeline/index.js +++ b/app/javascript/mastodon/features/public_timeline/index.js @@ -79,7 +79,7 @@ export default class PublicTimeline extends React.PureComponent { const pinned = !!columnId; return ( - <Column ref={this.setRef}> + <Column ref={this.setRef} name='federated'> <ColumnHeader icon='globe' active={hasUnread} diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index 7b65420d0..8c6994a07 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -107,8 +107,8 @@ export default class ActionBar extends React.PureComponent { ); let reblogIcon = 'retweet'; - if (status.get('visibility') === 'direct') reblogIcon = 'envelope'; - else if (status.get('visibility') === 'private') reblogIcon = 'lock'; + //if (status.get('visibility') === 'direct') reblogIcon = 'envelope'; + // else if (status.get('visibility') === 'private') reblogIcon = 'lock'; let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private'); diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 81f71749b..85a030ea8 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -3,14 +3,16 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Avatar from '../../../components/avatar'; import DisplayName from '../../../components/display_name'; -import StatusContent from '../../../components/status_content'; -import MediaGallery from '../../../components/media_gallery'; +import StatusContent from '../../../../glitch/components/status/content'; +import StatusGallery from '../../../../glitch/components/status/gallery'; +import StatusPlayer from '../../../../glitch/components/status/player'; import AttachmentList from '../../../components/attachment_list'; import { Link } from 'react-router-dom'; import { FormattedDate, FormattedNumber } from 'react-intl'; import CardContainer from '../containers/card_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import Video from '../../video'; +// import Video from '../../video'; +import VisibilityIcon from '../../../../glitch/components/status/visibility_icon'; export default class DetailedStatus extends ImmutablePureComponent { @@ -20,6 +22,7 @@ export default class DetailedStatus extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, + settings: ImmutablePropTypes.map.isRequired, onOpenMedia: PropTypes.func.isRequired, onOpenVideo: PropTypes.func.isRequired, }; @@ -33,14 +36,16 @@ export default class DetailedStatus extends ImmutablePureComponent { e.stopPropagation(); } - handleOpenVideo = startTime => { - this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); - } + // handleOpenVideo = startTime => { + // this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); + // } render () { const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; + const { settings } = this.props; let media = ''; + let mediaIcon = null; let applicationLink = ''; let reblogLink = ''; let reblogIcon = 'retweet'; @@ -49,32 +54,32 @@ export default class DetailedStatus extends ImmutablePureComponent { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { media = <AttachmentList media={status.get('media_attachments')} />; } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - const video = status.getIn(['media_attachments', 0]); - media = ( - <Video - preview={video.get('preview_url')} - src={video.get('url')} - width={300} - height={150} - onOpenVideo={this.handleOpenVideo} + <StatusPlayer sensitive={status.get('sensitive')} + media={status.getIn(['media_attachments', 0])} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + height={250} + onOpenVideo={this.props.onOpenVideo} + autoplay /> ); + mediaIcon = 'video-camera'; } else { media = ( - <MediaGallery - standalone + <StatusGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} - height={300} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + height={250} onOpenMedia={this.props.onOpenMedia} /> ); + mediaIcon = 'picture-o'; } - } else if (status.get('spoiler_text').length === 0) { - media = <CardContainer statusId={status.get('id')} />; - } + } else media = <CardContainer statusId={status.get('id')} />; if (status.get('application')) { applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>; @@ -104,9 +109,11 @@ export default class DetailedStatus extends ImmutablePureComponent { <DisplayName account={status.get('account')} /> </a> - <StatusContent status={status} /> - - {media} + <StatusContent + status={status} + media={media} + mediaIcon={mediaIcon} + /> <div className='detailed-status__meta'> <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'> @@ -116,7 +123,7 @@ export default class DetailedStatus extends ImmutablePureComponent { <span className='detailed-status__favorites'> <FormattedNumber value={status.get('favourites_count')} /> </span> - </Link> + </Link> · <VisibilityIcon visibility={status.get('visibility')} /> </div> </div> ); diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index cc28ff5fc..e7ea046dd 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -25,7 +25,7 @@ import { initReport } from '../../actions/reports'; import { makeGetStatus } from '../../selectors'; import { ScrollContainer } from 'react-router-scroll-4'; import ColumnBackButton from '../../components/column_back_button'; -import StatusContainer from '../../containers/status_container'; +import StatusContainer from '../../../glitch/components/status/container'; import { openModal } from '../../actions/modal'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -43,6 +43,7 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ status: getStatus(state, props.params.statusId), + settings: state.get('local_settings'), ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]), descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]), }); @@ -62,6 +63,7 @@ export default class Status extends ImmutablePureComponent { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, status: ImmutablePropTypes.map, + settings: ImmutablePropTypes.map.isRequired, ancestorsIds: ImmutablePropTypes.list, descendantsIds: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, @@ -253,8 +255,10 @@ export default class Status extends ImmutablePureComponent { if (status && ancestorsIds && ancestorsIds.size > 0) { const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1]; - element.scrollIntoView(true); - this._scrolledIntoView = true; + if (element) { + element.scrollIntoView(true); + this._scrolledIntoView = true; + } } } @@ -268,7 +272,7 @@ export default class Status extends ImmutablePureComponent { render () { let ancestors, descendants; - const { status, ancestorsIds, descendantsIds } = this.props; + const { status, settings, ancestorsIds, descendantsIds } = this.props; const { fullscreen } = this.state; if (status === null) { @@ -310,6 +314,7 @@ export default class Status extends ImmutablePureComponent { <div className='focusable' tabIndex='0'> <DetailedStatus status={status} + settings={settings} onOpenVideo={this.handleOpenVideo} onOpenMedia={this.handleOpenMedia} /> diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js index 0e9592c97..dfd1284e9 100644 --- a/app/javascript/mastodon/features/ui/components/boost_modal.js +++ b/app/javascript/mastodon/features/ui/components/boost_modal.js @@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Button from '../../../components/button'; -import StatusContent from '../../../components/status_content'; +import StatusContent from '../../../../glitch/components/status/content'; import Avatar from '../../../components/avatar'; import RelativeTimestamp from '../../../components/relative_timestamp'; import DisplayName from '../../../components/display_name'; diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js index 15538ea38..c1700f86e 100644 --- a/app/javascript/mastodon/features/ui/components/column.js +++ b/app/javascript/mastodon/features/ui/components/column.js @@ -13,6 +13,7 @@ export default class Column extends React.PureComponent { children: PropTypes.node, active: PropTypes.bool, hideHeadingOnMobile: PropTypes.bool, + name: PropTypes.string, }; handleHeaderClick = () => { @@ -47,7 +48,7 @@ export default class Column extends React.PureComponent { } render () { - const { heading, icon, children, active, hideHeadingOnMobile } = this.props; + const { heading, icon, children, active, hideHeadingOnMobile, name } = this.props; const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth))); @@ -59,6 +60,7 @@ export default class Column extends React.PureComponent { <div ref={this.setRef} role='region' + data-column={name} aria-labelledby={columnHeaderId} className='column' onScroll={this.handleScroll} diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js index 5425219c4..b845d1895 100644 --- a/app/javascript/mastodon/features/ui/components/column_link.js +++ b/app/javascript/mastodon/features/ui/components/column_link.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; -const ColumnLink = ({ icon, text, to, href, method }) => { +const ColumnLink = ({ icon, text, to, onClick, href, method }) => { if (href) { return ( <a href={href} className='column-link' data-method={method}> @@ -10,13 +10,20 @@ const ColumnLink = ({ icon, text, to, href, method }) => { {text} </a> ); - } else { + } else if (to) { return ( <Link to={to} className='column-link'> <i className={`fa fa-fw fa-${icon} column-link__icon`} /> {text} </Link> ); + } else { + return ( + <a onClick={onClick} className='column-link' role='button' tabIndex='0' data-method={method}> + <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + {text} + </a> + ); } }; @@ -24,9 +31,9 @@ ColumnLink.propTypes = { icon: PropTypes.string.isRequired, text: PropTypes.string.isRequired, to: PropTypes.string, + onClick: PropTypes.func, href: PropTypes.string, method: PropTypes.string, - hideOnMobile: PropTypes.bool, }; export default ColumnLink; diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 5610095b9..ee1064229 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -11,7 +11,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, FavouritedStatuses } from '../../ui/util/async-components'; +import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses } from '../../ui/util/async-components'; import detectPassiveEvents from 'detect-passive-events'; import { scrollRight } from '../../../scroll'; @@ -23,6 +23,7 @@ const componentMap = { 'PUBLIC': PublicTimeline, 'COMMUNITY': CommunityTimeline, 'HASHTAG': HashtagTimeline, + 'DIRECT': DirectTimeline, 'FAVOURITES': FavouritedStatuses, }; diff --git a/app/javascript/mastodon/features/ui/components/doodle_modal.js b/app/javascript/mastodon/features/ui/components/doodle_modal.js new file mode 100644 index 000000000..4efc9d2e6 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/doodle_modal.js @@ -0,0 +1,614 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from '../../../components/button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Atrament from 'atrament'; // the doodling library +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { doodleSet, uploadCompose } from '../../../actions/compose'; +import IconButton from '../../../components/icon_button'; +import { debounce, mapValues } from 'lodash'; +import classNames from 'classnames'; + +// palette nicked from MyPaint, CC0 +const palette = [ + ['rgb( 0, 0, 0)', 'Black'], + ['rgb( 38, 38, 38)', 'Gray 15'], + ['rgb( 77, 77, 77)', 'Grey 30'], + ['rgb(128, 128, 128)', 'Grey 50'], + ['rgb(171, 171, 171)', 'Grey 67'], + ['rgb(217, 217, 217)', 'Grey 85'], + ['rgb(255, 255, 255)', 'White'], + ['rgb(128, 0, 0)', 'Maroon'], + ['rgb(209, 0, 0)', 'English-red'], + ['rgb(255, 54, 34)', 'Tomato'], + ['rgb(252, 60, 3)', 'Orange-red'], + ['rgb(255, 140, 105)', 'Salmon'], + ['rgb(252, 232, 32)', 'Cadium-yellow'], + ['rgb(243, 253, 37)', 'Lemon yellow'], + ['rgb(121, 5, 35)', 'Dark crimson'], + ['rgb(169, 32, 62)', 'Deep carmine'], + ['rgb(255, 140, 0)', 'Orange'], + ['rgb(255, 168, 18)', 'Dark tangerine'], + ['rgb(217, 144, 88)', 'Persian orange'], + ['rgb(194, 178, 128)', 'Sand'], + ['rgb(255, 229, 180)', 'Peach'], + ['rgb(100, 54, 46)', 'Bole'], + ['rgb(108, 41, 52)', 'Dark cordovan'], + ['rgb(163, 65, 44)', 'Chestnut'], + ['rgb(228, 136, 100)', 'Dark salmon'], + ['rgb(255, 195, 143)', 'Apricot'], + ['rgb(255, 219, 188)', 'Unbleached silk'], + ['rgb(242, 227, 198)', 'Straw'], + ['rgb( 53, 19, 13)', 'Bistre'], + ['rgb( 84, 42, 14)', 'Dark chocolate'], + ['rgb(102, 51, 43)', 'Burnt sienna'], + ['rgb(184, 66, 0)', 'Sienna'], + ['rgb(216, 153, 12)', 'Yellow ochre'], + ['rgb(210, 180, 140)', 'Tan'], + ['rgb(232, 204, 144)', 'Dark wheat'], + ['rgb( 0, 49, 83)', 'Prussian blue'], + ['rgb( 48, 69, 119)', 'Dark grey blue'], + ['rgb( 0, 71, 171)', 'Cobalt blue'], + ['rgb( 31, 117, 254)', 'Blue'], + ['rgb(120, 180, 255)', 'Bright french blue'], + ['rgb(171, 200, 255)', 'Bright steel blue'], + ['rgb(208, 231, 255)', 'Ice blue'], + ['rgb( 30, 51, 58)', 'Medium jungle green'], + ['rgb( 47, 79, 79)', 'Dark slate grey'], + ['rgb( 74, 104, 93)', 'Dark grullo green'], + ['rgb( 0, 128, 128)', 'Teal'], + ['rgb( 67, 170, 176)', 'Turquoise'], + ['rgb(109, 174, 199)', 'Cerulean frost'], + ['rgb(173, 217, 186)', 'Tiffany green'], + ['rgb( 22, 34, 29)', 'Gray-asparagus'], + ['rgb( 36, 48, 45)', 'Medium dark teal'], + ['rgb( 74, 104, 93)', 'Xanadu'], + ['rgb(119, 198, 121)', 'Mint'], + ['rgb(175, 205, 182)', 'Timberwolf'], + ['rgb(185, 245, 246)', 'Celeste'], + ['rgb(193, 255, 234)', 'Aquamarine'], + ['rgb( 29, 52, 35)', 'Cal Poly Pomona'], + ['rgb( 1, 68, 33)', 'Forest green'], + ['rgb( 42, 128, 0)', 'Napier green'], + ['rgb(128, 128, 0)', 'Olive'], + ['rgb( 65, 156, 105)', 'Sea green'], + ['rgb(189, 246, 29)', 'Green-yellow'], + ['rgb(231, 244, 134)', 'Bright chartreuse'], + ['rgb(138, 23, 137)', 'Purple'], + ['rgb( 78, 39, 138)', 'Violet'], + ['rgb(193, 75, 110)', 'Dark thulian pink'], + ['rgb(222, 49, 99)', 'Cerise'], + ['rgb(255, 20, 147)', 'Deep pink'], + ['rgb(255, 102, 204)', 'Rose pink'], + ['rgb(255, 203, 219)', 'Pink'], + ['rgb(255, 255, 255)', 'White'], + ['rgb(229, 17, 1)', 'RGB Red'], + ['rgb( 0, 255, 0)', 'RGB Green'], + ['rgb( 0, 0, 255)', 'RGB Blue'], + ['rgb( 0, 255, 255)', 'CMYK Cyan'], + ['rgb(255, 0, 255)', 'CMYK Magenta'], + ['rgb(255, 255, 0)', 'CMYK Yellow'], +]; + +// re-arrange to the right order for display +let palReordered = []; +for (let row = 0; row < 7; row++) { + for (let col = 0; col < 11; col++) { + palReordered.push(palette[col * 7 + row]); + } + palReordered.push(null); // null indicates a <br /> +} + +// Utility for converting base64 image to binary for upload +// https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f +function dataURLtoFile(dataurl, filename) { + let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1], + bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n); + while(n--){ + u8arr[n] = bstr.charCodeAt(n); + } + return new File([u8arr], filename, { type: mime }); +} + +const DOODLE_SIZES = { + normal: [500, 500, 'Square 500'], + tootbanner: [702, 330, 'Tootbanner'], + s640x480: [640, 480, '640×480 - 480p'], + s800x600: [800, 600, '800×600 - SVGA'], + s720x480: [720, 405, '720x405 - 16:9'], +}; + + +const mapStateToProps = state => ({ + options: state.getIn(['compose', 'doodle']), +}); + +const mapDispatchToProps = dispatch => ({ + /** Set options in the redux store */ + setOpt: (opts) => dispatch(doodleSet(opts)), + /** Submit doodle for upload */ + submit: (file) => dispatch(uploadCompose([file])), +}); + +/** + * Doodling dialog with drawing canvas + * + * Keyboard shortcuts: + * - Delete: Clear screen, fill with background color + * - Backspace, Ctrl+Z: Undo one step + * - Ctrl held while drawing: Use background color + * - Shift held while clicking screen: Use fill tool + * + * Palette: + * - Left mouse button: pick foreground + * - Ctrl + left mouse button: pick background + * - Right mouse button: pick background + */ +@connect(mapStateToProps, mapDispatchToProps) +export default class DoodleModal extends ImmutablePureComponent { + + static propTypes = { + options: ImmutablePropTypes.map, + onClose: PropTypes.func.isRequired, + setOpt: PropTypes.func.isRequired, + submit: PropTypes.func.isRequired, + }; + + //region Option getters/setters + + /** Foreground color */ + get fg () { + return this.props.options.get('fg'); + } + set fg (value) { + this.props.setOpt({ fg: value }); + } + + /** Background color */ + get bg () { + return this.props.options.get('bg'); + } + set bg (value) { + this.props.setOpt({ bg: value }); + } + + /** Swap Fg and Bg for drawing */ + get swapped () { + return this.props.options.get('swapped'); + } + set swapped (value) { + this.props.setOpt({ swapped: value }); + } + + /** Mode - 'draw' or 'fill' */ + get mode () { + return this.props.options.get('mode'); + } + set mode (value) { + this.props.setOpt({ mode: value }); + } + + /** Base line weight */ + get weight () { + return this.props.options.get('weight'); + } + set weight (value) { + this.props.setOpt({ weight: value }); + } + + /** Drawing opacity */ + get opacity () { + return this.props.options.get('opacity'); + } + set opacity (value) { + this.props.setOpt({ opacity: value }); + } + + /** Adaptive stroke - change width with speed */ + get adaptiveStroke () { + return this.props.options.get('adaptiveStroke'); + } + set adaptiveStroke (value) { + this.props.setOpt({ adaptiveStroke: value }); + } + + /** Smoothing (for mouse drawing) */ + get smoothing () { + return this.props.options.get('smoothing'); + } + set smoothing (value) { + this.props.setOpt({ smoothing: value }); + } + + /** Size preset */ + get size () { + return this.props.options.get('size'); + } + set size (value) { + this.props.setOpt({ size: value }); + } + + //endregion + + /** Key up handler */ + handleKeyUp = (e) => { + if (e.target.nodeName === 'INPUT') return; + + if (e.key === 'Delete') { + e.preventDefault(); + this.handleClearBtn(); + return; + } + + if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) { + e.preventDefault(); + this.undo(); + } + + if (e.key === 'Control' || e.key === 'Meta') { + this.controlHeld = false; + this.swapped = false; + } + + if (e.key === 'Shift') { + this.shiftHeld = false; + this.mode = 'draw'; + } + }; + + /** Key down handler */ + handleKeyDown = (e) => { + if (e.key === 'Control' || e.key === 'Meta') { + this.controlHeld = true; + this.swapped = true; + } + + if (e.key === 'Shift') { + this.shiftHeld = true; + this.mode = 'fill'; + } + }; + + /** + * Component installed in the DOM, do some initial set-up + */ + componentDidMount () { + this.controlHeld = false; + this.shiftHeld = false; + this.swapped = false; + window.addEventListener('keyup', this.handleKeyUp, false); + window.addEventListener('keydown', this.handleKeyDown, false); + }; + + /** + * Tear component down + */ + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp, false); + window.removeEventListener('keydown', this.handleKeyDown, false); + if (this.sketcher) this.sketcher.destroy(); + } + + /** + * Set reference to the canvas element. + * This is called during component init + * + * @param elem - canvas element + */ + setCanvasRef = (elem) => { + this.canvas = elem; + if (elem) { + elem.addEventListener('dirty', () => { + this.saveUndo(); + this.sketcher._dirty = false; + }); + + elem.addEventListener('click', () => { + // sketcher bug - does not fire dirty on fill + if (this.mode === 'fill') { + this.saveUndo(); + } + }); + + // prevent context menu + elem.addEventListener('contextmenu', (e) => { + e.preventDefault(); + }); + + elem.addEventListener('mousedown', (e) => { + if (e.button === 2) { + this.swapped = true; + } + }); + + elem.addEventListener('mouseup', (e) => { + if (e.button === 2) { + this.swapped = this.controlHeld; + } + }); + + this.initSketcher(elem); + this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill' + } + }; + + /** + * Set up the sketcher instance + * + * @param canvas - canvas element. Null if we're just resizing + */ + initSketcher (canvas = null) { + const sizepreset = DOODLE_SIZES[this.size]; + + if (this.sketcher) this.sketcher.destroy(); + this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]); + + if (canvas) { + this.ctx = this.sketcher.context; + this.updateSketcherSettings(); + } + + this.clearScreen(); + } + + /** + * Done button handler + */ + onDoneButton = () => { + const dataUrl = this.sketcher.toImage(); + const file = dataURLtoFile(dataUrl, 'doodle.png'); + this.props.submit(file); + this.props.onClose(); // close dialog + }; + + /** + * Cancel button handler + */ + onCancelButton = () => { + if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) { + return; + } + + this.props.onClose(); // close dialog + }; + + /** + * Update sketcher options based on state + */ + updateSketcherSettings () { + if (!this.sketcher) return; + + if (this.oldSize !== this.size) this.initSketcher(); + + this.sketcher.color = (this.swapped ? this.bg : this.fg); + this.sketcher.opacity = this.opacity; + this.sketcher.weight = this.weight; + this.sketcher.mode = this.mode; + this.sketcher.smoothing = this.smoothing; + this.sketcher.adaptiveStroke = this.adaptiveStroke; + + this.oldSize = this.size; + } + + /** + * Fill screen with background color + */ + clearScreen = () => { + this.ctx.fillStyle = this.bg; + this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2); + this.undos = []; + + this.doSaveUndo(); + }; + + /** + * Undo one step + */ + undo = () => { + if (this.undos.length > 1) { + this.undos.pop(); + const buf = this.undos.pop(); + + this.sketcher.clear(); + this.ctx.putImageData(buf, 0, 0); + this.doSaveUndo(); + } + }; + + /** + * Save canvas content into the undo buffer immediately + */ + doSaveUndo = () => { + this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)); + }; + + /** + * Called on each canvas change. + * Saves canvas content to the undo buffer after some period of inactivity. + */ + saveUndo = debounce(() => { + this.doSaveUndo(); + }, 100); + + /** + * Palette left click. + * Selects Fg color (or Bg, if Control/Meta is held) + * + * @param e - event + */ + onPaletteClick = (e) => { + const c = e.target.dataset.color; + + if (this.controlHeld) { + this.bg = c; + } else { + this.fg = c; + } + + e.target.blur(); + e.preventDefault(); + }; + + /** + * Palette right click. + * Selects Bg color + * + * @param e - event + */ + onPaletteRClick = (e) => { + this.bg = e.target.dataset.color; + e.target.blur(); + e.preventDefault(); + }; + + /** + * Handle click on the Draw mode button + * + * @param e - event + */ + setModeDraw = (e) => { + this.mode = 'draw'; + e.target.blur(); + }; + + /** + * Handle click on the Fill mode button + * + * @param e - event + */ + setModeFill = (e) => { + this.mode = 'fill'; + e.target.blur(); + }; + + /** + * Handle click on Smooth checkbox + * + * @param e - event + */ + tglSmooth = (e) => { + this.smoothing = !this.smoothing; + e.target.blur(); + }; + + /** + * Handle click on Adaptive checkbox + * + * @param e - event + */ + tglAdaptive = (e) => { + this.adaptiveStroke = !this.adaptiveStroke; + e.target.blur(); + }; + + /** + * Handle change of the Weight input field + * + * @param e - event + */ + setWeight = (e) => { + this.weight = +e.target.value || 1; + }; + + /** + * Set size - clalback from the select box + * + * @param e - event + */ + changeSize = (e) => { + let newSize = e.target.value; + if (newSize === this.oldSize) return; + + if (this.undos.length > 1 && !confirm('Change size? This will erase your drawing!')) { + return; + } + + this.size = newSize; + }; + + handleClearBtn = () => { + if (this.undos.length > 1 && !confirm('Clear screen? This will erase your drawing!')) { + return; + } + + this.clearScreen(); + }; + + /** + * Render the component + */ + render () { + this.updateSketcherSettings(); + + return ( + <div className='modal-root__modal doodle-modal'> + <div className='doodle-modal__container'> + <canvas ref={this.setCanvasRef} /> + </div> + + <div className='doodle-modal__action-bar'> + <div className='doodle-toolbar'> + <Button text='Done' onClick={this.onDoneButton} /> + <Button text='Cancel' onClick={this.onCancelButton} /> + </div> + <div className='filler' /> + <div className='doodle-toolbar with-inputs'> + <div> + <label htmlFor='dd_smoothing'>Smoothing</label> + <span className='val'> + <input type='checkbox' id='dd_smoothing' onChange={this.tglSmooth} checked={this.smoothing} /> + </span> + </div> + <div> + <label htmlFor='dd_adaptive'>Adaptive</label> + <span className='val'> + <input type='checkbox' id='dd_adaptive' onChange={this.tglAdaptive} checked={this.adaptiveStroke} /> + </span> + </div> + <div> + <label htmlFor='dd_weight'>Weight</label> + <span className='val'> + <input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} /> + </span> + </div> + <div> + <select aria-label='Canvas size' onInput={this.changeSize} defaultValue={this.size}> + { Object.values(mapValues(DOODLE_SIZES, (val, k) => + <option key={k} value={k}>{val[2]}</option> + )) } + </select> + </div> + </div> + <div className='doodle-toolbar'> + <IconButton icon='pencil' title='Draw' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted /> + <IconButton icon='bath' title='Fill' label='Fill' onClick={this.setModeFill} size={18} active={this.mode === 'fill'} inverted /> + <IconButton icon='undo' title='Undo' label='Undo' onClick={this.undo} size={18} inverted /> + <IconButton icon='trash' title='Clear' label='Clear' onClick={this.handleClearBtn} size={18} inverted /> + </div> + <div className='doodle-palette'> + { + palReordered.map((c, i) => + c === null ? + <br key={i} /> : + <button + key={i} + style={{ backgroundColor: c[0] }} + onClick={this.onPaletteClick} + onContextMenu={this.onPaletteRClick} + data-color={c[0]} + title={c[1]} + className={classNames({ + 'foreground': this.fg === c[0], + 'background': this.bg === c[0], + })} + /> + ) + } + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 79d86370e..3e56fbf8e 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -7,11 +7,13 @@ import ActionsModal from './actions_modal'; import MediaModal from './media_modal'; import VideoModal from './video_modal'; import BoostModal from './boost_modal'; +import DoodleModal from './doodle_modal'; import ConfirmationModal from './confirmation_modal'; import { OnboardingModal, MuteModal, ReportModal, + SettingsModal, EmbedModal, } from '../../../features/ui/util/async-components'; @@ -20,9 +22,11 @@ const MODAL_COMPONENTS = { 'ONBOARDING': OnboardingModal, 'VIDEO': () => Promise.resolve({ default: VideoModal }), 'BOOST': () => Promise.resolve({ default: BoostModal }), + 'DOODLE': () => Promise.resolve({ default: DoodleModal }), 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }), 'MUTE': MuteModal, 'REPORT': ReportModal, + 'SETTINGS': SettingsModal, 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'EMBED': EmbedModal, }; @@ -41,7 +45,7 @@ export default class ModalRoot extends React.PureComponent { handleKeyUp = (e) => { if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) - && !!this.props.type) { + && !!this.props.type && !this.props.props.noEsc) { this.props.onClose(); } } @@ -86,7 +90,7 @@ export default class ModalRoot extends React.PureComponent { } renderLoading = modalId => () => { - return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; + return ['MEDIA', 'VIDEO', 'BOOST', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; } renderError = (props) => { diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js index 54673e223..1f9f0cd03 100644 --- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js +++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js @@ -10,7 +10,10 @@ import ComposeForm from '../../compose/components/compose_form'; import Search from '../../compose/components/search'; import NavigationBar from '../../compose/components/navigation_bar'; import ColumnHeader from './column_header'; -import { List as ImmutableList } from 'immutable'; +import { + List as ImmutableList, + Map as ImmutableMap, +} from 'immutable'; import { me } from '../../../initial_state'; const noop = () => { }; @@ -29,8 +32,8 @@ const PageOne = ({ acct, domain }) => ( </div> <div> - <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1> - <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' /></p> + <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to {domain}!' values={{ domain }} /></h1> + <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='{domain} is an "instance" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' values={{ domain }} /></p> <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>@{acct}@{domain}</strong> }} /></p> </div> </div> @@ -45,7 +48,7 @@ const PageTwo = ({ myAccount }) => ( <div className='onboarding-modal__page onboarding-modal__page-two'> <div className='figure non-interactive'> <div className='pseudo-drawer'> - <NavigationBar account={myAccount} /> + <NavigationBar onClose={noop} account={myAccount} /> </div> <ComposeForm text='Awoo! #introductions' @@ -60,7 +63,9 @@ const PageTwo = ({ myAccount }) => ( onClearSuggestions={noop} onFetchSuggestions={noop} onSuggestionSelected={noop} + onPrivacyChange={noop} showSearch + settings={ImmutableMap.of('side_arm', 'none')} /> </div> @@ -84,7 +89,7 @@ const PageThree = ({ myAccount }) => ( /> <div className='pseudo-drawer'> - <NavigationBar account={myAccount} /> + <NavigationBar onClose={noop} account={myAccount} /> </div> </div> @@ -149,8 +154,8 @@ const PageSix = ({ admin, domain }) => { <div className='onboarding-modal__page onboarding-modal__page-six'> <h1><FormattedMessage id='onboarding.page_six.almost_done' defaultMessage='Almost done...' /></h1> {adminSection} - <p><FormattedMessage id='onboarding.page_six.github' defaultMessage='Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ github: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p> - <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p> + <p><FormattedMessage id='onboarding.page_six.github' defaultMessage='{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ domain, fork: <a href='https://en.wikipedia.org/wiki/Fork_(software_development)' target='_blank' rel='noopener'>fork</a>, Mastodon: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>Mastodon</a>, github: <a href='https://github.com/glitch-soc/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p> + <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ domain, apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p> <p><em><FormattedMessage id='onboarding.page_six.appetoot' defaultMessage='Bon Appetoot!' /></em></p> </div> ); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index f28b37099..69eb1bbf7 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -15,6 +15,7 @@ import { clearHeight } from '../../actions/height_cache'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import UploadArea from './components/upload_area'; import ColumnsAreaContainer from './containers/columns_area_container'; +import classNames from 'classnames'; import { Compose, Status, @@ -28,6 +29,7 @@ import { Following, Reblogs, Favourites, + DirectTimeline, HashtagTimeline, Notifications, FollowRequests, @@ -43,7 +45,7 @@ import { defineMessages, injectIntl } from 'react-intl'; // Dummy import, to make sure that <Status /> ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. -import '../../components/status'; +import '../../../glitch/components/status'; const messages = defineMessages({ beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' }, @@ -52,6 +54,9 @@ const messages = defineMessages({ const mapStateToProps = state => ({ isComposing: state.getIn(['compose', 'is_composing']), hasComposingText: state.getIn(['compose', 'text']) !== '', + layout: state.getIn(['local_settings', 'layout']), + isWide: state.getIn(['local_settings', 'stretch']), + navbarUnder: state.getIn(['local_settings', 'navbar_under']), }); const keyMap = { @@ -72,6 +77,7 @@ const keyMap = { goToNotifications: 'g n', goToLocal: 'g l', goToFederated: 'g t', + goToDirect: 'g d', goToStart: 'g s', goToFavourites: 'g f', goToPinned: 'g p', @@ -92,6 +98,10 @@ export default class UI extends React.Component { static propTypes = { dispatch: PropTypes.func.isRequired, children: PropTypes.node, + layout: PropTypes.string, + isWide: PropTypes.bool, + systemFontUi: PropTypes.bool, + navbarUnder: PropTypes.bool, isComposing: PropTypes.bool, hasComposingText: PropTypes.bool, location: PropTypes.object, @@ -214,6 +224,7 @@ export default class UI extends React.Component { if (nextProps.isComposing !== this.props.isComposing) { // Avoid expensive update just to toggle a class this.node.classList.toggle('is-composing', nextProps.isComposing); + this.node.classList.toggle('navbar-under', nextProps.navbarUnder); return false; } @@ -313,6 +324,10 @@ export default class UI extends React.Component { this.context.router.history.push('/timelines/public'); } + handleHotkeyGoToDirect = () => { + this.context.router.history.push('/timelines/direct'); + } + handleHotkeyGoToStart = () => { this.context.router.history.push('/getting-started'); } @@ -339,7 +354,24 @@ export default class UI extends React.Component { render () { const { width, draggingOver } = this.state; - const { children } = this.props; + const { children, layout, isWide, navbarUnder } = this.props; + + const columnsClass = layout => { + switch (layout) { + case 'single': + return 'single-column'; + case 'multiple': + return 'multi-columns'; + default: + return 'auto-columns'; + } + }; + + const className = classNames('ui', columnsClass(layout), { + 'wide': isWide, + 'system-font': this.props.systemFontUi, + 'navbar-under': navbarUnder, + }); const handlers = { new: this.handleHotkeyNew, @@ -351,6 +383,7 @@ export default class UI extends React.Component { goToNotifications: this.handleHotkeyGoToNotifications, goToLocal: this.handleHotkeyGoToLocal, goToFederated: this.handleHotkeyGoToFederated, + goToDirect: this.handleHotkeyGoToDirect, goToStart: this.handleHotkeyGoToStart, goToFavourites: this.handleHotkeyGoToFavourites, goToPinned: this.handleHotkeyGoToPinned, @@ -361,16 +394,17 @@ export default class UI extends React.Component { return ( <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}> - <div className='ui' ref={this.setRef}> - <TabsBar /> + <div className={className} ref={this.setRef}> + {navbarUnder ? null : (<TabsBar />)} - <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width)}> + <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}> <WrappedSwitch> <Redirect from='/' to='/getting-started' exact /> <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} /> <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} /> <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} /> + <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} /> <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} /> <WrappedRoute path='/notifications' component={Notifications} content={children} /> @@ -396,6 +430,7 @@ export default class UI extends React.Component { </ColumnsAreaContainer> <NotificationsContainer /> + {navbarUnder ? (<TabsBar />) : null} <LoadingBarContainer className='loading-bar' /> <ModalContainer /> <UploadArea active={draggingOver} onClose={this.closeUploadModal} /> diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 39663d5ca..dc8e9dfb9 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -26,6 +26,10 @@ export function HashtagTimeline () { return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline'); } +export function DirectTimeline() { + return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline'); +} + export function Status () { return import(/* webpackChunkName: "features/status" */'../../status'); } @@ -94,6 +98,13 @@ export function ReportModal () { return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal'); } +export function SettingsModal () { + return import(/* webpackChunkName: "modals/settings_modal" */'glitch/components/local_settings/container'); +} + +// THESE AREN'T USED BY US; SEE `glitch/components/status` AND `mastodon/features/status`. // +// IF MASTODON EVER CHANGES DETAILED STATUSES TO REQUIRE THEM, WE'LL NEED TO UPDATE THE URLS OR SOMETHING LOL. // + export function MediaGallery () { return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery'); } diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 3fc45077d..ef5d8b0ef 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -1,5 +1,13 @@ const element = document.getElementById('initial-state'); -const initialState = element && JSON.parse(element.textContent); +const initialState = element && function () { + const result = JSON.parse(element.textContent); + try { + result.local_settings = JSON.parse(localStorage.getItem('mastodon-settings')); + } catch (e) { + result.local_settings = {}; + } + return result; +}(); const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop]; diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js index f96df1ebb..80e8e0a8a 100644 --- a/app/javascript/mastodon/is_mobile.js +++ b/app/javascript/mastodon/is_mobile.js @@ -2,8 +2,15 @@ import detectPassiveEvents from 'detect-passive-events'; const LAYOUT_BREAKPOINT = 630; -export function isMobile(width) { - return width <= LAYOUT_BREAKPOINT; +export function isMobile(width, columns) { + switch (columns) { + case 'multiple': + return false; + case 'single': + return true; + default: + return width <= LAYOUT_BREAKPOINT; + } }; const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index f400b283f..ebb514e69 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -758,6 +758,19 @@ { "descriptors": [ { + "defaultMessage": "Direct messages", + "id": "column.direct" + }, + { + "defaultMessage": "You don't have any direct messages yet. When you send or receive one, it will show up here.", + "id": "empty_column.direct" + } + ], + "path": "app/javascript/mastodon/features/direct_timeline/index.json" + }, + { + "descriptors": [ + { "defaultMessage": "Favourites", "id": "column.favourites" } @@ -817,6 +830,10 @@ "id": "navigation_bar.community_timeline" }, { + "defaultMessage": "Direct messages", + "id": "navigation_bar.direct" + }, + { "defaultMessage": "Preferences", "id": "navigation_bar.preferences" }, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 1d0bbcee5..efe0e1de9 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -28,6 +28,7 @@ "bundle_modal_error.retry": "Try again", "column.blocks": "Blocked users", "column.community": "Local timeline", + "column.direct": "Direct messages", "column.favourites": "Favourites", "column.follow_requests": "Follow requests", "column.home": "Home", @@ -80,6 +81,7 @@ "emoji_button.symbols": "Symbols", "emoji_button.travel": "Travel & Places", "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", + "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.", "empty_column.home.public_timeline": "the public timeline", @@ -106,6 +108,7 @@ "missing_indicator.label": "Not found", "navigation_bar.blocks": "Blocked users", "navigation_bar.community_timeline": "Local timeline", + "navigation_bar.direct": "Direct messages", "navigation_bar.edit_profile": "Edit profile", "navigation_bar.favourites": "Favourites", "navigation_bar.follow_requests": "Follow requests", diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js index 23b6b04fa..93d2eaf10 100644 --- a/app/javascript/mastodon/main.js +++ b/app/javascript/mastodon/main.js @@ -28,6 +28,11 @@ function main() { WebPushSubscription.register(); } perf.stop('main()'); + + // remember the initial URL + if (window.history && typeof window._mastoInitialHistoryLen === 'undefined') { + window._mastoInitialHistoryLen = window.history.length; + } }); } diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js index 1ed0fe3e3..1f795199b 100644 --- a/app/javascript/mastodon/reducers/accounts_counters.js +++ b/app/javascript/mastodon/reducers/accounts_counters.js @@ -126,6 +126,7 @@ export default function accountsCounters(state = initialState, action) { case STATUS_FETCH_SUCCESS: return normalizeAccountFromStatus(state, action.status); case ACCOUNT_FOLLOW_SUCCESS: + if (action.alreadyFollowing) { return state; } return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1); case ACCOUNT_UNFOLLOW_SUCCESS: return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1)); diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index c709fb88c..5d0acbd60 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -16,6 +16,7 @@ import { COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTION_SELECT, + COMPOSE_ADVANCED_OPTIONS_CHANGE, COMPOSE_SENSITIVITY_CHANGE, COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE, @@ -25,6 +26,7 @@ import { COMPOSE_UPLOAD_CHANGE_REQUEST, COMPOSE_UPLOAD_CHANGE_SUCCESS, COMPOSE_UPLOAD_CHANGE_FAIL, + COMPOSE_DOODLE_SET, COMPOSE_RESET, } from '../actions/compose'; import { TIMELINE_DELETE } from '../actions/timelines'; @@ -35,6 +37,9 @@ import { me } from '../initial_state'; const initialState = ImmutableMap({ mounted: false, + advanced_options: ImmutableMap({ + do_not_federate: false, + }), sensitive: false, spoiler: false, spoiler_text: '', @@ -50,10 +55,24 @@ const initialState = ImmutableMap({ media_attachments: ImmutableList(), suggestion_token: null, suggestions: ImmutableList(), + default_advanced_options: ImmutableMap({ + do_not_federate: false, + }), default_privacy: 'public', default_sensitive: false, resetFileKey: Math.floor((Math.random() * 0x10000)), idempotencyKey: null, + doodle: ImmutableMap({ + fg: 'rgb( 0, 0, 0)', + bg: 'rgb(255, 255, 255)', + swapped: false, + mode: 'draw', + size: 'normal', + weight: 2, + opacity: 1, + adaptiveStroke: true, + smoothing: false, + }), }); function statusToTextMentions(state, status) { @@ -73,6 +92,7 @@ function clearAll(state) { map.set('spoiler_text', ''); map.set('is_submitting', false); map.set('in_reply_to', null); + map.set('advanced_options', state.get('default_advanced_options')); map.set('privacy', state.get('default_privacy')); map.set('sensitive', false); map.update('media_attachments', list => list.clear()); @@ -114,7 +134,7 @@ function removeMedia(state, mediaId) { const insertSuggestion = (state, position, token, completion) => { return state.withMutations(map => { - map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`); + map.update('text', oldText => `${oldText.slice(0, position)}${completion}\u200B${oldText.slice(position + token.length)}`); map.set('suggestion_token', null); map.update('suggestions', ImmutableList(), list => list.clear()); map.set('focusDate', new Date()); @@ -126,7 +146,7 @@ const insertEmoji = (state, position, emojiData) => { const emoji = emojiData.native; return state.withMutations(map => { - map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`); + map.update('text', oldText => `${oldText.slice(0, position)}${emoji}\u200B${oldText.slice(position)}`); map.set('focusDate', new Date()); map.set('idempotencyKey', uuid()); }); @@ -164,6 +184,11 @@ export default function compose(state = initialState, action) { return state .set('mounted', false) .set('is_composing', false); + case COMPOSE_ADVANCED_OPTIONS_CHANGE: + return state + .set('advanced_options', + state.get('advanced_options').set(action.option, !state.getIn(['advanced_options', action.option]))) + .set('idempotencyKey', uuid()); case COMPOSE_SENSITIVITY_CHANGE: return state.withMutations(map => { if (!state.get('spoiler')) { @@ -201,6 +226,9 @@ export default function compose(state = initialState, action) { map.set('in_reply_to', action.status.get('id')); map.set('text', statusToTextMentions(state, action.status)); map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); + map.set('advanced_options', new ImmutableMap({ + do_not_federate: /👁\ufe0f?<\/p>$/.test(action.status.get('content')), + })); map.set('focusDate', new Date()); map.set('preselectDate', new Date()); map.set('idempotencyKey', uuid()); @@ -221,6 +249,7 @@ export default function compose(state = initialState, action) { map.set('spoiler', false); map.set('spoiler_text', ''); map.set('privacy', state.get('default_privacy')); + map.set('advanced_options', state.get('default_advanced_options')); map.set('idempotencyKey', uuid()); }); case COMPOSE_SUBMIT_REQUEST: @@ -270,6 +299,8 @@ export default function compose(state = initialState, action) { return item; })); + case COMPOSE_DOODLE_SET: + return state.mergeIn(['doodle'], action.options); default: return state; } diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 17c870351..593d0efa4 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -10,6 +10,7 @@ import accounts_counters from './accounts_counters'; import statuses from './statuses'; import relationships from './relationships'; import settings from './settings'; +import local_settings from '../../glitch/reducers/local_settings'; import push_notifications from './push_notifications'; import status_lists from './status_lists'; import cards from './cards'; @@ -36,6 +37,7 @@ const reducers = { statuses, relationships, settings, + local_settings, push_notifications, cards, mutes, diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index 264db4f55..48850ab01 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -8,6 +8,12 @@ import { NOTIFICATIONS_EXPAND_FAIL, NOTIFICATIONS_CLEAR, NOTIFICATIONS_SCROLL_TOP, + NOTIFICATIONS_DELETE_MARKED_REQUEST, + NOTIFICATIONS_DELETE_MARKED_SUCCESS, + NOTIFICATION_MARK_FOR_DELETE, + NOTIFICATIONS_DELETE_MARKED_FAIL, + NOTIFICATIONS_ENTER_CLEARING_MODE, + NOTIFICATIONS_MARK_ALL_FOR_DELETE, } from '../actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS, @@ -23,12 +29,16 @@ const initialState = ImmutableMap({ unread: 0, loaded: false, isLoading: true, + cleaningMode: false, + // notification removal mark of new notifs loaded whilst cleaningMode is true. + markNewForDelete: false, }); -const notificationToMap = notification => ImmutableMap({ +const notificationToMap = (state, notification) => ImmutableMap({ id: notification.id, type: notification.type, account: notification.account.id, + markedForDelete: state.get('markNewForDelete'), status: notification.status ? notification.status.id : null, }); @@ -44,7 +54,7 @@ const normalizeNotification = (state, notification) => { list = list.take(20); } - return list.unshift(notificationToMap(notification)); + return list.unshift(notificationToMap(state, notification)); }); }; @@ -53,7 +63,7 @@ const normalizeNotifications = (state, notifications, next) => { const loaded = state.get('loaded'); notifications.forEach((n, i) => { - items = items.set(i, notificationToMap(n)); + items = items.set(i, notificationToMap(state, n)); }); if (state.get('next') === null) { @@ -70,7 +80,7 @@ const appendNormalizedNotifications = (state, notifications, next) => { let items = ImmutableList(); notifications.forEach((n, i) => { - items = items.set(i, notificationToMap(n)); + items = items.set(i, notificationToMap(state, n)); }); return state @@ -95,11 +105,43 @@ const deleteByStatus = (state, statusId) => { return state.update('items', list => list.filterNot(item => item.get('status') === statusId)); }; +const markForDelete = (state, notificationId, yes) => { + return state.update('items', list => list.map(item => { + if(item.get('id') === notificationId) { + return item.set('markedForDelete', yes); + } else { + return item; + } + })); +}; + +const markAllForDelete = (state, yes) => { + return state.update('items', list => list.map(item => { + if(yes !== null) { + return item.set('markedForDelete', yes); + } else { + return item.set('markedForDelete', !item.get('markedForDelete')); + } + })); +}; + +const unmarkAllForDelete = (state) => { + return state.update('items', list => list.map(item => item.set('markedForDelete', false))); +}; + +const deleteMarkedNotifs = (state) => { + return state.update('items', list => list.filterNot(item => item.get('markedForDelete'))); +}; + export default function notifications(state = initialState, action) { + let st; + switch(action.type) { case NOTIFICATIONS_REFRESH_REQUEST: case NOTIFICATIONS_EXPAND_REQUEST: + case NOTIFICATIONS_DELETE_MARKED_REQUEST: return state.set('isLoading', true); + case NOTIFICATIONS_DELETE_MARKED_FAIL: case NOTIFICATIONS_REFRESH_FAIL: case NOTIFICATIONS_EXPAND_FAIL: return state.set('isLoading', false); @@ -118,6 +160,31 @@ export default function notifications(state = initialState, action) { return state.set('items', ImmutableList()).set('next', null); case TIMELINE_DELETE: return deleteByStatus(state, action.id); + + case NOTIFICATION_MARK_FOR_DELETE: + return markForDelete(state, action.id, action.yes); + + case NOTIFICATIONS_DELETE_MARKED_SUCCESS: + return deleteMarkedNotifs(state).set('isLoading', false); + + case NOTIFICATIONS_ENTER_CLEARING_MODE: + st = state.set('cleaningMode', action.yes); + if (!action.yes) { + return unmarkAllForDelete(st).set('markNewForDelete', false); + } else { + return st; + } + + case NOTIFICATIONS_MARK_ALL_FOR_DELETE: + st = state; + if (action.yes === null) { + // Toggle - this is a bit confusing, as it toggles the all-none mode + //st = st.set('markNewForDelete', !st.get('markNewForDelete')); + } else { + st = st.set('markNewForDelete', action.yes); + } + return markAllForDelete(st, action.yes); + default: return state; } diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index a9f3f9529..4b8a652d1 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -9,6 +9,7 @@ const initialState = ImmutableMap({ saved: true, onboarded: false, + layout: 'auto', skinTone: 1, @@ -57,6 +58,12 @@ const initialState = ImmutableMap({ body: '', }), }), + + direct: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), }); const defaultColumns = fromJS([ diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 116632dea..d275c3bb0 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -1,5 +1,12 @@ import loadPolyfills from '../mastodon/load_polyfills'; +// import default stylesheet with variables +require('font-awesome/css/font-awesome.css'); + +import '../styles/application.scss'; + +require.context('../images/', true); + loadPolyfills().then(() => { require('../mastodon/main').default(); }).catch(e => { diff --git a/app/javascript/packs/common.js b/app/javascript/packs/common.js index 96e6f4b16..5ac6504d4 100644 --- a/app/javascript/packs/common.js +++ b/app/javascript/packs/common.js @@ -1,6 +1,9 @@ import { start } from 'rails-ujs'; import 'font-awesome/css/font-awesome.css'; +// import common styling +require('../styles/common.scss'); + require.context('../images/', true); start(); diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index a47fc2830..59d0e98dd 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -1,4 +1,5 @@ import loadPolyfills from '../mastodon/load_polyfills'; +import { processBio } from '../glitch/util/bio_metadata'; import ready from '../mastodon/ready'; window.addEventListener('message', e => { @@ -121,7 +122,8 @@ function main() { const noteCounter = document.querySelector('.note-counter'); if (noteCounter) { - noteCounter.textContent = 160 - length(target.value); + const noteWithoutMetadata = processBio(target.value).text; + noteCounter.textContent = 500 - length(noteWithoutMetadata); } }); diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss index 44aa10564..efd34393f 100644 --- a/app/javascript/styles/application.scss +++ b/app/javascript/styles/application.scss @@ -1,5 +1,6 @@ @import 'mastodon/mixins'; @import 'mastodon/variables'; +@import 'variables-glitch'; @import 'fonts/roboto'; @import 'fonts/roboto-mono'; @import 'fonts/montserrat'; diff --git a/app/javascript/styles/common.scss b/app/javascript/styles/common.scss new file mode 100644 index 000000000..c1772e7ae --- /dev/null +++ b/app/javascript/styles/common.scss @@ -0,0 +1,5 @@ +// This makes our fonts available everywhere. + +@import 'fonts/roboto'; +@import 'fonts/roboto-mono'; +@import 'fonts/montserrat'; diff --git a/app/javascript/styles/doodle.scss b/app/javascript/styles/doodle.scss new file mode 100644 index 000000000..a4a1cfc84 --- /dev/null +++ b/app/javascript/styles/doodle.scss @@ -0,0 +1,86 @@ +$doodleBg: #d9e1e8; +.doodle-modal { + @extend .boost-modal; + width: unset; +} + +.doodle-modal__container { + background: $doodleBg; + text-align: center; + line-height: 0; // remove weird gap under canvas + canvas { + border: 5px solid $doodleBg; + } +} + +.doodle-modal__action-bar { + @extend .boost-modal__action-bar; + + .filler { + flex-grow: 1; + margin: 0; + padding: 0; + } + + .doodle-toolbar { + line-height: 1; + + display: flex; + flex-direction: column; + flex-grow: 0; + justify-content: space-around; + + &.with-inputs { + label { + display: inline-block; + width: 70px; + text-align: right; + margin-right: 2px; + } + + input[type="number"],input[type="text"] { + width: 40px; + } + span.val { + display: inline-block; + text-align: left; + width: 50px; + } + } + } + + .doodle-palette { + padding-right: 0 !important; + border: 1px solid black; + line-height: .2rem; + flex-grow: 0; + background: white; + + button { + appearance: none; + width: 1rem; + height: 1rem; + margin: 0; padding: 0; + text-align: center; + color: black; + text-shadow: 0 0 1px white; + cursor: pointer; + box-shadow: inset 0 0 1px rgba(white, .5); + border: 1px solid black; + outline-offset:-1px; + + &.foreground { + outline: 1px dashed white; + } + + &.background { + outline: 1px dashed red; + } + + &.foreground.background { + outline: 1px dashed red; + border-color: white; + } + } + } +} diff --git a/app/javascript/styles/mastodon/_mixins.scss b/app/javascript/styles/mastodon/_mixins.scss index 67d768a6c..7412991b8 100644 --- a/app/javascript/styles/mastodon/_mixins.scss +++ b/app/javascript/styles/mastodon/_mixins.scss @@ -1,5 +1,5 @@ @mixin avatar-radius() { - border-radius: 4px; + border-radius: $ui-avatar-border-size; background: transparent no-repeat; background-position: 50%; background-clip: padding-box; @@ -10,3 +10,33 @@ height: $size; background-size: $size $size; } + +@mixin single-column($media, $parent: '&') { + .auto-columns #{$parent} { + @media #{$media} { + @content; + } + } + .single-column #{$parent} { + @content; + } +} + +@mixin limited-single-column($media, $parent: '&') { + .auto-columns #{$parent}, .single-column #{$parent} { + @media #{$media} { + @content; + } + } +} + +@mixin multi-columns($media, $parent: '&') { + .auto-columns #{$parent} { + @media #{$media} { + @content; + } + } + .multi-columns #{$parent} { + @content; + } +} diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss index 358d86eec..4ec689427 100644 --- a/app/javascript/styles/mastodon/about.scss +++ b/app/javascript/styles/mastodon/about.scss @@ -424,16 +424,14 @@ text-align: center; .avatar { - width: 80px; - height: 80px; + @include avatar-size(80px); margin: 0 auto; margin-bottom: 15px; img { + @include avatar-radius(); + @include avatar-size(80px); display: block; - width: 80px; - height: 80px; - border-radius: 48px; } } diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss index 23e20a366..2cf98c642 100644 --- a/app/javascript/styles/mastodon/accounts.scss +++ b/app/javascript/styles/mastodon/accounts.scss @@ -83,16 +83,15 @@ } .avatar { - width: 120px; + @include avatar-size(120px); margin: 0 auto; position: relative; z-index: 2; img { - width: 120px; - height: 120px; + @include avatar-radius(); + @include avatar-size(120px); display: block; - border-radius: 120px; box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); } } @@ -207,6 +206,50 @@ color: $ui-secondary-color; } + .metadata { + $meta-table-border: darken($classic-highlight-color, 20%);//#174f77; + + border-collapse: collapse; + padding: 0; + margin: 15px -15px -10px -15px; + border: 0 none; + border-top: 1px solid $meta-table-border; + border-bottom: 1px solid $meta-table-border; + + td, th { + padding: 10px; + border: 0 none; + border-bottom: 1px solid $meta-table-border; + vertical-align: middle; + } + + tr:last-child { + td, th { + border-bottom: 0 none; + } + } + + td { + color: $ui-primary-color; + width:100%; // makes it stretch + padding-left: 0; + } + + th { + padding-left: 15px; + font-weight: bold; + text-align: left; + width: 94px; + color: $ui-secondary-color; + background: darken($ui-base-color, 8%); + //background: #131415; + } + + a { + color: $classic-highlight-color; + } + } + @media screen and (max-width: 480px) { display: block; @@ -364,14 +407,12 @@ } .avatar { - width: 80px; - height: 80px; + @include avatar-size(80px); img { display: block; - width: 80px; - height: 80px; - border-radius: 80px; + @include avatar-radius(); + @include avatar-size(80px); border: 2px solid $simple-background-color; background: $simple-background-color; } @@ -451,15 +492,14 @@ } & > div { + @include avatar-size(48px); float: left; margin-right: 10px; - width: 48px; - height: 48px; } .avatar { + @include avatar-radius(); display: block; - border-radius: 4px; } .display-name { diff --git a/app/javascript/styles/mastodon/boost.scss b/app/javascript/styles/mastodon/boost.scss index 31053decc..b07b72f8e 100644 --- a/app/javascript/styles/mastodon/boost.scss +++ b/app/javascript/styles/mastodon/boost.scss @@ -13,6 +13,16 @@ button.icon-button i.fa-retweet { } } +// Disabled variant button.icon-button.disabled i.fa-retweet { - background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(lighten($ui-base-color, 13%))}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($ui-highlight-color)}' stroke-width='0'/></svg>"); + &, &:hover { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(lighten($ui-base-color, 13%))}' stroke-width='0'/></svg>"); + } +} + +// Disabled variant for use with DMs +.status-direct button.icon-button.disabled i.fa-retweet { + &, &:hover { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(lighten($ui-base-color, 16%))}' stroke-width='0'/></svg>"); + } } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 0ded6f159..6a6d1bdca 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1,4 +1,13 @@ @import 'variables'; +@import 'variables-glitch'; + +@mixin fullwidth-gallery { + &.full-width { + margin-left: -22px; + margin-right: -22px; + width: inherit; + } +} .app-body { -webkit-overflow-scrolling: touch; @@ -302,7 +311,6 @@ font-family: inherit; font-size: 14px; background: $simple-background-color; - border-radius: 0 0 4px; } .compose-form__buttons-wrapper { @@ -323,6 +331,11 @@ } } +.compose-form__buttons-separator { + border-left: 1px solid #c3c3c3; + margin: 0 3px; +} + .compose-form__upload-button-icon { line-height: 27px; } @@ -452,12 +465,30 @@ .compose-form__publish { display: flex; + justify-content: flex-end; min-width: 0; } .compose-form__publish-button-wrapper { overflow: hidden; padding-top: 10px; + white-space: nowrap; + display: flex; + + button { + text-overflow: unset; + } +} + +.compose-form__publish__side-arm { + padding: 0 !important; + width: 36px; + text-align: center; + margin-right: 2px; +} + +.compose-form__publish__primary { + padding: 0 10px !important; } .emojione { @@ -511,13 +542,27 @@ cursor: pointer; } +.status-check-box { + .status__content, + .reply-indicator__content { + color: #3a3a3a; + a { + color: #005aa9; + } + } +} + .status__content, .reply-indicator__content { + position: relative; + margin: 10px 0; + padding: 0 12px; font-size: 15px; line-height: 20px; + color: $primary-text-color; word-wrap: break-word; font-weight: 400; - overflow: hidden; + overflow: visible; white-space: pre-wrap; padding-top: 5px; @@ -570,19 +615,10 @@ } } - .status__content__spoiler-link { - background: lighten($ui-base-color, 30%); - - &:hover { - background: lighten($ui-base-color, 33%); - text-decoration: none; - } - } - - .status__content__text { + .status__content__spoiler { display: none; - &.status__content__text--visible { + &.status__content__spoiler--visible { display: block; } } @@ -591,20 +627,54 @@ .status__content__spoiler-link { display: inline-block; border-radius: 2px; - background: transparent; - border: 0; + background: lighten($ui-base-color, 30%); + border: none; color: lighten($ui-base-color, 8%); font-weight: 500; font-size: 11px; - padding: 0 6px; + padding: 0 5px; text-transform: uppercase; line-height: inherit; cursor: pointer; + vertical-align: bottom; + + &:hover { + background: lighten($ui-base-color, 33%); + text-decoration: none; + } + + .status__content__spoiler-icon { + display: inline-block; + margin: 0 0 0 5px; + border-left: 1px solid currentColor; + padding: 0 0 0 4px; + font-size: 16px; + vertical-align: -2px; + } } .status__prepend-icon-wrapper { - left: -26px; - position: absolute; + float: left; + margin: 0 10px 0 -58px; + width: 48px; + text-align: right; +} + +.notif-cleaning { + .status, .notification-follow { + padding-right: ($dismiss-overlay-width + 0.5rem); + } +} + +.notification-follow { + position: relative; + + // same like Status + border-bottom: 1px solid lighten($ui-base-color, 8%); + + .account { + border-bottom: 0 none; + } } .focusable { @@ -625,8 +695,8 @@ .status { padding: 8px 10px; - padding-left: 68px; position: relative; + height: auto; min-height: 48px; border-bottom: 1px solid lighten($ui-base-color, 8%); cursor: default; @@ -693,6 +763,41 @@ } } } + + &.collapsed { + background-position: center; + background-size: cover; + user-select: none; + + &.has-background::before { + display: block; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-image: linear-gradient(to bottom, rgba($base-shadow-color, .75), rgba($base-shadow-color, .65) 24px, rgba($base-shadow-color, .8)); + content: ""; + } + + .display-name:hover .display-name__html { + text-decoration: none; + } + + .status__content { + height: 20px; + overflow: hidden; + text-overflow: ellipsis; + + a:hover { + text-decoration: none; + } + } + } + + .notification__message { + margin: -10px -10px 10px; + } } .notification-favourite { @@ -706,23 +811,39 @@ } .status__relative-time { + display: inline-block; + margin-left: auto; + padding-left: 18px; + width: 120px; color: $ui-base-lighter-color; - float: right; font-size: 14px; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .status__display-name { + margin: 0 auto 0 0; color: $ui-base-lighter-color; -} - -.status__info .status__display-name { - display: block; - max-width: 100%; - padding-right: 25px; + overflow: hidden; } .status__info { + display: flex; + margin: 2px 0 5px; font-size: 15px; + line-height: 24px; +} + +.status__info__icons { + flex: none; + position: relative; + color: lighten($ui-base-color, 26%); + + .status__visibility-icon { + padding-left: 6px; + } } .status-check-box { @@ -747,10 +868,9 @@ } .status__prepend { - margin-left: 68px; + margin: -10px -10px 10px; color: $ui-base-lighter-color; - padding: 8px 0; - padding-bottom: 2px; + padding: 8px 10px 0 68px; font-size: 14px; position: relative; @@ -768,18 +888,36 @@ .status__action-bar { align-items: center; display: flex; - margin-top: 5px; + margin: 10px 4px 0; } .status__action-bar-button { float: left; margin-right: 18px; + flex: 0 0 auto; } .status__action-bar-dropdown { float: left; height: 23.15px; width: 23.15px; + + // Dropdown style override for centering on the icon + .dropdown--active { + position: relative; + + .dropdown__content.dropdown__right { + left: calc(50% + 3px); + right: initial; + transform: translate(-50%, 0); + top: 22px; + } + + &::after { + right: 1px; + bottom: -2px; + } + } } .detailed-status__action-bar-dropdown { @@ -868,8 +1006,7 @@ .account__avatar-wrapper { float: left; - margin-left: 12px; - margin-right: 12px; + margin: 6px 16px 6px 6px; } .account__avatar { @@ -885,6 +1022,7 @@ } .account__avatar-overlay { + position: relative; @include avatar-size(48px); &-base { @@ -905,13 +1043,16 @@ .account__relationship { height: 18px; - padding: 10px; + padding: 12px 10px; white-space: nowrap; } -.account__header { +.account__header__wrapper { flex: 0 0 auto; background: lighten($ui-base-color, 4%); +} + +.account__header { text-align: center; background-size: cover; background-position: center; @@ -1003,6 +1144,59 @@ } } +.account__metadata { + width: 100%; + font-size: 15px; + line-height: 20px; + overflow: hidden; + border-collapse: collapse; + + a { + text-decoration: none; + + &:hover{ + text-decoration: underline; + } + } + + tr { + border-top: 1px solid lighten($ui-base-color, 8%); + } + + th, td { + padding: 14px 20px; + vertical-align: middle; + + & > div { + max-height: 40px; + overflow-y: auto; + white-space: pre-wrap; + text-overflow: ellipsis; + } + } + + th { + color: $ui-primary-color; + background: lighten($ui-base-color, 13%); + font-variant: small-caps; + max-width: 120px; + + a { + color: $primary-text-color; + } + } + + td { + flex: auto; + color: $primary-text-color; + background: $ui-base-color; + + a { + color: $ui-highlight-color; + } + } +} + .account__action-bar { border-top: 1px solid lighten($ui-base-color, 8%); border-bottom: 1px solid lighten($ui-base-color, 8%); @@ -1064,12 +1258,11 @@ } .account__header__avatar { - background-size: 90px 90px; + @include avatar-radius(); + @include avatar-size(90px); display: block; - height: 90px; margin: 0 auto 10px; overflow: hidden; - width: 90px; } .account-authorize { @@ -1109,15 +1302,6 @@ } } -.status__display-name, -.reply-indicator__display-name, -.detailed-status__display-name, -.account__display-name { - &:hover strong { - text-decoration: underline; - } -} - .account__display-name strong { display: block; overflow: hidden; @@ -1155,10 +1339,9 @@ } .status__avatar { + flex: none; + margin: 0 10px 0 0; height: 48px; - left: 10px; - position: absolute; - top: 10px; width: 48px; } @@ -1172,7 +1355,7 @@ color: $ui-base-lighter-color; } - .status__avatar { + .status__avatar, .emojione { opacity: 0.5; } @@ -1188,9 +1371,7 @@ } .notification__message { - margin-left: 68px; - padding: 8px 0; - padding-bottom: 0; + padding: 8px 10px 0 68px; cursor: default; color: $ui-primary-color; font-size: 15px; @@ -1208,8 +1389,10 @@ } .notification__favourite-icon-wrapper { - left: -26px; - position: absolute; + float: left; + margin: 0 10px 0 -58px; + width: 48px; + text-align: right; .star-icon { color: $gold-star; @@ -1233,18 +1416,37 @@ .display-name { display: block; + padding: 6px 0; max-width: 100%; + height: 36px; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.display-name__html { - font-weight: 500; -} + strong { + display: block; + height: 18px; + font-size: 16px; + font-weight: 500; + line-height: 18px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } -.display-name__account { - font-size: 14px; + span { + display: block; + height: 18px; + font-size: 15px; + line-height: 18px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + &:hover { + strong { + text-decoration: underline; + } + } } .status__relative-time, @@ -1493,11 +1695,12 @@ justify-content: flex-start; overflow-x: auto; position: relative; + padding: 10px; } -@media screen and (min-width: 360px) { +@include limited-single-column('screen and (max-width: 360px)', $parent: null) { .columns-area { - padding: 10px; + padding: 0; } .react-swipeable-view-container .columns-area { @@ -1527,6 +1730,13 @@ box-sizing: border-box; display: flex; flex-direction: column; + overflow: hidden; + + .wide & { + flex: auto; + min-width: 330px; + max-width: 400px; + } > .scrollable { background: $ui-base-color; @@ -1547,7 +1757,13 @@ box-sizing: border-box; display: flex; flex-direction: column; - overflow-y: hidden; + overflow-y: auto; + + .wide & { + flex: 1 1 200px; + min-width: 300px; + max-width: 400px; + } } .drawer__tab { @@ -1559,6 +1775,8 @@ text-align: center; font-size: 16px; border-bottom: 2px solid transparent; + outline: none; + cursor: pointer; } .column, @@ -1567,42 +1785,45 @@ overflow: hidden; } -@media screen and (min-width: 360px) { +@include limited-single-column('screen and (max-width: 360px)', $parent: null) { .tabs-bar { - margin: 10px; - margin-bottom: 0; + margin: 0; } .search { - margin-bottom: 10px; + margin-bottom: 0; } } -@media screen and (max-width: 630px) { - .column, - .drawer { - width: 100%; - padding: 0; - } +:root { // Overrides .wide stylings for mobile view + @include single-column('screen and (max-width: 630px)', $parent: null) { + .column, + .drawer { + flex: auto; + width: 100%; + min-width: 0; + max-width: none; + padding: 0; + } - .columns-area { - flex-direction: column; - } + .columns-area { + flex-direction: column; + } - .search__input, - .autosuggest-textarea__textarea { - font-size: 16px; + .search__input, + .autosuggest-textarea__textarea { + font-size: 16px; + } } } -@media screen and (min-width: 631px) { +@include multi-columns('screen and (min-width: 631px)', $parent: null) { .columns-area { padding: 0; } .column, .drawer { - flex: 0 0 auto; padding: 10px; padding-left: 5px; padding-right: 5px; @@ -1628,28 +1849,25 @@ .drawer__pager { box-sizing: border-box; padding: 0; - flex-grow: 1; + flex: 1 1 auto; position: relative; - overflow: hidden; - display: flex; } .drawer__inner { - position: absolute; - top: 0; - left: 0; background: lighten($ui-base-color, 13%); box-sizing: border-box; padding: 0; - display: flex; - flex-direction: column; - overflow: hidden; - overflow-y: auto; - width: 100%; + position: absolute; height: 100%; + width: 100%; &.darker { + position: absolute; + top: 0; + left: 0; background: $ui-base-color; + width: 100%; + height: 100%; } } @@ -1682,6 +1900,8 @@ background: lighten($ui-base-color, 8%); flex: 0 0 auto; overflow-y: auto; + margin: 10px; + margin-bottom: 0; } .tabs-bar__link { @@ -1709,7 +1929,7 @@ &:hover, &:focus, &:active { - @media screen and (min-width: 631px) { + @include multi-columns('screen and (min-width: 631px)') { background: lighten($ui-base-color, 14%); transition: all 100ms linear; } @@ -1721,7 +1941,7 @@ } } -@media screen and (min-width: 600px) { +@include limited-single-column('screen and (max-width: 600px)', $parent: null) { .tabs-bar__link { span { display: inline; @@ -1729,7 +1949,7 @@ } } -@media screen and (min-width: 631px) { +@include multi-columns('screen and (min-width: 631px)', $parent: null) { .tabs-bar { display: none; } @@ -1926,6 +2146,8 @@ font-size: 16px; padding: 15px; text-decoration: none; + cursor: pointer; + outline: none; &:hover { background: lighten($ui-base-color, 11%); @@ -1971,7 +2193,7 @@ outline: 0; } - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { font-size: 16px; } } @@ -1987,7 +2209,7 @@ padding-right: 10px + 22px; resize: none; - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { height: 100px !important; // prevent auto-resize textarea resize: vertical; } @@ -2105,7 +2327,7 @@ border-bottom-color: $ui-highlight-color; } - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { font-size: 16px; } @@ -2352,6 +2574,88 @@ button.icon-button.active i.fa-retweet { background: lighten($ui-base-color, 8%); } } + + // glitch - added focus ring for keyboard navigation + &:focus { + text-shadow: 0 0 4px darken($ui-highlight-color, 5%); + } +} + +.scrollable > div > :first-child .notification__dismiss-overlay > .wrappy { + border-top: 1px solid $ui-base-color; +} + +.notification__dismiss-overlay { + overflow: hidden; + position: absolute; + top: 0; + right: 0; + bottom: -1px; + padding-left: 15px; // space for the box shadow to be visible + + z-index: 999; + align-items: center; + justify-content: flex-end; + cursor: pointer; + + display: flex; + + .wrappy { + width: $dismiss-overlay-width; + align-self: stretch; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: lighten($ui-base-color, 8%); + border-left: 1px solid lighten($ui-base-color, 20%); + box-shadow: 0 0 5px black; + border-bottom: 1px solid $ui-base-color; + } + + .ckbox { + border: 2px solid $ui-primary-color; + border-radius: 2px; + width: 30px; + height: 30px; + font-size: 20px; + color: $ui-primary-color; + text-shadow: 0 0 5px black; + display: flex; + justify-content: center; + align-items: center; + } + + &:focus { + outline: 0 !important; + + .ckbox { + box-shadow: 0 0 1px 1px $ui-highlight-color; + } + } +} + +.column-header__notif-cleaning-buttons { + display: flex; + align-items: stretch; + justify-content: space-around; + + button { + @extend .column-header__button; + background: transparent; + text-align: center; + padding: 10px 0; + white-space: pre-wrap; + } + + b { + font-weight: bold; + } +} + +// The notifs drawer with no padding to have more space for the buttons +.column-header__collapsible-inner.nopad-drawer { + padding: 0; } .column-header__collapsible { @@ -2370,6 +2674,15 @@ button.icon-button.active i.fa-retweet { &.animating { overflow-y: hidden; } + + // notif cleaning drawer + &.ncd { + transition: none; + &.collapsed { + max-height: 0; + opacity: 0.7; + } + } } .column-header__collapsible-inner { @@ -2510,12 +2823,18 @@ button.icon-button.active i.fa-retweet { border: 0; width: 100%; height: 100%; + justify-content: center; + position: relative; + text-align: center; + z-index: 100; + display: flex; + flex-direction: column; - &:hover, - &:active, - &:focus { - color: lighten($ui-primary-color, 8%); + .status__content > & { + margin-top: 15px; // Add margin when used bare for NSFW video player } + + @include fullwidth-gallery; } .media-spoiler__warning { @@ -2974,8 +3293,82 @@ button.icon-button.active i.fa-retweet { } } +.advanced-options-dropdown { + position: relative; +} + +.advanced-options-dropdown__dropdown { + display: none; + position: absolute; + left: 0; + top: 27px; + width: 210px; + background: $simple-background-color; + border-radius: 0 4px 4px; + z-index: 2; + overflow: hidden; +} + +.advanced-options-dropdown__option { + color: $ui-base-color; + padding: 10px; + cursor: pointer; + display: flex; + + &:hover, + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + + .advanced-options-dropdown__option__content { + color: $primary-text-color; + + strong { + color: $primary-text-color; + } + } + } + + &.active:hover { + background: lighten($ui-highlight-color, 4%); + } +} + +.advanced-options-dropdown__option__toggle { + display: flex; + align-items: center; + justify-content: center; + margin-right: 10px; +} + +.advanced-options-dropdown__option__content { + flex: 1 1 auto; + color: darken($ui-primary-color, 24%); + + strong { + font-weight: 500; + display: block; + color: $ui-base-color; + } +} + +.advanced-options-dropdown.open { + .advanced-options-dropdown__value { + background: $simple-background-color; + border-radius: 4px 4px 0 0; + box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1); + } + + .advanced-options-dropdown__dropdown { + display: block; + box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1); + } +} + + .search { position: relative; + margin-bottom: 10px; } .search__input { @@ -3006,7 +3399,7 @@ button.icon-button.active i.fa-retweet { background: lighten($ui-base-color, 4%); } - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { font-size: 16px; } } @@ -3066,6 +3459,10 @@ button.icon-button.active i.fa-retweet { font-weight: 500; } +.search-results__section { + background: $ui-base-color; +} + .search-results__hashtag { display: block; padding: 10px; @@ -3528,17 +3925,7 @@ button.icon-button.active i.fa-retweet { flex-direction: column; .status__display-name { - display: block; - max-width: 100%; - padding-right: 25px; - } - - .status__avatar { - height: 28px; - left: 10px; - position: absolute; - top: 10px; - width: 48px; + display: flex; } } @@ -3805,10 +4192,18 @@ button.icon-button.active i.fa-retweet { /* Media Gallery */ .media-gallery { box-sizing: border-box; - margin-top: 8px; + margin-top: 15px; overflow: hidden; position: relative; + background: $base-shadow-color; width: 100%; + + .detailed-status & { + margin-left:-10px; + width: calc(100% + 22px); + } + + @include fullwidth-gallery; } .media-gallery__item { @@ -3827,16 +4222,20 @@ button.icon-button.active i.fa-retweet { .media-gallery__item-thumbnail { cursor: zoom-in; - display: block; text-decoration: none; + width: 100%; height: 100%; line-height: 0; + display: flex; - &, img { width: 100%; - height: 100%; - object-fit: cover; + object-fit: contain; + + &:not(.letterbox) { + height: 100%; + object-fit: cover; + } } } @@ -3845,17 +4244,21 @@ button.icon-button.active i.fa-retweet { overflow: hidden; position: relative; width: 100%; + display: flex; + justify-content: center; } .media-gallery__item-gifv-thumbnail { cursor: zoom-in; height: 100%; - object-fit: cover; position: relative; - top: 50%; - transform: translateY(-50%); - width: 100%; z-index: 1; + object-fit: contain; + + &:not(.letterbox) { + height: 100%; + object-fit: cover; + } } .media-gallery__item-thumbnail-label { @@ -3868,22 +4271,28 @@ button.icon-button.active i.fa-retweet { /* Status Video Player */ .status__video-player { - background: $base-overlay-background; + display: flex; + align-items: center; + background: $base-shadow-color; box-sizing: border-box; cursor: default; /* May not be needed */ - margin-top: 8px; + margin-top: 15px; overflow: hidden; position: relative; + width: 100%; + + @include fullwidth-gallery; } .status__video-player-video { - height: 100%; - object-fit: cover; position: relative; - top: 50%; - transform: translateY(-50%); width: 100%; z-index: 1; + + &:not(.letterbox) { + height: 100%; + object-fit: cover; + } } .status__video-player-expand, @@ -4105,8 +4514,12 @@ button.icon-button.active i.fa-retweet { background-repeat: no-repeat; background-position: center; cursor: pointer; - margin-top: 8px; + margin-top: 15px; position: relative; + width: 100%; + + @include fullwidth-gallery; + border: 0; display: block; } @@ -4313,6 +4726,42 @@ noscript { } } } + + // fixes for the navbar-under mode + .is-composing.navbar-under { + .search { + margin-top: -20px; + margin-bottom: -20px; + .search__icon { + display: none; + } + } + } +} + +// more fixes for the navbar-under mode +@mixin fix-margins-for-navbar-under { + .tabs-bar { + margin-top: 0 !important; + margin-bottom: -6px !important; + } +} + +.single-column.navbar-under { + @include fix-margins-for-navbar-under; +} + +.auto-columns.navbar-under { + @media screen and (max-width: 360px) { + @include fix-margins-for-navbar-under; + } +} + +.auto-columns.navbar-under .react-swipeable-view-container .columns-area, +.single-column.navbar-under .react-swipeable-view-container .columns-area { + @media screen and (max-width: 360px) { + height: 100% !important; + } } .embed-modal { @@ -4375,3 +4824,5 @@ noscript { } } } + +@import 'doodle'; diff --git a/app/javascript/styles/mastodon/stream_entries.scss b/app/javascript/styles/mastodon/stream_entries.scss index 4f323a378..453070b7c 100644 --- a/app/javascript/styles/mastodon/stream_entries.scss +++ b/app/javascript/styles/mastodon/stream_entries.scss @@ -93,28 +93,25 @@ .status__avatar { position: absolute; - left: 14px; - top: 14px; - width: 48px; - height: 48px; + @include avatar-size(48px); + margin-left: -62px; & > div { - width: 48px; - height: 48px; + @include avatar-size(48px); } img { + @include avatar-radius(); display: block; - border-radius: 4px; } } .display-name { display: block; max-width: 100%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + //overflow: hidden; + //white-space: nowrap; + //text-overflow: ellipsis; strong { font-weight: 500; @@ -180,12 +177,11 @@ } .avatar { - width: 48px; - height: 48px; + @include avatar-size(48px); img { + @include avatar-radius(); display: block; - border-radius: 4px; } } diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss index 52c8cd1cf..090706ff5 100644 --- a/app/javascript/styles/mastodon/variables.scss +++ b/app/javascript/styles/mastodon/variables.scss @@ -27,3 +27,6 @@ $ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkes $ui-primary-color: $classic-primary-color !default; // Lighter $ui-secondary-color: $classic-secondary-color !default; // Lightest $ui-highlight-color: $classic-highlight-color !default; // Vibrant + +// Avatar border size (8% default, 100% for rounded avatars) +$ui-avatar-border-size: 8%; diff --git a/app/javascript/styles/variables-glitch.scss b/app/javascript/styles/variables-glitch.scss new file mode 100644 index 000000000..44d3322f2 --- /dev/null +++ b/app/javascript/styles/variables-glitch.scss @@ -0,0 +1,3 @@ +// glitch-soc added variables + +$dismiss-overlay-width: 4rem; diff --git a/app/javascript/themes/default/theme.yml b/app/javascript/themes/default/theme.yml new file mode 100644 index 000000000..0b262cc82 --- /dev/null +++ b/app/javascript/themes/default/theme.yml @@ -0,0 +1,18 @@ +# (REQUIRED) The location of the pack file inside `pack_directory`. +pack: application.js + +# (OPTIONAL) The directory which contains the pack file. +# Defaults to the theme directory (`app/javascript/themes/[theme]`), +# but in the case of the vanilla Mastodon theme the pack file is +# somewhere else. +pack_directory: app/javascript/packs + +# (OPTIONAL) Additional javascript resources to preload, for use with +# lazy-loaded components. It is **STRONGLY RECOMMENDED** that you +# derive these pathnames from `themes/[your-theme]` to ensure that +# they stay unique. (Of course, vanilla doesn't do this ^^;;) +preload: +- features/getting_started +- features/compose +- features/home_timeline +- features/notifications diff --git a/app/javascript/themes/mastodon-go b/app/javascript/themes/mastodon-go new file mode 160000 +Subproject 74c0293e83dbb49ea4f27eea108526df6216d2a diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 58650efb6..3b16b5d52 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -141,7 +141,10 @@ class FeedManager return false if receiver_id == status.account_id return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) + return true if keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id)) + check_for_mutes = [status.account_id] + check_for_mutes.concat(status.mentions.pluck(:account_id)) check_for_mutes.concat([status.reblog.account_id]) if status.reblog? return true if Mute.where(account_id: receiver_id, target_account_id: check_for_mutes).any? @@ -157,7 +160,9 @@ class FeedManager should_filter &&= status.account_id != status.in_reply_to_account_id # and it's not a self-reply return should_filter elsif status.reblog? # Filter out a reblog - should_filter = Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists? # or if the author of the reblogged status is blocking me + src_id = status.account_id + should_filter = Follow.where(account_id: receiver_id, target_account_id: src_id, show_reblogs: false).exists? # if the reblogger's reblogs are suppressed + should_filter ||= Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists? # or if the author of the reblogged status is blocking me should_filter ||= AccountDomainBlock.where(account_id: receiver_id, domain: status.reblog.account.domain).exists? # or the author's domain is blocked return should_filter end @@ -165,6 +170,18 @@ class FeedManager false end + def keyword_filter?(status, matcher) + should_filter = matcher =~ status.text + should_filter ||= matcher =~ status.spoiler_text + + if status.reblog? + should_filter ||= matcher =~ status.reblog.text + should_filter ||= matcher =~ status.reblog.spoiler_text + end + + !!should_filter + end + def filter_from_mentions?(status, receiver_id) return true if receiver_id == status.account_id @@ -174,6 +191,7 @@ class FeedManager should_filter = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them + should_filter ||= keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id)) # or if the mention contains a muted keyword should_filter end diff --git a/app/lib/frontmatter_handler.rb b/app/lib/frontmatter_handler.rb new file mode 100644 index 000000000..83e5f465e --- /dev/null +++ b/app/lib/frontmatter_handler.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +require 'singleton' + +# See also `app/javascript/features/account/util/bio_metadata.js`. + +class FrontmatterHandler + include Singleton + + # CONVENIENCE FUNCTIONS # + + def self.unirex(str) + Regexp.new str, Regexp::MULTILINE, 'u' + end + def self.rexstr(exp) + '(?:' + exp.source + ')' + end + + # CHARACTER CLASSES # + + DOCUMENT_START = /^/ + DOCUMENT_END = /$/ + ALLOWED_CHAR = # c-printable` in the YAML 1.2 spec. + /[\t\n\r\u{20}-\u{7e}\u{85}\u{a0}-\u{d7ff}\u{e000}-\u{fffd}\u{10000}-\u{10ffff}]/u + WHITE_SPACE = /[ \t]/ + INDENTATION = / */ + LINE_BREAK = /\r?\n|\r|<br\s*\/?>/ + ESCAPE_CHAR = /[0abt\tnvfre "\/\\N_LP]/ + HEXADECIMAL_CHARS = /[0-9a-fA-F]/ + INDICATOR = /[-?:,\[\]{}&#*!|>'"%@`]/ + FLOW_CHAR = /[,\[\]{}]/ + + # NEGATED CHARACTER CLASSES # + + NOT_WHITE_SPACE = unirex '(?!' + rexstr(WHITE_SPACE) + ').' + NOT_LINE_BREAK = unirex '(?!' + rexstr(LINE_BREAK) + ').' + NOT_INDICATOR = unirex '(?!' + rexstr(INDICATOR) + ').' + NOT_FLOW_CHAR = unirex '(?!' + rexstr(FLOW_CHAR) + ').' + NOT_ALLOWED_CHAR = unirex '(?!' + rexstr(ALLOWED_CHAR) + ').' + + # BASIC CONSTRUCTS # + + ANY_WHITE_SPACE = unirex rexstr(WHITE_SPACE) + '*' + ANY_ALLOWED_CHARS = unirex rexstr(ALLOWED_CHAR) + '*' + NEW_LINE = unirex( + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) + ) + SOME_NEW_LINES = unirex( + '(?:' + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) + ')+' + ) + POSSIBLE_STARTS = unirex( + rexstr(DOCUMENT_START) + rexstr(/<p[^<>]*>/) + '?' + ) + POSSIBLE_ENDS = unirex( + rexstr(SOME_NEW_LINES) + '|' + + rexstr(DOCUMENT_END) + '|' + + rexstr(/<\/p>/) + ) + CHARACTER_ESCAPE = unirex( + rexstr(/\\/) + + '(?:' + + rexstr(ESCAPE_CHAR) + '|' + + rexstr(/x/) + rexstr(HEXADECIMAL_CHARS) + '{2}' + '|' + + rexstr(/u/) + rexstr(HEXADECIMAL_CHARS) + '{4}' + '|' + + rexstr(/U/) + rexstr(HEXADECIMAL_CHARS) + '{8}' + + ')' + ) + ESCAPED_CHAR = unirex( + rexstr(/(?!["\\])/) + rexstr(NOT_LINE_BREAK) + '|' + + rexstr(CHARACTER_ESCAPE) + ) + ANY_ESCAPED_CHARS = unirex( + rexstr(ESCAPED_CHAR) + '*' + ) + ESCAPED_APOS = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/) + ) + ANY_ESCAPED_APOS = unirex( + rexstr(ESCAPED_APOS) + '*' + ) + FIRST_KEY_CHAR = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + rexstr(NOT_INDICATOR) + '|' + + rexstr(/[?:-]/) + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + '(?=' + rexstr(NOT_FLOW_CHAR) + ')' + ) + FIRST_VALUE_CHAR = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + rexstr(NOT_INDICATOR) + '|' + + rexstr(/[?:-]/) + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + # Flow indicators are allowed in values. + ) + LATER_KEY_CHAR = unirex( + rexstr(WHITE_SPACE) + '|' + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + '(?=' + rexstr(NOT_FLOW_CHAR) + ')' + + rexstr(/[^:#]#?/) + '|' + + rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + ) + LATER_VALUE_CHAR = unirex( + rexstr(WHITE_SPACE) + '|' + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + # Flow indicators are allowed in values. + rexstr(/[^:#]#?/) + '|' + + rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + ) + + # YAML CONSTRUCTS # + + YAML_START = unirex( + rexstr(ANY_WHITE_SPACE) + rexstr(/---/) + ) + YAML_END = unirex( + rexstr(ANY_WHITE_SPACE) + rexstr(/(?:---|\.\.\.)/) + ) + YAML_LOOKAHEAD = unirex( + '(?=' + + rexstr(YAML_START) + + rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) + + rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) + + ')' + ) + YAML_DOUBLE_QUOTE = unirex( + rexstr(/"/) + rexstr(ANY_ESCAPED_CHARS) + rexstr(/"/) + ) + YAML_SINGLE_QUOTE = unirex( + rexstr(/'/) + rexstr(ANY_ESCAPED_APOS) + rexstr(/'/) + ) + YAML_SIMPLE_KEY = unirex( + rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*' + ) + YAML_SIMPLE_VALUE = unirex( + rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*' + ) + YAML_KEY = unirex( + rexstr(YAML_DOUBLE_QUOTE) + '|' + + rexstr(YAML_SINGLE_QUOTE) + '|' + + rexstr(YAML_SIMPLE_KEY) + ) + YAML_VALUE = unirex( + rexstr(YAML_DOUBLE_QUOTE) + '|' + + rexstr(YAML_SINGLE_QUOTE) + '|' + + rexstr(YAML_SIMPLE_VALUE) + ) + YAML_SEPARATOR = unirex( + rexstr(ANY_WHITE_SPACE) + + ':' + rexstr(WHITE_SPACE) + + rexstr(ANY_WHITE_SPACE) + ) + YAML_LINE = unirex( + '(' + rexstr(YAML_KEY) + ')' + + rexstr(YAML_SEPARATOR) + + '(' + rexstr(YAML_VALUE) + ')' + ) + + # FRONTMATTER REGEX # + + YAML_FRONTMATTER = unirex( + rexstr(POSSIBLE_STARTS) + + rexstr(YAML_LOOKAHEAD) + + rexstr(YAML_START) + rexstr(SOME_NEW_LINES) + + '(?:' + + '(' + rexstr(INDENTATION) + ')' + + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) + + '(?:' + + '\\1' + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) + + '){0,4}' + + ')?' + + rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) + ) + + # SEARCHES # + + FIND_YAML_LINES = unirex( + rexstr(NEW_LINE) + rexstr(INDENTATION) + rexstr(YAML_LINE) + ) + + # STRING PROCESSING # + + def process_string(str) + case str[0] + when '"' + str[1..-2] + .gsub(/\\0/, "\u{00}") + .gsub(/\\a/, "\u{07}") + .gsub(/\\b/, "\u{08}") + .gsub(/\\t/, "\u{09}") + .gsub(/\\\u{09}/, "\u{09}") + .gsub(/\\n/, "\u{0a}") + .gsub(/\\v/, "\u{0b}") + .gsub(/\\f/, "\u{0c}") + .gsub(/\\r/, "\u{0d}") + .gsub(/\\e/, "\u{1b}") + .gsub(/\\ /, "\u{20}") + .gsub(/\\"/, "\u{22}") + .gsub(/\\\//, "\u{2f}") + .gsub(/\\\\/, "\u{5c}") + .gsub(/\\N/, "\u{85}") + .gsub(/\\_/, "\u{a0}") + .gsub(/\\L/, "\u{2028}") + .gsub(/\\P/, "\u{2029}") + .gsub(/\\x([0-9a-fA-F]{2})/mu) {|s| $1.to_i.chr Encoding::UTF_8} + .gsub(/\\u([0-9a-fA-F]{4})/mu) {|s| $1.to_i.chr Encoding::UTF_8} + .gsub(/\\U([0-9a-fA-F]{8})/mu) {|s| $1.to_i.chr Encoding::UTF_8} + when "'" + str[1..-2].gsub(/''/, "'") + else + str + end + end + + # BIO PROCESSING # + + def process_bio content + result = { + text: content.gsub(/"/, '"').gsub(/'/, "'"), + metadata: [] + } + yaml = YAML_FRONTMATTER.match(result[:text]) + return result unless yaml + yaml = yaml[0] + start = YAML_START =~ result[:text] + ending = start + yaml.length - (YAML_START =~ yaml) + result[:text][start..ending - 1] = '' + metadata = nil + index = 0 + while metadata = FIND_YAML_LINES.match(yaml, index) do + index = metadata.end(0) + result[:metadata].push [ + process_string(metadata[1]), process_string(metadata[2]) + ] + end + return result + end + +end diff --git a/app/lib/themes.rb b/app/lib/themes.rb index 243ffb9ab..f7ec22fd2 100644 --- a/app/lib/themes.rb +++ b/app/lib/themes.rb @@ -7,7 +7,19 @@ class Themes include Singleton def initialize - @conf = YAML.load_file(Rails.root.join('config', 'themes.yml')) + result = Hash.new + Dir.glob(Rails.root.join('app', 'javascript', 'themes', '*', 'theme.yml')) do |path| + data = YAML.load_file(path) + name = File.basename(File.dirname(path)) + if data['pack'] + result[name] = data + end + end + @conf = result + end + + def get(name) + @conf[name] end def names diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index d48e1da65..d86959c0b 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -76,7 +76,7 @@ class UserSettingsDecorator def theme_preference settings['setting_theme'] end - + def boolean_cast_setting(key) settings[key] == '1' end diff --git a/app/models/account.rb b/app/models/account.rb index bc01d2448..230a5d298 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -54,6 +54,8 @@ class Account < ApplicationRecord include Attachmentable include Remotable + MAX_NOTE_LENGTH = 500 + enum protocol: [:ostatus, :activitypub] # Local users @@ -68,7 +70,7 @@ class Account < ApplicationRecord validates :username, format: { with: /\A[a-z0-9_]+\z/i }, uniqueness: { scope: :domain, case_sensitive: false }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? } validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? } validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? } - validates :note, length: { maximum: 160 }, if: -> { local? && will_save_change_to_note? } + validate :note_length_does_not_exceed_length_limit, if: -> { local? && will_save_change_to_note? } # Timelines has_many :stream_entries, inverse_of: :account, dependent: :destroy @@ -309,6 +311,22 @@ class Account < ApplicationRecord self.public_key = keypair.public_key.to_pem end + YAML_START = "---\r\n" + YAML_END = "\r\n...\r\n" + + def note_length_does_not_exceed_length_limit + note_without_metadata = note + if note.start_with? YAML_START + idx = note.index YAML_END + unless idx.nil? + note_without_metadata = note[(idx + YAML_END.length) .. -1] + end + end + if note_without_metadata.mb_chars.grapheme_length > MAX_NOTE_LENGTH + errors.add(:note, "can't be longer than 500 graphemes") + end + end + def normalize_domain return if local? diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 55ad812b2..c41f92581 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -5,7 +5,11 @@ module AccountInteractions class_methods do def following_map(target_account_ids, account_id) - follow_mapping(Follow.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + Follow.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow, mapping| + mapping[follow.target_account_id] = { + reblogs: follow.show_reblogs? + } + end end def followed_by_map(target_account_ids, account_id) @@ -25,7 +29,11 @@ module AccountInteractions end def requested_map(target_account_ids, account_id) - follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + FollowRequest.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow_request, mapping| + mapping[follow_request.target_account_id] = { + reblogs: follow_request.show_reblogs? + } + end end def domain_blocking_map(target_account_ids, account_id) @@ -66,8 +74,12 @@ module AccountInteractions has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy end - def follow!(other_account) - active_relationships.find_or_create_by!(target_account: other_account) + def follow!(other_account, reblogs: nil) + reblogs = true if reblogs.nil? + rel = active_relationships.create_with(show_reblogs: reblogs).find_or_create_by!(target_account: other_account) + rel.update!(show_reblogs: reblogs) + + rel end def block!(other_account) @@ -140,6 +152,10 @@ module AccountInteractions mute_relationships.where(target_account: other_account, hide_notifications: true).exists? end + def muting_reblogs?(other_account) + active_relationships.where(target_account: other_account, show_reblogs: false).exists? + end + def requested?(other_account) follow_requests.where(target_account: other_account).exists? end diff --git a/app/models/follow.rb b/app/models/follow.rb index 3d5447fb1..ea00a377a 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -5,9 +5,10 @@ # # created_at :datetime not null # updated_at :datetime not null -# account_id :bigint not null -# id :bigint not null, primary key -# target_account_id :bigint not null +# account_id :bigint not null +# id :bigint not null, primary key +# target_account_id :bigint not null +# show_reblogs :boolean default(TRUE), not null # class Follow < ApplicationRecord diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb index ce27fc921..962b61411 100644 --- a/app/models/follow_request.rb +++ b/app/models/follow_request.rb @@ -5,9 +5,10 @@ # # created_at :datetime not null # updated_at :datetime not null -# account_id :bigint not null -# id :bigint not null, primary key -# target_account_id :bigint not null +# account_id :bigint not null +# id :bigint not null, primary key +# target_account_id :bigint not null +# show_reblogs :boolean default(TRUE), not null # class FollowRequest < ApplicationRecord @@ -21,7 +22,7 @@ class FollowRequest < ApplicationRecord validates :account_id, uniqueness: { scope: :target_account_id } def authorize! - account.follow!(target_account) + account.follow!(target_account, reblogs: show_reblogs) MergeWorker.perform_async(target_account.id, account.id) destroy! diff --git a/app/models/glitch.rb b/app/models/glitch.rb new file mode 100644 index 000000000..0e497babc --- /dev/null +++ b/app/models/glitch.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Glitch + def self.table_name_prefix + 'glitch_' + end +end diff --git a/app/models/glitch/keyword_mute.rb b/app/models/glitch/keyword_mute.rb new file mode 100644 index 000000000..009de1880 --- /dev/null +++ b/app/models/glitch/keyword_mute.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: glitch_keyword_mutes +# +# id :integer not null, primary key +# account_id :integer not null +# keyword :string not null +# whole_word :boolean default(TRUE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class Glitch::KeywordMute < ApplicationRecord + belongs_to :account, required: true + + validates_presence_of :keyword + + after_commit :invalidate_cached_matcher + + def self.matcher_for(account_id) + Matcher.new(account_id) + end + + private + + def invalidate_cached_matcher + Rails.cache.delete("keyword_mutes:regex:#{account_id}") + end + + class Matcher + attr_reader :account_id + attr_reader :regex + + def initialize(account_id) + @account_id = account_id + regex_text = Rails.cache.fetch("keyword_mutes:regex:#{account_id}") { regex_text_for_account } + @regex = /#{regex_text}/ + end + + def =~(str) + regex =~ str + end + + private + + def keywords + Glitch::KeywordMute.where(account_id: account_id).select(:keyword, :id, :whole_word) + end + + def regex_text_for_account + kws = keywords.find_each.with_object([]) do |kw, a| + a << (kw.whole_word ? boundary_regex_for_keyword(kw.keyword) : kw.keyword) + end + + Regexp.union(kws).source + end + + def boundary_regex_for_keyword(keyword) + sb = keyword =~ /\A[[:word:]]/ ? '\b' : '' + eb = keyword =~ /[[:word:]]\Z/ ? '\b' : '' + + /(?mix:#{sb}#{Regexp.escape(keyword)}#{eb})/ + end + end +end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index f05418925..7a553f703 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -24,15 +24,32 @@ require 'mime/types' class MediaAttachment < ApplicationRecord self.inheritance_column = nil - enum type: [:image, :gifv, :video, :unknown] + enum type: [:image, :gifv, :video, :audio, :unknown] IMAGE_FILE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif'].freeze VIDEO_FILE_EXTENSIONS = ['.webm', '.mp4', '.m4v'].freeze + AUDIO_FILE_EXTENSIONS = ['.mp3', '.m4a', '.wav', '.ogg'].freeze IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze + AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze + AUDIO_STYLES = { + original: { + format: 'mp4', + convert_options: { + output: { + filter_complex: '"[0:a]compand,showwaves=s=640x360:mode=line,format=yuv420p[v]"', + map: '"[v]" -map 0:a', + threads: 2, + vcodec: 'libx264', + acodec: 'aac', + movflags: '+faststart', + }, + }, + }, + }.freeze VIDEO_STYLES = { small: { convert_options: { @@ -55,7 +72,7 @@ class MediaAttachment < ApplicationRecord include Remotable - validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES validates_attachment_size :file, less_than: 8.megabytes validates :account, presence: true @@ -110,6 +127,8 @@ class MediaAttachment < ApplicationRecord } elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type IMAGE_STYLES + elsif AUDIO_MIME_TYPES.include? f.instance.file_content_type + AUDIO_STYLES else VIDEO_STYLES end @@ -120,6 +139,8 @@ class MediaAttachment < ApplicationRecord [:gif_transcoder] elsif VIDEO_MIME_TYPES.include? f.file_content_type [:video_transcoder] + elsif AUDIO_MIME_TYPES.include? f.file_content_type + [:audio_transcoder] else [:thumbnail] end @@ -144,8 +165,8 @@ class MediaAttachment < ApplicationRecord end def set_type_and_extension - self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image - extension = appropriate_extension + self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : AUDIO_MIME_TYPES.include?(file_content_type) ? :audio : :image + extension = AUDIO_MIME_TYPES.include?(file_content_type) ? '.mp4' : appropriate_extension basename = Paperclip::Interpolations.basename(file, :original) file.instance_write :file_name, [basename, extension].delete_if(&:blank?).join('.') end diff --git a/app/models/mute.rb b/app/models/mute.rb index 105696da6..74b445c0b 100644 --- a/app/models/mute.rb +++ b/app/models/mute.rb @@ -3,11 +3,11 @@ # # Table name: mutes # -# id :integer not null, primary key +# id :bigint not null, primary key # created_at :datetime not null # updated_at :datetime not null -# account_id :integer not null -# target_account_id :integer not null +# account_id :bigint not null +# target_account_id :bigint not null # hide_notifications :boolean default(TRUE), not null # diff --git a/app/models/status.rb b/app/models/status.rb index b4f314311..1dda6594e 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -154,6 +154,14 @@ class Status < ApplicationRecord where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private]) end + def as_direct_timeline(account) + query = joins("LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = #{account.id}") + .where("mentions.account_id = #{account.id} OR statuses.account_id = #{account.id}") + .where(visibility: [:direct]) + + apply_timeline_filters(query, account, false) + end + def as_public_timeline(account = nil, local_only = false) query = timeline_scope(local_only).without_replies @@ -261,6 +269,11 @@ class Status < ApplicationRecord end end + def local_only? + # match both with and without U+FE0F (the emoji variation selector) + /👁\ufe0f?\z/.match?(content) + end + private def store_uri diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb index 50b900c3c..2b812d826 100644 --- a/app/models/stream_entry.rb +++ b/app/models/stream_entry.rb @@ -27,7 +27,7 @@ class StreamEntry < ApplicationRecord scope :recent, -> { reorder(id: :desc) } scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES) } - delegate :target, :title, :content, :thread, + delegate :target, :title, :content, :thread, :local_only?, to: :status, allow_nil: true diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 0373fdf04..8e0c1eef1 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -6,6 +6,8 @@ class StatusPolicy < ApplicationPolicy end def show? + return false if local_only? && account.nil? + if direct? owned? || record.mentions.where(account: current_account).exists? elsif private? @@ -46,4 +48,8 @@ class StatusPolicy < ApplicationPolicy def author record.account end + + def local_only? + record.local_only? + end end diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb index 4c1124d59..1c08fb3bc 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -32,6 +32,15 @@ class InstancePresenter Mastodon::Version end + def commit_hash + current_release_file = Pathname.new('CURRENT_RELEASE').expand_path + if current_release_file.file? + IO.read(current_release_file).strip! + else + '' + end + end + def source_url Mastodon::Version.source_url end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 4fa1981ed..787c5add3 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -53,6 +53,6 @@ class InitialStateSerializer < ActiveModel::Serializer end def media_attachments - { accept_content_types: MediaAttachment::IMAGE_FILE_EXTENSIONS + MediaAttachment::VIDEO_FILE_EXTENSIONS + MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES } + { accept_content_types: MediaAttachment::IMAGE_FILE_EXTENSIONS + MediaAttachment::VIDEO_FILE_EXTENSIONS + MediaAttachment::AUDIO_FILE_EXTENSIONS + MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES + MediaAttachment::AUDIO_MIME_TYPES } end end diff --git a/app/serializers/rest/mute_serializer.rb b/app/serializers/rest/mute_serializer.rb new file mode 100644 index 000000000..043a2f059 --- /dev/null +++ b/app/serializers/rest/mute_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class REST::MuteSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :id, :account, :target_account, :created_at, :hide_notifications + + def account + REST::AccountSerializer.new(object.account) + end + + def target_account + REST::AccountSerializer.new(object.target_account) + end +end \ No newline at end of file diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 676a5d04d..f68fa8837 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -40,6 +40,7 @@ class BatchedRemoveStatusService < BaseService # Cannot be batched statuses.each do |status| unpush_from_public_timelines(status) + unpush_from_direct_timelines(status) if status.direct_visibility? batch_salmon_slaps(status) if status.local? end @@ -100,6 +101,16 @@ class BatchedRemoveStatusService < BaseService end end + def unpush_from_direct_timelines(status) + payload = @json_payloads[status.id] + redis.pipelined do + @mentions[status.id].each do |mention| + redis.publish("timeline:direct:#{mention.account.id}", payload) if mention.account.local? + end + redis.publish("timeline:direct:#{status.account.id}", payload) if status.account.local? + end + end + def batch_salmon_slaps(status) return if @mentions[status.id].empty? diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 47a47a735..2214d73dd 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -10,15 +10,17 @@ class FanOutOnWriteService < BaseService deliver_to_self(status) if status.account.local? + render_anonymous_payload(status) + if status.direct_visibility? deliver_to_mentioned_followers(status) + deliver_to_direct_timelines(status) else deliver_to_followers(status) end return if status.account.silenced? || !status.public_visibility? || status.reblog? - render_anonymous_payload(status) deliver_to_hashtags(status) return if status.reply? && status.in_reply_to_account_id != status.account_id @@ -73,4 +75,13 @@ class FanOutOnWriteService < BaseService Redis.current.publish('timeline:public', @payload) Redis.current.publish('timeline:public:local', @payload) if status.local? end + + def deliver_to_direct_timelines(status) + Rails.logger.debug "Delivering status #{status.id} to direct timelines" + + status.mentions.includes(:account).each do |mention| + Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local? + end + Redis.current.publish("timeline:direct:#{status.account.id}", @payload) if status.account.local? + end end diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 791773f25..20579ca63 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -6,25 +6,38 @@ class FollowService < BaseService # Follow a remote user, notify remote user about the follow # @param [Account] source_account From which to follow # @param [String, Account] uri User URI to follow in the form of username@domain (or account record) - def call(source_account, uri) + # @param [true, false, nil] reblogs Whether or not to show reblogs, defaults to true + def call(source_account, uri, reblogs: nil) + reblogs = true if reblogs.nil? target_account = uri.is_a?(Account) ? uri : ResolveRemoteAccountService.new.call(uri) raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended? raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) - return if source_account.following?(target_account) || source_account.requested?(target_account) + if source_account.following?(target_account) + # We're already following this account, but we'll call follow! again to + # make sure the reblogs status is set correctly. + source_account.follow!(target_account, reblogs: reblogs) + return + elsif source_account.requested?(target_account) + # This isn't managed by a method in AccountInteractions, so we modify it + # ourselves if necessary. + req = follow_requests.find_by(target_account: other_account) + req.update!(show_reblogs: reblogs) + return + end if target_account.locked? || target_account.activitypub? - request_follow(source_account, target_account) + request_follow(source_account, target_account, reblogs: reblogs) else - direct_follow(source_account, target_account) + direct_follow(source_account, target_account, reblogs: reblogs) end end private - def request_follow(source_account, target_account) - follow_request = FollowRequest.create!(account: source_account, target_account: target_account) + def request_follow(source_account, target_account, reblogs: true) + follow_request = FollowRequest.create!(account: source_account, target_account: target_account, show_reblogs: reblogs) if target_account.local? NotifyService.new.call(target_account, follow_request) @@ -38,8 +51,8 @@ class FollowService < BaseService follow_request end - def direct_follow(source_account, target_account) - follow = source_account.follow!(target_account) + def direct_follow(source_account, target_account, reblogs: true) + follow = source_account.follow!(target_account, reblogs: reblogs) if target_account.local? NotifyService.new.call(target_account, follow) diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index 9b7cbd81f..547b2efa1 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -3,7 +3,6 @@ class MuteService < BaseService def call(account, target_account, notifications: nil) return if account.id == target_account.id - FeedManager.instance.clear_from_timeline(account, target_account) mute = account.mute!(target_account, notifications: notifications) BlockWorker.perform_async(account.id, target_account.id) mute diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 8a77f2f38..d5960c3ad 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -29,7 +29,7 @@ class NotifyService < BaseService end def blocked_reblog? - false + @recipient.muting_reblogs?(@notification.from_account) end def blocked_follow_request? diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index de350f8e6..974c586f2 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -39,9 +39,13 @@ class PostStatusService < BaseService LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text? DistributionWorker.perform_async(status.id) - Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) - ActivityPub::DistributionWorker.perform_async(status.id) - ActivityPub::ReplyDistributionWorker.perform_async(status.id) if status.reply? && status.thread.account.local? + + # match both with and without U+FE0F (the emoji variation selector) + unless /👁\ufe0f?\z/.match?(status.content) + Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) + ActivityPub::DistributionWorker.perform_async(status.id) + ActivityPub::ReplyDistributionWorker.perform_async(status.id) if status.reply? && status.thread.account.local? + end if options[:idempotency].present? redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id) diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 3c4e5847f..52e3ba0e0 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -20,8 +20,11 @@ class ReblogService < BaseService reblog = account.statuses.create!(reblog: reblogged_status, text: '') DistributionWorker.perform_async(reblog.id) - Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) - ActivityPub::DistributionWorker.perform_async(reblog.id) + + unless /👁$/.match?(reblogged_status.content) + Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) + ActivityPub::DistributionWorker.perform_async(reblog.id) + end create_notification(reblog) reblog diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 96d9208cc..8eef3e57e 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -18,6 +18,7 @@ class RemoveStatusService < BaseService remove_reblogs remove_from_hashtags remove_from_public + remove_from_direct if status.direct_visibility? @status.destroy! @@ -121,6 +122,13 @@ class RemoveStatusService < BaseService Redis.current.publish('timeline:public:local', @payload) if @status.local? end + def remove_from_direct + @mentions.each do |mention| + Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local? + end + Redis.current.publish("timeline:direct:#{@account.id}", @payload) if @account.local? + end + def redis Redis.current end diff --git a/app/validators/status_length_validator.rb b/app/validators/status_length_validator.rb index 77be3f1f5..2ce5d1ee9 100644 --- a/app/validators/status_length_validator.rb +++ b/app/validators/status_length_validator.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class StatusLengthValidator < ActiveModel::Validator - MAX_CHARS = 500 + MAX_CHARS = 512 def validate(status) return unless status.local? && !status.reblog? diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index b012606ce..7ffa5ecc3 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -55,4 +55,8 @@ .container %p = link_to t('about.source_code'), @instance_presenter.source_url - = " (#{@instance_presenter.version_number})" + - if @instance_presenter.commit_hash == "" + %strong= " (#{@instance_presenter.version_number})" + - else + %strong= "#{@instance_presenter.version_number}, " + %strong= "#{@instance_presenter.commit_hash}" diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml index f8f90ce24..385b0b1dc 100644 --- a/app/views/about/show.html.haml +++ b/app/views/about/show.html.haml @@ -47,6 +47,13 @@ %p= t('about.closed_registrations') - else = @instance_presenter.closed_registrations_message.html_safe + + = simple_form_for(:user, html: { style: 'margin-left: -20px' }, url: session_path(:user)) do |f| + = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } + = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' } + + .actions + = f.button :button, t('auth.login'), type: :submit = link_to t('about.find_another_instance'), 'https://joinmastodon.org/', class: 'button button-alternative button--block' .about-short @@ -68,4 +75,8 @@ .container %p = link_to t('about.source_code'), @instance_presenter.source_url - = " (#{@instance_presenter.version_number})" + - if @instance_presenter.commit_hash == "" + %strong= " (#{@instance_presenter.version_number})" + - else + %strong= " (#{@instance_presenter.version_number}, " + %strong= " #{@instance_presenter.commit_hash})" diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index e4c258acd..94ec5ae5b 100644 --- a/app/views/accounts/_header.html.haml +++ b/app/views/accounts/_header.html.haml @@ -1,3 +1,4 @@ +- processed_bio = FrontmatterHandler.instance.process_bio Formatter.instance.simplified_format account .card.h-card.p-author{ style: "background-image: url(#{account.header.url(:original)})" } .card__illustration - unless account.memorial? @@ -35,9 +36,14 @@ .roles .account-role.moderator = t 'accounts.roles.moderator' - .bio - .account__header__content.p-note.emojify= Formatter.instance.simplified_format(account) + .account__header__content.p-note.emojify!=processed_bio[:text] + - if processed_bio[:metadata].length > 0 + %table.metadata< + - processed_bio[:metadata].each do |i| + %tr.metadata-item>< + %th.emojify>!=i[0] + %td.emojify>!=i[1] .details-counters .counter{ class: active_nav_class(short_account_url(account)) } diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index 8c88d2d64..63b3a0c26 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -1,12 +1,12 @@ - content_for :header_tags do - %link{ href: asset_pack_path('features/getting_started.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ - %link{ href: asset_pack_path('features/compose.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ - %link{ href: asset_pack_path('features/home_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ - %link{ href: asset_pack_path('features/notifications.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ + - if theme_data['preload'] + - theme_data['preload'].each do |link| + %link{ href: asset_pack_path("#{link}.js"), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ %meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key} %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) - = javascript_pack_tag 'application', integrity: true, crossorigin: 'anonymous' + = javascript_pack_tag "themes/#{current_theme}", integrity: true, crossorigin: 'anonymous' + = stylesheet_pack_tag "themes/#{current_theme}", integrity: true, media: 'all' .app-holder#mastodon{ data: { props: Oj.dump(default_props) } } %noscript diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index ee995c987..24b74c787 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -19,11 +19,13 @@ = title = stylesheet_pack_tag 'common', media: 'all' - = stylesheet_pack_tag current_theme, media: 'all' = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous' = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous' = csrf_meta_tags + - if controller_name != 'home' + = stylesheet_pack_tag 'application', integrity: true, media: 'all' + = yield :header_tags - body_classes ||= @body_classes || '' diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml index ac11cfbe7..5fc60be17 100644 --- a/app/views/layouts/embedded.html.haml +++ b/app/views/layouts/embedded.html.haml @@ -5,8 +5,8 @@ %meta{ name: 'robots', content: 'noindex' }/ = stylesheet_pack_tag 'common', media: 'all' - = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all' = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous' + = stylesheet_pack_tag 'application', integrity: true, media: 'all' = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous' = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous' %body.embed diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml index 37359b89b..d0eae4434 100644 --- a/app/views/layouts/error.html.haml +++ b/app/views/layouts/error.html.haml @@ -6,7 +6,7 @@ %title= safe_join([yield(:page_title), Setting.default_settings['site_title']], ' - ') %meta{ content: 'width=device-width,initial-scale=1', name: 'viewport' }/ = stylesheet_pack_tag 'common', media: 'all' - = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all' + = stylesheet_pack_tag 'application', integrity: true, media: 'all' %body.error .dialog %img{ alt: Setting.default_settings['site_title'], src: '/oops.gif' }/ diff --git a/app/views/settings/keyword_mutes/_fields.html.haml b/app/views/settings/keyword_mutes/_fields.html.haml new file mode 100644 index 000000000..892676f18 --- /dev/null +++ b/app/views/settings/keyword_mutes/_fields.html.haml @@ -0,0 +1,11 @@ +.fields-group + = f.input :keyword + = f.check_box :whole_word + = f.label :whole_word, t('keyword_mutes.match_whole_word') + +.actions + - if f.object.persisted? + = f.button :button, t('generic.save_changes'), type: :submit + = link_to t('keyword_mutes.remove'), settings_keyword_mute_path(f.object), class: 'negative button', method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } + - else + = f.button :button, t('keyword_mutes.add_keyword'), type: :submit diff --git a/app/views/settings/keyword_mutes/_keyword_mute.html.haml b/app/views/settings/keyword_mutes/_keyword_mute.html.haml new file mode 100644 index 000000000..c45cc64fb --- /dev/null +++ b/app/views/settings/keyword_mutes/_keyword_mute.html.haml @@ -0,0 +1,10 @@ +%tr + %td + = keyword_mute.keyword + %td + - if keyword_mute.whole_word + %i.fa.fa-check + %td + = table_link_to 'edit', t('keyword_mutes.edit'), edit_settings_keyword_mute_path(keyword_mute) + %td + = table_link_to 'times', t('keyword_mutes.remove'), settings_keyword_mute_path(keyword_mute), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/settings/keyword_mutes/edit.html.haml b/app/views/settings/keyword_mutes/edit.html.haml new file mode 100644 index 000000000..af3949be2 --- /dev/null +++ b/app/views/settings/keyword_mutes/edit.html.haml @@ -0,0 +1,6 @@ +- content_for :page_title do + = t('keyword_mutes.edit_keyword') + += simple_form_for @keyword_mute, url: settings_keyword_mute_path(@keyword_mute), as: :keyword_mute do |f| + = render 'shared/error_messages', object: @keyword_mute + = render 'fields', f: f diff --git a/app/views/settings/keyword_mutes/index.html.haml b/app/views/settings/keyword_mutes/index.html.haml new file mode 100644 index 000000000..9ef8d55bc --- /dev/null +++ b/app/views/settings/keyword_mutes/index.html.haml @@ -0,0 +1,18 @@ +- content_for :page_title do + = t('settings.keyword_mutes') + +.table-wrapper + %table.table + %thead + %tr + %th= t('keyword_mutes.keyword') + %th= t('keyword_mutes.match_whole_word') + %th + %th + %tbody + = render partial: 'keyword_mute', collection: @keyword_mutes, as: :keyword_mute + += paginate @keyword_mutes +.simple_form + = link_to t('keyword_mutes.add_keyword'), new_settings_keyword_mute_path, class: 'button' + = link_to t('keyword_mutes.remove_all'), destroy_all_settings_keyword_mutes_path, class: 'button negative', method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/settings/keyword_mutes/new.html.haml b/app/views/settings/keyword_mutes/new.html.haml new file mode 100644 index 000000000..5c999c8d2 --- /dev/null +++ b/app/views/settings/keyword_mutes/new.html.haml @@ -0,0 +1,6 @@ +- content_for :page_title do + = t('keyword_mutes.add_keyword') + += simple_form_for @keyword_mute, url: settings_keyword_mutes_path, as: :keyword_mute do |f| + = render 'shared/error_messages', object: @keyword_mute + = render 'fields', f: f diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index 7a06cd014..551a7ca49 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -6,7 +6,7 @@ .fields-group = f.input :display_name, placeholder: t('simple_form.labels.defaults.display_name'), hint: t('simple_form.hints.defaults.display_name', count: 30 - @account.display_name.size).html_safe - = f.input :note, placeholder: t('simple_form.labels.defaults.note'), hint: t('simple_form.hints.defaults.note', count: 160 - @account.note.size).html_safe + = f.input :note, placeholder: t('simple_form.labels.defaults.note'), hint: t('simple_form.hints.defaults.note', count: 500 - @account.note.size).html_safe .card.compact{ style: "background-image: url(#{@account.header.url(:original)})", data: { original_src: @account.header.url(:original) } } .avatar= image_tag @account.avatar.url(:original), data: { original_src: @account.avatar.url(:original) } diff --git a/app/views/stream_entries/_content_spoiler.html.haml b/app/views/stream_entries/_content_spoiler.html.haml index 798dfce67..fb42d3f57 100644 --- a/app/views/stream_entries/_content_spoiler.html.haml +++ b/app/views/stream_entries/_content_spoiler.html.haml @@ -1,4 +1,4 @@ -.media-spoiler-wrapper{ class: sensitive == false && 'media-spoiler-wrapper__visible' } +.media-spoiler-wrapper{ class: sensitive == false && 'media-spoiler-wrapper__visible' }>< .spoiler-button .icon-button.overlayed %i.fa.fa-fw.fa-eye diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index 3119ebf4b..b488bd9ba 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -17,16 +17,16 @@ %p{ style: 'margin-bottom: 0' }< %span.p-summary> #{Formatter.instance.format_spoiler(status)} %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') - .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status, custom_emojify: true) - - - if !status.media_attachments.empty? - - if status.media_attachments.first.video? - - video = status.media_attachments.first - %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 670, height: 380) }} - - else - %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 380, sensitive: status.sensitive?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }} - - elsif status.preview_cards.first - %div{ data: { component: 'Card', props: Oj.dump('maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_cards.first, serializer: REST::PreviewCardSerializer).as_json) }} + .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }< + = Formatter.instance.format(status, custom_emojify: true) + - if !status.media_attachments.empty? + - if status.media_attachments.first.video? + - video = status.media_attachments.first + %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 670, height: 380) }}< + - else + %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 380, sensitive: status.sensitive?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}< + - elsif status.preview_cards.first + %div{ data: { component: 'Card', props: Oj.dump('maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_cards.first, serializer: REST::PreviewCardSerializer).as_json) }}< .detailed-status__meta %data.dt-published{ value: status.created_at.to_time.iso8601 } diff --git a/app/views/stream_entries/_media.html.haml b/app/views/stream_entries/_media.html.haml index 779f02c8d..32d024cf6 100644 --- a/app/views/stream_entries/_media.html.haml +++ b/app/views/stream_entries/_media.html.haml @@ -1,4 +1,4 @@ -.media-item +.media-item>< = link_to media.remote_url.blank? ? media.file.url(:original) : media.remote_url, style: media.image? ? "background-image: url(#{media.file.url(:original)})" : '', target: '_blank', rel: 'noopener', class: "u-#{media.video? || media.gifv? ? 'video' : 'photo'}" do - unless media.image? %video{ src: media.file.url(:original), autoplay: true, loop: true }/ diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml index b594c9da6..0b45ff308 100644 --- a/app/views/stream_entries/_simple_status.html.haml +++ b/app/views/stream_entries/_simple_status.html.haml @@ -18,11 +18,12 @@ %p{ style: 'margin-bottom: 0' }< %span.p-summary> #{Formatter.instance.format_spoiler(status)} %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') - .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status, custom_emojify: true) + .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }< + = Formatter.instance.format(status, custom_emojify: true) - - unless status.media_attachments.empty? - - if status.media_attachments.first.video? - - video = status.media_attachments.first - %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 610, height: 343) }} - - else - %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 343, sensitive: status.sensitive?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }} + - unless status.media_attachments.empty? + - if status.media_attachments.first.video? + - video = status.media_attachments.first + %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 610, height: 343) }}>< + - else + %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 343, sensitive: status.sensitive?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}>< diff --git a/config/application.rb b/config/application.rb index 0879d3c6a..b54ea1c40 100644 --- a/config/application.rb +++ b/config/application.rb @@ -9,6 +9,7 @@ Bundler.require(*Rails.groups) require_relative '../app/lib/exceptions' require_relative '../lib/paperclip/gif_transcoder' require_relative '../lib/paperclip/video_transcoder' +require_relative '../lib/paperclip/audio_transcoder' require_relative '../lib/mastodon/snowflake' require_relative '../lib/mastodon/version' @@ -70,12 +71,17 @@ module Mastodon config.active_job.queue_adapter = :sidekiq + #config.middleware.insert_before 0, Rack::Cors, debug: true, logger: (-> { Rails.logger }) do config.middleware.insert_before 0, Rack::Cors do allow do origins '*' resource '/@:username', headers: :any, methods: [:get], credentials: false resource '/api/*', headers: :any, methods: [:post, :put, :delete, :get, :patch, :options], credentials: false, expose: ['Link', 'X-RateLimit-Reset', 'X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-Request-Id'] resource '/oauth/token', headers: :any, methods: [:post], credentials: false + resource '/assets/*', headers: :any, methods: [:get, :head, :options] + resource '/stylesheets/*', headers: :any, methods: [:get, :head, :options] + resource '/javascripts/*', headers: :any, methods: [:get, :head, :options] + resource '/packs/*', headers: :any, methods: [:get, :head, :options] end end diff --git a/config/environments/production.rb b/config/environments/production.rb index 5705ffcfe..f7cb4b08a 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -91,9 +91,14 @@ Rails.application.configure do config.action_mailer.delivery_method = ENV.fetch('SMTP_DELIVERY_METHOD', 'smtp').to_sym config.action_dispatch.default_headers = { - 'Server' => 'Mastodon', - 'X-Frame-Options' => 'DENY', - 'X-Content-Type-Options' => 'nosniff', - 'X-XSS-Protection' => '1; mode=block', + 'Server' => 'Mastodon', + 'X-Frame-Options' => 'DENY', + 'X-Content-Type-Options' => 'nosniff', + 'X-XSS-Protection' => '1; mode=block', + 'Content-Security-Policy' => "frame-ancestors 'none'; object-src 'none'; script-src 'self' https://dev-static.glitch.social 'unsafe-inline'; base-uri 'none';" , + 'Referrer-Policy' => 'no-referrer, strict-origin-when-cross-origin', + 'Strict-Transport-Security' => 'max-age=63072000; includeSubDomains; preload', + 'X-Clacks-Overhead' => 'GNU Natalie Nguyen' + } end diff --git a/config/locales/en.yml b/config/locales/en.yml index 11c34b912..cebf704ce 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -394,6 +394,14 @@ en: muting: Muting list upload: Upload in_memoriam_html: In Memoriam. + keyword_mutes: + add_keyword: Add keyword + edit: Edit + edit_keyword: Edit keyword + keyword: Keyword + match_whole_word: Match whole word + remove: Remove + remove_all: Remove all landing_strip_html: "<strong>%{name}</strong> is a user on %{link_to_root_path}. You can follow them or interact with them if you have an account anywhere in the fediverse." landing_strip_signup_html: If you don't, you can <a href="%{sign_up_path}">sign up here</a>. media_attachments: @@ -512,6 +520,7 @@ en: export: Data export followers: Authorized followers import: Import + keyword_mutes: Muted keywords notifications: Notifications preferences: Preferences settings: Settings diff --git a/config/navigation.rb b/config/navigation.rb index 5b4800f07..16c48849b 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -7,6 +7,7 @@ SimpleNavigation::Configuration.run do |navigation| primary.item :settings, safe_join([fa_icon('cog fw'), t('settings.settings')]), settings_profile_url do |settings| settings.item :profile, safe_join([fa_icon('user fw'), t('settings.edit_profile')]), settings_profile_url settings.item :preferences, safe_join([fa_icon('sliders fw'), t('settings.preferences')]), settings_preferences_url + settings.item :keyword_mutes, safe_join([fa_icon('volume-off fw'), t('settings.keyword_mutes')]), settings_keyword_mutes_url settings.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_notifications_url settings.item :password, safe_join([fa_icon('lock fw'), t('auth.change_password')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete} settings.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_url, highlights_on: %r{/settings/two_factor_authentication} diff --git a/config/routes.rb b/config/routes.rb index 4a98b0b95..36acd428d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,6 +66,13 @@ Rails.application.routes.draw do namespace :settings do resource :profile, only: [:show, :update] + + resources :keyword_mutes do + collection do + delete :destroy_all + end + end + resource :preferences, only: [:show, :update] resource :notifications, only: [:show, :update] resource :import, only: [:show, :create] @@ -209,6 +216,7 @@ Rails.application.routes.draw do end namespace :timelines do + resource :direct, only: :show, controller: :direct resource :home, only: :show, controller: :home resource :public, only: :show, controller: :public resources :tag, only: :show @@ -222,7 +230,11 @@ Rails.application.routes.draw do resources :follows, only: [:create] resources :media, only: [:create, :update] resources :blocks, only: [:index] - resources :mutes, only: [:index] + resources :mutes, only: [:index] do + collection do + get 'details' + end + end resources :favourites, only: [:index] resources :reports, only: [:index, :create] @@ -242,10 +254,11 @@ Rails.application.routes.draw do end end - resources :notifications, only: [:index, :show] do + resources :notifications, only: [:index, :show, :destroy] do collection do post :clear post :dismiss + delete :destroy_multiple end end diff --git a/config/settings.yml b/config/settings.yml index a4df4094d..1b8a6673e 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -7,7 +7,7 @@ # For more information, see docs/Running-Mastodon/Administration-guide.md # defaults: &defaults - site_title: Mastodon + site_title: 'dev.glitch.social' site_description: '' site_extended_description: '' site_terms: '' @@ -16,7 +16,7 @@ defaults: &defaults open_registrations: true closed_registrations_message: '' open_deletion: true - timeline_preview: true + timeline_preview: false default_sensitive: false unfollow_modal: false boost_modal: false diff --git a/config/themes.yml b/config/themes.yml deleted file mode 100644 index a1049fae7..000000000 --- a/config/themes.yml +++ /dev/null @@ -1 +0,0 @@ -default: styles/application.scss diff --git a/config/webpack/configuration.js b/config/webpack/configuration.js index 822329490..74f75d89b 100644 --- a/config/webpack/configuration.js +++ b/config/webpack/configuration.js @@ -1,16 +1,27 @@ // Common configuration for webpacker loaded from config/webpacker.yml -const { join, resolve } = require('path'); +const { basename, dirname, join, resolve } = require('path'); const { env } = require('process'); const { safeLoad } = require('js-yaml'); const { readFileSync } = require('fs'); +const glob = require('glob'); const configPath = resolve('config', 'webpacker.yml'); const loadersDir = join(__dirname, 'loaders'); const settings = safeLoad(readFileSync(configPath), 'utf8')[env.NODE_ENV]; +const themeFiles = glob.sync('app/javascript/themes/*/theme.yml'); +const themes = {}; -const themePath = resolve('config', 'themes.yml'); -const themes = safeLoad(readFileSync(themePath), 'utf8'); +for (let i = 0; i < themeFiles.length; i++) { + const themeFile = themeFiles[i]; + const data = safeLoad(readFileSync(themeFile), 'utf8'); + if (!data.pack_directory) { + data.pack_directory = dirname(themeFile); + } + if (data.pack) { + themes[basename(dirname(themeFile))] = data; + } +} function removeOuterSlashes(string) { return string.replace(/^\/*/, '').replace(/\/*$/, ''); diff --git a/config/webpack/generateLocalePacks.js b/config/webpack/generateLocalePacks.js index b71cf2ade..cd3bed50c 100644 --- a/config/webpack/generateLocalePacks.js +++ b/config/webpack/generateLocalePacks.js @@ -34,6 +34,23 @@ locales.forEach(locale => { ].filter(filename => fs.existsSync(path.join(outPath, filename))) .map(filename => filename.replace(/..\/..\/node_modules\//, ''))[0]; + let glitchInject = ` +const mergedMessages = messages; +`; + + const glitchPath = `../../app/javascript/glitch/locales/${locale}.json`; + if (fs.existsSync(path.join(outPath, glitchPath))) { + glitchInject = ` +import glitchMessages from ${JSON.stringify(glitchPath)}; + +let mergedMessages = messages; +Object.keys(glitchMessages).forEach(function (key) { + mergedMessages[key] = glitchMessages[key]; +}); + +`; + } + const localeContent = `// // locale_${locale}.js // automatically generated by generateLocalePacks.js @@ -41,7 +58,8 @@ locales.forEach(locale => { import messages from '../../app/javascript/mastodon/locales/${locale}.json'; import localeData from ${JSON.stringify(localeDataPath)}; import { setLocale } from '../../app/javascript/mastodon/locales'; -setLocale({messages, localeData}); +${glitchInject} +setLocale({messages: mergedMessages, localeData: localeData}); `; fs.writeFileSync(localePath, localeContent, 'utf8'); outPaths.push(localePath); diff --git a/config/webpack/loaders/babel.js b/config/webpack/loaders/babel.js index e17d2fa70..770c89aa7 100644 --- a/config/webpack/loaders/babel.js +++ b/config/webpack/loaders/babel.js @@ -7,7 +7,8 @@ module.exports = { exclude: /node_modules/, loader: 'babel-loader', options: { - forceEnv: env, + forceEnv: process.env.NODE_ENV || 'development', + sourceRoot: 'app/javascript', cacheDirectory: env === 'development' ? false : resolve(__dirname, '..', '..', '..', 'tmp', 'cache', 'babel-loader'), }, }; diff --git a/config/webpack/loaders/sass.js b/config/webpack/loaders/sass.js index 88d94c684..96ad7abe8 100644 --- a/config/webpack/loaders/sass.js +++ b/config/webpack/loaders/sass.js @@ -9,7 +9,7 @@ module.exports = { { loader: 'css-loader', options: { minimize: env.NODE_ENV === 'production' } }, { loader: 'postcss-loader', options: { sourceMap: true } }, 'resolve-url-loader', - 'sass-loader', + { loader: 'sass-loader', options: { includePaths: ['app/javascript'] } }, ], }), }; diff --git a/config/webpack/shared.js b/config/webpack/shared.js index 50fa48175..5d176db4e 100644 --- a/config/webpack/shared.js +++ b/config/webpack/shared.js @@ -1,7 +1,7 @@ // Note: You must restart bin/webpack-dev-server for changes to take effect const webpack = require('webpack'); -const { basename, dirname, join, relative, resolve, sep } = require('path'); +const { basename, dirname, join, relative, resolve } = require('path'); const { sync } = require('glob'); const ExtractTextPlugin = require('extract-text-webpack-plugin'); const ManifestPlugin = require('webpack-manifest-plugin'); @@ -26,10 +26,13 @@ module.exports = { localMap[basename(entry, extname(entry, extname(entry)))] = resolve(entry); return localMap; }, {}), - Object.keys(themes).reduce((themePaths, name) => { - themePaths[name] = resolve(join(settings.source_path, themes[name])); - return themePaths; - }, {}) + Object.keys(themes).reduce( + (themePaths, name) => { + const themeData = themes[name]; + themePaths[`themes/${name}`] = resolve(themeData.pack_directory, themeData.pack); + return themePaths; + }, {} + ) ), output: { @@ -52,25 +55,17 @@ module.exports = { resource.request = resource.request.replace(/^history/, 'history/es'); } ), - new ExtractTextPlugin(env.NODE_ENV === 'production' ? '[name]-[contenthash].css' : '[name].css'), + new ExtractTextPlugin({ + filename: env.NODE_ENV === 'production' ? '[name]-[contenthash].css' : '[name].css', + allChunks: true, + }), new ManifestPlugin({ publicPath: output.publicPath, writeToFileEmit: true, }), new webpack.optimize.CommonsChunkPlugin({ name: 'common', - minChunks: (module, count) => { - const reactIntlPathRegexp = new RegExp(`node_modules\\${sep}react-intl`); - - if (module.resource && reactIntlPathRegexp.test(module.resource)) { - // skip react-intl because it's useless to put in the common chunk, - // e.g. because "shared" modules between zh-TW and zh-CN will never - // be loaded together - return false; - } - - return count >= 2; - }, + minChunks: Infinity, // It doesn't make sense to use common chunks with multiple frontend support. }), ], diff --git a/db/migrate/20170716191202_add_hide_notifications_to_mute.rb b/db/migrate/20170716191202_add_hide_notifications_to_mute.rb index 0410938c9..de7d2a4a2 100644 --- a/db/migrate/20170716191202_add_hide_notifications_to_mute.rb +++ b/db/migrate/20170716191202_add_hide_notifications_to_mute.rb @@ -1,15 +1,5 @@ -require Rails.root.join('lib', 'mastodon', 'migration_helpers') - class AddHideNotificationsToMute < ActiveRecord::Migration[5.1] - include Mastodon::MigrationHelpers - - disable_ddl_transaction! - - def up - add_column_with_default :mutes, :hide_notifications, :boolean, default: true, allow_null: false - end - - def down - remove_column :mutes, :hide_notifications + def change + add_column :mutes, :hide_notifications, :boolean, default: false, null: false end end diff --git a/db/migrate/20170914032032_default_existing_mutes_to_hiding_notifications.rb b/db/migrate/20170914032032_default_existing_mutes_to_hiding_notifications.rb new file mode 100644 index 000000000..8e6cac455 --- /dev/null +++ b/db/migrate/20170914032032_default_existing_mutes_to_hiding_notifications.rb @@ -0,0 +1,8 @@ +class DefaultExistingMutesToHidingNotifications < ActiveRecord::Migration[5.1] + def up + change_column_default :mutes, :hide_notifications, from: false, to: true + + # Unfortunately if this is applied sometime after the one to add the table we lose some data, so this is irreversible. + Mute.update_all(hide_notifications: true) + end +end diff --git a/db/migrate/20171009222537_create_keyword_mutes.rb b/db/migrate/20171009222537_create_keyword_mutes.rb new file mode 100644 index 000000000..ec0c756fb --- /dev/null +++ b/db/migrate/20171009222537_create_keyword_mutes.rb @@ -0,0 +1,12 @@ +class CreateKeywordMutes < ActiveRecord::Migration[5.1] + def change + create_table :keyword_mutes do |t| + t.references :account, null: false + t.string :keyword, null: false + t.boolean :whole_word, null: false, default: true + t.timestamps + end + + add_foreign_key :keyword_mutes, :accounts, on_delete: :cascade + end +end diff --git a/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb b/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb new file mode 100644 index 000000000..269bb49d6 --- /dev/null +++ b/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb @@ -0,0 +1,7 @@ +class MoveKeywordMutesIntoGlitchNamespace < ActiveRecord::Migration[5.1] + def change + safety_assured do + rename_table :keyword_mutes, :glitch_keyword_mutes + end + end +end diff --git a/db/migrate/20171028221157_add_reblogs_to_follows.rb b/db/migrate/20171028221157_add_reblogs_to_follows.rb new file mode 100644 index 000000000..eb4640a20 --- /dev/null +++ b/db/migrate/20171028221157_add_reblogs_to_follows.rb @@ -0,0 +1,21 @@ +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddReblogsToFollows < ActiveRecord::Migration[5.1] + include Mastodon::MigrationHelpers + + safety_assured do + disable_ddl_transaction! + end + + def up + safety_assured do + add_column_with_default :follows, :show_reblogs, :boolean, default: true, allow_null: false + add_column_with_default :follow_requests, :show_reblogs, :boolean, default: true, allow_null: false + end + end + + def down + remove_column :follows, :show_reblogs + remove_column :follow_requests, :show_reblogs + end +end diff --git a/db/schema.rb b/db/schema.rb index 2d763e2f4..0691c4220 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -147,6 +147,7 @@ ActiveRecord::Schema.define(version: 20171114080328) do t.datetime "updated_at", null: false t.bigint "account_id", null: false t.bigint "target_account_id", null: false + t.boolean "show_reblogs", default: true, null: false t.index ["account_id", "target_account_id"], name: "index_follow_requests_on_account_id_and_target_account_id", unique: true end @@ -155,9 +156,19 @@ ActiveRecord::Schema.define(version: 20171114080328) do t.datetime "updated_at", null: false t.bigint "account_id", null: false t.bigint "target_account_id", null: false + t.boolean "show_reblogs", default: true, null: false t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true end + create_table "glitch_keyword_mutes", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "keyword", null: false + t.boolean "whole_word", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_glitch_keyword_mutes_on_account_id" + end + create_table "imports", force: :cascade do |t| t.integer "type", null: false t.boolean "approved", default: false, null: false @@ -201,6 +212,7 @@ ActiveRecord::Schema.define(version: 20171114080328) do create_table "mutes", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "hide_notifications", default: true, null: false t.bigint "account_id", null: false t.bigint "target_account_id", null: false t.boolean "hide_notifications", default: true, null: false @@ -477,6 +489,7 @@ ActiveRecord::Schema.define(version: 20171114080328) do add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade + add_foreign_key "glitch_keyword_mutes", "accounts", on_delete: :cascade add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade add_foreign_key "media_attachments", "accounts", name: "fk_96dd81e81b", on_delete: :nullify add_foreign_key "media_attachments", "statuses", on_delete: :nullify diff --git a/lib/paperclip/audio_transcoder.rb b/lib/paperclip/audio_transcoder.rb new file mode 100644 index 000000000..631ccb0be --- /dev/null +++ b/lib/paperclip/audio_transcoder.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Paperclip + class AudioTranscoder < Paperclip::Processor + def make + meta = ::Av.cli.identify(@file.path) + # {:length=>"0:00:02.14", :duration=>2.14, :audio_encode=>"mp3", :audio_bitrate=>"44100 Hz", :audio_channels=>"mono"} + if meta[:duration] > 60.0 + raise Mastodon::ValidationError, "Audio uploads must be less than 60 seconds in length." + end + + final_file = Paperclip::Transcoder.make(file, options, attachment) + + attachment.instance.file_file_name = 'media.mp4' + attachment.instance.file_content_type = 'video/mp4' + attachment.instance.type = MediaAttachment.types[:video] + + final_file + end + end +end diff --git a/package.json b/package.json index cd088e5c0..159181030 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "private": true, "dependencies": { "array-includes": "^3.0.3", + "atrament": "^0.2.3", "autoprefixer": "^7.1.6", "axios": "~0.16.2", "babel-core": "^6.25.0", diff --git a/public/500.html b/public/500.html index 45a907808..e69de29bb 120000..100644 --- a/public/500.html +++ b/public/500.html @@ -1 +0,0 @@ -assets/500.html \ No newline at end of file diff --git a/public/background-cybre.png b/public/background-cybre.png new file mode 100644 index 000000000..151fd5584 --- /dev/null +++ b/public/background-cybre.png Binary files differdiff --git a/public/clock.js b/public/clock.js new file mode 100644 index 000000000..ffb9beae8 --- /dev/null +++ b/public/clock.js @@ -0,0 +1,54 @@ +document.addEventListener("DOMContentLoaded", function(event) { + updateClock(); + setInterval(updateClock, 1000); +}); + +function getNextOpen(now) { + var days = [[0, 14], [4, 18], [8, 22], [12], [2, 16], [6, 20], [10]] + var nowday = now.getUTCDay(); + var nour = now.getUTCHours(); + + var open_hour = -1; + var d = 0; + + while (open_hour == -1) { + var times = days[(nowday + d) % 7]; + for (var i = 0; i < times.length; ++i) { + var time = times[i]; + if (time == nour) { + return "refresh"; + } else if (time > nour || d > 0) { + open_hour = time; + break; + } + } + if (open_hour == -1) { + d += 1; + nour = -1; + } + } + + var open = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + d)); + var ts = open.setUTCHours(open_hour); + return open; +} + +function updateClock() { + var clock = document.querySelector(".closed-registrations-message .clock"); + var now = new Date(); + var open = getNextOpen(now); + + if (open == "refresh") { + location.reload(); + return; + } + + var until = open - now; + var ms = until % 1000; + var s = Math.floor((until / 1000)) % 60; + var m = Math.floor((until / 1000 / 60)) % 60; + var h = Math.floor((until / 1000 / 60 / 60)); + if (m < 10) m = "0" + m; + if (s < 10) s = "0" + s; + clock.innerHTML = h + ":" + m + ":" + s; +} diff --git a/public/logo-cybre-glitch.gif b/public/logo-cybre-glitch.gif new file mode 100644 index 000000000..abe9b2a9a --- /dev/null +++ b/public/logo-cybre-glitch.gif Binary files differdiff --git a/public/riot-glitch.png b/public/riot-glitch.png new file mode 100644 index 000000000..1c97ce5f1 --- /dev/null +++ b/public/riot-glitch.png Binary files differdiff --git a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb index 461b8b34b..247420d08 100644 --- a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb @@ -51,7 +51,9 @@ describe Api::V1::Accounts::CredentialsController do describe 'with invalid data' do before do - patch :update, params: { note: 'This is too long. ' * 10 } + note = 'This is too long. ' + note = note + 'a' * (Account::MAX_NOTE_LENGTH - note.length + 1) + patch :update, params: { note: note } end it 'returns http unprocessable entity' do diff --git a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb index 431fc2194..f25b86ac1 100644 --- a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb @@ -32,7 +32,7 @@ describe Api::V1::Accounts::RelationshipsController do json = body_as_json expect(json).to be_a Enumerable - expect(json.first[:following]).to be true + expect(json.first[:following]).to be_truthy expect(json.first[:followed_by]).to be false end end @@ -51,7 +51,7 @@ describe Api::V1::Accounts::RelationshipsController do expect(json).to be_a Enumerable expect(json.first[:id]).to eq simon.id.to_s - expect(json.first[:following]).to be true + expect(json.first[:following]).to be_truthy expect(json.first[:followed_by]).to be false expect(json.first[:muting]).to be false expect(json.first[:requested]).to be false diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index 053c53e5a..f3b879421 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -31,10 +31,10 @@ RSpec.describe Api::V1::AccountsController, type: :controller do expect(response).to have_http_status(:success) end - it 'returns JSON with following=true and requested=false' do + it 'returns JSON with following=truthy and requested=false' do json = body_as_json - expect(json[:following]).to be true + expect(json[:following]).to be_truthy expect(json[:requested]).to be false end @@ -50,11 +50,11 @@ RSpec.describe Api::V1::AccountsController, type: :controller do expect(response).to have_http_status(:success) end - it 'returns JSON with following=false and requested=true' do + it 'returns JSON with following=false and requested=truthy' do json = body_as_json expect(json[:following]).to be false - expect(json[:requested]).to be true + expect(json[:requested]).to be_truthy end it 'creates a follow request relation between user and target user' do diff --git a/spec/controllers/api/v1/mutes_controller_spec.rb b/spec/controllers/api/v1/mutes_controller_spec.rb index 97d6c2773..7387b9d2d 100644 --- a/spec/controllers/api/v1/mutes_controller_spec.rb +++ b/spec/controllers/api/v1/mutes_controller_spec.rb @@ -18,4 +18,24 @@ RSpec.describe Api::V1::MutesController, type: :controller do expect(response).to have_http_status(:success) end end + + describe 'GET #details' do + before do + get :details, params: { limit: 1 } + end + + let(:mutes) { JSON.parse(response.body) } + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'returns one mute' do + expect(mutes.size).to be(1) + end + + it 'returns whether the mute hides notifications' do + expect(mutes.first["hide_notifications"]).to be(false) + end + end end diff --git a/spec/controllers/settings/keyword_mutes_controller_spec.rb b/spec/controllers/settings/keyword_mutes_controller_spec.rb new file mode 100644 index 000000000..a8c37a072 --- /dev/null +++ b/spec/controllers/settings/keyword_mutes_controller_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Settings::KeywordMutesController, type: :controller do + +end diff --git a/spec/fabricators/glitch_keyword_mute_fabricator.rb b/spec/fabricators/glitch_keyword_mute_fabricator.rb new file mode 100644 index 000000000..20d393320 --- /dev/null +++ b/spec/fabricators/glitch_keyword_mute_fabricator.rb @@ -0,0 +1,2 @@ +Fabricator('Glitch::KeywordMute') do +end diff --git a/spec/helpers/settings/keyword_mutes_helper_spec.rb b/spec/helpers/settings/keyword_mutes_helper_spec.rb new file mode 100644 index 000000000..a19d518dd --- /dev/null +++ b/spec/helpers/settings/keyword_mutes_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the Settings::KeywordMutesHelper. For example: +# +# describe Settings::KeywordMutesHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe Settings::KeywordMutesHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index 0f97a579e..715d85306 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -56,6 +56,13 @@ RSpec.describe FeedManager do expect(FeedManager.instance.filter?(:home, reblog, bob.id)).to be true end + it 'returns true for reblog from account with reblogs disabled' do + status = Fabricate(:status, text: 'Hello world', account: jeff) + reblog = Fabricate(:status, reblog: status, account: alice) + bob.follow!(alice, reblogs: false) + expect(FeedManager.instance.filter?(:home, reblog, bob.id)).to be true + end + it 'returns false for reply by followee to another followee' do status = Fabricate(:status, text: 'Hello world', account: jeff) reply = Fabricate(:status, text: 'Nay', thread: status, account: alice) @@ -105,6 +112,13 @@ RSpec.describe FeedManager do expect(FeedManager.instance.filter?(:home, status, bob.id)).to be true end + it 'returns true for status by followee mentioning muted account' do + bob.mute!(jeff) + bob.follow!(alice) + status = PostStatusService.new.call(alice, 'Hey @jeff') + expect(FeedManager.instance.filter?(:home, status, bob.id)).to be true + end + it 'returns true for reblog of a personally blocked domain' do alice.block_domain!('example.com') alice.follow!(jeff) @@ -112,6 +126,44 @@ RSpec.describe FeedManager do reblog = Fabricate(:status, reblog: status, account: jeff) expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true end + + it 'returns true for a status containing a muted keyword' do + Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take') + status = Fabricate(:status, text: 'This is a hot take', account: bob) + + expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true + end + + it 'returns true for a reply containing a muted keyword' do + Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take') + s1 = Fabricate(:status, text: 'Something', account: alice) + s2 = Fabricate(:status, text: 'This is a hot take', thread: s1, account: bob) + + expect(FeedManager.instance.filter?(:home, s2, alice.id)).to be true + end + + it 'returns true for a status whose spoiler text contains a muted keyword' do + Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take') + status = Fabricate(:status, spoiler_text: 'This is a hot take', account: bob) + + expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true + end + + it 'returns true for a reblog containing a muted keyword' do + Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take') + status = Fabricate(:status, text: 'This is a hot take', account: bob) + reblog = Fabricate(:status, reblog: status, account: jeff) + + expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true + end + + it 'returns true for a reblog whose spoiler text contains a muted keyword' do + Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take') + status = Fabricate(:status, spoiler_text: 'This is a hot take', account: bob) + reblog = Fabricate(:status, reblog: status, account: jeff) + + expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true + end end context 'for mentions feed' do @@ -140,6 +192,13 @@ RSpec.describe FeedManager do bob.follow!(alice) expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be false end + + it 'returns true for status that contains a muted keyword' do + Fabricate('Glitch::KeywordMute', account: bob, keyword: 'take') + status = Fabricate(:status, text: 'This is a hot take', account: alice) + bob.follow!(alice) + expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true + end end end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 9c1492c90..7501c498c 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -636,8 +636,8 @@ RSpec.describe Account, type: :model do expect(account).to model_have_error_on_field(:display_name) end - it 'is invalid if the note is longer than 160 characters' do - account = Fabricate.build(:account, note: Faker::Lorem.characters(161)) + it 'is invalid if the note is longer than 500 characters' do + account = Fabricate.build(:account, note: Faker::Lorem.characters(501)) account.valid? expect(account).to model_have_error_on_field(:note) end @@ -676,8 +676,8 @@ RSpec.describe Account, type: :model do expect(account).not_to model_have_error_on_field(:display_name) end - it 'is valid even if the note is longer than 160 characters' do - account = Fabricate.build(:account, domain: 'domain', note: Faker::Lorem.characters(161)) + it 'is valid even if the note is longer than 500 characters' do + account = Fabricate.build(:account, domain: 'domain', note: Faker::Lorem.characters(501)) account.valid? expect(account).not_to model_have_error_on_field(:note) end diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb index a468549d8..1e238e27c 100644 --- a/spec/models/concerns/account_interactions_spec.rb +++ b/spec/models/concerns/account_interactions_spec.rb @@ -31,7 +31,44 @@ describe AccountInteractions do end it 'does mute notifications' do - expect(me.muting_notifications?(you)).to be true + expect(me.muting_notifications?(you)).to be true + end + end + end + + describe 'ignoring reblogs from an account' do + before do + @me = Fabricate(:account, username: 'Me') + @you = Fabricate(:account, username: 'You') + end + + context 'with the reblogs option unspecified' do + before do + @me.follow!(@you) + end + + it 'defaults to showing reblogs' do + expect(@me.muting_reblogs?(@you)).to be(false) + end + end + + context 'with the reblogs option set to false' do + before do + @me.follow!(@you, reblogs: false) + end + + it 'does mute reblogs' do + expect(@me.muting_reblogs?(@you)).to be(true) + end + end + + context 'with the reblogs option set to true' do + before do + @me.follow!(@you, reblogs: true) + end + + it 'does not mute reblogs' do + expect(@me.muting_reblogs?(@you)).to be(false) end end end diff --git a/spec/models/follow_request_spec.rb b/spec/models/follow_request_spec.rb index 1436501e9..7bc93a2aa 100644 --- a/spec/models/follow_request_spec.rb +++ b/spec/models/follow_request_spec.rb @@ -7,10 +7,31 @@ RSpec.describe FollowRequest, type: :model do let(:target_account) { Fabricate(:account) } it 'calls Account#follow!, MergeWorker.perform_async, and #destroy!' do - expect(account).to receive(:follow!).with(target_account) + expect(account).to receive(:follow!).with(target_account, reblogs: true) expect(MergeWorker).to receive(:perform_async).with(target_account.id, account.id) expect(follow_request).to receive(:destroy!) follow_request.authorize! end + + it 'generates a Follow' do + follow_request = Fabricate.create(:follow_request) + follow_request.authorize! + target = follow_request.target_account + expect(follow_request.account.following?(target)).to be true + end + + it 'correctly passes show_reblogs when true' do + follow_request = Fabricate.create(:follow_request, show_reblogs: true) + follow_request.authorize! + target = follow_request.target_account + expect(follow_request.account.muting_reblogs?(target)).to be false + end + + it 'correctly passes show_reblogs when false' do + follow_request = Fabricate.create(:follow_request, show_reblogs: false) + follow_request.authorize! + target = follow_request.target_account + expect(follow_request.account.muting_reblogs?(target)).to be true + end end end diff --git a/spec/models/glitch/keyword_mute_spec.rb b/spec/models/glitch/keyword_mute_spec.rb new file mode 100644 index 000000000..9685c6493 --- /dev/null +++ b/spec/models/glitch/keyword_mute_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +RSpec.describe Glitch::KeywordMute, type: :model do + let(:alice) { Fabricate(:account, username: 'alice').tap(&:save!) } + let(:bob) { Fabricate(:account, username: 'bob').tap(&:save!) } + + describe '.matcher_for' do + let(:matcher) { Glitch::KeywordMute.matcher_for(alice) } + + describe 'with no mutes' do + before do + Glitch::KeywordMute.delete_all + end + + it 'does not match' do + expect(matcher =~ 'This is a hot take').to be_falsy + end + end + + describe 'with mutes' do + it 'does not match keywords set by a different account' do + Glitch::KeywordMute.create!(account: bob, keyword: 'take') + + expect(matcher =~ 'This is a hot take').to be_falsy + end + + it 'does not match if no keywords match the status text' do + Glitch::KeywordMute.create!(account: alice, keyword: 'cold') + + expect(matcher =~ 'This is a hot take').to be_falsy + end + + it 'considers word boundaries when matching' do + Glitch::KeywordMute.create!(account: alice, keyword: 'bob', whole_word: true) + + expect(matcher =~ 'bobcats').to be_falsy + end + + it 'matches substrings if whole_word is false' do + Glitch::KeywordMute.create!(account: alice, keyword: 'take', whole_word: false) + + expect(matcher =~ 'This is a shiitake mushroom').to be_truthy + end + + it 'matches keywords at the beginning of the text' do + Glitch::KeywordMute.create!(account: alice, keyword: 'take') + + expect(matcher =~ 'Take this').to be_truthy + end + + it 'matches keywords at the end of the text' do + Glitch::KeywordMute.create!(account: alice, keyword: 'take') + + expect(matcher =~ 'This is a hot take').to be_truthy + end + + it 'matches if at least one keyword case-insensitively matches the text' do + Glitch::KeywordMute.create!(account: alice, keyword: 'hot') + + expect(matcher =~ 'This is a HOT take').to be_truthy + end + + it 'maintains case-insensitivity when combining keywords into a single matcher' do + Glitch::KeywordMute.create!(account: alice, keyword: 'hot') + Glitch::KeywordMute.create!(account: alice, keyword: 'cold') + + expect(matcher =~ 'This is a HOT take').to be_truthy + end + + it 'matches keywords surrounded by non-alphanumeric ornamentation' do + Glitch::KeywordMute.create!(account: alice, keyword: 'hot') + + expect(matcher =~ '(hot take)').to be_truthy + end + + it 'escapes metacharacters in keywords' do + Glitch::KeywordMute.create!(account: alice, keyword: '(hot take)') + + expect(matcher =~ '(hot take)').to be_truthy + end + + it 'uses case-folding rules appropriate for more than just English' do + Glitch::KeywordMute.create!(account: alice, keyword: 'großeltern') + + expect(matcher =~ 'besuch der grosseltern').to be_truthy + end + + it 'matches keywords that are composed of multiple words' do + Glitch::KeywordMute.create!(account: alice, keyword: 'a shiitake') + + expect(matcher =~ 'This is a shiitake').to be_truthy + expect(matcher =~ 'This is shiitake').to_not be_truthy + end + end + end +end diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index d58ff5019..89ad3adcf 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -281,6 +281,55 @@ RSpec.describe Status, type: :model do end end + describe '.as_direct_timeline' do + let(:account) { Fabricate(:account) } + let(:followed) { Fabricate(:account) } + let(:not_followed) { Fabricate(:account) } + + before do + Fabricate(:follow, account: account, target_account: followed) + + @self_public_status = Fabricate(:status, account: account, visibility: :public) + @self_direct_status = Fabricate(:status, account: account, visibility: :direct) + @followed_public_status = Fabricate(:status, account: followed, visibility: :public) + @followed_direct_status = Fabricate(:status, account: followed, visibility: :direct) + @not_followed_direct_status = Fabricate(:status, account: not_followed, visibility: :direct) + + @results = Status.as_direct_timeline(account) + end + + it 'does not include public statuses from self' do + expect(@results).to_not include(@self_public_status) + end + + it 'includes direct statuses from self' do + expect(@results).to include(@self_direct_status) + end + + it 'does not include public statuses from followed' do + expect(@results).to_not include(@followed_public_status) + end + + it 'includes direct statuses mentioning recipient from followed' do + Fabricate(:mention, account: account, status: @followed_direct_status) + expect(@results).to include(@followed_direct_status) + end + + it 'does not include direct statuses not mentioning recipient from followed' do + expect(@results).to_not include(@followed_direct_status) + end + + it 'includes direct statuses mentioning recipient from non-followed' do + Fabricate(:mention, account: account, status: @not_followed_direct_status) + expect(@results).to include(@not_followed_direct_status) + end + + it 'does not include direct statuses not mentioning recipient from non-followed' do + expect(@results).to_not include(@not_followed_direct_status) + end + + end + describe '.as_public_timeline' do it 'only includes statuses with public visibility' do public_status = Fabricate(:status, visibility: :public) diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb index ceb39e5e6..e59a2f1a6 100644 --- a/spec/services/follow_service_spec.rb +++ b/spec/services/follow_service_spec.rb @@ -13,8 +13,20 @@ RSpec.describe FollowService do subject.call(sender, bob.acct) end - it 'creates a follow request' do - expect(FollowRequest.find_by(account: sender, target_account: bob)).to_not be_nil + it 'creates a follow request with reblogs' do + expect(FollowRequest.find_by(account: sender, target_account: bob, show_reblogs: true)).to_not be_nil + end + end + + describe 'locked account, no reblogs' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, locked: true, username: 'bob')).account } + + before do + subject.call(sender, bob.acct, reblogs: false) + end + + it 'creates a follow request without reblogs' do + expect(FollowRequest.find_by(account: sender, target_account: bob, show_reblogs: false)).to_not be_nil end end @@ -25,8 +37,22 @@ RSpec.describe FollowService do subject.call(sender, bob.acct) end - it 'creates a following relation' do + it 'creates a following relation with reblogs' do + expect(sender.following?(bob)).to be true + expect(sender.muting_reblogs?(bob)).to be false + end + end + + describe 'unlocked account, no reblogs' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + + before do + subject.call(sender, bob.acct, reblogs: false) + end + + it 'creates a following relation without reblogs' do expect(sender.following?(bob)).to be true + expect(sender.muting_reblogs?(bob)).to be true end end @@ -42,6 +68,32 @@ RSpec.describe FollowService do expect(sender.following?(bob)).to be true end end + + describe 'already followed account, turning reblogs off' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + + before do + sender.follow!(bob, reblogs: true) + subject.call(sender, bob.acct, reblogs: false) + end + + it 'disables reblogs' do + expect(sender.muting_reblogs?(bob)).to be true + end + end + + describe 'already followed account, turning reblogs on' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + + before do + sender.follow!(bob, reblogs: false) + subject.call(sender, bob.acct, reblogs: true) + end + + it 'disables reblogs' do + expect(sender.muting_reblogs?(bob)).to be false + end + end end context 'remote OStatus account' do diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb index fad0dd369..a8ebc16b8 100644 --- a/spec/services/notify_service_spec.rb +++ b/spec/services/notify_service_spec.rb @@ -48,6 +48,26 @@ RSpec.describe NotifyService do is_expected.to_not change(Notification, :count) end + describe 'reblogs' do + let(:status) { Fabricate(:status, account: Fabricate(:account)) } + let(:activity) { Fabricate(:status, account: sender, reblog: status) } + + it 'shows reblogs by default' do + recipient.follow!(sender) + is_expected.to change(Notification, :count) + end + + it 'shows reblogs when explicitly enabled' do + recipient.follow!(sender, reblogs: true) + is_expected.to change(Notification, :count) + end + + it 'hides reblogs when disabled' do + recipient.follow!(sender, reblogs: false) + is_expected.to_not change(Notification, :count) + end + end + context 'for direct messages' do let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct)) } diff --git a/spec/validators/status_length_validator_spec.rb b/spec/validators/status_length_validator_spec.rb index e2d1a15ec..9355c7e3f 100644 --- a/spec/validators/status_length_validator_spec.rb +++ b/spec/validators/status_length_validator_spec.rb @@ -7,26 +7,31 @@ describe StatusLengthValidator do it 'does not add errors onto remote statuses' it 'does not add errors onto local reblogs' - it 'adds an error when content warning is over 500 characters' do - status = double(spoiler_text: 'a' * 520, text: '', errors: double(add: nil), local?: true, reblog?: false) + it 'adds an error when content warning is over MAX_CHARS characters' do + chars = StatusLengthValidator::MAX_CHARS + 1 + status = double(spoiler_text: 'a' * chars, text: '', errors: double(add: nil), local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end - it 'adds an error when text is over 500 characters' do - status = double(spoiler_text: '', text: 'a' * 520, errors: double(add: nil), local?: true, reblog?: false) + it 'adds an error when text is over MAX_CHARS characters' do + chars = StatusLengthValidator::MAX_CHARS + 1 + status = double(spoiler_text: '', text: 'a' * chars, errors: double(add: nil), local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end - it 'adds an error when text and content warning are over 500 characters total' do - status = double(spoiler_text: 'a' * 250, text: 'b' * 251, errors: double(add: nil), local?: true, reblog?: false) + it 'adds an error when text and content warning are over MAX_CHARS characters total' do + chars1 = 20 + chars2 = StatusLengthValidator::MAX_CHARS + 1 - chars1 + status = double(spoiler_text: 'a' * chars1, text: 'b' * chars2, errors: double(add: nil), local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end it 'counts URLs as 23 characters flat' do - text = ('a' * 476) + " http://#{'b' * 30}.com/example" + chars = StatusLengthValidator::MAX_CHARS - 1 - 23 + text = ('a' * chars) + " http://#{'b' * 30}.com/example" status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false) subject.validate(status) @@ -34,7 +39,9 @@ describe StatusLengthValidator do end it 'counts only the front part of remote usernames' do - text = ('a' * 475) + " @alice@#{'b' * 30}.com" + username = '@alice' + chars = StatusLengthValidator::MAX_CHARS - 1 - username.length + text = ('a' * 475) + " #{username}@#{'b' * 30}.com" status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false) subject.validate(status) diff --git a/spec/views/about/show.html.haml_spec.rb b/spec/views/about/show.html.haml_spec.rb index 724643cbc..ca59fa9e3 100644 --- a/spec/views/about/show.html.haml_spec.rb +++ b/spec/views/about/show.html.haml_spec.rb @@ -3,6 +3,8 @@ require 'rails_helper' describe 'about/show.html.haml', without_verify_partial_doubles: true do + let(:commit_hash) { '8925731c9869f55780644304e4420a1998e52607' } + before do allow(view).to receive(:site_hostname).and_return('example.com') allow(view).to receive(:site_title).and_return('example site') @@ -16,7 +18,9 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do source_url: 'https://github.com/tootsuite/mastodon', open_registrations: false, thumbnail: nil, - closed_registrations_message: 'yes') + closed_registrations_message: 'yes', + commit_hash: commit_hash) + assign(:instance_presenter, instance_presenter) render diff --git a/streaming/index.js b/streaming/index.js index 83903b89b..8adc5174a 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -402,6 +402,10 @@ const startWorker = (workerId) => { streamFrom('timeline:public:local', req, streamToHttp(req, res), streamHttpEnd(req), true); }); + app.get('/api/v1/streaming/direct', (req, res) => { + streamFrom(`timeline:direct:${req.accountId}`, req, streamToHttp(req, res), streamHttpEnd(req), true); + }); + app.get('/api/v1/streaming/hashtag', (req, res) => { streamFrom(`timeline:hashtag:${req.query.tag.toLowerCase()}`, req, streamToHttp(req, res), streamHttpEnd(req), true); }); @@ -437,6 +441,9 @@ const startWorker = (workerId) => { case 'public:local': streamFrom('timeline:public:local', req, streamToWs(req, ws), streamWsEnd(req, ws), true); break; + case 'direct': + streamFrom(`timeline:direct:${req.accountId}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true); + break; case 'hashtag': streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true); break; diff --git a/yarn.lock b/yarn.lock index b7aa18a01..0805e8af3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -301,6 +301,10 @@ atob@~1.1.0: version "1.1.3" resolved "https://registry.yarnpkg.com/atob/-/atob-1.1.3.tgz#95f13629b12c3a51a5d215abdce2aa9f32f80773" +atrament@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/atrament/-/atrament-0.2.3.tgz#6ccbc0daa6d3f25e5aeaeb31befeb78e86980348" + autoprefixer@^6.3.1: version "6.7.7" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.7.7.tgz#1dbd1c835658e35ce3f9984099db00585c782014" |